mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-21 14:21:48 +00:00
merge(main): fold in the H4c workflow child kind and the replay-floor pass, renumbering the automation contract to v1.37
This commit is contained in:
commit
9f809670c7
52 changed files with 2326 additions and 372 deletions
|
|
@ -49,8 +49,8 @@ correct `cursor_expired` or resync.
|
|||
|
||||
## 3. Non-goals
|
||||
|
||||
- Pruning events. Nothing raises the floor in production yet; that belongs to
|
||||
the retention work.
|
||||
- Pruning events. A flag-gated scheduled pass now raises the floor (see 4.4),
|
||||
but it deletes nothing; pruning still belongs to the retention work.
|
||||
- The WebShell Session's `replayFloorSequence` and `snapshotThroughSequence`,
|
||||
which stay `planned`.
|
||||
- `listItems`, which stays `partial` until Snapshot versions are paged.
|
||||
|
|
@ -77,7 +77,8 @@ correct `cursor_expired` or resync.
|
|||
`PublicContentPart.part_id`. A Part id ends with a sequence, so it exceeds 64
|
||||
characters once the sequence has ten digits.
|
||||
- `replay_floor_sequence` gains a description: events at or below it may be
|
||||
pruned, and a cursor below it has expired.
|
||||
pruned, and a cursor below it has expired while the Snapshot backs the
|
||||
floor.
|
||||
- `SessionResyncRequired` tells a client to resume after the
|
||||
`snapshot_through_sequence` that the Items list returns, since the value in
|
||||
the frame can be older than the Snapshot the client then reads.
|
||||
|
|
@ -152,8 +153,9 @@ follows tells clients to reload the Snapshot.
|
|||
|
||||
### 4.4 Replay floor
|
||||
|
||||
- A cursor has expired when it is below the floor. A cursor equal to the floor
|
||||
is valid, because the next event is retained.
|
||||
- A cursor has expired when it is below the floor and the Snapshot backs the
|
||||
floor. A cursor equal to the floor is valid, because the next event is
|
||||
retained.
|
||||
- A read checks the floor after it reads the events. The floor only rises and
|
||||
the retention work prunes only below it, so a floor that is not above the
|
||||
cursor after the read was not above it during the read.
|
||||
|
|
@ -169,14 +171,22 @@ follows tells clients to reload the Snapshot.
|
|||
- `ManagedAgentStore.advanceReplayFloor` raises the floor and never lowers it.
|
||||
It caps the floor at the Snapshot's covered sequence, so that a client that
|
||||
reloads the Snapshot can resume after `snapshot_through_sequence`. Tests call
|
||||
it; the retention work will call it before it deletes events.
|
||||
it, and the scheduled `ReplayFloorAdvancer` pass calls it with the cap, so an
|
||||
enabled deployment raises each Session's floor as far as its Snapshot proves
|
||||
safe; the retention work will call it before it deletes events. The pass is
|
||||
gated by `qwen.managed-agent.events.replay-floor-enabled`
|
||||
(`QWEN_MANAGED_AGENT_REPLAY_FLOOR_ENABLED`, default `false`) and runs every
|
||||
`qwen.managed-agent.events.replay-floor-interval`
|
||||
(`QWEN_MANAGED_AGENT_REPLAY_FLOOR_INTERVAL`, default 60s).
|
||||
- `PublicSession.replay_floor_sequence` returns the stored floor.
|
||||
- A stream reconciliation discards the Snapshot, so its covered sequence is `0`
|
||||
until the Items are rebuilt. With a raised floor, a public client told to
|
||||
resync during that time would find its cursor expired again, with `409` or
|
||||
another resync frame, until the rebuild finishes.
|
||||
Nothing raises the floor in production yet; the retention work must close
|
||||
this window before it does.
|
||||
- A stream reconciliation discards the Snapshot, so its covered sequence is
|
||||
`0` until the Items are rebuilt, and the floor never lowers, so it can exceed
|
||||
the coverage during the rebuild. A read expires a cursor below the floor only
|
||||
while the Snapshot backs it (`floor <= snapshot_through_sequence`); nothing
|
||||
prunes events yet, so during the rebuild the cursor is still served from the
|
||||
retained events instead of looping `409` or another resync frame. The
|
||||
retention work must revisit this condition once it deletes rows below the
|
||||
stored floor: a cursor it then serves could name events that are gone.
|
||||
- The WebShell transcript does not check the floor. Once events are pruned,
|
||||
its older pages must end at the floor; that also belongs to the retention
|
||||
work.
|
||||
|
|
@ -259,6 +269,12 @@ placeholder watermarks (`replayFloorSequence: 0`,
|
|||
idle stream does not read the store during a test and live events can reach
|
||||
it only through the hub.
|
||||
|
||||
- `ReplayFloorRetractionTest` pauses the materializer, raises the floor to the
|
||||
Snapshot and retracts the continuation output, then checks that a cursor
|
||||
below the floor is still served while the Snapshot is gone, that the stored
|
||||
floor never regresses, and that the cursor expires again once the rebuilt
|
||||
Snapshot backs the floor.
|
||||
|
||||
- `EventIdentityTest` pins the rule. Integration tests compare every event's
|
||||
identity with the materialized Snapshot after deltas appended in two batches
|
||||
with a reasoning Part that spans both, after single appends, and after
|
||||
|
|
@ -295,7 +311,8 @@ placeholder watermarks (`replayFloorSequence: 0`,
|
|||
have.
|
||||
- A client that ignores the resync frame sees the stream end, reconnects with
|
||||
the same cursor and gets the frame again. The web-shell client handles it.
|
||||
Until the retention work raises the floor, no production stream sends it.
|
||||
Only a deployment that opts in to the replay-floor pass sends it in
|
||||
production.
|
||||
- The generated `@qwen-code/web-shell` types add `WebShellResyncRequired`, and
|
||||
the stream operation no longer lists `409`. The optional event fields were
|
||||
already in the WebShell event schema.
|
||||
|
|
@ -319,9 +336,10 @@ placeholder watermarks (`replayFloorSequence: 0`,
|
|||
|
||||
## 8. Follow-up
|
||||
|
||||
- Retention: prune events, raise the floor before pruning, keep the floor at
|
||||
or below the Snapshot while a stream reconciliation rebuilds the Items, and
|
||||
end the WebShell transcript's older pages at the floor.
|
||||
- Retention: prune events below the floor the pass already raises, and end
|
||||
the WebShell transcript's older pages at the floor. The Snapshot-discard
|
||||
window above is closed at the read site only while nothing prunes; the
|
||||
pruning half must re-decide the effective floor before it deletes rows.
|
||||
- The WebShell Session's floor and Snapshot watermarks.
|
||||
- `listItems` with paged Snapshot versions.
|
||||
- Lifecycle work: the three remaining gap lines.
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ resync。
|
|||
|
||||
## 3. 非目标
|
||||
|
||||
- 清理事件。生产环境中目前没有任何路径提升下限,这属于保留策略的工作。
|
||||
- 清理事件。现在有一个受开关控制的定时过程会提升下限(见 4.4),但它不删除
|
||||
任何内容;清理仍属于保留策略的工作。
|
||||
- WebShell Session 的 `replayFloorSequence` 与 `snapshotThroughSequence`,仍为
|
||||
`planned`。
|
||||
- `listItems`,在 Snapshot 版本分页之前仍为 `partial`。
|
||||
|
|
@ -65,8 +66,8 @@ resync。
|
|||
- `content_part_id` 与 `contentPartId` 允许 128 个字符,与
|
||||
`PublicContentPart.part_id` 一致。Part id 以 sequence 结尾,sequence 达到十位数
|
||||
时就会超过 64 个字符。
|
||||
- `replay_floor_sequence` 增加说明:不超过它的事件可能被清理,低于它的游标已
|
||||
过期。
|
||||
- `replay_floor_sequence` 增加说明:不超过它的事件可能被清理,低于它的游标在
|
||||
Snapshot 支撑下限时才过期。
|
||||
- `SessionResyncRequired` 要求客户端从 Items 列表返回的 `snapshot_through_sequence`
|
||||
之后继续,因为帧中的值可能比客户端随后读到的 Snapshot 更旧。
|
||||
- WebShell transcript 写明它原本的返回内容:没有游标时,有 Snapshot 的 Session
|
||||
|
|
@ -125,7 +126,8 @@ Snapshot。
|
|||
|
||||
### 4.4 回放下限
|
||||
|
||||
- 游标低于下限即为过期。等于下限的游标有效,因为下一条事件仍被保留。
|
||||
- 游标低于下限且 Snapshot 支撑下限时即为过期。等于下限的游标有效,因为下一条
|
||||
事件仍被保留。
|
||||
- 读取在读完事件之后才检查下限。下限只会上升,保留策略也只清理下限以下的事件,
|
||||
因此读取之后不高于游标的下限,在读取期间也不高于游标。
|
||||
- JSON 查询对过期游标返回 `409 cursor_expired`,错误信封带有契约早已定义的
|
||||
|
|
@ -136,13 +138,20 @@ Snapshot。
|
|||
`id`,因此客户端的 `Last-Event-ID` 不会越过它缺失的事件。
|
||||
- `ManagedAgentStore.advanceReplayFloor` 只提升、不降低下限,并且不超过 Snapshot
|
||||
已覆盖的 sequence,保证重新读取 Snapshot 的客户端可以从
|
||||
`snapshot_through_sequence` 之后继续。测试调用它;保留策略的工作将在删除事件之前
|
||||
调用它。
|
||||
`snapshot_through_sequence` 之后继续。测试调用它;定时的 `ReplayFloorAdvancer`
|
||||
过程以该上限调用它,因此开启开关的部署会把每个 Session 的下限提升到 Snapshot
|
||||
所证明的安全位置;保留策略的工作将在删除事件之前调用它。该过程由
|
||||
`qwen.managed-agent.events.replay-floor-enabled`
|
||||
(`QWEN_MANAGED_AGENT_REPLAY_FLOOR_ENABLED`,默认 `false`)控制,每
|
||||
`qwen.managed-agent.events.replay-floor-interval`
|
||||
(`QWEN_MANAGED_AGENT_REPLAY_FLOOR_INTERVAL`,默认 60 秒)运行一次。
|
||||
- `PublicSession.replay_floor_sequence` 返回已存储的下限。
|
||||
- 事件流重整会丢弃 Snapshot,因此在 Items 重建之前,其已覆盖的 sequence 为 `0`。
|
||||
如果下限已被提升,这段时间内收到 resync 的公共客户端会发现游标再次过期(得到
|
||||
`409` 或又一帧 resync),直到重建完成。生产环境中目前没有任何路径提升下限;保留策略的工作必须在提升下限之前消除
|
||||
这段窗口。
|
||||
- 事件流重整会丢弃 Snapshot,因此在 Items 重建之前,其已覆盖的 sequence 为 `0`;
|
||||
而下限从不降低,所以重建期间下限可能超过覆盖位置。只有当 Snapshot 能支撑下限
|
||||
(`floor <= snapshot_through_sequence`)时,读取才会判定低于下限的游标过期;由于
|
||||
尚无任何路径清理事件,重建期间这样的游标仍从保留的事件中读出,而不是循环返回
|
||||
`409` 或又一帧 resync。一旦保留策略开始删除已存储下限之下的行,它必须重新评估
|
||||
这个条件:届时被放行的游标可能指向已不存在的事件。
|
||||
- WebShell transcript 不检查下限。事件被清理之后,它的更早分页必须止于下限;这同样
|
||||
属于保留策略的工作。
|
||||
|
||||
|
|
@ -200,6 +209,10 @@ resync。连续损坏超过三帧(即第四帧起,其间的心跳不打断
|
|||
非零游标续传。该测试集把轮询间隔与心跳间隔都设为一分钟,因此空闲的事件流在测试期间不会读取
|
||||
存储,实时事件只能经由 hub 到达事件流。
|
||||
|
||||
- `ReplayFloorRetractionTest` 暂停物化器,把下限提升到 Snapshot 并撤回
|
||||
continuation 输出,然后验证:Snapshot 缺失期间低于下限的游标仍可读取、已存储的
|
||||
下限从不回退、重建后的 Snapshot 重新支撑下限后该游标再次过期。
|
||||
|
||||
- `EventIdentityTest` 固定该规则。集成测试在以下情形后把每条事件的身份与物化后的
|
||||
Snapshot 对照:分两批追加、且有一个 reasoning Part 跨越两批的增量;逐条追加的
|
||||
增量;以及撤回,包括接续了被撤回增量的保留增量。
|
||||
|
|
@ -223,8 +236,8 @@ resync。连续损坏超过三帧(即第四帧起,其间的心跳不打断
|
|||
- 所有副本需要一起升级。V14 之后仍运行旧版本的副本写入的事件没有身份,新副本在
|
||||
其后追加的文本增量会开始一个 Items 中没有的 Part。
|
||||
- 忽略 resync 帧的客户端会看到事件流结束,用同一个游标重连后再次收到该帧。
|
||||
web-shell 客户端会处理它。在保留策略的工作提升下限之前,生产环境中的事件流不会
|
||||
发送它。
|
||||
web-shell 客户端会处理它。只有选择开启回放下限过程的部署,其生产环境的事件流
|
||||
才会发送它。
|
||||
- 生成的 `@qwen-code/web-shell` 类型新增 `WebShellResyncRequired`,事件流
|
||||
operation 不再列出 `409`。可选的事件字段原本就在 WebShell 事件 schema 中。
|
||||
|
||||
|
|
@ -243,8 +256,9 @@ resync。连续损坏超过三帧(即第四帧起,其间的心跳不打断
|
|||
|
||||
## 8. 后续工作
|
||||
|
||||
- 保留策略:清理事件,在清理之前提升下限,在事件流重整重建 Items 期间把下限保持
|
||||
在 Snapshot 之下或与之相等,并让 WebShell transcript 的更早分页止于下限。
|
||||
- 保留策略:清理上述过程已提升的下限之下的事件,并让 WebShell transcript 的更早
|
||||
分页止于下限。上述 Snapshot 丢弃窗口只在尚不清理事件时于读取侧关闭;清理那一半
|
||||
在删除行之前必须重新决策生效下限。
|
||||
- WebShell Session 的下限与 Snapshot 水位。
|
||||
- 带 Snapshot 版本分页的 `listItems`。
|
||||
- 生命周期工作:剩余的三行差异。
|
||||
|
|
|
|||
|
|
@ -289,6 +289,8 @@ Later H4 slices (not scheduled here): `independent_worktree` with its
|
|||
lifecycle and merge policy; `shared_serialized` with its generation/barrier;
|
||||
the `workflow` task kind; team domains and mailbox; cross-workspace children.
|
||||
|
||||
**Scope settled (2026-10-09).** The delivered slicing follows the six-slice map of [H4a](2026-10-06-managed-child-agent-runtime.md). The H4c row above was absorbed by H4b (background notification, close cascade, depth and concurrency quotas), H4f (task cancel) and the Detach follow-up. H4c itself became the `workflow` kind and the child launch budget, and the isolation policies are their own later slice; see the [H4c design](2026-10-09-managed-workflow-child-kind.md).
|
||||
|
||||
## Validation plan
|
||||
|
||||
- Fixture parity for both bodies, replayed by TypeScript and by Java, as
|
||||
|
|
|
|||
|
|
@ -80,6 +80,8 @@ Legacy 子 Agent 是进程内的:Agent 工具、默认后台的顶层子 Agent
|
|||
|
||||
后续 H4 切片(本文不排期):带生命周期与合并策略的 `independent_worktree`;带代数/屏障的 `shared_serialized`;`workflow` 任务种类;team domain 与邮箱;跨工作区 child。
|
||||
|
||||
**范围已裁定(2026-10-09)。** 实际交付的切分以 [H4a](2026-10-06-managed-child-agent-runtime.zh-CN.md) 的六片式地图为准。上表 H4c 行的内容已被 H4b(后台通知、关闭级联、深度与并发配额)、H4f(任务取消)与 Detach 后续工作吸收。H4c 本身成为 `workflow` kind 与 child launch 预算,隔离策略是其后单独的切片;见 [H4c 设计](2026-10-09-managed-workflow-child-kind.zh-CN.md)。
|
||||
|
||||
## 验证计划
|
||||
|
||||
- 两个正文的 fixture 一致性,TypeScript 与 Java 双方回放,沿用 H0b/H0c 为 `monitor_run`、MCP 与 Hooks 固定的 fixture 文件做法。
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ H4 is delivered in six slices, as H0 was delivered as H0a/H0b/H0c. This change i
|
|||
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **H4a — child record contract (this change)** | The `child_agent` body of `managed-child_run` and the `managed-child_acceptance` body, with commit-time consistency, resource closure and dual-language fixtures. No enablement, no runtime path. | In both languages, every malformed shape and every illegal transition in the fixtures is refused, every valid case is accepted, and the authority and the Java store commit and rebuild the chains ahead of enablement. |
|
||||
| H4b — child Session runtime | `startChildRun` / `queryChildRun` / `continueChildRun` / `commitChildResult` / `acceptChildResult` / `cancelChildRun` / `closeChildScope`; Java lineage storage and idempotent child creation; the first cross-Session dispatcher (the child result relay); the foreground and background completion paths; close cascade; quotas; enablement with the server-first deployment order. | The reference design §13 H4 gate for child agents and the §14.7 items, on real stacks. |
|
||||
| H4c — workflow kind | The `workflow` body kind and the Workflow tool's managed admission. | A workflow child rides the same lifecycle; its definition pin names the workflow revision. |
|
||||
| H4c — workflow kind | The `workflow` body kind (registered disabled) and the child launch budget, per the [H4c design](2026-10-09-managed-workflow-child-kind.md); the Workflow tool's managed admission moves to the workflow runtime slice. | A workflow child rides the same lifecycle; its definition pin names the workflow revision. |
|
||||
| H4d — peer messages | The `session_message` body and durable `send_message` routing without a team (background `task_id` and named peer paths of automation §5.1). | Durable delivery, per-recipient accepted and consumed, across restart and reconnect. |
|
||||
| H4e — teams | The `team_state` / `team_task` / `team_message` / `team_plan` bodies, the seven team tools, `resolveTeamPlan`, `requestMemberShutdown`, and legacy team import. | Automation §8 A06 team matrix. |
|
||||
| H4f — public task cancel (shared with H5) | The `cancelSessionTask` / `cancelWebShellTask` routes with durable command persistence, per H3's follow-up table and #12847 A6/A7 and B12. | Contract items A6/A7 settled; a cancel of an H4 child acts on its record. |
|
||||
|
|
@ -164,7 +164,7 @@ Schema version 1. The chain is keyed by `childRunId` — one acceptance chain pe
|
|||
| Slice | Scope |
|
||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| H4b | The six child operations; Java lineage storage (V48+), the internal idempotent creation route and the result relay; the completion paths; close cascade; quotas; enablement with the deployment order; `drain` phase wiring for close. Also owes the delivery-direction decision surfaced by real-stack review: today a `child_run` delivery may advance to `consumed` with no acceptance record, and may move to `unknown`/`rejected` after an acceptance exists — the two are independent facts by decision 4, so a run marked `rejected` while its acceptance says `accepted` is a contradiction. H4b either gates such back-off on the acceptance record (a reverse check) or declares the acceptance record authoritative in its design. |
|
||||
| H4c | The `workflow` body kind; Workflow-tool admission as a child run with its definition pin. |
|
||||
| H4c | Delivered by the [H4c design](2026-10-09-managed-workflow-child-kind.md): the `workflow` body kind (disabled) and the child launch budget. Workflow-tool admission moved to the workflow runtime slice. |
|
||||
| H4d | The `session_message` body and durable `send_message` routing without a team. |
|
||||
| H4e | The `team_state` / `team_task` / `team_message` / `team_plan` bodies and runtime; the seven team tools; plan resolution; member shutdown; legacy team import. |
|
||||
| H4f | Public task cancel routes with durable command persistence, settling #12847 A6/A7 and B12, shared with H5. |
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ H4 分六个切片交付,如同 H0 分为 H0a/H0b/H0c。本次变更是第一个
|
|||
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **H4a — child 记录契约(本次变更)** | `managed-child_run` 的 `child_agent` 记录体与 `managed-child_acceptance` 记录体,含提交时一致性、引用闭包与双语言 fixtures。不启用、不改运行时路径。 | 两种语言下,fixtures 中每一种畸形结构与非法迁移都被拒绝,每一个有效用例都被接受,且 authority 与 Java store 能在启用前提交并重建这些链。 |
|
||||
| H4b — child Session 运行时 | `startChildRun` / `queryChildRun` / `continueChildRun` / `commitChildResult` / `acceptChildResult` / `cancelChildRun` / `closeChildScope`;Java 血缘存储与幂等 child 创建;首个跨 Session dispatcher(child 结果 relay);前台与后台完成路径;关闭级联;配额;按 server-first 部署顺序启用。 | 参考设计 §13 的 child agent 门槛与 §14.7 各项,在真实栈上验证。 |
|
||||
| H4c — workflow kind | `workflow` 记录体 kind 与 Workflow 工具的 Managed 准入。 | workflow child 复用同一生命周期;其 definition pin 指向 workflow 修订。 |
|
||||
| H4c — workflow kind | `workflow` 记录体 kind(已登记、禁用)与 child launch 预算,见 [H4c 设计](2026-10-09-managed-workflow-child-kind.zh-CN.md);Workflow 工具的 Managed 准入移到 workflow 运行时切片。 | workflow child 复用同一生命周期;其 definition pin 指向 workflow 修订。 |
|
||||
| H4d — peer 消息 | `session_message` 记录体与无团队的持久 `send_message` 路由(自动化 §5.1 的 background `task_id` 与命名 peer 两条路径)。 | 持久送达;每个收件方各有 accepted 与 consumed;跨重启与重连可恢复。 |
|
||||
| H4e — 团队 | `team_state` / `team_task` / `team_message` / `team_plan` 记录体、七个团队工具、`resolveTeamPlan`、`requestMemberShutdown` 与旧团队导入。 | 自动化 §8 的 A06 团队矩阵。 |
|
||||
| H4f — 公开任务取消(与 H5 共享) | `cancelSessionTask` / `cancelWebShellTask` 路由,带持久命令存储,见 H3 的 follow-up 表与 #12847 A6/A7、B12。 | 契约项 A6/A7 落定;对 H4 child 的 cancel 作用于其记录。 |
|
||||
|
|
@ -164,7 +164,7 @@ Schema version 1。链以 `childRunId` 键控——每个 child run 一条 accep
|
|||
| 切片 | 范围 |
|
||||
| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| H4b | 六个 child 操作;Java 血缘存储(V48+)、内部幂等创建路由与结果 relay;完成路径;关闭级联;配额;按部署顺序启用;关闭时的 `drain` phase 接线。另欠真栈评审暴露的交付方向决策:当前 `child_run` 的 delivery 可在没有 acceptance 记录时推进到 `consumed`,也可在 acceptance 已存在后改到 `unknown`/`rejected`——按决策 4 两者是独立事实,因此 run 标 `rejected` 而 acceptance 写着 `accepted` 是矛盾态。H4b 要么把这类反向迁移门到 acceptance 记录(反向检查),要么在其设计中声明 acceptance 记录为准。 |
|
||||
| H4c | `workflow` 记录体 kind;Workflow 工具作为带 definition pin 的 child run 准入。 |
|
||||
| H4c | 由 [H4c 设计](2026-10-09-managed-workflow-child-kind.zh-CN.md)交付:`workflow` 记录体 kind(禁用)与 child launch 预算。Workflow 工具准入已移到 workflow 运行时切片。 |
|
||||
| H4d | `session_message` 记录体与无团队的持久 `send_message` 路由。 |
|
||||
| H4e | `team_state` / `team_task` / `team_message` / `team_plan` 记录体与运行时;七个团队工具;plan resolution;成员关停;旧团队导入。 |
|
||||
| H4f | 公开任务取消路由,带持久命令存储,落定 #12847 A6/A7 与 B12,与 H5 共享。 |
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ Each commit moves every state line at most one allowed step (the H0b successor r
|
|||
|
||||
| Slice | Scope |
|
||||
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| H4c | The `workflow` body kind and Workflow-tool admission as a child run (definition pin names the workflow revision). |
|
||||
| H4c | Done: `workflow` body kind (disabled), child launch budget ([design](2026-10-09-managed-workflow-child-kind.md)). Workflow-tool admission: later slice. |
|
||||
| H4d | `session_message` bodies, `continueChildRun`'s first honest producer, revive of completed children as new chains. |
|
||||
| H4e | Team domains and the seven team tools, plan resolution, member shutdown, legacy import. |
|
||||
| H4f | Public task cancel routes settling #12847 A6/A7 and B12; the `unknown`-delivery operator story (open question 1). |
|
||||
|
|
|
|||
|
|
@ -143,11 +143,11 @@ H4b 交付该流水线的 child-agent 部分:
|
|||
|
||||
## 后续工作
|
||||
|
||||
| 切片 | 范围 |
|
||||
| ------ | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| H4c | `workflow` 记录体 kind 与 Workflow 工具作为 child run 的准入(definition pin 指名 workflow revision)。 |
|
||||
| H4d | `session_message` 记录体,`continueChildRun` 的第一个诚实生产者,以新链复活已完成 child。 |
|
||||
| H4e | team 各 domain 与七个 team 工具、plan 决议、成员关停、legacy 导入。 |
|
||||
| H4f | 公开任务取消路由,结清 #12847 A6/A7 与 B12;`unknown` delivery 的运维故事(开放问题 1)。 |
|
||||
| Detach | 把显式 detach 的 child 迁往独立 durable owner,跨父方关闭。 |
|
||||
| 隔离 | `snapshot` 与 `worktree` 模式及其 Runtime 能力;depth > 1;与 D8b/D8c 协调的自定义定义应用;经 Artifact 暂存的更大结果。 |
|
||||
| 切片 | 范围 |
|
||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| H4c | 已完成:`workflow` 记录体 kind(禁用)、child launch 预算([设计](2026-10-09-managed-workflow-child-kind.zh-CN.md))。Workflow 工具准入:后续切片。 |
|
||||
| H4d | `session_message` 记录体,`continueChildRun` 的第一个诚实生产者,以新链复活已完成 child。 |
|
||||
| H4e | team 各 domain 与七个 team 工具、plan 决议、成员关停、legacy 导入。 |
|
||||
| H4f | 公开任务取消路由,结清 #12847 A6/A7 与 B12;`unknown` delivery 的运维故事(开放问题 1)。 |
|
||||
| Detach | 把显式 detach 的 child 迁往独立 durable owner,跨父方关闭。 |
|
||||
| 隔离 | `snapshot` 与 `worktree` 模式及其 Runtime 能力;depth > 1;与 D8b/D8c 协调的自定义定义应用;经 Artifact 暂存的更大结果。 |
|
||||
|
|
|
|||
124
docs/design/2026-10-09-managed-workflow-child-kind.md
Normal file
124
docs/design/2026-10-09-managed-workflow-child-kind.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# Managed workflow child kind and child launch budget (H4c)
|
||||
|
||||
[English](2026-10-09-managed-workflow-child-kind.md) | [简体中文](2026-10-09-managed-workflow-child-kind.zh-CN.md)
|
||||
|
||||
Status: implemented in this change. Landed: the `workflow` body kind of `managed-child_run`, registered and validated in both languages but not enabled for submission, and the cumulative child launch budget refused with `budget_exhausted`. Still design: the workflow runtime that would produce `workflow` records, and the Workspace isolation policies, each with its own later slice (Follow-up work). This is slice **H4c** of [#12827](https://github.com/QwenLM/qwen-code/issues/12827), stage H of the Managed Agent proposal [#12380](https://github.com/QwenLM/qwen-code/issues/12380), tracked by [#13743](https://github.com/QwenLM/qwen-code/issues/13743). It follows H4a ([record contract](2026-10-06-managed-child-agent-runtime.md), #13505) and H4b ([child Session runtime](2026-10-07-managed-child-session-runtime.md), #13550). The earlier [H4 problem framing](2026-10-04-managed-child-agents.md) remains background; where it disagrees with H4a, H4b or this document, these govern.
|
||||
|
||||
## Problem and scope
|
||||
|
||||
Issue #13743 asked for three things under the H4c name: the `workflow` child kind (C1), the Workspace isolation policies `independent_worktree` and `shared_serialized` (C2), and depth/concurrency/budget quotas on children (C3). It also recorded a documentation drift: the 2026-10-04 framing gives H4c a different scope from the six-slice map that H4a published. The maintainer settled the scope on 2026-10-09:
|
||||
|
||||
- **C1 lands here** as a record contract: the body kind, its validators and fixed-field rules, shared fixtures replayed in TypeScript and Java, and admission behind the existing kind gate, disabled.
|
||||
- **C3 lands here, narrowed to what was actually missing.** H4b already refuses depth beyond 1 and more than 4 active children per scope at launch. What no rule bounded was the cumulative number of launches: a model could launch 4 children, wait, and launch 4 more without end. The budget closes that gap.
|
||||
- **C2 moves to its own slice.** No Workspace provider in `managed-agent-server` or `runtime-broker` can create a worktree for a child or merge one back. Both policies need that capability before any record change makes sense (decision 11).
|
||||
|
||||
### Where the 2026-10-04 H4c row went
|
||||
|
||||
| Item in the 2026-10-04 H4c row | Where it landed |
|
||||
| --------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| Background child notification | H4b (`"sent"` completion: notification input plus generated wake) |
|
||||
| Close cascade: cancel by default, orphaned results | H4b (cascade in the close coordinator; relay `orphaned` ledger state) |
|
||||
| Detach with a durable owner | The "Detach" follow-up of H4a/H4b |
|
||||
| Child task cancel through the planned cancel route | H4f (public task cancel) |
|
||||
| Depth and concurrency quotas | H4b (`depth_limit`, `count_limit` at launch) |
|
||||
| Budget quota | **This slice** (`budget_exhausted` at launch) |
|
||||
| `workflow` kind (listed under "Later H4 slices") | **This slice**, as a disabled record contract |
|
||||
| `independent_worktree`, `shared_serialized` ("Later H4 slices") | The isolation slice (decision 11) |
|
||||
|
||||
## Current state
|
||||
|
||||
The facts below are from `main` at `15c11bb898`.
|
||||
|
||||
- **Body dispatch.** `parseChildRun` (`managed-child-run-record.ts`) dispatches exactly two kinds: `shell` and `child_agent`. `ManagedExtensionRecords.requireChildRun` mirrors it in Java. The shared fixtures use `kind: "workflow"` as their unknown-kind witness (`kind-unknown`, `agent-kind-workflow`).
|
||||
- **Kind gate.** `MANAGED_SESSION_ENABLED_CHILD_RUN_KINDS` is `['child_agent']`. Two production sites check it: the authority's commit path and the hosted tool turn's Agent tool admission.
|
||||
- **Isolation vocabulary.** The `child_agent` body carries `workspaceMode` with the closed values `shared`, `snapshot` and `worktree` (H4a). `MANAGED_CHILD_ADMITTED_WORKSPACE_MODES` admits `shared` only, and the launch admission refuses the others with `workspace_mode`. The framing's names `read_only_snapshot`, `independent_worktree` and `shared_serialized` appear nowhere in code.
|
||||
- **Quotas.** `admitChildLaunch` refuses at launch time, before anything commits: `closing`, `workspace_mode`, `definition_scope`, `depth_limit` (depth above 1), `count_limit` (4 active children per owner scope) and `byte_limit` (32 KiB launch envelope). Depth is bounded in practice as well: a child Session's own turn never advertises the Agent tool (`childDepth === 0` in the hosted tool turn). No rule bounds the total number of launches.
|
||||
- **Java runtime paths.** The relay's discovery page (`ChildResultRelayStore.PENDING_SQL`) and the close cascade (`findLiveScopes`) select every `child_run` row with a delivery line. Today only `child_agent` rows have one, and the relay's comment says so.
|
||||
- **Readers.** The hosted workspace restore enumeration skips `child_agent` records explicitly. The child agent funnel (`HostedChildAgentSession`) reads `child_agent` records only.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **`workflow` is a third body kind of `managed-child_run` schema version 1.** The body's own `kind` field dispatches it; the envelope and `recordRef.schemaVersion` do not change (H4a decision 1). An older reader refuses a `workflow` body, which is the fail-stop stance H3 and H4a already took. No Session can hold one yet, because the kind gate refuses every commit, so there is no mixed-version window.
|
||||
2. **One shape for both child Session kinds.** `workflow` and `child_agent` share the closed key set, the fixed keys, the stop reasons and every run, delivery and acceptance rule. A workflow child is a child Session whose launch input runs a workflow instead of a prompt. Every fact the pipeline commits is the same for both: launch identities, dispatch, attach, result copies, acceptance, consumption and cascade cancellation. What a workflow launch needs beyond a prompt (the workflow to run and its arguments) travels in the launch input that `inputRef` names. The workflow runtime slice defines that envelope, exactly as H4b defined the child agent's.
|
||||
3. **The kinds differ in three places.**
|
||||
- Task kind: a `workflow` run projects task kind `workflow`. The public `TaskKind` enum already lists it (`partial`), so the OpenAPI contract does not change.
|
||||
- Enablement: the kind gate admits `child_agent` and not `workflow`.
|
||||
- The definition pin: a `workflow` run must carry `run.definition` on every revision, the opening one included. The pin names the workflow it runs: `definitionId` is the workflow's stable identity, `definitionRevision` its revision, and `definitionDigest` the digest of the definition that runs. The workflow is the subject of the launch, so a launch without it is refused with "Workflow run must pin its workflow definition from launch." A child agent may still open without its pin and must carry it by its dispatch (H4a).
|
||||
4. **Cross-record rules apply per child Session, not per kind.** The TypeScript authority and the Java store apply the same three rules to both kinds:
|
||||
- A first-level run's `rootSessionId` must be the Session that owns the journal.
|
||||
- A `child_acceptance` may name a run of either kind. The refusal text becomes "Child acceptance must name a child Session run of this Session."
|
||||
- The reverse acceptance check of H4b decision 7.
|
||||
5. **`workflow` stays disabled.** `MANAGED_SESSION_ENABLED_CHILD_RUN_KINDS` stays `['child_agent']`. The authority refuses a `workflow` commit with "domain child_run kind workflow is registered but not enabled for submission." before it publishes anything. The Java store validates and materializes `workflow` bodies ahead of any writer (the server-first order H1–H4b kept) and projects their task kind.
|
||||
6. **The Java runtime paths stay with `child_agent`.** The relay's discovery page and the close cascade's live-scope query now also require `task_kind = 'child_agent'`. The relay creates a child Session that runs a prompt, and the cascade cancels through the child agent funnel. If either acted on a `workflow` row, the relay would create a prompt-driven child from a workflow launch, and the cascade would wedge the close on a cancel that the funnel refuses. Widening both belongs to the workflow runtime slice. Enabling `workflow` is therefore more than adding one gate entry: it needs the Workflow tool's managed admission, the launch envelope and these two widenings, all in that slice.
|
||||
7. **Readers tolerate the new kind.** The workspace restore enumeration skips every child Session kind, because neither owns an output manifest. The child agent funnel keeps reading `child_agent` records only, so its record lookups never return a `workflow` run as a child agent.
|
||||
8. **Depth and concurrency stay as H4b shipped them.** Depth 1, at most 4 active children per owner scope, each refused at launch with `depth_limit` or `count_limit`, and nothing commits.
|
||||
9. **The launch budget.** An owner scope may launch at most `MANAGED_CHILD_LIMITS.maxLaunchesPerScope` = 64 child Session runs over the Session's lifetime.
|
||||
- What counts: every committed child Session run of the scope, of either kind and in any state. Failed and cancelled launches count, because each one cost a creation attempt and a chain the rebuild replays (H0c open question 1).
|
||||
- The refusal: `admitChildLaunch` answers `budget_exhausted`, an H0b quota reason, and the Agent tool answers the call with the tool error "Hosted child agent refused this launch (budget_exhausted)." Nothing commits but the call's tool result.
|
||||
- The order: the budget is checked after depth and before `count_limit`. A spent budget never recovers, while the concurrency cap may clear for a later launch, so the permanent reason is reported first.
|
||||
- Replay safety: both counts read committed records only, so a replay re-derives the same answer. A re-driven launch whose record already exists skips the admission (H4b's rule), so a child never counts against its own replay.
|
||||
- The value: 64 is 16 full rounds of the concurrency cap. At the 64 KiB result-copy bound it also limits result copies to about 4 MiB per Session.
|
||||
10. **The quotas count child Sessions, not kinds.** Concurrency and the budget both count `child_agent` and `workflow` runs together, so enabling workflows later cannot double a scope's allowance.
|
||||
11. **Isolation is its own slice, and its vocabulary is settled now.** The vocabulary H4a shipped governs: `shared`, `snapshot`, `worktree`. The framing's `read_only_snapshot` and `independent_worktree` are `snapshot` and `worktree`. `shared_serialized` gets no name of its own here: `shared` children already serialize through the Workspace lease discipline (H4b decision 3). Whether a per-child generation and barrier needs its own value is for the isolation slice to decide. That slice starts with a Workspace capability, because no provider can create a worktree for a child or merge one back. Record changes come only after that capability exists. The slice is tracked in [#13753](https://github.com/QwenLM/qwen-code/issues/13753).
|
||||
|
||||
## Records
|
||||
|
||||
### `managed-child_run`, kind `workflow`
|
||||
|
||||
Schema version 1. The chain is keyed by `childRunId`, as for `child_agent`. Every key and rule of the [`child_agent` table](2026-10-06-managed-child-agent-runtime.md#managed-child_run-kind-child_agent) applies unchanged, except:
|
||||
|
||||
| Key | Rule |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `kind` | `"workflow"`. A chain never changes kind: `kind` is a fixed key. |
|
||||
| `run.definition` | Required on every revision, from the opening one: the workflow's identity, revision and digest. The shared run rules then make it set-once, so it never changes. |
|
||||
|
||||
The task projection maps a `workflow` run to task kind `workflow`. `child_acceptance` is unchanged: it now may name a `workflow` run as well as a `child_agent` one.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **The workflow runtime**: the Workflow tool's managed admission, the launch envelope, a child Session that executes a workflow, widening the relay and the cascade, and enabling the kind.
|
||||
- **Workspace isolation policies** (decision 11).
|
||||
- **Depth beyond 1**, and a per-root budget for nested trees.
|
||||
- **Model, token and duration budgets.** `budget_exhausted` here bounds launches, not tokens; `duration_limit` stays unused.
|
||||
- **Any public contract change.** The OpenAPI contract, routes and Flyway migrations stay as they are.
|
||||
|
||||
## Files affected
|
||||
|
||||
- `packages/core/src/managed-runtime/managed-child-run-record.ts`: the `workflow` kind (`WorkflowRun`, `ChildSessionRun`), the shared child Session parser and the launch-time pin rule.
|
||||
- `packages/core/src/managed-runtime/managed-extension-projection.ts`: `workflow` task kind.
|
||||
- `packages/core/src/managed-runtime/managed-session-authority.ts`: the root, acceptance and reverse checks apply to every child Session kind.
|
||||
- `packages/core/src/managed-runtime/managed-child-operations.ts`: `maxLaunchesPerScope`, `launchedInScope` and the `budget_exhausted` refusal.
|
||||
- `packages/core/src/managed-runtime/contracts/managed-child-run-record-v1.fixtures.json`: the `workflow` template, 15 cases and 9 successor pairs, the agent counterpart `agent-start-without-definition`, and the unknown-kind witnesses repointed to `kind: "unregistered"`. `managed-extension-projection-v1.fixtures.json`: `child_run.workflow → workflow`.
|
||||
- `packages/cli/src/serve/hosted-child-agent-session.ts` (`launchedChildRunsOf`; `activeChildRunsOf` counts both kinds), `hosted-workspace-tool-turn.ts` (passes the launch count) and `hosted-harness-session.ts` (the restore skips every child Session kind).
|
||||
- `packages/sdk-java/managed-agent-server`: `ManagedExtensionRecords` (dispatch, `requireChildSession`, the pin rule, the task kind), `ManagedExtensionRecordStore` (the three cross-record rules per child Session), `ChildResultRelayStore` (the relay page and the cascade scoped to `child_agent`).
|
||||
- Tests beside each file in both languages; this design in both languages, plus a scope note in the H4 framing, H4a and H4b designs.
|
||||
|
||||
## Validation
|
||||
|
||||
- **Fixture parity.** The `workflow` cases and successors replay in both languages through `managed-child-run-record.test.ts` and `ManagedChildRunRecordContractTest`. Both validators refuse every invalid case on the clause that the fixture names, and Java also pins each valid case's task kind.
|
||||
- **Authority.** The real kind gate refuses a `workflow` launch and nothing publishes. With the gate lifted for planting only, a `workflow` chain commits through acceptance and consumption, rebuilds on reopen with no gate (the gate refuses submission, never a reader), projects task kind `workflow` with the pinned `definitionRevision`, and obeys the root, acceptance and reverse rules.
|
||||
- **Java store and relay.** A `workflow` chain commits under the same rules and projects `workflow`. A `workflow` row with a pending delivery appears neither on the relay's page nor among the cascade's live scopes.
|
||||
- **Budget.** The admission matrix covers the boundary and the order. The tool-turn suite launches the 64th child, then refuses the 65th twice with `budget_exhausted` while no child is active. No record commits for either refusal, and the second refusal shows the replay re-deriving the first.
|
||||
- **Readers.** The workspace restore succeeds with a `child_agent` or a `workflow` record beside a detached Shell lineage. The quota counts include a planted `workflow` run, and the funnel's own record lookup does not return it.
|
||||
- **Mutation checks.** Each new guard was disabled in turn and its witness went red: 11 in TypeScript and 8 in Java. One more mutant, the restore skip narrowed back to `child_agent`, behaves identically to the change, because the enumeration's fall-through stores `undefined`, which the lineage check already treats as absent. `tsc` refuses that mutant instead, because a `workflow` run has no `outputRef`.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- TypeScript and Java produce and refuse identical `workflow` chains from the shared fixtures, and every H3, H4a and H4b case replays unchanged except the two repointed unknown-kind witnesses.
|
||||
- `MANAGED_SESSION_ENABLED_CHILD_RUN_KINDS` is `['child_agent']`, and a `workflow` commit is refused with "registered but not enabled".
|
||||
- A launch beyond the budget is refused with `budget_exhausted` and commits no child, and a replay answers the same.
|
||||
- No public API or migration changes, and every existing child agent suite stays green.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **The workflow launch envelope**: whether it names a saved workflow, an extension workflow or inline source, and how arguments are bounded. This is for the workflow runtime slice.
|
||||
2. **Where a workflow's own agents run**: as nested child Sessions, which needs depth 2 and a real tree depth (`childLaunchAdmission` passes depth 1 today), or inside the workflow child itself. This is for the workflow runtime slice.
|
||||
3. **The budget value**: whether 64 needs tuning, and a per-root budget once nesting lands.
|
||||
|
||||
## Follow-up work
|
||||
|
||||
| Slice | Scope |
|
||||
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Workflow runtime | The Workflow tool's managed admission, the workflow launch envelope, child Session execution of a workflow, the relay and cascade widened to `workflow`, then the kind gate entry. |
|
||||
| Isolation | [#13753](https://github.com/QwenLM/qwen-code/issues/13753): a Workspace capability for child worktrees and their merge back; then `worktree` (lifecycle, merge policy) and `snapshot`, and a decision on whether serialized sharing needs its own value. |
|
||||
| H4d–H4f, Detach | Unchanged from the H4a/H4b delivery map. |
|
||||
124
docs/design/2026-10-09-managed-workflow-child-kind.zh-CN.md
Normal file
124
docs/design/2026-10-09-managed-workflow-child-kind.zh-CN.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# Managed workflow child kind 与 child launch 预算(H4c)
|
||||
|
||||
[English](2026-10-09-managed-workflow-child-kind.md) | [简体中文](2026-10-09-managed-workflow-child-kind.zh-CN.md)
|
||||
|
||||
状态:已在本变更中实现。已落地:`managed-child_run` 的 `workflow` 记录体 kind,在两种语言中均已登记并校验,但不开放提交;以及累计 child launch 预算,超出时以 `budget_exhausted` 拒绝。仍为设计:产出 `workflow` 记录的 workflow 运行时,以及 Workspace 隔离策略,二者各有后续切片(见"后续工作")。这是 [#12827](https://github.com/QwenLM/qwen-code/issues/12827) 的 **H4c** 切片,即 Managed Agent 提案 [#12380](https://github.com/QwenLM/qwen-code/issues/12380) 的 H 阶段,由 [#13743](https://github.com/QwenLM/qwen-code/issues/13743) 跟踪。它承接 H4a([记录契约](2026-10-06-managed-child-agent-runtime.md),#13505)与 H4b([child Session 运行时](2026-10-07-managed-child-session-runtime.md),#13550)。更早的 [H4 问题框架](2026-10-04-managed-child-agents.md) 仍是背景;凡它与 H4a、H4b 或本文档冲突之处,以后者为准。
|
||||
|
||||
## 问题与范围
|
||||
|
||||
Issue #13743 在 H4c 名下提出三件事:`workflow` child kind(C1)、Workspace 隔离策略 `independent_worktree` 与 `shared_serialized`(C2),以及 child 的深度/并发/预算配额(C3)。它还记录了一处文档漂移:2026-10-04 的问题框架给 H4c 的范围,与 H4a 发布的六片式交付地图不同。维护者于 2026-10-09 裁定了范围:
|
||||
|
||||
- **C1 在这里落地**,形式是记录契约:记录体 kind、其校验器与固定字段规则、由 TypeScript 与 Java 共同重放的共享 fixture,以及挂在既有 kind 门禁之后、默认禁用的准入。
|
||||
- **C3 在这里落地,收窄到真正缺失的部分。** H4b 已在 launch 时拒绝深度超过 1 和每个 scope 超过 4 个活跃 child。没有任何规则约束的是 launch 的累计次数:模型可以 launch 4 个 child、等待,再 launch 4 个,无休无止。预算补上了这个缺口。
|
||||
- **C2 移到它自己的切片。** `managed-agent-server` 与 `runtime-broker` 中没有任何 Workspace provider 能为 child 创建 worktree 或把它合并回去。两种策略都要先有这项能力,任何记录层的改动才有意义(决策 11)。
|
||||
|
||||
### 2026-10-04 H4c 行各项的去向
|
||||
|
||||
| 2026-10-04 H4c 行中的项 | 落地位置 |
|
||||
| -------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| 后台 child 通知 | H4b(`"sent"` 完成方式:notification input 加生成的 wake) |
|
||||
| 关闭级联:默认取消、orphaned 结果 | H4b(关闭协调器中的级联;relay 账本的 `orphaned` 状态) |
|
||||
| 带持久 owner 的 detach | H4a/H4b 的"Detach"后续工作 |
|
||||
| 经 planned 取消路由的 child 任务取消 | H4f(公开任务取消) |
|
||||
| 深度与并发配额 | H4b(launch 时的 `depth_limit`、`count_limit`) |
|
||||
| 预算配额 | **本切片**(launch 时的 `budget_exhausted`) |
|
||||
| `workflow` kind(列在"Later H4 slices"下) | **本切片**,作为禁用的记录契约 |
|
||||
| `independent_worktree`、`shared_serialized`("Later H4 slices") | 隔离切片(决策 11) |
|
||||
|
||||
## 现状
|
||||
|
||||
以下事实取自 `main` 的 `15c11bb898`。
|
||||
|
||||
- **记录体分发。** `parseChildRun`(`managed-child-run-record.ts`)恰好分发两个 kind:`shell` 与 `child_agent`。Java 中由 `ManagedExtensionRecords.requireChildRun` 镜像。共享 fixture 用 `kind: "workflow"` 作为未知 kind 的见证(`kind-unknown`、`agent-kind-workflow`)。
|
||||
- **Kind 门禁。** `MANAGED_SESSION_ENABLED_CHILD_RUN_KINDS` 为 `['child_agent']`。两个生产位置会检查它:authority 的提交路径,以及 hosted tool turn 中 Agent 工具的准入。
|
||||
- **隔离词汇。** `child_agent` 记录体携带 `workspaceMode`,封闭取值为 `shared`、`snapshot`、`worktree`(H4a)。`MANAGED_CHILD_ADMITTED_WORKSPACE_MODES` 只准入 `shared`,launch 准入以 `workspace_mode` 拒绝其余取值。问题框架中的 `read_only_snapshot`、`independent_worktree`、`shared_serialized` 在代码里一次都没有出现。
|
||||
- **配额。** `admitChildLaunch` 在 launch 时、任何东西提交之前拒绝:`closing`、`workspace_mode`、`definition_scope`、`depth_limit`(深度超过 1)、`count_limit`(每个 owner scope 4 个活跃 child)与 `byte_limit`(32 KiB launch envelope)。实际运行中深度同样有界:child Session 自己的 turn 从不声明 Agent 工具(hosted tool turn 中的 `childDepth === 0`)。没有任何规则约束 launch 的总次数。
|
||||
- **Java 运行时路径。** relay 的发现页(`ChildResultRelayStore.PENDING_SQL`)与关闭级联(`findLiveScopes`)选中所有带交付线的 `child_run` 行。目前只有 `child_agent` 行带交付线,relay 的注释也这么写着。
|
||||
- **读取方。** hosted workspace restore 枚举显式跳过 `child_agent` 记录。child agent 漏斗(`HostedChildAgentSession`)只读取 `child_agent` 记录。
|
||||
|
||||
## 决策
|
||||
|
||||
1. **`workflow` 是 `managed-child_run` schema version 1 的第三个记录体 kind。** 由记录体自身的 `kind` 字段分发;envelope 与 `recordRef.schemaVersion` 不变(H4a 决策 1)。旧读取方会拒绝 `workflow` 记录体,这正是 H3 与 H4a 已采取的 fail-stop 立场。目前还没有 Session 能持有这种记录,因为 kind 门禁拒绝每一次提交,所以不存在混合版本窗口。
|
||||
2. **两种 child Session kind 共用同一形状。** `workflow` 与 `child_agent` 共享封闭键集、固定键、停止原因,以及每一条 run、交付与 acceptance 规则。workflow child 是一个 child Session,它的 launch 输入运行一个 workflow,而不是一段提示词。流水线提交的每个事实对二者都相同:launch 身份、dispatch、attach、结果副本、acceptance、consumption 与级联取消。workflow launch 在提示词之外需要的东西(要运行的 workflow 及其参数)放在 `inputRef` 指向的 launch 输入里。该 envelope 由 workflow 运行时切片定义,正如 H4b 定义了 child agent 的 envelope。
|
||||
3. **两种 kind 有三处不同。**
|
||||
- 任务 kind:`workflow` run 投影为任务 kind `workflow`。公开的 `TaskKind` 枚举已列出它(`partial`),因此 OpenAPI 契约不变。
|
||||
- 启用:kind 门禁准入 `child_agent`,不准入 `workflow`。
|
||||
- definition pin:`workflow` run 必须在每个修订上都携带 `run.definition`,包括第一个修订。pin 指名它运行的 workflow:`definitionId` 是 workflow 的稳定身份,`definitionRevision` 是其修订,`definitionDigest` 是所运行定义的摘要。workflow 是这次 launch 的主体,所以缺少它的 launch 会以 "Workflow run must pin its workflow definition from launch." 被拒绝。child agent 仍可在没有 pin 的情况下开启,但最迟在 dispatch 时必须带上(H4a)。
|
||||
4. **跨记录规则按 child Session 适用,而不是按 kind。** TypeScript authority 与 Java store 对两种 kind 适用同样三条规则:
|
||||
- 第一层 run 的 `rootSessionId` 必须是持有该日志的 Session。
|
||||
- `child_acceptance` 可以指名任一 kind 的 run。拒绝文本改为 "Child acceptance must name a child Session run of this Session."
|
||||
- H4b 决策 7 的反向 acceptance 检查。
|
||||
5. **`workflow` 保持禁用。** `MANAGED_SESSION_ENABLED_CHILD_RUN_KINDS` 仍为 `['child_agent']`。authority 在发布任何内容之前,以 "domain child_run kind workflow is registered but not enabled for submission." 拒绝 `workflow` 提交。Java store 先于任何写入方校验并物化 `workflow` 记录体(H1–H4b 一直保持的 server-first 顺序),并投影其任务 kind。
|
||||
6. **Java 运行时路径仍只处理 `child_agent`。** relay 的发现页与关闭级联的 live-scope 查询现在都额外要求 `task_kind = 'child_agent'`。relay 创建的是运行提示词的 child Session,级联则经由 child agent 漏斗取消。如果二者处理 `workflow` 行,relay 会从一次 workflow launch 创建出由提示词驱动的 child,级联会卡在一个漏斗拒绝的取消上,让关闭无法完成。放宽二者属于 workflow 运行时切片。因此启用 `workflow` 不只是加一条门禁条目:它需要 Workflow 工具的 Managed 准入、launch envelope 以及这两处放宽,全部在那个切片中完成。
|
||||
7. **读取方容忍新 kind。** workspace restore 枚举跳过所有 child Session kind,因为二者都不拥有输出 manifest。child agent 漏斗仍只读取 `child_agent` 记录,所以它的记录查找永远不会把 `workflow` run 当作 child agent 返回。
|
||||
8. **深度与并发保持 H4b 交付时的样子。** 深度 1,每个 owner scope 最多 4 个活跃 child,超出时分别在 launch 时以 `depth_limit` 或 `count_limit` 拒绝,且不提交任何东西。
|
||||
9. **launch 预算。** 一个 owner scope 在 Session 生命周期内最多 launch `MANAGED_CHILD_LIMITS.maxLaunchesPerScope` = 64 个 child Session run。
|
||||
- 计入什么:该 scope 已提交的每个 child Session run,不论 kind、不论状态。失败与取消的 launch 也计入,因为每一次都消耗了一次创建尝试,并留下一条重建时要回放的链(H0c 未决问题 1)。
|
||||
- 拒绝方式:`admitChildLaunch` 回答 `budget_exhausted`(H0b 的配额原因之一),Agent 工具以工具错误 "Hosted child agent refused this launch (budget_exhausted)." 回答该调用。除该调用的工具结果外不提交任何东西。
|
||||
- 顺序:预算在深度之后、`count_limit` 之前检查。用尽的预算永远不会恢复,而并发上限在之后的 launch 时可能已经腾出,所以先报告永久性的原因。
|
||||
- 重放安全:两个计数都只读已提交的记录,所以重放会推导出同样的回答。记录已存在的重驱 launch 会跳过准入(H4b 的规则),所以 child 永远不会被计入它自己的重放。
|
||||
- 取值:64 相当于并发上限的 16 个整轮。在 64 KiB 的结果副本上限下,它还把每个 Session 的结果副本限制在约 4 MiB。
|
||||
10. **配额按 child Session 计数,而不是按 kind。** 并发与预算都把 `child_agent` 与 `workflow` run 合在一起计数,所以以后启用 workflow 不会让一个 scope 的额度翻倍。
|
||||
11. **隔离是它自己的切片,其词汇现在即确定。** 以 H4a 交付的词汇为准:`shared`、`snapshot`、`worktree`。问题框架中的 `read_only_snapshot` 与 `independent_worktree` 即 `snapshot` 与 `worktree`。`shared_serialized` 在这里不单独命名:`shared` child 已经经由 Workspace lease 纪律串行化(H4b 决策 3)。按 child 的 generation 与 barrier 是否需要独立取值,由隔离切片决定。那个切片从 Workspace 能力开始,因为没有 provider 能为 child 创建 worktree 或把它合并回去。记录层的改动要等这项能力存在之后。该切片由 [#13753](https://github.com/QwenLM/qwen-code/issues/13753) 跟踪。
|
||||
|
||||
## 记录
|
||||
|
||||
### `managed-child_run`,kind `workflow`
|
||||
|
||||
Schema version 1。链以 `childRunId` 为键,与 `child_agent` 相同。[`child_agent` 表](2026-10-06-managed-child-agent-runtime.zh-CN.md)中的每个键与规则原样适用,以下除外:
|
||||
|
||||
| 键 | 规则 |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `kind` | `"workflow"`。链永远不改变 kind:`kind` 是固定键。 |
|
||||
| `run.definition` | 从第一个修订起,每个修订都必须携带:workflow 的身份、修订与摘要。共享 run 规则随后使它只设一次,永不改变。 |
|
||||
|
||||
任务投影把 `workflow` run 映射为任务 kind `workflow`。`child_acceptance` 不变:它现在除了 `child_agent` run,也可以指名 `workflow` run。
|
||||
|
||||
## 非目标
|
||||
|
||||
- **workflow 运行时**:Workflow 工具的 Managed 准入、launch envelope、执行 workflow 的 child Session、放宽 relay 与级联,以及启用该 kind。
|
||||
- **Workspace 隔离策略**(决策 11)。
|
||||
- **深度超过 1**,以及嵌套树的按根预算。
|
||||
- **模型、token 与时长预算。** 这里的 `budget_exhausted` 约束的是 launch 次数,不是 token;`duration_limit` 仍未使用。
|
||||
- **任何公开契约变更。** OpenAPI 契约、路由与 Flyway 迁移保持不变。
|
||||
|
||||
## 受影响文件
|
||||
|
||||
- `packages/core/src/managed-runtime/managed-child-run-record.ts`:`workflow` kind(`WorkflowRun`、`ChildSessionRun`)、共享的 child Session 解析器,以及 launch 时的 pin 规则。
|
||||
- `packages/core/src/managed-runtime/managed-extension-projection.ts`:`workflow` 任务 kind。
|
||||
- `packages/core/src/managed-runtime/managed-session-authority.ts`:root、acceptance 与反向检查适用于所有 child Session kind。
|
||||
- `packages/core/src/managed-runtime/managed-child-operations.ts`:`maxLaunchesPerScope`、`launchedInScope` 与 `budget_exhausted` 拒绝。
|
||||
- `packages/core/src/managed-runtime/contracts/managed-child-run-record-v1.fixtures.json`:`workflow` 模板、15 个用例与 9 个后继对,child agent 的对照用例 `agent-start-without-definition`,以及改指向 `kind: "unregistered"` 的未知 kind 见证。`managed-extension-projection-v1.fixtures.json`:`child_run.workflow → workflow`。
|
||||
- `packages/cli/src/serve/hosted-child-agent-session.ts`(`launchedChildRunsOf`;`activeChildRunsOf` 计入两种 kind)、`hosted-workspace-tool-turn.ts`(传入 launch 计数)与 `hosted-harness-session.ts`(restore 跳过所有 child Session kind)。
|
||||
- `packages/sdk-java/managed-agent-server`:`ManagedExtensionRecords`(分发、`requireChildSession`、pin 规则、任务 kind)、`ManagedExtensionRecordStore`(三条跨记录规则按 child Session 适用)、`ChildResultRelayStore`(relay 发现页与级联限定到 `child_agent`)。
|
||||
- 两种语言中各文件旁的测试;本设计的两种语言版本,以及 H4 问题框架、H4a 与 H4b 设计中的范围说明。
|
||||
|
||||
## 验证
|
||||
|
||||
- **Fixture 一致性。** `workflow` 的用例与后继对在两种语言中经 `managed-child-run-record.test.ts` 与 `ManagedChildRunRecordContractTest` 重放。两个校验器都以 fixture 指名的子句拒绝每个非法用例,Java 还钉住了每个合法用例的任务 kind。
|
||||
- **Authority。** 真实的 kind 门禁拒绝 `workflow` launch,且没有任何内容被发布。仅在植入时放开门禁的情况下,`workflow` 链经过 acceptance 与 consumption 提交,在没有门禁的情况下重新打开时重建(门禁拒绝的是提交,从不拒绝读取方),投影任务 kind `workflow` 并带上 pin 的 `definitionRevision`,并遵守 root、acceptance 与反向规则。
|
||||
- **Java store 与 relay。** `workflow` 链在同样规则下提交并投影为 `workflow`。交付待处理的 `workflow` 行既不出现在 relay 的发现页上,也不出现在级联的 live scope 中。
|
||||
- **预算。** 准入矩阵覆盖了边界与顺序。tool turn 测试 launch 了第 64 个 child,随后在没有活跃 child 的情况下两次以 `budget_exhausted` 拒绝第 65 个。两次拒绝都没有提交任何记录,第二次拒绝表明重放推导出了与第一次相同的回答。
|
||||
- **读取方。** 在分离的 Shell lineage 旁边放一条 `child_agent` 或 `workflow` 记录时,workspace restore 都能成功。配额计数包含一条植入的 `workflow` run,而漏斗自己的记录查找不会返回它。
|
||||
- **变异检查。** 每条新守卫都逐一禁用过,其见证均变红:TypeScript 11 个,Java 8 个。另有一个变异(把 restore 跳过收窄回 `child_agent`)与本变更行为相同,因为枚举的落空分支写入 `undefined`,而 lineage 检查本就把它视为不存在。这个变异改由 `tsc` 拒绝,因为 `workflow` run 没有 `outputRef`。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- TypeScript 与 Java 从共享 fixture 产出并拒绝完全相同的 `workflow` 链,除两个改指向的未知 kind 见证外,所有 H3、H4a 与 H4b 用例原样重放。
|
||||
- `MANAGED_SESSION_ENABLED_CHILD_RUN_KINDS` 为 `['child_agent']`,`workflow` 提交以 "registered but not enabled" 被拒绝。
|
||||
- 超出预算的 launch 以 `budget_exhausted` 被拒绝且不提交任何 child,重放回答相同。
|
||||
- 没有公开 API 或迁移变更,所有既有 child agent 测试保持通过。
|
||||
|
||||
## 未决问题
|
||||
|
||||
1. **workflow launch envelope**:它指名的是已保存 workflow、扩展 workflow 还是内联源码,以及参数如何约束。由 workflow 运行时切片决定。
|
||||
2. **workflow 自己的 agent 在哪里运行**:作为嵌套的 child Session(需要深度 2 与真实的树深度;`childLaunchAdmission` 目前传入的是深度 1),还是在 workflow child 内部运行。由 workflow 运行时切片决定。
|
||||
3. **预算取值**:64 是否需要调整,以及嵌套落地后的按根预算。
|
||||
|
||||
## 后续工作
|
||||
|
||||
| 切片 | 范围 |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Workflow 运行时 | Workflow 工具的 Managed 准入、workflow launch envelope、child Session 执行 workflow、relay 与级联放宽到 `workflow`,最后是 kind 门禁条目。 |
|
||||
| 隔离 | [#13753](https://github.com/QwenLM/qwen-code/issues/13753):为 child worktree 及其合并回去提供 Workspace 能力;然后是 `worktree`(生命周期、合并策略)与 `snapshot`,并决定串行共享是否需要独立取值。 |
|
||||
| H4d–H4f、Detach | 与 H4a/H4b 交付地图相同。 |
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SessionWriterLease } from '@qwen-code/qwen-code-core/services/session-writer-lease.js';
|
||||
import { LocalManagedSessionAuthority } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-authority.js';
|
||||
import { LocalManagedSessionResourceStore } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-resources.js';
|
||||
|
|
@ -21,7 +21,29 @@ import {
|
|||
|
||||
// The H4b gates are real: the kind gate admits `child_agent` and
|
||||
// `child_acceptance` sits in the plain enabled list, so this suite drives
|
||||
// the hosted orchestrator with no enablement mock.
|
||||
// the hosted orchestrator with no enablement mock. One H4c case plants a
|
||||
// `workflow` record ahead of that kind's enablement; the flag lifts the
|
||||
// kind gate for that planting only.
|
||||
const enablement = vi.hoisted(() => ({ workflowKind: false }));
|
||||
|
||||
vi.mock(
|
||||
'@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js',
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import('@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js')
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
assertManagedSessionChildRunKindEnabled: (kind: string) => {
|
||||
if (kind !== 'workflow' || !enablement.workflowKind) {
|
||||
actual.assertManagedSessionChildRunKindEnabled(kind);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const sessionKey = {
|
||||
tenantId: 'tenant-1',
|
||||
|
|
@ -42,6 +64,7 @@ const BINDING = { runtimeBindingId: 'binding-1', generation: '1' };
|
|||
const temporaryDirectories = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
enablement.workflowKind = false;
|
||||
for (const directory of temporaryDirectories) {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -512,6 +535,49 @@ describe('hosted child agent session (H4b)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// H4c: the quotas count child Sessions, so a workflow child spends the
|
||||
// same concurrency and launch budget a child agent does, while the
|
||||
// child agent funnel itself never reads it as one of its own.
|
||||
it('counts a workflow child against the scope quotas', async () => {
|
||||
enablement.workflowKind = true;
|
||||
const harness = await createHarness();
|
||||
await withAuthority(harness, async (authority) => {
|
||||
const children = new HostedChildAgentSession(
|
||||
{ authority, resources: harness.store },
|
||||
sessionKey,
|
||||
);
|
||||
const { inputRef } = await children.admit(launchParams());
|
||||
await authority.commitExtensionRecord(
|
||||
{
|
||||
operation: 'commitChildRunRecord',
|
||||
commandId: 'workflow-1:1',
|
||||
sessionKey,
|
||||
contentDigest: 'd'.repeat(64),
|
||||
},
|
||||
{
|
||||
domain: 'child_run',
|
||||
record: {
|
||||
...children.record('run-1')!,
|
||||
kind: 'workflow',
|
||||
childRunId: 'workflow-1',
|
||||
inputRef,
|
||||
},
|
||||
},
|
||||
{ class: 'trusted_entry' },
|
||||
);
|
||||
expect(
|
||||
children
|
||||
.launchedChildRunsOf('scope-main')
|
||||
.map((run) => [run.kind, run.childRunId]),
|
||||
).toEqual([
|
||||
['child_agent', 'run-1'],
|
||||
['workflow', 'workflow-1'],
|
||||
]);
|
||||
expect(children.activeChildRunsOf('scope-main')).toHaveLength(2);
|
||||
expect(children.record('workflow-1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses consumption steps the acceptance never reached', async () => {
|
||||
const harness = await createHarness();
|
||||
await withAuthority(harness, async (authority) => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
ChildAgentRun,
|
||||
ChildAgentStopReason,
|
||||
ChildCompletion,
|
||||
ChildSessionRun,
|
||||
} from '@qwen-code/qwen-code-core/managed-runtime/managed-child-run-record.js';
|
||||
import { parseChildRun } from '@qwen-code/qwen-code-core/managed-runtime/managed-child-run-record.js';
|
||||
import type { ChildAcceptance } from '@qwen-code/qwen-code-core/managed-runtime/managed-child-acceptance-record.js';
|
||||
|
|
@ -120,12 +121,14 @@ export function childLaunchAdmission(params: {
|
|||
readonly sameDefinition: boolean;
|
||||
readonly closing: boolean;
|
||||
readonly activeInScope: number;
|
||||
readonly launchedInScope: number;
|
||||
readonly envelopeBytes: number;
|
||||
}): ChildAdmission {
|
||||
return admitChildLaunch({
|
||||
closing: params.closing,
|
||||
depth: 1,
|
||||
activeInScope: params.activeInScope,
|
||||
launchedInScope: params.launchedInScope,
|
||||
envelopeBytes: params.envelopeBytes,
|
||||
workspaceMode: params.workspaceMode,
|
||||
sameDefinition: params.sameDefinition,
|
||||
|
|
@ -227,19 +230,27 @@ export class HostedChildAgentSession {
|
|||
return existing ? parseChildAcceptance(existing.record) : undefined;
|
||||
}
|
||||
|
||||
/** The non-terminal child agent runs of one owner scope. */
|
||||
activeChildRunsOf(ownerScopeId: string): readonly ChildAgentRun[] {
|
||||
/**
|
||||
* Every child Session run one owner scope launched, ended or not. The
|
||||
* quotas count child Sessions, so both child Session kinds count.
|
||||
*/
|
||||
launchedChildRunsOf(ownerScopeId: string): readonly ChildSessionRun[] {
|
||||
return this.store.authority
|
||||
.extensionRecordsInDomain('child_run')
|
||||
.map((entry) => parseChildRun(entry.record))
|
||||
.filter(
|
||||
(record): record is ChildAgentRun =>
|
||||
record.kind === 'child_agent' &&
|
||||
record.ownerScopeId === ownerScopeId &&
|
||||
!isTerminalRunState(record.run.state),
|
||||
(record): record is ChildSessionRun =>
|
||||
record.kind !== 'shell' && record.ownerScopeId === ownerScopeId,
|
||||
);
|
||||
}
|
||||
|
||||
/** The non-terminal child Session runs of one owner scope. */
|
||||
activeChildRunsOf(ownerScopeId: string): readonly ChildSessionRun[] {
|
||||
return this.launchedChildRunsOf(ownerScopeId).filter(
|
||||
(record) => !isTerminalRunState(record.run.state),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revision 1 (`startChildRun`): the launch intent, before any physical
|
||||
* side effect. The retried launch returns its opening command, so the
|
||||
|
|
|
|||
|
|
@ -6014,7 +6014,7 @@ describe('Hosted Harness no-tool session', () => {
|
|||
async function prewriteDetachedOutput(
|
||||
family: DetachedFamily,
|
||||
publication: boolean,
|
||||
withChildAgent = false,
|
||||
childSessionKind?: 'child_agent' | 'workflow',
|
||||
): Promise<void> {
|
||||
const key = {
|
||||
tenantId: 'tenant',
|
||||
|
|
@ -6159,10 +6159,10 @@ describe('Hosted Harness no-tool session', () => {
|
|||
await monitors.advanceOutput('bg-1', tip);
|
||||
await monitors.settleQuiet('bg-1', 'exited');
|
||||
}
|
||||
if (withChildAgent) {
|
||||
// A child agent record beside the detached shell one: it owns no
|
||||
// output manifest, so the workspace restore must skip it rather
|
||||
// than refuse the whole Session.
|
||||
if (childSessionKind !== undefined) {
|
||||
// A child Session record (either kind) beside the detached shell
|
||||
// one: it owns no output manifest, so the workspace restore must
|
||||
// skip it rather than refuse the whole Session.
|
||||
const inputRef = await resources.publish(
|
||||
'managed-input',
|
||||
Buffer.from('{"prompt":"audit the diff"}'),
|
||||
|
|
@ -6177,7 +6177,7 @@ describe('Hosted Harness no-tool session', () => {
|
|||
{
|
||||
domain: 'child_run',
|
||||
record: {
|
||||
kind: 'child_agent',
|
||||
kind: childSessionKind,
|
||||
childRunId: 'agent-1',
|
||||
ownerScopeId: key.sessionId,
|
||||
rootSessionId: key.sessionId,
|
||||
|
|
@ -6266,22 +6266,25 @@ describe('Hosted Harness no-tool session', () => {
|
|||
},
|
||||
);
|
||||
|
||||
it('restores a Session whose detached lineage sits beside a child agent record', async () => {
|
||||
domainEnablement.childRun = true;
|
||||
// The child_run domain holds both kinds at H4: the shell lineage must
|
||||
// still verify while the child agent record is skipped, not misparsed.
|
||||
await prewriteDetachedOutput('child_run', false, true);
|
||||
const { server, loaded } = await loadDetachedSession();
|
||||
expect(loaded.status).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await headers(supertest(server).delete(`/session/${SESSION_ID}`)).set(
|
||||
'X-Qwen-Client-Id',
|
||||
loaded.body.clientId as string,
|
||||
)
|
||||
).status,
|
||||
).toBe(204);
|
||||
});
|
||||
it.each(['child_agent', 'workflow'] as const)(
|
||||
'restores a Session whose detached lineage sits beside a %s record',
|
||||
async (childSessionKind) => {
|
||||
domainEnablement.childRun = true;
|
||||
// The child_run domain holds every kind at H4: the shell lineage must
|
||||
// still verify while a child Session record is skipped, not misparsed.
|
||||
await prewriteDetachedOutput('child_run', false, childSessionKind);
|
||||
const { server, loaded } = await loadDetachedSession();
|
||||
expect(loaded.status).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await headers(supertest(server).delete(`/session/${SESSION_ID}`)).set(
|
||||
'X-Qwen-Client-Id',
|
||||
loaded.body.clientId as string,
|
||||
)
|
||||
).status,
|
||||
).toBe(204);
|
||||
},
|
||||
);
|
||||
|
||||
it('still refuses the restore when a detached capture loses page content', async () => {
|
||||
domainEnablement.childRun = true;
|
||||
|
|
|
|||
|
|
@ -1754,9 +1754,10 @@ async function verifyWorkspaceRestore(
|
|||
: parseMonitorRun(
|
||||
JSON.parse((await resources.read(recordRef)).toString('utf8')),
|
||||
);
|
||||
// A child agent owns no output manifest — its result travels the
|
||||
// Session delivery line — so it has no detached lineage to verify.
|
||||
if ('kind' in record && record.kind === 'child_agent') continue;
|
||||
// A child Session run (child agent or workflow) owns no output
|
||||
// manifest — its result travels the Session delivery line — so it has
|
||||
// no detached lineage to verify.
|
||||
if ('kind' in record && record.kind !== 'shell') continue;
|
||||
if (record.run.executionCallId !== null)
|
||||
detached.set(record.run.executionCallId, record.outputRef);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
import { createManagedHarnessHandle } from '@qwen-code/qwen-code-core/managed-runtime/managed-harness-factory.js';
|
||||
import { LocalManagedSessionResourceStore } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-resources.js';
|
||||
import { managedExtensionRecordKey } from '@qwen-code/qwen-code-core/managed-runtime/managed-extension-projection.js';
|
||||
import { MANAGED_CHILD_LIMITS } from '@qwen-code/qwen-code-core/managed-runtime/managed-child-operations.js';
|
||||
import type { ToolCallRequestInfo } from '@qwen-code/qwen-code-core/core/turn.js';
|
||||
import {
|
||||
HostedWorkspaceToolTurn,
|
||||
|
|
@ -343,6 +344,57 @@ it('refuses a fifth concurrent launch with the count limit', async () => {
|
|||
);
|
||||
});
|
||||
|
||||
// H4c: the launch budget counts every child Session run the scope ever
|
||||
// committed, ended or not, so a spent budget refuses with no child active.
|
||||
// The refusal commits no record, and a re-driven batch re-derives it from
|
||||
// the same committed records.
|
||||
it('refuses a launch past the spent launch budget, replaying the refusal', async () => {
|
||||
for (
|
||||
let index = 0;
|
||||
index < MANAGED_CHILD_LIMITS.maxLaunchesPerScope - 1;
|
||||
index++
|
||||
) {
|
||||
const childRunId = `prompt:seed-${index}`;
|
||||
await children.admit({
|
||||
childRunId,
|
||||
ownerScopeId: sessionKey.sessionId,
|
||||
rootSessionId: sessionKey.sessionId,
|
||||
completion: 'sent',
|
||||
description: `seed ${index}`,
|
||||
prompt: 'seed',
|
||||
definition: {
|
||||
definitionId: 'hosted-agent/hosted-workspace-shell/1',
|
||||
definitionRevision: 1,
|
||||
definitionDigest: session.authority.sessionHeader.definitionRef.digest,
|
||||
},
|
||||
workingDirectory: '.',
|
||||
executionCallId: childRunId,
|
||||
});
|
||||
await children.settleCancelled(childRunId, { started: false });
|
||||
}
|
||||
const last = await executeAgent(
|
||||
createTurn(),
|
||||
call({ description: 'last', prompt: 'work' }, 'call-last'),
|
||||
);
|
||||
expect(JSON.stringify(last)).toContain('started in the background');
|
||||
const launched = MANAGED_CHILD_LIMITS.maxLaunchesPerScope;
|
||||
expect(session.authority.extensionRecordsInDomain('child_run')).toHaveLength(
|
||||
launched,
|
||||
);
|
||||
await children.settleCancelled('prompt:call-last', { started: false });
|
||||
expect(children.activeChildRunsOf(sessionKey.sessionId)).toHaveLength(0);
|
||||
for (const turn of [createTurn(), createTurn()]) {
|
||||
const refused = await executeAgent(
|
||||
turn,
|
||||
call({ description: 'one more', prompt: 'work' }, 'call-over'),
|
||||
);
|
||||
expect(JSON.stringify(refused)).toContain('budget_exhausted');
|
||||
expect(
|
||||
session.authority.extensionRecordsInDomain('child_run'),
|
||||
).toHaveLength(launched);
|
||||
}
|
||||
});
|
||||
|
||||
it('answers the tool arm from the committed acceptance, accepting it', async () => {
|
||||
const turn = createTurn();
|
||||
const childRunId = `prompt:call-1`;
|
||||
|
|
|
|||
|
|
@ -2712,14 +2712,16 @@ export class HostedWorkspaceToolTurn {
|
|||
}
|
||||
// Quotas gate NEW children only: a re-driven batch names the same
|
||||
// run id, and `children.admit` answers that replay identically —
|
||||
// counting the replayed child against `count_limit` would refuse
|
||||
// the launch it is already running.
|
||||
// counting the replayed child against `count_limit` or the launch
|
||||
// budget would refuse the launch it is already running. Both counts
|
||||
// read committed records only, so a refusal re-derives on replay.
|
||||
if (children.record(childRunId) === undefined) {
|
||||
const admission = childLaunchAdmission({
|
||||
workspaceMode: 'shared',
|
||||
sameDefinition: true,
|
||||
closing: authority.currentActivation?.phase !== 'active',
|
||||
activeInScope: children.activeChildRunsOf(key.sessionId).length,
|
||||
launchedInScope: children.launchedChildRunsOf(key.sessionId).length,
|
||||
envelopeBytes,
|
||||
});
|
||||
if (!admission.admitted) {
|
||||
|
|
|
|||
|
|
@ -92,6 +92,49 @@
|
|||
"terminalReceiptRef": null,
|
||||
"workspaceMode": "shared",
|
||||
"workingDirectory": "."
|
||||
},
|
||||
"workflow": {
|
||||
"childRunId": "run-1",
|
||||
"childSessionId": null,
|
||||
"completion": "sent",
|
||||
"depth": 1,
|
||||
"inputRef": {
|
||||
"resourceId": "input-1",
|
||||
"kind": "managed-input",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
},
|
||||
"kind": "workflow",
|
||||
"ownerScopeId": "scope-1",
|
||||
"predecessorChildRunId": null,
|
||||
"resultRef": null,
|
||||
"resultVersion": 1,
|
||||
"rootSessionId": "session-1",
|
||||
"run": {
|
||||
"state": "admitted",
|
||||
"reason": null,
|
||||
"definition": {
|
||||
"definitionId": "workflow-1",
|
||||
"definitionRevision": 3,
|
||||
"definitionDigest": "9999999999999999999999999999999999999999999999999999999999999999"
|
||||
},
|
||||
"executionCallId": "call-1",
|
||||
"effectId": null,
|
||||
"dispatchId": null,
|
||||
"deliveryId": null,
|
||||
"execution": "intent",
|
||||
"runtime": null,
|
||||
"delivery": {
|
||||
"target": "session",
|
||||
"state": "planned"
|
||||
}
|
||||
},
|
||||
"stopReason": null,
|
||||
"stopRequested": false,
|
||||
"terminalReceiptRef": null,
|
||||
"workspaceMode": "shared",
|
||||
"workingDirectory": "."
|
||||
}
|
||||
},
|
||||
"cases": [
|
||||
|
|
@ -113,10 +156,10 @@
|
|||
"id": "kind-unknown",
|
||||
"domain": "child_run",
|
||||
"patch": {
|
||||
"kind": "workflow"
|
||||
"kind": "unregistered"
|
||||
},
|
||||
"valid": false,
|
||||
"error": "must be one of shell, child_agent in schema version 1",
|
||||
"error": "must be one of shell, child_agent, workflow in schema version 1",
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
|
|
@ -1324,13 +1367,13 @@
|
|||
{
|
||||
"template": "child_agent",
|
||||
"domain": "child_run",
|
||||
"id": "agent-kind-workflow",
|
||||
"id": "agent-kind-unknown",
|
||||
"patch": {
|
||||
"kind": "workflow"
|
||||
"kind": "unregistered"
|
||||
},
|
||||
"valid": false,
|
||||
"start": false,
|
||||
"error": "must be one of shell, child_agent in schema version 1"
|
||||
"error": "must be one of shell, child_agent, workflow in schema version 1"
|
||||
},
|
||||
{
|
||||
"template": "child_agent",
|
||||
|
|
@ -2277,6 +2320,236 @@
|
|||
"valid": false,
|
||||
"start": false,
|
||||
"error": "cannot hand over a result before the run ends"
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-start",
|
||||
"patch": {},
|
||||
"valid": true,
|
||||
"start": true
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-tool-completion",
|
||||
"patch": { "completion": "tool" },
|
||||
"valid": true,
|
||||
"start": true
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-dispatched",
|
||||
"patch": {
|
||||
"run": {
|
||||
"state": "running",
|
||||
"execution": "dispatch_started",
|
||||
"dispatchId": "dispatch-1",
|
||||
"runtime": { "runtimeBindingId": "binding-1", "generation": "1" }
|
||||
}
|
||||
},
|
||||
"valid": true,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-attached",
|
||||
"patch": {
|
||||
"childSessionId": "session-2",
|
||||
"run": {
|
||||
"state": "running",
|
||||
"execution": "running_attached",
|
||||
"dispatchId": "dispatch-1",
|
||||
"runtime": { "runtimeBindingId": "binding-1", "generation": "1" }
|
||||
}
|
||||
},
|
||||
"valid": true,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-settled",
|
||||
"patch": {
|
||||
"childSessionId": "session-2",
|
||||
"stopReason": "completed",
|
||||
"resultRef": {
|
||||
"resourceId": "result-1",
|
||||
"kind": "managed-child-result",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||
},
|
||||
"terminalReceiptRef": {
|
||||
"resourceId": "receipt-1",
|
||||
"kind": "managed-runtime-receipt",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
||||
},
|
||||
"run": {
|
||||
"state": "settled",
|
||||
"execution": "settled",
|
||||
"dispatchId": "dispatch-1",
|
||||
"runtime": { "runtimeBindingId": "binding-1", "generation": "1" },
|
||||
"delivery": { "target": "session", "state": "accepting" }
|
||||
}
|
||||
},
|
||||
"valid": true,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-creation-failed",
|
||||
"patch": {
|
||||
"stopReason": "creation_failed",
|
||||
"run": {
|
||||
"state": "failed",
|
||||
"execution": "not_started_proven",
|
||||
"dispatchId": "dispatch-1",
|
||||
"delivery": { "target": "session", "state": "cancelled" }
|
||||
}
|
||||
},
|
||||
"valid": true,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-child-failed",
|
||||
"patch": {
|
||||
"childSessionId": "session-2",
|
||||
"stopReason": "child_failed",
|
||||
"run": {
|
||||
"state": "failed",
|
||||
"execution": "settled",
|
||||
"dispatchId": "dispatch-1",
|
||||
"runtime": { "runtimeBindingId": "binding-1", "generation": "1" },
|
||||
"delivery": { "target": "session", "state": "cancelled" }
|
||||
}
|
||||
},
|
||||
"valid": true,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-quota-exceeded",
|
||||
"patch": {
|
||||
"childSessionId": "session-2",
|
||||
"stopReason": "quota_exceeded",
|
||||
"run": {
|
||||
"state": "failed",
|
||||
"reason": "budget_exhausted",
|
||||
"execution": "settled",
|
||||
"dispatchId": "dispatch-1",
|
||||
"runtime": { "runtimeBindingId": "binding-1", "generation": "1" },
|
||||
"delivery": { "target": "session", "state": "cancelled" }
|
||||
}
|
||||
},
|
||||
"valid": true,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-cancelled-after-execution",
|
||||
"patch": {
|
||||
"childSessionId": "session-2",
|
||||
"stopRequested": true,
|
||||
"stopReason": "stop_requested",
|
||||
"run": {
|
||||
"state": "cancelled",
|
||||
"execution": "settled",
|
||||
"dispatchId": "dispatch-1",
|
||||
"runtime": { "runtimeBindingId": "binding-1", "generation": "1" },
|
||||
"delivery": { "target": "session", "state": "cancelled" }
|
||||
}
|
||||
},
|
||||
"valid": true,
|
||||
"start": false
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-start-without-definition",
|
||||
"patch": { "run": { "definition": null } },
|
||||
"valid": false,
|
||||
"start": false,
|
||||
"error": "must pin its workflow definition from launch"
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-dispatched-without-definition",
|
||||
"patch": {
|
||||
"run": {
|
||||
"state": "running",
|
||||
"execution": "dispatch_started",
|
||||
"dispatchId": "dispatch-1",
|
||||
"runtime": { "runtimeBindingId": "binding-1", "generation": "1" },
|
||||
"definition": null
|
||||
}
|
||||
},
|
||||
"valid": false,
|
||||
"start": false,
|
||||
"error": "must pin its workflow definition from launch"
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-cancelled-without-definition",
|
||||
"patch": {
|
||||
"stopRequested": true,
|
||||
"stopReason": "stop_requested",
|
||||
"run": {
|
||||
"state": "cancelled",
|
||||
"execution": "not_started_proven",
|
||||
"definition": null,
|
||||
"delivery": { "target": "session", "state": "cancelled" }
|
||||
}
|
||||
},
|
||||
"valid": false,
|
||||
"start": false,
|
||||
"error": "must pin its workflow definition from launch"
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-extra-key",
|
||||
"patch": { "extra": "x" },
|
||||
"valid": false,
|
||||
"start": false,
|
||||
"error": "exactly"
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-no-delivery",
|
||||
"patch": { "run": { "delivery": null } },
|
||||
"valid": false,
|
||||
"start": false,
|
||||
"error": "delivery must target the parent session"
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-depth-nine",
|
||||
"patch": { "depth": 9 },
|
||||
"valid": false,
|
||||
"start": false,
|
||||
"error": "depth must be an integer from 1 to 8"
|
||||
},
|
||||
{
|
||||
"template": "child_agent",
|
||||
"domain": "child_run",
|
||||
"id": "agent-start-without-definition",
|
||||
"patch": { "run": { "definition": null } },
|
||||
"valid": true,
|
||||
"start": true
|
||||
}
|
||||
],
|
||||
"successors": [
|
||||
|
|
@ -3861,6 +4134,244 @@
|
|||
"stopRequested": true
|
||||
},
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-admit-dispatch",
|
||||
"before": {},
|
||||
"after": {
|
||||
"run": {
|
||||
"state": "running",
|
||||
"execution": "dispatch_started",
|
||||
"dispatchId": "dispatch-1",
|
||||
"runtime": { "runtimeBindingId": "binding-1", "generation": "1" }
|
||||
}
|
||||
},
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-dispatch-attach",
|
||||
"before": {
|
||||
"run": {
|
||||
"state": "running",
|
||||
"execution": "dispatch_started",
|
||||
"dispatchId": "dispatch-1",
|
||||
"runtime": { "runtimeBindingId": "binding-1", "generation": "1" }
|
||||
}
|
||||
},
|
||||
"after": {
|
||||
"childSessionId": "session-2",
|
||||
"run": {
|
||||
"state": "running",
|
||||
"execution": "running_attached",
|
||||
"dispatchId": "dispatch-1",
|
||||
"runtime": { "runtimeBindingId": "binding-1", "generation": "1" }
|
||||
}
|
||||
},
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-settle-result",
|
||||
"before": {
|
||||
"childSessionId": "session-2",
|
||||
"run": {
|
||||
"state": "running",
|
||||
"execution": "running_attached",
|
||||
"dispatchId": "dispatch-1",
|
||||
"runtime": { "runtimeBindingId": "binding-1", "generation": "1" }
|
||||
}
|
||||
},
|
||||
"after": {
|
||||
"childSessionId": "session-2",
|
||||
"stopReason": "completed",
|
||||
"resultRef": {
|
||||
"resourceId": "result-1",
|
||||
"kind": "managed-child-result",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||
},
|
||||
"terminalReceiptRef": {
|
||||
"resourceId": "receipt-1",
|
||||
"kind": "managed-runtime-receipt",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
||||
},
|
||||
"run": {
|
||||
"state": "settled",
|
||||
"execution": "settled",
|
||||
"dispatchId": "dispatch-1",
|
||||
"runtime": { "runtimeBindingId": "binding-1", "generation": "1" },
|
||||
"delivery": { "target": "session", "state": "accepting" }
|
||||
}
|
||||
},
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-delivery-accept",
|
||||
"before": {
|
||||
"childSessionId": "session-2",
|
||||
"stopReason": "completed",
|
||||
"resultRef": {
|
||||
"resourceId": "result-1",
|
||||
"kind": "managed-child-result",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||
},
|
||||
"terminalReceiptRef": {
|
||||
"resourceId": "receipt-1",
|
||||
"kind": "managed-runtime-receipt",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
||||
},
|
||||
"run": {
|
||||
"state": "settled",
|
||||
"execution": "settled",
|
||||
"dispatchId": "dispatch-1",
|
||||
"runtime": { "runtimeBindingId": "binding-1", "generation": "1" },
|
||||
"delivery": { "target": "session", "state": "accepting" }
|
||||
}
|
||||
},
|
||||
"after": {
|
||||
"childSessionId": "session-2",
|
||||
"stopReason": "completed",
|
||||
"resultRef": {
|
||||
"resourceId": "result-1",
|
||||
"kind": "managed-child-result",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||
},
|
||||
"terminalReceiptRef": {
|
||||
"resourceId": "receipt-1",
|
||||
"kind": "managed-runtime-receipt",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
||||
},
|
||||
"run": {
|
||||
"state": "settled",
|
||||
"execution": "settled",
|
||||
"dispatchId": "dispatch-1",
|
||||
"runtime": { "runtimeBindingId": "binding-1", "generation": "1" },
|
||||
"delivery": { "target": "session", "state": "accepted" }
|
||||
}
|
||||
},
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-delivery-consume",
|
||||
"before": {
|
||||
"childSessionId": "session-2",
|
||||
"stopReason": "completed",
|
||||
"resultRef": {
|
||||
"resourceId": "result-1",
|
||||
"kind": "managed-child-result",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||
},
|
||||
"terminalReceiptRef": {
|
||||
"resourceId": "receipt-1",
|
||||
"kind": "managed-runtime-receipt",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
||||
},
|
||||
"run": {
|
||||
"state": "settled",
|
||||
"execution": "settled",
|
||||
"dispatchId": "dispatch-1",
|
||||
"runtime": { "runtimeBindingId": "binding-1", "generation": "1" },
|
||||
"delivery": { "target": "session", "state": "accepted" }
|
||||
}
|
||||
},
|
||||
"after": {
|
||||
"childSessionId": "session-2",
|
||||
"stopReason": "completed",
|
||||
"resultRef": {
|
||||
"resourceId": "result-1",
|
||||
"kind": "managed-child-result",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||
},
|
||||
"terminalReceiptRef": {
|
||||
"resourceId": "receipt-1",
|
||||
"kind": "managed-runtime-receipt",
|
||||
"schemaVersion": 1,
|
||||
"byteLength": 2,
|
||||
"digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
||||
},
|
||||
"run": {
|
||||
"state": "settled",
|
||||
"execution": "settled",
|
||||
"dispatchId": "dispatch-1",
|
||||
"runtime": { "runtimeBindingId": "binding-1", "generation": "1" },
|
||||
"delivery": { "target": "session", "state": "consumed" }
|
||||
}
|
||||
},
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-cancel-before-dispatch",
|
||||
"before": {},
|
||||
"after": {
|
||||
"stopRequested": true,
|
||||
"stopReason": "stop_requested",
|
||||
"run": {
|
||||
"state": "cancelled",
|
||||
"execution": "not_started_proven",
|
||||
"delivery": { "target": "session", "state": "cancelled" }
|
||||
}
|
||||
},
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-kind-changed",
|
||||
"before": {},
|
||||
"after": { "kind": "child_agent" },
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-definition-changed",
|
||||
"before": {},
|
||||
"after": {
|
||||
"run": {
|
||||
"definition": {
|
||||
"definitionId": "workflow-1",
|
||||
"definitionRevision": 4,
|
||||
"definitionDigest": "8888888888888888888888888888888888888888888888888888888888888888"
|
||||
}
|
||||
}
|
||||
},
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"template": "workflow",
|
||||
"domain": "child_run",
|
||||
"id": "workflow-identity-changed",
|
||||
"before": {},
|
||||
"after": { "childRunId": "run-2" },
|
||||
"valid": false
|
||||
}
|
||||
],
|
||||
"childAgentKeys": [
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
"hook_execution": null,
|
||||
"child_run": {
|
||||
"shell": "background_shell",
|
||||
"child_agent": "child_agent"
|
||||
"child_agent": "child_agent",
|
||||
"workflow": "workflow"
|
||||
},
|
||||
"child_acceptance": null,
|
||||
"channel_route": null,
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ describe('managed child operations (H4b)', () => {
|
|||
closing: false,
|
||||
depth: 1,
|
||||
activeInScope: 0,
|
||||
launchedInScope: 0,
|
||||
envelopeBytes: 100,
|
||||
workspaceMode: 'shared',
|
||||
sameDefinition: true,
|
||||
|
|
@ -173,6 +174,10 @@ describe('managed child operations (H4b)', () => {
|
|||
{ activeInScope: MANAGED_CHILD_LIMITS.maxActivePerScope },
|
||||
'count_limit',
|
||||
],
|
||||
[
|
||||
{ launchedInScope: MANAGED_CHILD_LIMITS.maxLaunchesPerScope },
|
||||
'budget_exhausted',
|
||||
],
|
||||
[
|
||||
{ envelopeBytes: MANAGED_CHILD_LIMITS.maxEnvelopeBytes + 1 },
|
||||
'byte_limit',
|
||||
|
|
@ -185,6 +190,24 @@ describe('managed child operations (H4b)', () => {
|
|||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('spends the launch budget on every launch, ended ones included', () => {
|
||||
expect(
|
||||
admitChildLaunch({
|
||||
...base,
|
||||
launchedInScope: MANAGED_CHILD_LIMITS.maxLaunchesPerScope - 1,
|
||||
}),
|
||||
).toEqual({ admitted: true });
|
||||
// A spent budget never recovers, so it wins over the concurrency
|
||||
// cap, which a later launch may find cleared.
|
||||
expect(
|
||||
admitChildLaunch({
|
||||
...base,
|
||||
activeInScope: MANAGED_CHILD_LIMITS.maxActivePerScope,
|
||||
launchedInScope: MANAGED_CHILD_LIMITS.maxLaunchesPerScope,
|
||||
}),
|
||||
).toEqual({ admitted: false, reason: 'budget_exhausted' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('run revision builders', () => {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@ export const MANAGED_CHILD_LIMITS = Object.freeze({
|
|||
/** The contract cap is 8; the first runtime slice admits depth 1 only. */
|
||||
maxDepth: 1,
|
||||
maxActivePerScope: 4,
|
||||
/**
|
||||
* The launches one owner scope may make over the Session's lifetime
|
||||
* (H4c): every committed child Session run counts, ended or not, since
|
||||
* each one cost a creation attempt and grows the journal its rebuild
|
||||
* replays.
|
||||
*/
|
||||
maxLaunchesPerScope: 64,
|
||||
maxEnvelopeBytes: 32 * 1024,
|
||||
maxDescriptionBytes: 512,
|
||||
// The result copy is pinned to the parent Session's durable inline
|
||||
|
|
@ -141,6 +148,7 @@ export type ChildAdmissionRefusal =
|
|||
| 'closing'
|
||||
| 'depth_limit'
|
||||
| 'count_limit'
|
||||
| 'budget_exhausted'
|
||||
| 'byte_limit'
|
||||
| 'workspace_mode'
|
||||
| 'definition_scope';
|
||||
|
|
@ -155,6 +163,8 @@ export function admitChildLaunch(params: {
|
|||
readonly closing: boolean;
|
||||
readonly depth: number;
|
||||
readonly activeInScope: number;
|
||||
/** Every child Session run this scope ever committed, ended or not. */
|
||||
readonly launchedInScope: number;
|
||||
readonly envelopeBytes: number;
|
||||
readonly workspaceMode: ChildWorkspaceMode;
|
||||
readonly sameDefinition: boolean;
|
||||
|
|
@ -169,6 +179,11 @@ export function admitChildLaunch(params: {
|
|||
if (params.depth > MANAGED_CHILD_LIMITS.maxDepth) {
|
||||
return { admitted: false, reason: 'depth_limit' };
|
||||
}
|
||||
// The spent budget never recovers, so it is reported ahead of the
|
||||
// concurrency cap, which a later launch may find cleared.
|
||||
if (params.launchedInScope >= MANAGED_CHILD_LIMITS.maxLaunchesPerScope) {
|
||||
return { admitted: false, reason: 'budget_exhausted' };
|
||||
}
|
||||
if (params.activeInScope >= MANAGED_CHILD_LIMITS.maxActivePerScope) {
|
||||
return { admitted: false, reason: 'count_limit' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@ function templateOf(fixture: {
|
|||
describe('managed-child-run-record/1 shared contract', () => {
|
||||
it('projects per-kind tasks behind a per-kind enablement gate', () => {
|
||||
// H4b admits the child_agent kind; the domain itself still stays off
|
||||
// the plain enabled list, so H3's shell keeps its own disabled gate.
|
||||
// the plain enabled list, so H3's shell keeps its own disabled gate,
|
||||
// and H4c registers the workflow kind without admitting it.
|
||||
const body = MANAGED_EXTENSION_RECORD_BODIES.child_run!;
|
||||
expect(
|
||||
body.taskKindOf(parseChildRun(fixtures.templates['child_run'])),
|
||||
|
|
@ -102,8 +103,12 @@ describe('managed-child-run-record/1 shared contract', () => {
|
|||
expect(
|
||||
body.taskKindOf(parseChildRun(fixtures.templates['child_agent'])),
|
||||
).toBe('child_agent');
|
||||
expect(body.taskKindOf(parseChildRun(fixtures.templates['workflow']))).toBe(
|
||||
'workflow',
|
||||
);
|
||||
expect(MANAGED_TASK_KINDS).toContain('background_shell');
|
||||
expect(MANAGED_TASK_KINDS).toContain('child_agent');
|
||||
expect(MANAGED_TASK_KINDS).toContain('workflow');
|
||||
expect(MANAGED_SESSION_ENABLED_DOMAINS).not.toContain('child_run');
|
||||
expect(MANAGED_SESSION_ENABLED_CHILD_RUN_KINDS).toEqual(['child_agent']);
|
||||
expect(() =>
|
||||
|
|
@ -112,6 +117,9 @@ describe('managed-child-run-record/1 shared contract', () => {
|
|||
expect(() => assertManagedSessionChildRunKindEnabled('shell')).toThrow(
|
||||
'domain child_run kind shell is registered but not enabled for submission.',
|
||||
);
|
||||
expect(() => assertManagedSessionChildRunKindEnabled('workflow')).toThrow(
|
||||
'domain child_run kind workflow is registered but not enabled for submission.',
|
||||
);
|
||||
});
|
||||
|
||||
it('pins the closed keys and the closed stop-reason vocabulary', () => {
|
||||
|
|
@ -217,10 +225,13 @@ describe('managed-child-run-record/1 shared contract', () => {
|
|||
).toBe(fixture.valid);
|
||||
});
|
||||
|
||||
it('refuses a child_agent body at the shell-only entry point', () => {
|
||||
it('refuses a child Session body at the shell-only entry point', () => {
|
||||
expect(() => parseChildShellRun(fixtures.templates['child_agent'])).toThrow(
|
||||
"Child run kind must be 'shell' for this consumer, got child_agent.",
|
||||
);
|
||||
expect(() => parseChildShellRun(fixtures.templates['workflow'])).toThrow(
|
||||
"Child run kind must be 'shell' for this consumer, got workflow.",
|
||||
);
|
||||
expect(parseChildShellRun(fixtures.templates['child_run']).kind).toBe(
|
||||
'shell',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -25,12 +25,14 @@ import { MANAGED_TOOL_RESULT_KINDS } from './managed-tool-result.js';
|
|||
// The `managed-child_run` record bodies under recordRef `managed-child_run`
|
||||
// schema version 1: one closed shape per `kind`, dispatched by the body's
|
||||
// own `kind` field. H3 of #12827 defined `kind: "shell"` (one background
|
||||
// Shell per record); H4 of #12827 adds `kind: "child_agent"` (one child
|
||||
// Session per record). The shared fixtures in
|
||||
// contracts/managed-child-run-record-v1.fixtures.json pin both shapes, and
|
||||
// Shell per record); H4 of #12827 adds the two child Session kinds,
|
||||
// `kind: "child_agent"` (H4a) and `kind: "workflow"` (H4c), one child
|
||||
// Session per record under one shared shape. The shared fixtures in
|
||||
// contracts/managed-child-run-record-v1.fixtures.json pin every shape, and
|
||||
// ManagedExtensionRecords in packages/sdk-java/managed-agent-server replays
|
||||
// the same cases. See docs/design/2026-10-03-managed-shell-monitor-runtime.md
|
||||
// and docs/design/2026-10-06-managed-child-agent-runtime.md.
|
||||
// the same cases. See docs/design/2026-10-03-managed-shell-monitor-runtime.md,
|
||||
// docs/design/2026-10-06-managed-child-agent-runtime.md and
|
||||
// docs/design/2026-10-09-managed-workflow-child-kind.md.
|
||||
|
||||
/** Why a background Shell ended, by the state its run ended in. */
|
||||
export const CHILD_RUN_STOP_REASONS = Object.freeze({
|
||||
|
|
@ -46,7 +48,7 @@ export const CHILD_RUN_STOP_REASONS = Object.freeze({
|
|||
export type ChildRunStopReason =
|
||||
(typeof CHILD_RUN_STOP_REASONS)[keyof typeof CHILD_RUN_STOP_REASONS][number];
|
||||
|
||||
/** Why a child agent ended, by the state its run ended in. */
|
||||
/** Why a child Session run ended, by the state its run ended in. */
|
||||
export const CHILD_AGENT_STOP_REASONS = Object.freeze({
|
||||
settled: Object.freeze(['completed'] as const),
|
||||
failed: Object.freeze([
|
||||
|
|
@ -85,9 +87,8 @@ export type ChildCompletion = 'tool' | 'sent';
|
|||
/** The Workspace isolation policy fixed at launch. */
|
||||
export type ChildWorkspaceMode = 'shared' | 'snapshot' | 'worktree';
|
||||
|
||||
/** The body of a `managed-child_run` schema version 1 (`kind: "child_agent"`). */
|
||||
export interface ChildAgentRun {
|
||||
readonly kind: 'child_agent';
|
||||
/** The fields of a child Session run, shared by both child Session kinds. */
|
||||
interface ChildSessionRunFields {
|
||||
readonly childRunId: string;
|
||||
/** The parent activation scope that owns the child and receives its result. */
|
||||
readonly ownerScopeId: string;
|
||||
|
|
@ -116,12 +117,29 @@ export interface ChildAgentRun {
|
|||
readonly run: ExtensionRun;
|
||||
}
|
||||
|
||||
/** The body of a `managed-child_run` schema version 1 (`kind: "child_agent"`). */
|
||||
export interface ChildAgentRun extends ChildSessionRunFields {
|
||||
readonly kind: 'child_agent';
|
||||
}
|
||||
|
||||
/**
|
||||
* The body of a `managed-child_run` schema version 1 (`kind: "workflow"`):
|
||||
* a child Session that runs one workflow, whose definition pin names that
|
||||
* workflow's revision from the launch on.
|
||||
*/
|
||||
export interface WorkflowRun extends ChildSessionRunFields {
|
||||
readonly kind: 'workflow';
|
||||
}
|
||||
|
||||
/** A child run executed by a child Session of its own, of either kind. */
|
||||
export type ChildSessionRun = ChildAgentRun | WorkflowRun;
|
||||
|
||||
/**
|
||||
* The body of any `managed-child_run` schema version 1 record. `ChildRun`
|
||||
* keeps the meaning H3 shipped — a background Shell — and the dispatching
|
||||
* parse returns it for `kind: "shell"`.
|
||||
*/
|
||||
export type AnyChildRun = ChildRun | ChildAgentRun;
|
||||
export type AnyChildRun = ChildRun | ChildSessionRun;
|
||||
|
||||
const SHELL_KEYS = [
|
||||
'commandRef',
|
||||
|
|
@ -143,6 +161,8 @@ const SHELL_FIXED_KEYS = [
|
|||
'ownerScopeId',
|
||||
'shellId',
|
||||
] as const;
|
||||
// The closed keys of both child Session kinds: a workflow child rides the
|
||||
// child agent's lifecycle field for field.
|
||||
const CHILD_AGENT_KEYS = [
|
||||
'childRunId',
|
||||
'childSessionId',
|
||||
|
|
@ -162,9 +182,10 @@ const CHILD_AGENT_KEYS = [
|
|||
'workspaceMode',
|
||||
'workingDirectory',
|
||||
] as const;
|
||||
// The fields that no revision of a child agent may change. `resultVersion`
|
||||
// is not here on purpose: the parser forces it to 1, so no two revisions
|
||||
// can ever differ on it, and a fixed-key entry for it could never refuse.
|
||||
// The fields that no revision of a child Session run may change.
|
||||
// `resultVersion` is not here on purpose: the parser forces it to 1, so no
|
||||
// two revisions can ever differ on it, and a fixed-key entry for it could
|
||||
// never refuse.
|
||||
const CHILD_AGENT_FIXED_KEYS = [
|
||||
'childRunId',
|
||||
'completion',
|
||||
|
|
@ -303,8 +324,12 @@ export function parseChildRun(value: unknown): AnyChildRun {
|
|||
? (value as Record<string, unknown>)['kind']
|
||||
: undefined;
|
||||
if (kind === 'shell') return parseChildShellRecord(value);
|
||||
if (kind === 'child_agent') return parseChildAgentRun(value);
|
||||
fail('Child run kind must be one of shell, child_agent in schema version 1.');
|
||||
if (kind === 'child_agent' || kind === 'workflow') {
|
||||
return parseChildSessionRun(value, kind);
|
||||
}
|
||||
fail(
|
||||
'Child run kind must be one of shell, child_agent, workflow in schema version 1.',
|
||||
);
|
||||
}
|
||||
|
||||
/** Parses a `kind: "shell"` child run and refuses any other kind. */
|
||||
|
|
@ -445,12 +470,15 @@ function parseChildShellRecord(value: unknown): ChildRun {
|
|||
});
|
||||
}
|
||||
|
||||
function parseChildAgentRun(value: unknown): ChildAgentRun {
|
||||
function parseChildSessionRun(
|
||||
value: unknown,
|
||||
kind: ChildSessionRun['kind'],
|
||||
): ChildSessionRun {
|
||||
const body = closed(value, CHILD_AGENT_KEYS);
|
||||
const run = parseExtensionRun(body.run);
|
||||
// A child agent is started by one tool call; its result travels the
|
||||
// session delivery line its relay scans, never an effect identity or an
|
||||
// external delivery.
|
||||
// A child Session run is started by one tool call; its result travels
|
||||
// the session delivery line its relay scans, never an effect identity or
|
||||
// an external delivery.
|
||||
if (
|
||||
run.executionCallId === null ||
|
||||
run.effectId !== null ||
|
||||
|
|
@ -464,6 +492,12 @@ function parseChildAgentRun(value: unknown): ChildAgentRun {
|
|||
if (delivery === null || delivery.target !== 'session') {
|
||||
fail('Child run delivery must target the parent session.');
|
||||
}
|
||||
// A workflow run exists to run one workflow revision, so the launch
|
||||
// itself names it: the pin is required from the opening revision on,
|
||||
// never merely by the dispatch.
|
||||
if (kind === 'workflow' && run.definition === null) {
|
||||
fail('Workflow run must pin its workflow definition from launch.');
|
||||
}
|
||||
// The launched definition is pinned no later than the dispatch that
|
||||
// admits the creation; the shared successor rule makes it unaddable
|
||||
// after that dispatch, so its absence can never be repaired.
|
||||
|
|
@ -615,7 +649,7 @@ function parseChildAgentRun(value: unknown): ChildAgentRun {
|
|||
fail('Child run child_failed needs its settled execution.');
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: 'child_agent',
|
||||
kind,
|
||||
childRunId: id(body.childRunId, 'childRunId'),
|
||||
ownerScopeId: id(body.ownerScopeId, 'ownerScopeId'),
|
||||
rootSessionId: id(body.rootSessionId, 'rootSessionId'),
|
||||
|
|
@ -666,8 +700,8 @@ function isRelativeDirectory(value: string): boolean {
|
|||
|
||||
/**
|
||||
* Whether `value` may open its chain: a Shell's run opens with no stop
|
||||
* request and no output; a child agent's run opens with the delivery planned,
|
||||
* no Session created, no result and no stop request.
|
||||
* request and no output; a child Session run (either kind) opens with the
|
||||
* delivery planned, no Session created, no result and no stop request.
|
||||
*/
|
||||
export function isChildRunStart(value: unknown): boolean {
|
||||
return accepts(() => {
|
||||
|
|
@ -688,9 +722,9 @@ export function isChildRunStart(value: unknown): boolean {
|
|||
* Whether `next` may follow `previous` as a later revision of the same
|
||||
* record, per kind: the identity is fixed, the run moves forward, a Shell's
|
||||
* start receipt is set once and never changes while its output may only
|
||||
* grow, a child agent's Session is created once, either kind's stop
|
||||
* grow, a child Session run's Session is created once, every kind's stop
|
||||
* request is set but never cleared, and once the run is terminal the
|
||||
* remaining rules freeze everything but the child agent's delivery line.
|
||||
* remaining rules freeze everything but a child Session run's delivery line.
|
||||
* The result and its receipt need no set-once rule here: they appear
|
||||
* exactly at the settling revision, and the terminal freeze forbids any
|
||||
* restatement from then on.
|
||||
|
|
@ -715,9 +749,7 @@ export function isChildRunSuccessor(previous: unknown, next: unknown): boolean {
|
|||
}
|
||||
return true;
|
||||
}
|
||||
if (before.kind !== 'child_agent' || after.kind !== 'child_agent') {
|
||||
return false;
|
||||
}
|
||||
if (before.kind === 'shell' || after.kind === 'shell') return false;
|
||||
// Once the run is terminal the record changes only its delivery line:
|
||||
// the run's own freeze confines movement to the delivery, and nothing
|
||||
// outside the run may change at all.
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ describe('managed-extension-projection/1 fixtures', () => {
|
|||
? {
|
||||
shell: body?.taskKindOf({ kind: 'shell' }),
|
||||
child_agent: body?.taskKindOf({ kind: 'child_agent' }),
|
||||
workflow: body?.taskKindOf({ kind: 'workflow' }),
|
||||
}
|
||||
: body?.taskKindOf({
|
||||
configurationId: 'probe',
|
||||
|
|
|
|||
|
|
@ -174,10 +174,14 @@ export const MANAGED_EXTENSION_RECORD_BODIES: Readonly<
|
|||
isSuccessor: isMonitorRunSuccessor,
|
||||
}),
|
||||
child_run: Object.freeze({
|
||||
taskKindOf: (record: unknown) =>
|
||||
(record as AnyChildRun).kind === 'shell'
|
||||
taskKindOf: (record: unknown) => {
|
||||
const kind = (record as AnyChildRun).kind;
|
||||
return kind === 'shell'
|
||||
? 'background_shell'
|
||||
: 'child_agent',
|
||||
: kind === 'workflow'
|
||||
? 'workflow'
|
||||
: 'child_agent';
|
||||
},
|
||||
parse: (value: unknown) => {
|
||||
const record = parseChildRun(value);
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -17,9 +17,13 @@ import { type ManagedSessionDurableRef } from './managed-session-records.js';
|
|||
// The H4b enablement gates are real in this suite: `child_acceptance` sits
|
||||
// in the plain enabled list and the kind gate admits `child_agent`, so no
|
||||
// mock is needed to run commits. One case still plants a `shell` chain to
|
||||
// prove an acceptance cannot name it; the flag below lifts the kind gate
|
||||
// for that planting only.
|
||||
const enablement = vi.hoisted(() => ({ shellKind: false }));
|
||||
// prove an acceptance cannot name it, and the H4c cases commit `workflow`
|
||||
// chains ahead of that kind's enablement; the flags below lift the kind
|
||||
// gate for exactly those plantings.
|
||||
const enablement = vi.hoisted(() => ({
|
||||
shellKind: false,
|
||||
workflowKind: false,
|
||||
}));
|
||||
|
||||
vi.mock('./managed-session-records.js', async (importOriginal) => {
|
||||
const actual =
|
||||
|
|
@ -27,7 +31,10 @@ vi.mock('./managed-session-records.js', async (importOriginal) => {
|
|||
return {
|
||||
...actual,
|
||||
assertManagedSessionChildRunKindEnabled: (kind: string) => {
|
||||
if (kind !== 'shell' || !enablement.shellKind) {
|
||||
if (
|
||||
!(kind === 'shell' && enablement.shellKind) &&
|
||||
!(kind === 'workflow' && enablement.workflowKind)
|
||||
) {
|
||||
actual.assertManagedSessionChildRunKindEnabled(kind);
|
||||
}
|
||||
},
|
||||
|
|
@ -38,6 +45,7 @@ const temporaryDirectories = new Set<string>();
|
|||
|
||||
afterEach(async () => {
|
||||
enablement.shellKind = false;
|
||||
enablement.workflowKind = false;
|
||||
for (const directory of temporaryDirectories) {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -266,6 +274,22 @@ function life(refs: ChildRefs) {
|
|||
];
|
||||
}
|
||||
|
||||
// A workflow child's pin names the workflow revision it runs, from launch.
|
||||
const WORKFLOW_PIN = {
|
||||
definitionId: 'workflow-review',
|
||||
definitionRevision: 3,
|
||||
definitionDigest: '9'.repeat(64),
|
||||
};
|
||||
|
||||
/** The same lifecycle as {@link life}, as a workflow child (H4c). */
|
||||
function workflowLife(refs: ChildRefs) {
|
||||
return life(refs).map((record) => ({
|
||||
...record,
|
||||
kind: 'workflow',
|
||||
run: { ...record.run, definition: WORKFLOW_PIN },
|
||||
}));
|
||||
}
|
||||
|
||||
/** A valid background-Shell launch body, for kind-gate and cross-kind cases. */
|
||||
function shellRun(refs: ChildRefs, runOverrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
|
|
@ -679,7 +703,7 @@ describe('managed session authority child_agent records', () => {
|
|||
{ domain: 'child_acceptance', record: acceptance(refs) },
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toThrow('must name a child agent run of this Session');
|
||||
).rejects.toThrow('must name a child Session run of this Session');
|
||||
expect(await publishedBodies(harness, 'child_acceptance')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -700,7 +724,7 @@ describe('managed session authority child_agent records', () => {
|
|||
{ domain: 'child_acceptance', record: acceptance(refs) },
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toThrow('must name a child agent run of this Session');
|
||||
).rejects.toThrow('must name a child Session run of this Session');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1111,3 +1135,127 @@ describe('managed session authority child_agent records', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('managed session authority workflow records (H4c)', () => {
|
||||
it('refuses a workflow launch through the kind gate, committing nothing', async () => {
|
||||
const harness = await createHarness();
|
||||
const refs = await publishRefs(harness);
|
||||
await withAuthority(harness, async (authority) => {
|
||||
await expect(
|
||||
authority.commitExtensionRecord(
|
||||
command('run-1:1'),
|
||||
{ domain: 'child_run', record: workflowLife(refs)[0] },
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'domain child_run kind workflow is registered but not enabled for submission.',
|
||||
);
|
||||
expect(await publishedBodies(harness, 'child_run')).toBe(0);
|
||||
expect(authority.extensionRecord('child_run', 'run-1')).toBeUndefined();
|
||||
expect(authority.taskViews()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('chains a workflow child through acceptance and consumption, rebuilt on reopen', async () => {
|
||||
enablement.workflowKind = true;
|
||||
const harness = await createHarness();
|
||||
const refs = await publishRefs(harness);
|
||||
const chain = workflowLife(refs);
|
||||
const view = {
|
||||
taskId: TASK_ID,
|
||||
sessionId,
|
||||
kind: 'workflow',
|
||||
state: 'completed',
|
||||
runtimeState: null,
|
||||
// The task names the workflow revision the launch pinned.
|
||||
definitionRevision: 3,
|
||||
createdAt: 1_000,
|
||||
startedAt: 2_000,
|
||||
settledAt: 4_000,
|
||||
};
|
||||
await withAuthority(harness, async (authority) => {
|
||||
await settleChild(harness, authority, chain);
|
||||
expect(authority.taskViews()).toEqual([view]);
|
||||
harness.now = 5_000;
|
||||
await authority.commitExtensionRecord(
|
||||
command('accept-1:1'),
|
||||
{ domain: 'child_acceptance', record: acceptance(refs) },
|
||||
TRUSTED,
|
||||
);
|
||||
await authority.commitExtensionRecord(
|
||||
command('run-1:5'),
|
||||
{ domain: 'child_run', record: chain[4] },
|
||||
TRUSTED,
|
||||
);
|
||||
await authority.commitExtensionRecord(
|
||||
command('accept-1:2'),
|
||||
{ domain: 'child_acceptance', record: acceptance(refs, 'consumed') },
|
||||
TRUSTED,
|
||||
);
|
||||
await authority.commitExtensionRecord(
|
||||
command('run-1:6'),
|
||||
{ domain: 'child_run', record: chain[5] },
|
||||
TRUSTED,
|
||||
);
|
||||
});
|
||||
// Rebuild needs no gate: the gate refuses submission, never a reader.
|
||||
enablement.workflowKind = false;
|
||||
await withAuthority(
|
||||
harness,
|
||||
async (authority) => {
|
||||
expect(authority.taskViews()).toEqual([view]);
|
||||
expect(authority.extensionRecord('child_run', 'run-1')).toMatchObject({
|
||||
revision: 6,
|
||||
record: chain[5],
|
||||
});
|
||||
expect(
|
||||
authority.extensionRecord('child_acceptance', 'run-1'),
|
||||
).toMatchObject({ revision: 2, record: acceptance(refs, 'consumed') });
|
||||
},
|
||||
{ create: false },
|
||||
);
|
||||
});
|
||||
|
||||
it('binds a workflow child to the child Session commit rules', async () => {
|
||||
enablement.workflowKind = true;
|
||||
const harness = await createHarness();
|
||||
const refs = await publishRefs(harness);
|
||||
const chain = workflowLife(refs);
|
||||
await withAuthority(harness, async (authority) => {
|
||||
await expect(
|
||||
authority.commitExtensionRecord(
|
||||
command('run-1:1'),
|
||||
{
|
||||
domain: 'child_run',
|
||||
record: { ...chain[0], rootSessionId: 'session-other' },
|
||||
},
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'rootSessionId must be this Session for a first-level child',
|
||||
);
|
||||
await settleChild(harness, authority, chain);
|
||||
await expect(
|
||||
authority.commitExtensionRecord(
|
||||
command('run-1:5'),
|
||||
{ domain: 'child_run', record: chain[4] },
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'reaches accepted or consumed only with its acceptance record',
|
||||
);
|
||||
await expect(
|
||||
authority.commitExtensionRecord(
|
||||
command('accept-1:1'),
|
||||
{
|
||||
domain: 'child_acceptance',
|
||||
record: acceptance(refs, 'accepted', { parentScopeId: 'scope-x' }),
|
||||
},
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toThrow('must match its child run scope and result version');
|
||||
expect(await publishedBodies(harness, 'child_run')).toBe(4);
|
||||
expect(await publishedBodies(harness, 'child_acceptance')).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2113,7 +2113,7 @@ export class LocalManagedSessionAuthority {
|
|||
if (domain === 'child_run') {
|
||||
const child = parseChildRun(parsed.record);
|
||||
if (
|
||||
child.kind === 'child_agent' &&
|
||||
child.kind !== 'shell' &&
|
||||
child.depth === 1 &&
|
||||
child.rootSessionId !== this.sessionKey.sessionId
|
||||
) {
|
||||
|
|
@ -2127,8 +2127,10 @@ export class LocalManagedSessionAuthority {
|
|||
const target = this.extensionRecord('child_run', acceptance.childRunId);
|
||||
const child =
|
||||
target === undefined ? undefined : parseChildRun(target.record);
|
||||
if (child === undefined || child.kind !== 'child_agent') {
|
||||
reject('Child acceptance must name a child agent run of this Session.');
|
||||
if (child === undefined || child.kind === 'shell') {
|
||||
reject(
|
||||
'Child acceptance must name a child Session run of this Session.',
|
||||
);
|
||||
}
|
||||
if (child.run.state !== 'settled' || child.stopReason !== 'completed') {
|
||||
reject(
|
||||
|
|
@ -2169,7 +2171,7 @@ export class LocalManagedSessionAuthority {
|
|||
// does. Before any acceptance, accepting → unknown stays legal as
|
||||
// the relay's retry vocabulary.
|
||||
const child = parseChildRun(parsed.record);
|
||||
if (child.kind === 'child_agent') {
|
||||
if (child.kind !== 'shell') {
|
||||
const delivery = child.run.delivery!.state;
|
||||
const acceptance = this.extensionRecord(
|
||||
'child_acceptance',
|
||||
|
|
|
|||
|
|
@ -48,7 +48,11 @@ deltas lose their text and identity, later deltas may name other Parts, and a
|
|||
`stream.reconciled` event announces it. A client that sees one reloads the
|
||||
Items and resumes after their `snapshot_through_sequence`. A
|
||||
cursor below a Session's replay floor gets `409 cursor_expired` from the JSON
|
||||
event query and one `agent.session.resync_required` frame from either stream.
|
||||
event query and one `agent.session.resync_required` frame from either stream
|
||||
only while the Snapshot backs the floor
|
||||
(`replay_floor_sequence <= snapshot_through_sequence`); a stream reconciliation
|
||||
discards the Snapshot without lowering the floor, and during that rebuild such
|
||||
a cursor is served from the retained events.
|
||||
`GET /v1/agents/sessions/{id}/turns` lists a Session's Turns newest first with
|
||||
an opaque cursor, and `GET /v1/agents/sessions/{id}/turns/{turnId}` reads one.
|
||||
Design: [English](../../../docs/design/2026-09-27-managed-agent-api-contract.md) |
|
||||
|
|
|
|||
|
|
@ -523,6 +523,7 @@ public class ManagedAgentProperties {
|
|||
private Duration batchInterval = Duration.ofMillis(75);
|
||||
private int batchMaxEvents = 64;
|
||||
private int batchMaxBytes = 65536;
|
||||
private boolean replayFloorEnabled;
|
||||
|
||||
public Duration getPollInterval() {
|
||||
return pollInterval;
|
||||
|
|
@ -584,6 +585,19 @@ public class ManagedAgentProperties {
|
|||
public void setBatchMaxBytes(int batchMaxBytes) {
|
||||
this.batchMaxBytes = batchMaxBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the scheduled pass raises each Session's replay floor as
|
||||
* far as its Snapshot proves safe. Disabled by default; events are
|
||||
* never deleted here either way.
|
||||
*/
|
||||
public boolean isReplayFloorEnabled() {
|
||||
return replayFloorEnabled;
|
||||
}
|
||||
|
||||
public void setReplayFloorEnabled(boolean replayFloorEnabled) {
|
||||
this.replayFloorEnabled = replayFloorEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
public static class RuntimeBroker {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,18 @@ public class ManagedArtifactConfiguration {
|
|||
return builder.poolSize(1).threadNamePrefix("managed-automation-").build();
|
||||
}
|
||||
|
||||
/**
|
||||
* The replay-floor pass's own tick: a first pass after an operator opts
|
||||
* in drains every Session with a Snapshot, which on a large deployment
|
||||
* takes minutes, and on the shared one-thread default taskScheduler it
|
||||
* would hold off the message materializer and every recovery tick for
|
||||
* that long.
|
||||
*/
|
||||
@Bean
|
||||
public ThreadPoolTaskScheduler replayFloorScheduler(ThreadPoolTaskSchedulerBuilder builder) {
|
||||
return builder.poolSize(1).threadNamePrefix("replay-floor-").build();
|
||||
}
|
||||
|
||||
/**
|
||||
* The child result relay's own tick: the shared default taskScheduler
|
||||
* also runs every sibling recovery, and this page's sequential
|
||||
|
|
|
|||
|
|
@ -618,14 +618,20 @@ public class ManagedAgentService {
|
|||
|
||||
// Events are read before the floor: a floor that is not above the cursor
|
||||
// after the read was not above it during the read either, so no event
|
||||
// after the cursor had been pruned.
|
||||
// after the cursor had been pruned. A stream reconciliation discards the
|
||||
// Snapshot without lowering the floor, so the floor can exceed the
|
||||
// coverage while the Items rebuild; nothing prunes events yet, so such
|
||||
// a cursor is still served instead of looping 409/resync until the
|
||||
// rebuild finishes. The pruning work must revisit this condition once
|
||||
// rows below the floor can be gone.
|
||||
private List<EventRecord> replayableEvents(SessionRecord session,
|
||||
long afterSequence, int limit) {
|
||||
List<EventRecord> events = store.findEvents(session.tenantId(),
|
||||
session.sessionId(), afterSequence, limit);
|
||||
ReplayWindow window = store.findReplayWindow(session.tenantId(),
|
||||
session.sessionId());
|
||||
if (afterSequence < window.floorSequence()) {
|
||||
if (afterSequence < window.floorSequence()
|
||||
&& window.floorSequence() <= window.snapshotThroughSequence()) {
|
||||
throw new ReplayCursorExpired(window);
|
||||
}
|
||||
return events;
|
||||
|
|
@ -1063,6 +1069,7 @@ public class ManagedAgentService {
|
|||
* Reads the next catch-up page of a stream.
|
||||
*
|
||||
* @throws ReplayCursorExpired when the cursor is below the replay floor
|
||||
* while the Snapshot backs it
|
||||
*/
|
||||
List<EventRecord> streamEvents(SessionRecord session, long afterSequence) {
|
||||
requireEventCursor(afterSequence);
|
||||
|
|
|
|||
|
|
@ -6,8 +6,11 @@ import java.util.Map;
|
|||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* A replay cursor below the Session's replay floor. JSON reads answer it with
|
||||
* {@code 409 cursor_expired}; streams send one resync frame instead.
|
||||
* A replay cursor below the Session's replay floor while the Snapshot backs
|
||||
* the floor; a stream reconciliation discards the Snapshot without lowering
|
||||
* the floor, and during the rebuild such a cursor is still served. JSON reads
|
||||
* answer it with {@code 409 cursor_expired}; streams send one resync frame
|
||||
* instead.
|
||||
*/
|
||||
public final class ReplayCursorExpired extends ApiException {
|
||||
private final transient ReplayWindow window;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
package com.alibaba.qwen.code.managedagent.service;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
|
||||
import com.alibaba.qwen.code.managedagent.store.AgentStateStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.ReplayFloorTarget;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Raises each Session's replay floor as far as its Snapshot proves safe, the
|
||||
* first half of the Session event retention work (#12380): events at or below
|
||||
* the floor may be pruned later, and a replay cursor below it expires while
|
||||
* the Snapshot backs the floor.
|
||||
* Nothing deletes events here, and the pass is disabled until a deployment
|
||||
* opts in; the retention window and the reader-aware cleanup remain separate
|
||||
* decisions.
|
||||
*/
|
||||
@Component
|
||||
public class ReplayFloorAdvancer {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(
|
||||
ReplayFloorAdvancer.class);
|
||||
private static final int TARGET_LIMIT = 64;
|
||||
private final AgentStateStore store;
|
||||
private final ManagedAgentProperties properties;
|
||||
|
||||
public ReplayFloorAdvancer(AgentStateStore store,
|
||||
ManagedAgentProperties properties) {
|
||||
this.store = store;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Scheduled(scheduler = "replayFloorScheduler", fixedDelayString =
|
||||
"${qwen.managed-agent.events.replay-floor-interval:60s}")
|
||||
public void advance() {
|
||||
if (!properties.getEvents().isReplayFloorEnabled()) {
|
||||
return;
|
||||
}
|
||||
// A pass drains the backlog, not just the first batch: the candidate
|
||||
// query orders by updated_at, which the advance never moves, so a
|
||||
// Session that keeps appending would sort last of every full batch
|
||||
// and starve while more than TARGET_LIMIT candidates remain. A target
|
||||
// whose advance keeps failing stays a candidate, so the attempted set
|
||||
// keeps it from wedging the pass.
|
||||
Set<ReplayFloorTarget> attempted = new HashSet<>();
|
||||
while (true) {
|
||||
List<ReplayFloorTarget> batch =
|
||||
store.findReplayFloorTargets(TARGET_LIMIT);
|
||||
boolean fresh = false;
|
||||
for (ReplayFloorTarget target : batch) {
|
||||
if (!attempted.add(target)) {
|
||||
continue;
|
||||
}
|
||||
fresh = true;
|
||||
try {
|
||||
// MAX_VALUE asks for as far as the Snapshot proves safe;
|
||||
// the store clamps the floor to the covered sequence.
|
||||
store.advanceReplayFloor(target.tenantId(),
|
||||
target.sessionId(), Long.MAX_VALUE);
|
||||
} catch (RuntimeException error) {
|
||||
LOG.warn("Failed to advance the replay floor of Managed"
|
||||
+ " Agent session {}", target.sessionId(), error);
|
||||
}
|
||||
}
|
||||
if (!fresh || batch.size() < TARGET_LIMIT) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationAdmission;
|
|||
import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationKind;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationRecord;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationTarget;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.ReplayFloorTarget;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.ReplayWindow;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionPage;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionRecord;
|
||||
|
|
@ -282,6 +283,16 @@ public interface AgentStateStore {
|
|||
|
||||
ReplayWindow findReplayWindow(String tenantId, String sessionId);
|
||||
|
||||
/** The Sessions whose Snapshot covers more than their replay floor. */
|
||||
List<ReplayFloorTarget> findReplayFloorTargets(int limit);
|
||||
|
||||
/**
|
||||
* Raises the Session's replay floor, never above the Snapshot's covered
|
||||
* sequence, so a client told to resync can resume from the Snapshot.
|
||||
*/
|
||||
ReplayWindow advanceReplayFloor(String tenantId, String sessionId,
|
||||
long floorSequence);
|
||||
|
||||
List<MaterializationTarget> findMaterializationTargets(int limit);
|
||||
|
||||
MaterializationResult materializeNextBatch(String tenantId,
|
||||
|
|
|
|||
|
|
@ -55,8 +55,10 @@ public class ChildResultRelayStore {
|
|||
}
|
||||
}
|
||||
|
||||
// delivery_state is null on the shell kind, so a child_run row with a
|
||||
// pending delivery is always a child_agent row; the ledger join
|
||||
// delivery_state is null on the shell kind, and the relay drives only
|
||||
// the child_agent kind: a workflow child's execution belongs to the
|
||||
// workflow runtime, which does not exist yet (H4c registers the kind
|
||||
// disabled), so its rows never reach this page; the ledger join
|
||||
// drops every row whose classification is terminal (delivered,
|
||||
// orphaned or given up), because the bounded discovery set is for
|
||||
// work still owed — accumulated terminal rows would otherwise starve
|
||||
|
|
@ -92,6 +94,7 @@ public class ChildResultRelayStore {
|
|||
+ " ON l.parent_session_id = r.session_id"
|
||||
+ " AND l.child_run_id = r.record_id"
|
||||
+ " WHERE r.domain = 'child_run'"
|
||||
+ " AND r.task_kind = 'child_agent'"
|
||||
+ " AND ((r.delivery_state IN ('planned', 'accepting',"
|
||||
+ " 'unknown') AND (l.state IS NULL OR l.state NOT IN"
|
||||
+ " ('done', 'orphaned', 'unknown')))"
|
||||
|
|
@ -203,7 +206,9 @@ public class ChildResultRelayStore {
|
|||
/**
|
||||
* The non-terminal child-agent runs of one Session, for the close
|
||||
* cascade: task projections say what is still alive; the bodies name
|
||||
* the child Sessions (null when creation never attached).
|
||||
* the child Sessions (null when creation never attached). Like the
|
||||
* relay's discovery page, the cascade acts on the child_agent kind
|
||||
* only until the workflow runtime exists.
|
||||
*/
|
||||
public List<LiveScope> findLiveScopes(String tenantId,
|
||||
String parentSessionId) {
|
||||
|
|
@ -212,6 +217,7 @@ public class ChildResultRelayStore {
|
|||
+ " FROM qwen_managed_session_extension_record"
|
||||
+ " WHERE tenant_id = ? AND session_id = ?"
|
||||
+ " AND domain = 'child_run' AND delivery_state IS NOT NULL"
|
||||
+ " AND task_kind = 'child_agent'"
|
||||
+ " AND task_state NOT IN ('completed', 'failed',"
|
||||
+ " 'cancelled')"
|
||||
+ " ORDER BY created_at, record_id",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationKind;
|
|||
import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationRecord;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationTarget;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.ProjectedEvent;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.ReplayFloorTarget;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.ReplayWindow;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionPage;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionRecord;
|
||||
|
|
@ -1946,6 +1947,19 @@ public class ManagedAgentStore implements AgentStateStore {
|
|||
return rows.getFirst();
|
||||
}
|
||||
|
||||
public List<ReplayFloorTarget> findReplayFloorTargets(int limit) {
|
||||
return jdbc.query("SELECT s.tenant_id, s.session_id FROM"
|
||||
+ " managed_agent_session s JOIN"
|
||||
+ " managed_agent_snapshot p ON p.tenant_id ="
|
||||
+ " s.tenant_id AND p.session_id = s.session_id WHERE"
|
||||
+ " p.covered_sequence > s.replay_floor_sequence"
|
||||
+ " ORDER BY s.updated_at ASC LIMIT ?",
|
||||
(result, row) -> new ReplayFloorTarget(
|
||||
result.getString("tenant_id"),
|
||||
result.getString("session_id")),
|
||||
limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises the replay floor, never above the Snapshot's covered sequence,
|
||||
* so that a client told to resync can resume from the Snapshot. Nothing
|
||||
|
|
|
|||
|
|
@ -657,7 +657,7 @@ public class ManagedExtensionRecordStore {
|
|||
requireReference(resources.apply(ref.get("resourceId").textValue()), ref);
|
||||
}
|
||||
}
|
||||
require(!"child_agent".equals(record.get("kind").textValue())
|
||||
require("shell".equals(record.get("kind").textValue())
|
||||
|| record.get("depth").longValue() != 1
|
||||
|| record.get("rootSessionId").textValue()
|
||||
.equals(sessionId),
|
||||
|
|
@ -779,10 +779,10 @@ public class ManagedExtensionRecordStore {
|
|||
(result, row) -> result.getString("record_resource_id"),
|
||||
scopeKey, childRunKey).stream().findFirst().orElse(null);
|
||||
require(childRunResource != null,
|
||||
"Child acceptance must name a child agent run of this Session.");
|
||||
"Child acceptance must name a child Session run of this Session.");
|
||||
JsonNode child = readBody(resources.apply(childRunResource));
|
||||
require("child_agent".equals(child.get("kind").textValue()),
|
||||
"Child acceptance must name a child agent run of this Session.");
|
||||
require(!"shell".equals(child.get("kind").textValue()),
|
||||
"Child acceptance must name a child Session run of this Session.");
|
||||
require("settled".equals(child.get("run").get("state").textValue())
|
||||
&& "completed".equals(child.get("stopReason").textValue()),
|
||||
"Child acceptance must name a run that ended with its"
|
||||
|
|
@ -811,7 +811,7 @@ public class ManagedExtensionRecordStore {
|
|||
"Child acceptance must bind the result and receipt its"
|
||||
+ " child run committed.");
|
||||
}
|
||||
if (domain.equals("child_run") && "child_agent".equals(
|
||||
if (domain.equals("child_run") && !"shell".equals(
|
||||
record.get("kind").textValue())) {
|
||||
// H4b decision 7 (the reverse of the acceptance's check): the
|
||||
// acceptance record is authoritative — the run's delivery may
|
||||
|
|
|
|||
|
|
@ -169,13 +169,15 @@ public final class ManagedExtensionRecords {
|
|||
"stopReason", "stopRequested", "exitCode", "exitSignal", "run");
|
||||
private static final List<String> CHILD_FIXED = List.of("kind", "shellId",
|
||||
"ownerScopeId", "commandRef");
|
||||
// The closed keys of both child Session kinds: a workflow child rides
|
||||
// the child agent's lifecycle field for field.
|
||||
private static final Set<String> CHILD_AGENT_KEYS = Set.of("kind",
|
||||
"childRunId", "ownerScopeId", "rootSessionId", "depth",
|
||||
"completion", "inputRef", "workspaceMode", "workingDirectory",
|
||||
"childSessionId", "predecessorChildRunId", "resultVersion",
|
||||
"resultRef", "terminalReceiptRef", "stopReason", "stopRequested",
|
||||
"run");
|
||||
// The fields that no revision of a child agent may change.
|
||||
// The fields that no revision of a child Session run may change.
|
||||
// `resultVersion` is not here on purpose: the parser forces it to 1,
|
||||
// so no two revisions can ever differ on it, and a fixed-key entry
|
||||
// for it could never refuse.
|
||||
|
|
@ -719,8 +721,9 @@ public final class ManagedExtensionRecords {
|
|||
|
||||
/**
|
||||
* Checks the body of a managed-child_run schema version 1 record by its
|
||||
* own {@code kind} field: {@code "shell"} (H3) or {@code "child_agent"}
|
||||
* (H4), each a closed shape (see managed-child-run-record.ts for the
|
||||
* own {@code kind} field: {@code "shell"} (H3), or one of the child
|
||||
* Session kinds {@code "child_agent"} (H4a) and {@code "workflow"}
|
||||
* (H4c), each a closed shape (see managed-child-run-record.ts for the
|
||||
* same rules).
|
||||
*/
|
||||
public static void requireChildRun(JsonNode child) {
|
||||
|
|
@ -730,19 +733,21 @@ public final class ManagedExtensionRecords {
|
|||
requireChildShell(child);
|
||||
return;
|
||||
}
|
||||
if ("child_agent".equals(kind.textValue())) {
|
||||
requireChildAgent(child);
|
||||
if ("child_agent".equals(kind.textValue())
|
||||
|| "workflow".equals(kind.textValue())) {
|
||||
requireChildSession(child);
|
||||
return;
|
||||
}
|
||||
}
|
||||
require(false, "Child run kind must be one of shell, child_agent in"
|
||||
+ " schema version 1");
|
||||
require(false, "Child run kind must be one of shell, child_agent,"
|
||||
+ " workflow in schema version 1");
|
||||
}
|
||||
|
||||
/** The task kind one child run projects, by its own kind. */
|
||||
public static String childRunTaskKind(JsonNode child) {
|
||||
return "shell".equals(child.get("kind").textValue())
|
||||
? "background_shell" : "child_agent";
|
||||
String kind = child.get("kind").textValue();
|
||||
return "shell".equals(kind) ? "background_shell"
|
||||
: "workflow".equals(kind) ? "workflow" : "child_agent";
|
||||
}
|
||||
|
||||
/** The identity that keys a child run's revision chain, by its kind. */
|
||||
|
|
@ -875,18 +880,20 @@ public final class ManagedExtensionRecords {
|
|||
}
|
||||
|
||||
/**
|
||||
* Checks the body of a managed-child_run schema version 1 record
|
||||
* ({@code kind: "child_agent"}): one child Session per record (H4 of
|
||||
* #12827; the checks run in the same order as the TypeScript validator
|
||||
* so both report the same clause of a doubly broken body).
|
||||
* Checks the body of a managed-child_run schema version 1 record of a
|
||||
* child Session kind ({@code kind: "child_agent"} or
|
||||
* {@code "workflow"}): one child Session per record under one shared
|
||||
* shape (H4 of #12827; the checks run in the same order as the
|
||||
* TypeScript validator so both report the same clause of a doubly
|
||||
* broken body).
|
||||
*/
|
||||
private static void requireChildAgent(JsonNode child) {
|
||||
private static void requireChildSession(JsonNode child) {
|
||||
closed(child, CHILD_AGENT_KEYS, "childRun");
|
||||
JsonNode run = child.get("run");
|
||||
requireRun(run);
|
||||
// A child agent is started by one tool call; its result travels
|
||||
// the session delivery line its relay scans, never an effect
|
||||
// identity or an external delivery.
|
||||
// A child Session run is started by one tool call; its result
|
||||
// travels the session delivery line its relay scans, never an
|
||||
// effect identity or an external delivery.
|
||||
require(!run.get("executionCallId").isNull()
|
||||
&& run.get("effectId").isNull()
|
||||
&& run.get("deliveryId").isNull(),
|
||||
|
|
@ -896,6 +903,12 @@ public final class ManagedExtensionRecords {
|
|||
require(!delivery.isNull()
|
||||
&& "session".equals(delivery.get("target").textValue()),
|
||||
"Child run delivery must target the parent session");
|
||||
// A workflow run exists to run one workflow revision, so the
|
||||
// launch itself names it: the pin is required from the opening
|
||||
// revision on, never merely by the dispatch.
|
||||
require(!"workflow".equals(child.get("kind").textValue())
|
||||
|| !run.get("definition").isNull(),
|
||||
"Workflow run must pin its workflow definition from launch");
|
||||
// The launched definition is pinned no later than the dispatch
|
||||
// that admits the creation; the shared successor rule makes it
|
||||
// unaddable after that dispatch, so an absence is unrepairable.
|
||||
|
|
@ -1068,8 +1081,9 @@ public final class ManagedExtensionRecords {
|
|||
|
||||
/**
|
||||
* Whether {@code child} may open its chain: a Shell's run opens with no
|
||||
* stop request and no output; a child agent's run opens with the
|
||||
* delivery planned, no Session created, no result and no stop request.
|
||||
* stop request and no output; a child Session run (either kind) opens
|
||||
* with the delivery planned, no Session created, no result and no stop
|
||||
* request.
|
||||
*/
|
||||
public static boolean isChildRunStart(JsonNode child) {
|
||||
if (!accepts(() -> requireChildRun(child))
|
||||
|
|
|
|||
|
|
@ -61,7 +61,9 @@ public final class StoreModels {
|
|||
/**
|
||||
* Events at or below {@code floorSequence} may be pruned. A client that
|
||||
* falls below it reloads the Snapshot, which covers events through
|
||||
* {@code snapshotThroughSequence}.
|
||||
* {@code snapshotThroughSequence}. A stream reconciliation discards the
|
||||
* Snapshot without lowering the floor, so the floor can exceed the
|
||||
* coverage while the Items rebuild; the cursor is still served then.
|
||||
*/
|
||||
public record ReplayWindow(long floorSequence,
|
||||
long snapshotThroughSequence) {
|
||||
|
|
@ -198,6 +200,10 @@ public final class StoreModels {
|
|||
long coveredSequence) {
|
||||
}
|
||||
|
||||
/** A Session whose Snapshot covers more than its replay floor. */
|
||||
public record ReplayFloorTarget(String tenantId, String sessionId) {
|
||||
}
|
||||
|
||||
public record DispatchTarget(String tenantId, String sessionId,
|
||||
String turnId) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ qwen:
|
|||
read-grant-recheck-interval: '5s'
|
||||
batch-interval: '75ms'
|
||||
materialize-interval: '100ms'
|
||||
replay-floor-enabled: '${QWEN_MANAGED_AGENT_REPLAY_FLOOR_ENABLED:false}'
|
||||
replay-floor-interval: '${QWEN_MANAGED_AGENT_REPLAY_FLOOR_INTERVAL:60s}'
|
||||
batch-max-events: 64
|
||||
batch-max-bytes: 65536
|
||||
runtime-broker:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -152,6 +152,10 @@ class ChildResultRelayStoreTest {
|
|||
"accepted", "completed");
|
||||
insertRecordRow(scopeKey, session, "shell-x", "shell", null,
|
||||
"running");
|
||||
// H4c registers the workflow kind ahead of its runtime: neither
|
||||
// the relay nor the close cascade acts on its rows yet.
|
||||
insertRecordRow(scopeKey, session, "workflow-live", "workflow",
|
||||
"planned", "pending");
|
||||
jdbc.update("INSERT INTO managed_agent_session (tenant_id,"
|
||||
+ " session_id, agent_id, status, created_at,"
|
||||
+ " updated_at) VALUES (?, ?, 'qwen-code', 'ACTIVE', 1,"
|
||||
|
|
@ -244,8 +248,7 @@ class ChildResultRelayStoreTest {
|
|||
+ " 'h', 1, ?, ?, ?, ?, ?, 1)",
|
||||
scopeKey, recordId + "-key", TENANT, sessionId, recordId,
|
||||
"resource-" + recordId,
|
||||
"background_shell".equals(taskState) ? "background_shell"
|
||||
: "child_agent", taskState,
|
||||
"shell".equals(kind) ? "background_shell" : kind, taskState,
|
||||
deliveryState == null ? null : "session",
|
||||
deliveryState);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,10 +70,11 @@ class ManagedChildRunRecordContractTest {
|
|||
() -> "case " + id + " names an unknown template");
|
||||
JsonNode record = merge(base, fixture.get("patch"));
|
||||
// The kind the merged record carries decides the task kind, so
|
||||
// each valid case pins its own mapping.
|
||||
// each valid case pins its own mapping: a Shell projects
|
||||
// background_shell, a child Session kind its own name.
|
||||
if (fixture.get("valid").booleanValue()) {
|
||||
assertEquals("shell".equals(record.get("kind").textValue())
|
||||
? "background_shell" : "child_agent",
|
||||
String kind = record.get("kind").textValue();
|
||||
assertEquals("shell".equals(kind) ? "background_shell" : kind,
|
||||
body.taskKindOf().apply(record), id);
|
||||
}
|
||||
if (fixture.get("valid").booleanValue()) {
|
||||
|
|
|
|||
|
|
@ -44,7 +44,10 @@ class ManagedExtensionProjectionContractTest {
|
|||
JSON.createObjectNode().put("kind", "shell")))
|
||||
.put("child_agent", body.taskKindOf().apply(
|
||||
JSON.createObjectNode()
|
||||
.put("kind", "child_agent"))));
|
||||
.put("kind", "child_agent")))
|
||||
.put("workflow", body.taskKindOf().apply(
|
||||
JSON.createObjectNode()
|
||||
.put("kind", "workflow"))));
|
||||
} else {
|
||||
// Probe with a record-shaped node carrying the identity
|
||||
// fields a mapping could regress into reading — the real
|
||||
|
|
|
|||
|
|
@ -303,7 +303,7 @@ class ManagedExtensionRecordStoreTest {
|
|||
ExtensionRecordJournal missingJournal = journal(missingChild);
|
||||
assertRefused("an acceptance without its child run", missingChild,
|
||||
ManagedExtensionRecordStore.ERROR_REJECTED,
|
||||
"must name a child agent run of this Session",
|
||||
"must name a child Session run of this Session",
|
||||
() -> missingJournal.commit(missingJournal.requestDomain(
|
||||
"accept-1", "child_acceptance",
|
||||
acceptance(resultResource, receiptResource, "accepted"),
|
||||
|
|
@ -382,7 +382,7 @@ class ManagedExtensionRecordStoreTest {
|
|||
namingShell.put("childRunId", "shell-x");
|
||||
assertRefused("an acceptance naming a background Shell", shellSession,
|
||||
ManagedExtensionRecordStore.ERROR_REJECTED,
|
||||
"must name a child agent run of this Session",
|
||||
"must name a child Session run of this Session",
|
||||
() -> shellJournal.commit(shellJournal.requestDomain(
|
||||
"accept-1", "child_acceptance", namingShell,
|
||||
List.of(resultResource, receiptResource), 1_000)));
|
||||
|
|
@ -484,6 +484,74 @@ class ManagedExtensionRecordStoreTest {
|
|||
List.of(resultResource, receiptResource), 1_000)));
|
||||
}
|
||||
|
||||
/** A child agent body reshaped into the workflow kind (H4c): the pin
|
||||
* names the workflow revision the launch runs. */
|
||||
private static ObjectNode workflow(ObjectNode body) {
|
||||
body.put("kind", "workflow");
|
||||
ObjectNode definition = body.withObject("/run")
|
||||
.withObject("/definition");
|
||||
definition.put("definitionId", "workflow-review");
|
||||
definition.put("definitionRevision", 3);
|
||||
definition.put("definitionDigest", "9".repeat(64));
|
||||
return body;
|
||||
}
|
||||
|
||||
@Test
|
||||
void commitsAWorkflowChainUnderTheChildSessionRules() throws Exception {
|
||||
CommitResource inputResource = hookResource("input-w", "managed-input",
|
||||
"{}".getBytes(StandardCharsets.UTF_8));
|
||||
CommitResource resultResource = hookResource("result-w",
|
||||
"managed-child-result",
|
||||
"{\"summary\":\"clean\"}".getBytes(StandardCharsets.UTF_8));
|
||||
CommitResource receiptResource = hookResource("receipt-w",
|
||||
"managed-runtime-receipt",
|
||||
"{}".getBytes(StandardCharsets.UTF_8));
|
||||
String sessionId = UUID.randomUUID().toString();
|
||||
ExtensionRecordJournal journal = journal(sessionId);
|
||||
ObjectNode foreign = workflow(childAgent(sessionId, "sent",
|
||||
"admitted", "intent", null, inputResource));
|
||||
foreign.put("rootSessionId", "session-other");
|
||||
assertRefused("a first-level workflow rooted at another Session",
|
||||
sessionId, ManagedExtensionRecordStore.ERROR_REJECTED,
|
||||
"rootSessionId must be this Session for a first-level child",
|
||||
() -> journal.commit(journal.requestDomain("agent-0",
|
||||
"child_run", foreign, List.of(inputResource), 500)));
|
||||
settleChildSessionChain(sessionId, "sent", journal, inputResource,
|
||||
resultResource, receiptResource,
|
||||
ManagedExtensionRecordStoreTest::workflow);
|
||||
String taskId = ManagedExtensionProjection.taskId(
|
||||
ManagedExtensionProjection.recordKey(sessionId, "child_run",
|
||||
"run-x"));
|
||||
var task = records.findTask(TENANT, sessionId, taskId).orElseThrow();
|
||||
assertThat(task.kind()).isEqualTo("workflow");
|
||||
assertThat(task.projection().state()).isEqualTo("completed");
|
||||
assertThat(task.projection().definitionRevision()).isEqualTo(3L);
|
||||
// The acceptance gate and its reverse bind a workflow run exactly
|
||||
// as they bind a child agent.
|
||||
ObjectNode accepted = workflow(childAgent(sessionId, "sent",
|
||||
"settled", "settled", "binding-1", inputResource));
|
||||
accepted.withObject("/run").put("dispatchId", "dispatch-1");
|
||||
accepted.put("childSessionId", "session-child");
|
||||
accepted.put("stopReason", "completed");
|
||||
accepted.set("resultRef", hookRef(resultResource));
|
||||
accepted.set("terminalReceiptRef", hookRef(receiptResource));
|
||||
accepted.withObject("/run").withObject("/delivery")
|
||||
.put("state", "accepted");
|
||||
assertRefused("a workflow delivery reaching accepted ahead of its"
|
||||
+ " acceptance", sessionId,
|
||||
ManagedExtensionRecordStore.ERROR_REJECTED,
|
||||
"reaches accepted or consumed only with its acceptance record",
|
||||
() -> journal.commit(journal.requestDomain("agent-5",
|
||||
"child_run", accepted, List.of(), 5_000)));
|
||||
commitDomain(journal, "accept-1", "child_acceptance",
|
||||
acceptance(resultResource, receiptResource, "accepted"),
|
||||
List.of(resultResource, receiptResource));
|
||||
commitDomain(journal, "agent-6", "child_run", accepted, List.of());
|
||||
assertThat(records.listRecords(TENANT, sessionId, "child_run")
|
||||
.get(0).required("run").required("delivery")
|
||||
.required("state").textValue()).isEqualTo("accepted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesAFirstLevelChildAgentRootedAtAnotherSession()
|
||||
throws Exception {
|
||||
|
|
@ -613,21 +681,33 @@ class ManagedExtensionRecordStoreTest {
|
|||
String completion, ExtensionRecordJournal journal,
|
||||
CommitResource inputResource, CommitResource resultResource,
|
||||
CommitResource receiptResource) {
|
||||
settleChildSessionChain(sessionId, completion, journal,
|
||||
inputResource, resultResource, receiptResource,
|
||||
body -> body);
|
||||
}
|
||||
|
||||
/** Commits a child Session run through its settled result, each body
|
||||
* shaped into its kind by {@code shape}. */
|
||||
private static void settleChildSessionChain(String sessionId,
|
||||
String completion, ExtensionRecordJournal journal,
|
||||
CommitResource inputResource, CommitResource resultResource,
|
||||
CommitResource receiptResource,
|
||||
UnaryOperator<ObjectNode> shape) {
|
||||
commitDomain(journal, "agent-1", "child_run",
|
||||
childAgent(sessionId, completion, "admitted", "intent", null,
|
||||
inputResource),
|
||||
shape.apply(childAgent(sessionId, completion, "admitted",
|
||||
"intent", null, inputResource)),
|
||||
List.of(inputResource));
|
||||
ObjectNode dispatching = childAgent(sessionId, completion, "running",
|
||||
"dispatch_started", "binding-1", inputResource);
|
||||
ObjectNode dispatching = shape.apply(childAgent(sessionId, completion,
|
||||
"running", "dispatch_started", "binding-1", inputResource));
|
||||
dispatching.withObject("/run").put("dispatchId", "dispatch-1");
|
||||
commitDomain(journal, "agent-2", "child_run", dispatching, List.of());
|
||||
ObjectNode attached = childAgent(sessionId, completion, "running",
|
||||
"running_attached", "binding-1", inputResource);
|
||||
ObjectNode attached = shape.apply(childAgent(sessionId, completion,
|
||||
"running", "running_attached", "binding-1", inputResource));
|
||||
attached.withObject("/run").put("dispatchId", "dispatch-1");
|
||||
attached.put("childSessionId", "session-child");
|
||||
commitDomain(journal, "agent-3", "child_run", attached, List.of());
|
||||
ObjectNode settled = childAgent(sessionId, completion, "settled",
|
||||
"settled", "binding-1", inputResource);
|
||||
ObjectNode settled = shape.apply(childAgent(sessionId, completion,
|
||||
"settled", "settled", "binding-1", inputResource));
|
||||
settled.withObject("/run").put("dispatchId", "dispatch-1");
|
||||
settled.put("childSessionId", "session-child");
|
||||
settled.put("stopReason", "completed");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
package com.alibaba.qwen.code.managedagent.service;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.api.TenantContextFilter;
|
||||
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.LongStream;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter.DataWithMediaType;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
/**
|
||||
* The harness the event read-path tests share: a RecordingEmitter capturing
|
||||
* event ids, resync frames, failures and completion — able to stall the
|
||||
* stream thread on one sequence so a test can overflow the hub behind it —
|
||||
* plus the stream-service factory with poll and heartbeat pinned past the
|
||||
* test, so frames come only from the opening read or a live publish.
|
||||
*/
|
||||
final class EventStreams {
|
||||
private EventStreams() {
|
||||
}
|
||||
|
||||
/**
|
||||
* An idle stream re-reads the store on a poll or heartbeat; pin both
|
||||
* past the test so the frames come only from the opening read.
|
||||
*/
|
||||
static ManagedAgentProperties pinnedProperties() {
|
||||
ManagedAgentProperties properties = new ManagedAgentProperties();
|
||||
properties.getEvents().setPollInterval(Duration.ofSeconds(60));
|
||||
properties.getEvents().setHeartbeatInterval(Duration.ofSeconds(60));
|
||||
return properties;
|
||||
}
|
||||
|
||||
static ManagedEventStreamService streams(ManagedAgentService service,
|
||||
SessionEventHub hub, ExecutorService executor,
|
||||
SseEmitter emitter) {
|
||||
return streams(service, hub, executor, pinnedProperties(), emitter);
|
||||
}
|
||||
|
||||
static ManagedEventStreamService streams(ManagedAgentService service,
|
||||
SessionEventHub hub, ExecutorService executor,
|
||||
ManagedAgentProperties properties, SseEmitter emitter) {
|
||||
return new ManagedEventStreamService(service, hub, executor,
|
||||
properties) {
|
||||
@Override
|
||||
SseEmitter emitter() {
|
||||
return emitter;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static MockHttpServletRequestBuilder events(String tenant,
|
||||
String sessionId) {
|
||||
return get("/v1/agents/sessions/{id}/events", sessionId)
|
||||
.header(TenantContextFilter.HEADER, tenant)
|
||||
.accept(MediaType.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
static JsonNode json(ObjectMapper objectMapper,
|
||||
MockHttpServletResponse response) throws Exception {
|
||||
return objectMapper.readTree(
|
||||
response.getContentAsString(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
static List<Long> range(long first, long last) {
|
||||
return LongStream.rangeClosed(first, last).boxed().toList();
|
||||
}
|
||||
|
||||
/** Records the sequences and resync frames a stream delivers. */
|
||||
static final class RecordingEmitter extends SseEmitter {
|
||||
private static final Pattern ID = Pattern.compile("(?m)^id:(\\d+)$");
|
||||
final List<Long> ids = new CopyOnWriteArrayList<>();
|
||||
final List<Object> resync = new CopyOnWriteArrayList<>();
|
||||
final List<Throwable> failed = new CopyOnWriteArrayList<>();
|
||||
final CountDownLatch blocked = new CountDownLatch(1);
|
||||
final CountDownLatch release = new CountDownLatch(1);
|
||||
final CountDownLatch completed = new CountDownLatch(1);
|
||||
private final long blockOn;
|
||||
|
||||
RecordingEmitter() {
|
||||
this(null, -1);
|
||||
}
|
||||
|
||||
private RecordingEmitter(Long timeoutMillis, long blockOn) {
|
||||
super(timeoutMillis);
|
||||
this.blockOn = blockOn;
|
||||
}
|
||||
|
||||
/** An emitter that stalls the stream thread on {@code blockOn}. */
|
||||
static RecordingEmitter blockingOn(long blockOn) {
|
||||
return new RecordingEmitter(null, blockOn);
|
||||
}
|
||||
|
||||
static RecordingEmitter withTimeout(long timeoutMillis) {
|
||||
return new RecordingEmitter(timeoutMillis, -1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(SseEventBuilder builder) {
|
||||
StringBuilder text = new StringBuilder();
|
||||
Object data = null;
|
||||
for (DataWithMediaType part : builder.build()) {
|
||||
if (part.getData() instanceof String value) {
|
||||
text.append(value);
|
||||
} else {
|
||||
data = part.getData();
|
||||
}
|
||||
}
|
||||
Matcher matcher = ID.matcher(text);
|
||||
if (!matcher.find()) {
|
||||
if (text.toString().contains(
|
||||
"event:" + ManagedEventStreamService.RESYNC)) {
|
||||
resync.add(data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
long id = Long.parseLong(matcher.group(1));
|
||||
ids.add(id);
|
||||
if (id == blockOn) {
|
||||
blocked.countDown();
|
||||
try {
|
||||
release.await(10, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException error) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void complete() {
|
||||
completed.countDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void completeWithError(Throwable error) {
|
||||
failed.add(error);
|
||||
completed.countDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -50,13 +50,10 @@ import java.util.Map;
|
|||
import java.util.TreeMap;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.sql.DataSource;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
|
|
@ -64,8 +61,6 @@ import org.junit.jupiter.api.Test;
|
|||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter.DataWithMediaType;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
/**
|
||||
* Pinned query budgets for GitHub issue #13181: four managed-agent hot paths
|
||||
|
|
@ -469,23 +464,18 @@ class Issue13181QueryBudgetTest {
|
|||
long after = fixture.store.requireSession(tenant, sessionId)
|
||||
.lastSequence();
|
||||
int events = 20;
|
||||
RecordingEmitter emitter = new RecordingEmitter(60_000);
|
||||
EventStreams.RecordingEmitter emitter = EventStreams.RecordingEmitter
|
||||
.withTimeout(60_000);
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
ManagedAgentProperties properties = new ManagedAgentProperties();
|
||||
properties.getEvents().setPollInterval(Duration.ofSeconds(60));
|
||||
properties.getEvents().setHeartbeatInterval(Duration.ofSeconds(60));
|
||||
ManagedAgentProperties properties = EventStreams.pinnedProperties();
|
||||
// Longer than the completion budget, so no in-loop recheck can
|
||||
// fire mid-test even on a stalled runner.
|
||||
properties.getEvents()
|
||||
.setReadGrantRecheckInterval(Duration.ofSeconds(60));
|
||||
ManagedEventStreamService streams = new ManagedEventStreamService(
|
||||
fixture.service, fixture.hub, executor, properties) {
|
||||
@Override
|
||||
SseEmitter emitter() {
|
||||
return emitter;
|
||||
}
|
||||
};
|
||||
ManagedEventStreamService streams = EventStreams.streams(
|
||||
fixture.service, fixture.hub, executor, properties,
|
||||
emitter);
|
||||
fixture.ledger.reset();
|
||||
streams.publicStream(tenant, "actor", sessionId, after);
|
||||
// The first in-loop grant check proves the hub subscription
|
||||
|
|
@ -525,19 +515,12 @@ class Issue13181QueryBudgetTest {
|
|||
long after = fixture.store.requireSession(tenant, sessionId)
|
||||
.lastSequence();
|
||||
int events = 10;
|
||||
RecordingEmitter emitter = new RecordingEmitter(60_000);
|
||||
EventStreams.RecordingEmitter emitter = EventStreams.RecordingEmitter
|
||||
.withTimeout(60_000);
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
ManagedAgentProperties properties = new ManagedAgentProperties();
|
||||
properties.getEvents().setPollInterval(Duration.ofSeconds(60));
|
||||
properties.getEvents().setHeartbeatInterval(Duration.ofSeconds(60));
|
||||
ManagedEventStreamService streams = new ManagedEventStreamService(
|
||||
fixture.service, fixture.hub, executor, properties) {
|
||||
@Override
|
||||
SseEmitter emitter() {
|
||||
return emitter;
|
||||
}
|
||||
};
|
||||
ManagedEventStreamService streams = EventStreams.streams(
|
||||
fixture.service, fixture.hub, executor, emitter);
|
||||
streams.publicStream(tenant, null, sessionId, after);
|
||||
for (int index = 0; index < events; index++) {
|
||||
fixture.store.appendPublicEventIfAbsent(tenant, sessionId,
|
||||
|
|
@ -1857,41 +1840,4 @@ class Issue13181QueryBudgetTest {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Captures delivered event ids and stream completion. */
|
||||
static final class RecordingEmitter extends SseEmitter {
|
||||
private static final Pattern ID = Pattern.compile("(?m)^id:(\\d+)$");
|
||||
final List<Long> ids = new CopyOnWriteArrayList<>();
|
||||
final List<Throwable> failed = new CopyOnWriteArrayList<>();
|
||||
final CountDownLatch completed = new CountDownLatch(1);
|
||||
|
||||
RecordingEmitter(long timeoutMillis) {
|
||||
super(timeoutMillis);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(SseEventBuilder builder) {
|
||||
StringBuilder text = new StringBuilder();
|
||||
for (DataWithMediaType part : builder.build()) {
|
||||
if (part.getData() instanceof String value) {
|
||||
text.append(value);
|
||||
}
|
||||
}
|
||||
Matcher matcher = ID.matcher(text);
|
||||
if (matcher.find()) {
|
||||
ids.add(Long.parseLong(matcher.group(1)));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void complete() {
|
||||
completed.countDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void completeWithError(Throwable error) {
|
||||
failed.add(error);
|
||||
completed.countDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||
import com.alibaba.qwen.code.managedagent.api.ApiModels.SessionResyncRequired;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellResyncRequired;
|
||||
import com.alibaba.qwen.code.managedagent.api.TenantContextFilter;
|
||||
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.ReplayWindow;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
|
@ -20,14 +19,11 @@ import java.util.List;
|
|||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.LongStream;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
|
@ -38,9 +34,6 @@ import org.springframework.boot.test.context.SpringBootTest;
|
|||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter.DataWithMediaType;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
/**
|
||||
* Replays committed events through JSON pages and both streams: resuming from
|
||||
|
|
@ -100,7 +93,8 @@ class ManagedEventReplayTest {
|
|||
JsonNode page;
|
||||
int pages = 0;
|
||||
do {
|
||||
page = json(mvc.perform(events(tenant, sessionId)
|
||||
page = EventStreams.json(objectMapper,
|
||||
mvc.perform(EventStreams.events(tenant, sessionId)
|
||||
.param("after", cursor).param("limit", "7"))
|
||||
.andReturn().getResponse());
|
||||
pages++;
|
||||
|
|
@ -111,15 +105,18 @@ class ManagedEventReplayTest {
|
|||
} while (page.get("has_more").asBoolean());
|
||||
|
||||
assertThat(pages).isEqualTo(35);
|
||||
assertThat(sequences).containsExactlyElementsOf(range(1, last));
|
||||
assertThat(sequences)
|
||||
.containsExactlyElementsOf(EventStreams.range(1, last));
|
||||
assertThat(page.get("next_cursor").isNull()).isTrue();
|
||||
assertThat(mvc.perform(events(tenant, sessionId)
|
||||
assertThat(mvc.perform(EventStreams.events(tenant, sessionId)
|
||||
.param("limit", "1000"))
|
||||
.andReturn().getResponse().getStatus()).isEqualTo(200);
|
||||
MockHttpServletResponse tooMany = mvc.perform(events(tenant,
|
||||
sessionId).param("limit", "1001")).andReturn().getResponse();
|
||||
MockHttpServletResponse tooMany = mvc.perform(EventStreams.events(
|
||||
tenant, sessionId).param("limit", "1001"))
|
||||
.andReturn().getResponse();
|
||||
assertThat(tooMany.getStatus()).isEqualTo(400);
|
||||
assertThat(json(tooMany).at("/error/code").asText())
|
||||
assertThat(EventStreams.json(objectMapper, tooMany)
|
||||
.at("/error/code").asText())
|
||||
.isEqualTo("invalid_limit");
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +125,8 @@ class ManagedEventReplayTest {
|
|||
String tenant = tenant();
|
||||
String sessionId = session(tenant);
|
||||
long caughtUp = append(tenant, sessionId, 250);
|
||||
MockHttpServletResponse stream = mvc.perform(events(tenant, sessionId)
|
||||
MockHttpServletResponse stream = mvc.perform(EventStreams.events(
|
||||
tenant, sessionId)
|
||||
.param("stream", "true").param("after", "5")
|
||||
.header("Last-Event-ID", "120")
|
||||
.accept(MediaType.TEXT_EVENT_STREAM))
|
||||
|
|
@ -141,7 +139,8 @@ class ManagedEventReplayTest {
|
|||
|
||||
await().atMost(Duration.ofSeconds(10))
|
||||
.until(() -> ids(stream).contains(last));
|
||||
assertThat(ids(stream)).containsExactlyElementsOf(range(121, last));
|
||||
assertThat(ids(stream))
|
||||
.containsExactlyElementsOf(EventStreams.range(121, last));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -152,7 +151,8 @@ class ManagedEventReplayTest {
|
|||
append(tenant, sessionId, 150);
|
||||
CompletableFuture<Long> writer = CompletableFuture.supplyAsync(
|
||||
() -> append(tenant, sessionId, 300));
|
||||
MockHttpServletResponse stream = mvc.perform(events(tenant, sessionId)
|
||||
MockHttpServletResponse stream = mvc.perform(EventStreams.events(
|
||||
tenant, sessionId)
|
||||
.param("stream", "true")
|
||||
.accept(MediaType.TEXT_EVENT_STREAM))
|
||||
.andReturn().getResponse();
|
||||
|
|
@ -161,7 +161,8 @@ class ManagedEventReplayTest {
|
|||
|
||||
await().atMost(Duration.ofSeconds(10))
|
||||
.until(() -> ids(stream).contains(last));
|
||||
assertThat(ids(stream)).containsExactlyElementsOf(range(1, last));
|
||||
assertThat(ids(stream))
|
||||
.containsExactlyElementsOf(EventStreams.range(1, last));
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "web shell: {0}")
|
||||
|
|
@ -172,12 +173,14 @@ class ManagedEventReplayTest {
|
|||
String tenant = tenant();
|
||||
String sessionId = session(tenant);
|
||||
long caughtUp = append(tenant, sessionId, 20);
|
||||
RecordingEmitter emitter = new RecordingEmitter(caughtUp + 1);
|
||||
EventStreams.RecordingEmitter emitter = EventStreams.RecordingEmitter
|
||||
.blockingOn(caughtUp + 1);
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
open(streams(executor, emitter), webShell, tenant, sessionId);
|
||||
open(EventStreams.streams(agentService, hub, executor, emitter),
|
||||
webShell, tenant, sessionId);
|
||||
await().atMost(Duration.ofSeconds(5))
|
||||
.until(() -> emitter.ids().contains(caughtUp));
|
||||
.until(() -> emitter.ids.contains(caughtUp));
|
||||
append(tenant, sessionId, 1);
|
||||
assertThat(emitter.blocked.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
// The hub drops the oldest events while the client is stuck.
|
||||
|
|
@ -187,8 +190,8 @@ class ManagedEventReplayTest {
|
|||
|
||||
assertThat(emitter.completed.await(10, TimeUnit.SECONDS))
|
||||
.isTrue();
|
||||
assertThat(emitter.ids())
|
||||
.containsExactlyElementsOf(range(RESUME_AFTER + 1, last));
|
||||
assertThat(emitter.ids).containsExactlyElementsOf(
|
||||
EventStreams.range(RESUME_AFTER + 1, last));
|
||||
assertThat(emitter.failed).isEmpty();
|
||||
// Catching up from the store needs no Snapshot reload.
|
||||
assertThat(emitter.resync).isEmpty();
|
||||
|
|
@ -204,12 +207,14 @@ class ManagedEventReplayTest {
|
|||
String tenant = tenant();
|
||||
String sessionId = session(tenant);
|
||||
long caughtUp = append(tenant, sessionId, 20);
|
||||
RecordingEmitter emitter = new RecordingEmitter(caughtUp + 1);
|
||||
EventStreams.RecordingEmitter emitter = EventStreams.RecordingEmitter
|
||||
.blockingOn(caughtUp + 1);
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
open(streams(executor, emitter), webShell, tenant, sessionId);
|
||||
open(EventStreams.streams(agentService, hub, executor, emitter),
|
||||
webShell, tenant, sessionId);
|
||||
await().atMost(Duration.ofSeconds(5))
|
||||
.until(() -> emitter.ids().contains(caughtUp));
|
||||
.until(() -> emitter.ids.contains(caughtUp));
|
||||
long stuck = append(tenant, sessionId, 1);
|
||||
assertThat(emitter.blocked.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
long last = append(tenant, sessionId, OVERFLOW);
|
||||
|
|
@ -218,8 +223,8 @@ class ManagedEventReplayTest {
|
|||
|
||||
assertThat(emitter.completed.await(10, TimeUnit.SECONDS))
|
||||
.isTrue();
|
||||
assertThat(emitter.ids())
|
||||
.containsExactlyElementsOf(range(RESUME_AFTER + 1, stuck));
|
||||
assertThat(emitter.ids).containsExactlyElementsOf(
|
||||
EventStreams.range(RESUME_AFTER + 1, stuck));
|
||||
assertThat(emitter.resync).containsExactly(webShell
|
||||
? new WebShellResyncRequired(
|
||||
ManagedEventStreamService.RESYNC, sessionId,
|
||||
|
|
@ -249,11 +254,13 @@ class ManagedEventReplayTest {
|
|||
assertThat(store.advanceReplayFloor(tenant, sessionId, 1))
|
||||
.isEqualTo(window);
|
||||
|
||||
MockHttpServletResponse expired = mvc.perform(events(tenant,
|
||||
sessionId).param("after", Long.toString(floor - 1)))
|
||||
MockHttpServletResponse expired = mvc.perform(EventStreams.events(
|
||||
tenant, sessionId)
|
||||
.param("after", Long.toString(floor - 1)))
|
||||
.andReturn().getResponse();
|
||||
assertThat(expired.getStatus()).isEqualTo(409);
|
||||
JsonNode error = json(expired).get("error");
|
||||
JsonNode error = EventStreams.json(objectMapper, expired)
|
||||
.get("error");
|
||||
assertThat(error.get("code").asText()).isEqualTo("cursor_expired");
|
||||
assertThat(error.get("replay_floor_sequence").asLong())
|
||||
.isEqualTo(floor);
|
||||
|
|
@ -261,16 +268,18 @@ class ManagedEventReplayTest {
|
|||
.isEqualTo(floor);
|
||||
assertThat(error.get("request_id").asText())
|
||||
.isEqualTo(expired.getHeader("X-Request-Id"));
|
||||
assertThat(mvc.perform(events(tenant, sessionId)
|
||||
assertThat(mvc.perform(EventStreams.events(tenant, sessionId)
|
||||
.param("after", Long.toString(floor)))
|
||||
.andReturn().getResponse().getStatus()).isEqualTo(200);
|
||||
assertThat(json(mvc.perform(get("/v1/agents/sessions/{id}",
|
||||
sessionId).header(TENANT, tenant))
|
||||
.andReturn().getResponse())
|
||||
assertThat(EventStreams.json(objectMapper,
|
||||
mvc.perform(get("/v1/agents/sessions/{id}", sessionId)
|
||||
.header(TENANT, tenant))
|
||||
.andReturn().getResponse())
|
||||
.get("replay_floor_sequence").asLong()).isEqualTo(floor);
|
||||
|
||||
MockHttpServletResponse publicStream = mvc.perform(events(tenant,
|
||||
sessionId).param("stream", "true")
|
||||
MockHttpServletResponse publicStream = mvc.perform(
|
||||
EventStreams.events(tenant, sessionId)
|
||||
.param("stream", "true")
|
||||
.accept(MediaType.TEXT_EVENT_STREAM))
|
||||
.andReturn().getResponse();
|
||||
MockHttpServletResponse webShellStream = mvc.perform(
|
||||
|
|
@ -322,22 +331,6 @@ class ManagedEventReplayTest {
|
|||
}
|
||||
}
|
||||
|
||||
private ManagedEventStreamService streams(ExecutorService executor,
|
||||
SseEmitter emitter) {
|
||||
ManagedAgentProperties properties = new ManagedAgentProperties();
|
||||
// Only an overflow or the end of catch-up makes the stream read
|
||||
// the store again.
|
||||
properties.getEvents().setPollInterval(Duration.ofSeconds(60));
|
||||
properties.getEvents().setHeartbeatInterval(Duration.ofSeconds(60));
|
||||
return new ManagedEventStreamService(agentService, hub, executor,
|
||||
properties) {
|
||||
@Override
|
||||
SseEmitter emitter() {
|
||||
return emitter;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private ReplayWindow advanceFloor(String tenant, String sessionId,
|
||||
long floor) {
|
||||
long last = store.requireSession(tenant, sessionId).lastSequence();
|
||||
|
|
@ -369,16 +362,6 @@ class ManagedEventReplayTest {
|
|||
return store.requireSession(tenant, sessionId).lastSequence();
|
||||
}
|
||||
|
||||
private MockHttpServletRequestBuilder events(String tenant,
|
||||
String sessionId) {
|
||||
return get("/v1/agents/sessions/{id}/events", sessionId)
|
||||
.header(TENANT, tenant).accept(MediaType.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
private JsonNode json(MockHttpServletResponse response) throws Exception {
|
||||
return objectMapper.readTree(content(response));
|
||||
}
|
||||
|
||||
private static String content(MockHttpServletResponse response)
|
||||
throws Exception {
|
||||
return response.getContentAsString(StandardCharsets.UTF_8);
|
||||
|
|
@ -394,72 +377,7 @@ class ManagedEventReplayTest {
|
|||
return ids;
|
||||
}
|
||||
|
||||
private static List<Long> range(long first, long last) {
|
||||
return LongStream.rangeClosed(first, last).boxed().toList();
|
||||
}
|
||||
|
||||
private static String tenant() {
|
||||
return "tenant-replay-" + UUID.randomUUID();
|
||||
}
|
||||
|
||||
/** Records frames and blocks the stream thread on one sequence. */
|
||||
private static final class RecordingEmitter extends SseEmitter {
|
||||
private final long blockOn;
|
||||
private final List<Long> sent = new CopyOnWriteArrayList<>();
|
||||
private final List<Object> resync = new CopyOnWriteArrayList<>();
|
||||
private final List<Throwable> failed = new CopyOnWriteArrayList<>();
|
||||
private final CountDownLatch blocked = new CountDownLatch(1);
|
||||
private final CountDownLatch release = new CountDownLatch(1);
|
||||
private final CountDownLatch completed = new CountDownLatch(1);
|
||||
|
||||
RecordingEmitter(long blockOn) {
|
||||
this.blockOn = blockOn;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(SseEventBuilder builder) {
|
||||
StringBuilder text = new StringBuilder();
|
||||
Object data = null;
|
||||
for (DataWithMediaType part : builder.build()) {
|
||||
if (part.getData() instanceof String value) {
|
||||
text.append(value);
|
||||
} else {
|
||||
data = part.getData();
|
||||
}
|
||||
}
|
||||
Matcher matcher = ID.matcher(text);
|
||||
if (!matcher.find()) {
|
||||
if (text.toString().contains(
|
||||
"event:" + ManagedEventStreamService.RESYNC)) {
|
||||
resync.add(data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
long id = Long.parseLong(matcher.group(1));
|
||||
sent.add(id);
|
||||
if (id == blockOn) {
|
||||
blocked.countDown();
|
||||
try {
|
||||
release.await(10, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException error) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void complete() {
|
||||
completed.countDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void completeWithError(Throwable error) {
|
||||
failed.add(error);
|
||||
completed.countDown();
|
||||
}
|
||||
|
||||
List<Long> ids() {
|
||||
return List.copyOf(sent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
package com.alibaba.qwen.code.managedagent.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
/**
|
||||
* A backlog bigger than one batch: the candidate query orders by
|
||||
* {@code updated_at}, which the advance never moves, so a Session that keeps
|
||||
* appending sorts last of every full batch and starves unless the pass
|
||||
* drains past the first batch (#12380). The scheduled passes are pinned an
|
||||
* hour out so the test drives the pass itself.
|
||||
*/
|
||||
@SpringBootTest(properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:managed-replay-backlog;MODE=MySQL;"
|
||||
+ "DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE",
|
||||
"spring.datasource.driver-class-name=org.h2.Driver",
|
||||
"spring.datasource.username=sa",
|
||||
"spring.datasource.password=",
|
||||
"qwen.managed-agent.harness.enabled=false",
|
||||
"qwen.managed-agent.dispatch.scan-delay=1h",
|
||||
"qwen.managed-agent.events.materialize-interval=1h",
|
||||
"qwen.managed-agent.events.replay-floor-enabled=true",
|
||||
"qwen.managed-agent.events.replay-floor-interval=1h"
|
||||
})
|
||||
class ReplayFloorAdvancerBacklogTest {
|
||||
private static final int BATCH_LIMIT = 64;
|
||||
|
||||
@Autowired
|
||||
private ManagedAgentStore store;
|
||||
|
||||
@Test
|
||||
void theAppendingSessionAdvancesInOnePassOverAFullBacklog() {
|
||||
String tenant = "tenant-replay-backlog-" + UUID.randomUUID();
|
||||
List<String> quiet = new ArrayList<>();
|
||||
for (int index = 0; index < BATCH_LIMIT; index++) {
|
||||
String sessionId = store.insertSessionCommand(tenant,
|
||||
"CREATE_SESSION", "backlog-" + UUID.randomUUID(),
|
||||
"digest", "qwen-code", null, null, List.of(), "payload")
|
||||
.sessionId();
|
||||
append(tenant, sessionId, 5);
|
||||
quiet.add(sessionId);
|
||||
}
|
||||
String busy = store.insertSessionCommand(tenant, "CREATE_SESSION",
|
||||
"backlog-busy-" + UUID.randomUUID(), "digest", "qwen-code",
|
||||
null, null, List.of(), "payload").sessionId();
|
||||
append(tenant, busy, 5);
|
||||
// The Session that keeps appending sorts last on updated_at, out of
|
||||
// the first batch.
|
||||
long busyCoverage = append(tenant, busy, 1);
|
||||
for (String sessionId : quiet) {
|
||||
store.materializeNextBatch(tenant, sessionId, 100);
|
||||
}
|
||||
store.materializeNextBatch(tenant, busy, 100);
|
||||
assertThat(store.findReplayFloorTargets(BATCH_LIMIT))
|
||||
.hasSize(BATCH_LIMIT);
|
||||
|
||||
ManagedAgentProperties properties = new ManagedAgentProperties();
|
||||
properties.getEvents().setReplayFloorEnabled(true);
|
||||
new ReplayFloorAdvancer(store, properties).advance();
|
||||
|
||||
assertThat(store.findReplayWindow(tenant, busy).floorSequence())
|
||||
.isEqualTo(busyCoverage);
|
||||
assertThat(store.findReplayFloorTargets(BATCH_LIMIT)).isEmpty();
|
||||
}
|
||||
|
||||
private long append(String tenant, String sessionId, int count) {
|
||||
for (int index = 0; index < count; index++) {
|
||||
store.appendPublicEventIfAbsent(tenant, sessionId,
|
||||
"turn_replay_backlog", "test.progress",
|
||||
Map.of("index", index), false,
|
||||
"replay-backlog:" + UUID.randomUUID());
|
||||
}
|
||||
return store.requireSession(tenant, sessionId).lastSequence();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
package com.alibaba.qwen.code.managedagent.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
|
||||
import com.alibaba.qwen.code.managedagent.store.AgentStateStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.ReplayFloorTarget;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
/**
|
||||
* The retention pass raises a Session's replay floor as far as its Snapshot
|
||||
* proves safe, and only when it is enabled: the store is never touched while
|
||||
* {@code replay-floor-enabled} is false, a converged Session is never
|
||||
* selected again, one failing Session does not stop the rest, and a backlog
|
||||
* beyond one batch is drained within the pass without retrying the failure.
|
||||
*/
|
||||
@SpringBootTest(properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:managed-replay-floor;MODE=MySQL;"
|
||||
+ "DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE",
|
||||
"spring.datasource.driver-class-name=org.h2.Driver",
|
||||
"spring.datasource.username=sa",
|
||||
"spring.datasource.password=",
|
||||
"qwen.managed-agent.harness.enabled=false",
|
||||
"qwen.managed-agent.events.materialize-interval=10ms",
|
||||
"qwen.managed-agent.events.replay-floor-enabled=true",
|
||||
"qwen.managed-agent.events.replay-floor-interval=10ms"
|
||||
})
|
||||
class ReplayFloorAdvancerTest {
|
||||
|
||||
@Autowired
|
||||
private ManagedAgentStore store;
|
||||
|
||||
@Test
|
||||
void scheduledPassRaisesTheFloorToTheSnapshotCoverage() {
|
||||
String tenant = tenant();
|
||||
String sessionId = session(tenant);
|
||||
long last = append(tenant, sessionId, 5);
|
||||
|
||||
await().atMost(Duration.ofSeconds(10)).until(() ->
|
||||
store.findReplayWindow(tenant, sessionId)
|
||||
.snapshotThroughSequence() == last);
|
||||
await().atMost(Duration.ofSeconds(10)).until(() ->
|
||||
store.findReplayWindow(tenant, sessionId)
|
||||
.floorSequence() == last);
|
||||
}
|
||||
|
||||
@Test
|
||||
void convergedSessionsAreNotSelectedAgain() {
|
||||
String tenant = tenant();
|
||||
String sessionId = session(tenant);
|
||||
long last = append(tenant, sessionId, 3);
|
||||
await().atMost(Duration.ofSeconds(10)).until(() ->
|
||||
store.findReplayWindow(tenant, sessionId)
|
||||
.floorSequence() == last);
|
||||
|
||||
assertThat(store.findReplayFloorTargets(64))
|
||||
.noneMatch(target -> target.sessionId().equals(sessionId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledPassNeverTouchesTheStore() {
|
||||
AgentStateStore mockStore = mock(AgentStateStore.class);
|
||||
|
||||
new ReplayFloorAdvancer(mockStore, new ManagedAgentProperties())
|
||||
.advance();
|
||||
|
||||
verifyNoInteractions(mockStore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledPassAdvancesEveryTargetAsFarAsTheSnapshotProves() {
|
||||
AgentStateStore mockStore = mock(AgentStateStore.class);
|
||||
when(mockStore.findReplayFloorTargets(anyInt())).thenReturn(List.of(
|
||||
new ReplayFloorTarget("tenant-a", "session-a"),
|
||||
new ReplayFloorTarget("tenant-b", "session-b")));
|
||||
|
||||
enabledAdvancer(mockStore).advance();
|
||||
|
||||
verify(mockStore).advanceReplayFloor("tenant-a", "session-a",
|
||||
Long.MAX_VALUE);
|
||||
verify(mockStore).advanceReplayFloor("tenant-b", "session-b",
|
||||
Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFullBacklogIsDrainedUntilAShortBatch() {
|
||||
AgentStateStore mockStore = mock(AgentStateStore.class);
|
||||
List<ReplayFloorTarget> first = new ArrayList<>();
|
||||
for (int index = 0; index < 64; index++) {
|
||||
first.add(new ReplayFloorTarget("tenant-a", "session-" + index));
|
||||
}
|
||||
List<ReplayFloorTarget> rest = List.of(
|
||||
new ReplayFloorTarget("tenant-a", "session-64"),
|
||||
new ReplayFloorTarget("tenant-a", "session-65"));
|
||||
when(mockStore.findReplayFloorTargets(anyInt()))
|
||||
.thenReturn(first, rest);
|
||||
|
||||
enabledAdvancer(mockStore).advance();
|
||||
|
||||
verify(mockStore, times(2)).findReplayFloorTargets(64);
|
||||
verify(mockStore, times(66)).advanceReplayFloor(anyString(),
|
||||
anyString(), eq(Long.MAX_VALUE));
|
||||
verify(mockStore).advanceReplayFloor("tenant-a", "session-64",
|
||||
Long.MAX_VALUE);
|
||||
verify(mockStore).advanceReplayFloor("tenant-a", "session-65",
|
||||
Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailingTargetIsNotRetriedWithinAPass() {
|
||||
AgentStateStore mockStore = mock(AgentStateStore.class);
|
||||
List<ReplayFloorTarget> batch = new ArrayList<>();
|
||||
for (int index = 0; index < 64; index++) {
|
||||
batch.add(new ReplayFloorTarget("tenant-a", "session-" + index));
|
||||
}
|
||||
// The failed advance leaves session-0 a candidate, so the next fetch
|
||||
// returns the same full batch; the pass must skip what it tried.
|
||||
when(mockStore.findReplayFloorTargets(anyInt())).thenReturn(batch);
|
||||
doThrow(new IllegalStateException("locked")).when(mockStore)
|
||||
.advanceReplayFloor("tenant-a", "session-0", Long.MAX_VALUE);
|
||||
|
||||
enabledAdvancer(mockStore).advance();
|
||||
|
||||
verify(mockStore, times(2)).findReplayFloorTargets(64);
|
||||
verify(mockStore, times(64)).advanceReplayFloor(anyString(),
|
||||
anyString(), eq(Long.MAX_VALUE));
|
||||
verify(mockStore, times(1)).advanceReplayFloor("tenant-a",
|
||||
"session-0", Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneFailingTargetDoesNotStopTheRest() {
|
||||
AgentStateStore mockStore = mock(AgentStateStore.class);
|
||||
when(mockStore.findReplayFloorTargets(anyInt())).thenReturn(List.of(
|
||||
new ReplayFloorTarget("tenant-a", "session-a"),
|
||||
new ReplayFloorTarget("tenant-b", "session-b")));
|
||||
doThrow(new IllegalStateException("locked")).when(mockStore)
|
||||
.advanceReplayFloor("tenant-a", "session-a", Long.MAX_VALUE);
|
||||
|
||||
enabledAdvancer(mockStore).advance();
|
||||
|
||||
verify(mockStore).advanceReplayFloor("tenant-b", "session-b",
|
||||
Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
private static ReplayFloorAdvancer enabledAdvancer(AgentStateStore store) {
|
||||
ManagedAgentProperties properties = new ManagedAgentProperties();
|
||||
properties.getEvents().setReplayFloorEnabled(true);
|
||||
return new ReplayFloorAdvancer(store, properties);
|
||||
}
|
||||
|
||||
private String session(String tenant) {
|
||||
return store.insertSessionCommand(tenant, "CREATE_SESSION",
|
||||
"replay-floor-" + UUID.randomUUID(), "digest", "qwen-code",
|
||||
null, null, List.of(), "payload").sessionId();
|
||||
}
|
||||
|
||||
private long append(String tenant, String sessionId, int count) {
|
||||
for (int index = 0; index < count; index++) {
|
||||
store.appendPublicEventIfAbsent(tenant, sessionId,
|
||||
"turn_replay_floor", "test.progress",
|
||||
Map.of("index", index), false,
|
||||
"replay-floor:" + UUID.randomUUID());
|
||||
}
|
||||
return store.requireSession(tenant, sessionId).lastSequence();
|
||||
}
|
||||
|
||||
private static String tenant() {
|
||||
return "tenant-replay-floor-" + UUID.randomUUID();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
package com.alibaba.qwen.code.managedagent.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.SessionResyncRequired;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.Admission;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.ReplayWindow;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.MockMvcPrint;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
/**
|
||||
* A stream reconciliation discards the Snapshot without lowering the replay
|
||||
* floor, so the floor can exceed the coverage while the Items rebuild.
|
||||
* Nothing prunes events yet, so a cursor below the floor is still served
|
||||
* during that window instead of looping {@code 409}/resync frames; once the
|
||||
* rebuilt Snapshot backs the floor again, the cursor expires (#12380). The
|
||||
* materializer and the dispatch scanners are paused, so the test controls
|
||||
* when the window opens and closes.
|
||||
*/
|
||||
@SpringBootTest(properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:managed-replay-retract;MODE=MySQL;"
|
||||
+ "DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE",
|
||||
"spring.datasource.driver-class-name=org.h2.Driver",
|
||||
"spring.datasource.username=sa",
|
||||
"spring.datasource.password=",
|
||||
"qwen.managed-agent.harness.enabled=false",
|
||||
"qwen.managed-agent.dispatch.scan-delay=1h",
|
||||
"qwen.managed-agent.events.materialize-interval=1h"
|
||||
})
|
||||
@AutoConfigureMockMvc(print = MockMvcPrint.NONE)
|
||||
class ReplayFloorRetractionTest {
|
||||
@Autowired
|
||||
private MockMvc mvc;
|
||||
|
||||
@Autowired
|
||||
private ManagedAgentStore store;
|
||||
|
||||
@Autowired
|
||||
private ManagedAgentService agentService;
|
||||
|
||||
@Autowired
|
||||
private SessionEventHub hub;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Test
|
||||
void aDiscardedSnapshotDoesNotExpireCursorsBelowTheRaisedFloor()
|
||||
throws Exception {
|
||||
String tenant = "tenant-floor-retract-" + UUID.randomUUID();
|
||||
String sessionId = store.insertSessionCommand(tenant,
|
||||
"CREATE_SESSION", "floor-retract-" + UUID.randomUUID(),
|
||||
"digest", "qwen-code", null, null, List.of(), "payload")
|
||||
.sessionId();
|
||||
Admission turn = store.insertTurnCommand(tenant, "SUBMIT_TURN",
|
||||
"floor-retract-turn-" + UUID.randomUUID(), "digest-turn",
|
||||
sessionId, List.of(), "payload-turn");
|
||||
String owner = "floor-retract-owner";
|
||||
assertThat(store.claimTurn(tenant, sessionId, turn.turnId(), owner,
|
||||
Duration.ofMinutes(1))).isPresent();
|
||||
long covered = append(tenant, sessionId, 5);
|
||||
store.materializeNextBatch(tenant, sessionId, 100);
|
||||
ReplayWindow raised = store.advanceReplayFloor(tenant, sessionId,
|
||||
Long.MAX_VALUE);
|
||||
assertThat(raised.floorSequence()).isEqualTo(covered);
|
||||
assertThat(raised.snapshotThroughSequence()).isEqualTo(covered);
|
||||
|
||||
store.retractContinuationOutput(tenant, sessionId, turn.turnId(),
|
||||
owner, "boot-floor-retract", "epoch-floor-retract");
|
||||
|
||||
// The retraction discards the Snapshot but never lowers the floor.
|
||||
ReplayWindow window = store.findReplayWindow(tenant, sessionId);
|
||||
assertThat(window.snapshotThroughSequence()).isZero();
|
||||
assertThat(window.floorSequence()).isEqualTo(covered);
|
||||
long last = store.requireSession(tenant, sessionId).lastSequence();
|
||||
assertThat(last).isEqualTo(covered + 1);
|
||||
|
||||
MockHttpServletResponse below = mvc.perform(EventStreams.events(
|
||||
tenant, sessionId)
|
||||
.param("after", Long.toString(covered - 1)))
|
||||
.andReturn().getResponse();
|
||||
assertThat(below.getStatus()).isEqualTo(200);
|
||||
assertThat(sequences(EventStreams.json(objectMapper, below)))
|
||||
.containsExactlyElementsOf(EventStreams.range(covered, last));
|
||||
MockHttpServletResponse omitted = mvc.perform(EventStreams.events(
|
||||
tenant, sessionId))
|
||||
.andReturn().getResponse();
|
||||
assertThat(omitted.getStatus()).isEqualTo(200);
|
||||
assertThat(sequences(EventStreams.json(objectMapper, omitted)))
|
||||
.containsExactlyElementsOf(EventStreams.range(1, last));
|
||||
|
||||
// Once the rebuilt Snapshot backs the floor, the cursor expires.
|
||||
store.materializeNextBatch(tenant, sessionId, 100);
|
||||
assertThat(store.findReplayWindow(tenant, sessionId)
|
||||
.snapshotThroughSequence()).isEqualTo(last);
|
||||
MockHttpServletResponse expired = mvc.perform(EventStreams.events(
|
||||
tenant, sessionId)
|
||||
.param("after", Long.toString(covered - 1)))
|
||||
.andReturn().getResponse();
|
||||
assertThat(expired.getStatus()).isEqualTo(409);
|
||||
JsonNode error = EventStreams.json(objectMapper, expired).get("error");
|
||||
assertThat(error.get("code").asText()).isEqualTo("cursor_expired");
|
||||
assertThat(error.get("replay_floor_sequence").asLong())
|
||||
.isEqualTo(covered);
|
||||
assertThat(error.get("snapshot_through_sequence").asLong())
|
||||
.isEqualTo(last);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHalfRebuiltSnapshotStillServesBothReadPathsBelowTheFloor()
|
||||
throws Exception {
|
||||
String tenant = "tenant-floor-mid-" + UUID.randomUUID();
|
||||
String sessionId = store.insertSessionCommand(tenant,
|
||||
"CREATE_SESSION", "floor-mid-" + UUID.randomUUID(),
|
||||
"digest", "qwen-code", null, null, List.of(), "payload")
|
||||
.sessionId();
|
||||
Admission turn = store.insertTurnCommand(tenant, "SUBMIT_TURN",
|
||||
"floor-mid-turn-" + UUID.randomUUID(), "digest-turn",
|
||||
sessionId, List.of(), "payload-turn");
|
||||
String owner = "floor-mid-owner";
|
||||
assertThat(store.claimTurn(tenant, sessionId, turn.turnId(), owner,
|
||||
Duration.ofMinutes(1))).isPresent();
|
||||
long covered = append(tenant, sessionId, 5);
|
||||
store.materializeNextBatch(tenant, sessionId, 100);
|
||||
ReplayWindow raised = store.advanceReplayFloor(tenant, sessionId,
|
||||
Long.MAX_VALUE);
|
||||
assertThat(raised.floorSequence()).isEqualTo(covered);
|
||||
|
||||
store.retractContinuationOutput(tenant, sessionId, turn.turnId(),
|
||||
owner, "boot-floor-mid", "epoch-floor-mid");
|
||||
long last = store.requireSession(tenant, sessionId).lastSequence();
|
||||
|
||||
// A rebuild is multi-batch, so the window normally sits at a floor
|
||||
// above a non-zero coverage, not only at a discarded Snapshot.
|
||||
store.materializeNextBatch(tenant, sessionId, 1);
|
||||
ReplayWindow mid = store.findReplayWindow(tenant, sessionId);
|
||||
assertThat(mid.floorSequence()).isEqualTo(covered);
|
||||
assertThat(mid.snapshotThroughSequence()).isPositive()
|
||||
.isLessThan(covered);
|
||||
|
||||
MockHttpServletResponse midRead = mvc.perform(EventStreams.events(
|
||||
tenant, sessionId)
|
||||
.param("after", Long.toString(covered - 1)))
|
||||
.andReturn().getResponse();
|
||||
assertThat(midRead.getStatus()).isEqualTo(200);
|
||||
assertThat(sequences(EventStreams.json(objectMapper, midRead)))
|
||||
.containsExactlyElementsOf(EventStreams.range(covered, last));
|
||||
|
||||
ExecutorService executor = Executors.newCachedThreadPool();
|
||||
try {
|
||||
EventStreams.RecordingEmitter open =
|
||||
new EventStreams.RecordingEmitter();
|
||||
EventStreams.streams(agentService, hub, executor, open)
|
||||
.publicStream(tenant, null, sessionId, covered - 1);
|
||||
await().atMost(Duration.ofSeconds(10)).until(
|
||||
() -> open.ids.contains(last) || !open.resync.isEmpty()
|
||||
|| !open.failed.isEmpty());
|
||||
assertThat(open.failed).isEmpty();
|
||||
assertThat(open.resync).isEmpty();
|
||||
assertThat(open.ids)
|
||||
.containsExactlyElementsOf(EventStreams.range(covered,
|
||||
last));
|
||||
|
||||
// The rewrite of a one-batch-behind Snapshot defers until it
|
||||
// ages out, so the rebuild converges over later passes.
|
||||
await().atMost(Duration.ofSeconds(15)).until(() -> {
|
||||
store.materializeNextBatch(tenant, sessionId, 100);
|
||||
return store.findReplayWindow(tenant, sessionId)
|
||||
.snapshotThroughSequence() == last;
|
||||
});
|
||||
|
||||
EventStreams.RecordingEmitter expired =
|
||||
new EventStreams.RecordingEmitter();
|
||||
EventStreams.streams(agentService, hub, executor, expired)
|
||||
.publicStream(tenant, null, sessionId, covered - 1);
|
||||
assertThat(expired.completed.await(10, TimeUnit.SECONDS))
|
||||
.isTrue();
|
||||
assertThat(expired.failed).isEmpty();
|
||||
assertThat(expired.ids).isEmpty();
|
||||
assertThat(expired.resync).containsExactly(
|
||||
new SessionResyncRequired(ManagedEventStreamService.RESYNC,
|
||||
sessionId, covered, last,
|
||||
ManagedEventStreamService.RESYNC_ACTION));
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private long append(String tenant, String sessionId, int count) {
|
||||
for (int index = 0; index < count; index++) {
|
||||
store.appendPublicEventIfAbsent(tenant, sessionId,
|
||||
"turn_floor_retract", "test.progress",
|
||||
Map.of("index", index), false,
|
||||
"floor-retract:" + UUID.randomUUID());
|
||||
}
|
||||
return store.requireSession(tenant, sessionId).lastSequence();
|
||||
}
|
||||
|
||||
private static List<Long> sequences(JsonNode page) {
|
||||
List<Long> sequences = new ArrayList<>();
|
||||
page.get("data").forEach(
|
||||
event -> sequences.add(event.get("sequence").asLong()));
|
||||
return sequences;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.alibaba.qwen.code.managedagent.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
|
||||
/**
|
||||
* The first pass after an operator opts in drains every Session with a
|
||||
* Snapshot, which on a large deployment takes minutes; the pass must run on
|
||||
* its own scheduler so the shared one-thread default pool keeps serving the
|
||||
* message materializer and the recovery ticks meanwhile.
|
||||
*/
|
||||
class ReplayFloorSchedulerTest {
|
||||
@Test
|
||||
void thePassDoesNotShareTheDefaultScheduler() throws Exception {
|
||||
Scheduled scheduled = ReplayFloorAdvancer.class.getMethod("advance")
|
||||
.getAnnotation(Scheduled.class);
|
||||
assertThat(scheduled.scheduler()).isEqualTo("replayFloorScheduler");
|
||||
}
|
||||
}
|
||||
|
|
@ -59,7 +59,7 @@ export interface paths {
|
|||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** @description POST SSE consumed through fetch response.body. An afterSequence below the replay floor ends the stream with one agent.session.resync_required frame instead of a 409; reload the transcript and resume after its lastSequence. */
|
||||
/** @description POST SSE consumed through fetch response.body. An afterSequence below the replay floor ends the stream with one agent.session.resync_required frame instead of a 409 only while the Snapshot backs the floor (replay_floor_sequence <= snapshot_through_sequence); a stream reconciliation discards the Snapshot without lowering the floor, and during that rebuild such a cursor is served from the retained events. On the resync frame, reload the transcript and resume after its lastSequence. */
|
||||
post: operations["webShellStreamEvents"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue