diff --git a/.qwen/e2e-tests/h4e-b2-team-mailbox.md b/.qwen/e2e-tests/h4e-b2-team-mailbox.md new file mode 100644 index 00000000000..313870be0f1 --- /dev/null +++ b/.qwen/e2e-tests/h4e-b2-team-mailbox.md @@ -0,0 +1,79 @@ +# E2E plan: H4e-b2 the lead's mailbox over Session messages + +Scope: the lead's `send_message` to a member by name and to every member +(`to: "*"`), continued members (the roster follows a member's continuation +chain), the member preamble, the `` label on a member's messages +and continued results, and the owner rule's "work given in this Turn", on a +real Hosted stack with `team_state`, `team_task` and `session_message` +enabled. See `docs/design/2026-10-11-managed-agent-team-mailbox.md`. + +## Rig + +- aarch64 Linux host. The packaged `qwen-managed-agent-server` jar (Store, + Runtime Broker, child result relay, Session message relay, Session + coordinator), the bundled CLI as `serve --profile hosted-harness`, durable + local workers, MySQL, and a scripted OpenAI-compatible model that answers + each Turn with the tool calls the scenario names. +- Driving goes through the public `/v1/agents/sessions` API only. One + declared rig step, as in H4e-b1: a fresh Session is moved to the private + `hosted-workspace-shell/1` profile by SQL before its first Turn; child + Sessions inherit it. +- Evidence: every revision of `team_state`, `team_task`, `child_run`, + `child_acceptance` and `session_message` read back from the Store, the + launch envelopes of member runs, the message relay ledger, the inputs the + lead and the members received, and Session and Turn states. + +## Physical acceptance + +1. Lead creates a team, launches two members by name, and receives both + reports labeled. Expect: each member's launch envelope prompt opens with + the `` preamble; both notifications carry + ``. +2. In one batch, the lead sends one finished member new work by name and + records the task on the board with that member as owner. Expect: a + continuation `child_run` whose predecessor is the member's first run and + whose instruction is the preamble plus the message; the `task_update` + commits the owner on the `in_progress` task, and its answer acknowledges + that send and asks for no second one. +3. The continued result arrives labeled with the member's name; then the + lead broadcasts with `to: "*"`. Expect: one message or continuation per + member under `msgb_…` / `bcast_…` ids, each with the broadcast call's key + as `executionCallId`; the answer lists every member. +4. A member asks the lead something with `send_message` to `"parent"`. + Expect: the lead's input carries `` and H4d-b's reply hint by + task id; the lead's answer by task id is answered naming the member, and + reaches it as a message if it still runs, or as a continuation if it has + settled; the result is labeled. + Whether the member's last answer repeats its earlier work is recorded as + an observation. +5. `team_delete` is refused while a continued member runs, and succeeds once + it ends. After the delete, a send by `task_id` to the former member + continues it with no preamble, and its result arrives unlabeled. + +## Results + +Final run on `7cd23e2fb4` (the PR's code, rebased on main `d9c6f8c0fe`), +2026-10-11 07:27–07:30Z, Linux aarch64 (Orange Pi 6 Plus), JDK 21, MySQL 8.4 +in Docker, Node 24: **57/57 checks passed**, no product bug found. Earlier +runs on `f741e8df22` (55/55) and `48d2a4fa3f` (57/57) also passed. Only the +scripted model was used. + +| Scenario | Checks | Result | +| -------- | ------ | ------ | +| Rig: fresh lead moved to `hosted-workspace-shell/1` before its first Turn | 1 | PASS | +| 1. Named launches with the preamble, member tools, labeled reports | 12 | PASS | +| 2. Send by name + `task_update` in one response; continuation envelope and input; acknowledged answer; labeled result | 7 | PASS | +| 2b/2c. Same Turn after the continuation settled accepts the owner; a later Turn refuses with the conditional text | 3 | PASS | +| 3. Broadcast to finished members (`bcast_…`) and to running ones (`msgb_…`); ids equal the design digests; answers list every member; labeled results | 9 | PASS | +| 4a/4b. Member questions labeled with the task-id hint; replies by task id answered naming the member, as a message or a continuation | 11 | PASS | +| 5. `team_delete` refused while a continued member runs, then deleted; former member continued by task id with no preamble, unlabeled; `to` refused | 9 | PASS | +| Run: Sessions closed, child runs settled, relay ledgers done, no failed Turn | 5 | PASS | + +Observations: + +- A member's result after a broadcast holds only its answer to the last + instruction (H4d-b's result rule, open question 3). The scripted model + answers only the last instruction, so whether a real model repeats its + earlier work is not shown. +- `HostedTeamLeadMySqlIT` (#13867) passed on the same head against MySQL + 8.4 (3 tests), with its owner-refusal assertion updated for this slice. diff --git a/docs/design/2026-10-10-managed-session-message-runtime.md b/docs/design/2026-10-10-managed-session-message-runtime.md index 216d92acf26..bf902b08eaa 100644 --- a/docs/design/2026-10-10-managed-session-message-runtime.md +++ b/docs/design/2026-10-10-managed-session-message-runtime.md @@ -47,7 +47,7 @@ The facts below are from `main` at `ba8615f4c4`. 13. **Enablement, server first.** `session_message` joins `MANAGED_SESSION_ENABLED_DOMAINS` and the continuation gate opens. The Java store has validated both since the H4d-a release, and the relay ships in the same release as the writers, so the server-first order of H1–H4c holds: a server that does not run the relay never sees a writer that produces these records. 14. **No public contract change.** The OpenAPI contract, its routes and `contract-known-gaps.txt` do not move. The new Hosted route `POST /session/:id/messages/operations` is private between the control plane and `qwen serve`, like H4b's `/children/operations`. V64 is the only migration. 15. **A stopped run stops its child's message work (H4f).** H4f's stop arm decides from the child's API Turn, but message turns never become API Turns (decision 8), so for a child holding session messages it also reads the child's journal. While a message input waits or runs, the stop sends the child's message route a `stop`: from then on the child's wake pump starts none of its message inputs, a message turn in flight is aborted (its own end settles it cancelled), and once no turn runs the waiting message inputs settle cancelled with `stop_requested`, the Session held as a close holds it. A child this control plane no longer holds is loaded with the stop already on it (`stopMessages` on a passive load), so not even the first pass that load kicks starts a message input. That load is authorized by the committed stop the arm read, like a persisted cancellation, checking only the Session's own binding: message work leaves no `CANCELLING` Turn behind, and a grant changed since does not undo a stop. The stop goes out before the arm's Turn cancel, whose own load would attach the child without it, and while the stop still settles the child's message work the message relay attaches the child for none of its own steps: a consume reconciliation in it and the child's own outbox steps wait. Once no message work is owed they go on, because a load then starts nothing and a natural settlement may be waiting for the child's outbox. A message turn that crashed in an earlier process is not a waiting input; the stop runs its aftermath, which the pump no longer picks for a stopped Session. The arm repeats the `stop` on the heartbeat and settles the run only once nothing is owed; a child that waits on its recovery or is closing refuses the `stop`, which spends no attempt, and a message input no turn took within decision 8's 30-minute bound stops holding the stop, so the run settles and the child's close cancels what is left. The natural outcome that wins over a stop is decision 8's newest settled turn, API or message: a message turn that completed first delivers its result, one that failed on its own settles `child_failed`, and a cancelled one is the stop's. A message turn whose abort fails into recovery (its tool's cancel failed) settles as an error, and the run then settles `child_failed`: the journal holds nothing that tells that end from the child's own failure. A message handed over before the stop but not yet received is rejected rather than received (`accepting → rejected`), as the handover already cancels one still planned, so nothing wakes a child going away. The stop closes the child without the close hold on its own outbox: like any closing sender (decision 10), its entries that still owe their handover are orphaned, so a child's last messages to its parent are lost with a cancel. -16. **An interrupted `send_message` is answered by what it committed.** Like a team call or a background launch, a `send_message` writes only to the journal before its answer. Every route that settles or resumes an interrupted turn answers it from the records, the parked round's gap fill included: an outbox entry as queued for delivery, a continuation as continued, and, on a route that resumes the turn, one that committed nothing as never run. The model never sends a committed message again, and a resumed round is whole. +16. **An interrupted `send_message` is answered by what it committed.** Like a team call or a background launch, a `send_message` writes only to the journal before its answer. Every route that settles or resumes an interrupted turn answers it from the records, the parked round's gap fill included: an outbox entry as queued for delivery, a continuation as continued, and, on a route that resumes the turn, one that committed nothing as never run. The model never sends a committed message again, and a resumed round is whole. The [H4e-b2 design](2026-10-11-managed-agent-team-mailbox.md) (its decision 9) refines these answers: a send whose run proved since that it never started, or whose message's delivery failed, is answered as not arrived, with how to send it again. ## The pipeline diff --git a/docs/design/2026-10-10-managed-session-message-runtime.zh-CN.md b/docs/design/2026-10-10-managed-session-message-runtime.zh-CN.md index 5bf3be73775..1db232a95ab 100644 --- a/docs/design/2026-10-10-managed-session-message-runtime.zh-CN.md +++ b/docs/design/2026-10-10-managed-session-message-runtime.zh-CN.md @@ -47,7 +47,7 @@ H4d-a 让投递所需的每个事实都成为已提交的记录,并在两种语 13. **启用,server 优先。** `session_message` 加入 `MANAGED_SESSION_ENABLED_DOMAINS`,续跑门禁打开。Java store 自 H4d-a 发布起就校验二者,relay 与写入方在同一个版本中发布,所以 H1–H4c 的 server 优先顺序成立:不运行该 relay 的 server 永远不会遇到生产这些记录的写入方。 14. **不改公开契约。** OpenAPI 契约、其路由与 `contract-known-gaps.txt` 都不变。新的 Hosted 路由 `POST /session/:id/messages/operations` 是控制面与 `qwen serve` 之间的私有路由,与 H4b 的 `/children/operations` 相同。V64 是唯一的迁移。 15. **被停止的 run 连同其 child 的消息工作一起停止(H4f)。** H4f 的停止分支依据 child 的 API Turn 作决定,但消息 turn 从不成为 API Turn(决策 8),所以对持有 session message 的 child,它还会读取该 child 的 journal。只要还有消息 input 在等待或运行,停止就向 child 的消息路由发送 `stop`:此后 child 的 wake pump 不再启动它的任何消息 input,运行中的消息 turn 被中止(由它自己的结束结算为 cancelled),等到没有 turn 在运行时,等待中的消息 input 以 `stop_requested` 结算为 cancelled,其间 Session 像关闭时一样被占住。本控制面已不再持有的 child,会在加载时就带上停止(被动加载上的 `stopMessages`),所以连这次加载触发的第一轮也不会启动任何消息 input。该加载与已持久化的取消一样,由停止分支读到的已提交停止请求授权,只校验 Session 自身的绑定:消息工作不会留下 `CANCELLING` 的 Turn,事后变更的授权也不能撤销一次停止。停止先于停止分支对 Turn 的取消发出,因为后者自己的加载不会带上停止;在停止仍在结算 child 的消息工作期间,消息 relay 也不会为该 child 的任何步骤 attach 它:其中的 consume 对账与 child 自己的 outbox 步骤都会等待。一旦不再欠消息工作,它们就继续进行,因为这时加载不会启动任何东西,而自然结算可能正在等待 child 的 outbox。在更早进程中崩溃的消息 turn 不是等待中的 input;wake pump 不会再为已停止的 Session 挑选它,由停止执行其善后。停止分支在心跳时重复发送 `stop`,只有在不再欠任何东西时才结算该 run;正在等待恢复或正在关闭的 child 会拒绝 `stop`,这不消耗尝试次数;在决策 8 的 30 分钟上限内没有 turn 接手的消息 input 不再挡住停止,于是 run 得以结算,剩下的由 child 的关闭取消。胜过停止的自然结果,是决策 8 所说的最新已结算 turn(API 或消息):先完成的消息 turn 交付其结果,自行失败的以 `child_failed` 结算,被取消的则归于停止。中止失败而进入恢复的消息 turn(其工具的取消失败)会以 error 结算,run 随之以 `child_failed` 结算:journal 中没有任何东西能把这种结束与 child 自身的失败区分开。停止之前已交接、尚未收到的消息会被拒绝而不是被收下(`accepting → rejected`),正如交接本就会取消仍处于 planned 的消息,所以不会唤醒一个正在离开的 child。停止关闭 child 时不经过其自身 outbox 的关闭挡板:与任何正在关闭的发送方一样(决策 10),其仍欠交接的条目会成为 orphaned,所以 child 发往父的最后几条消息会随取消一起丢失。 -16. **被中断的 `send_message` 按其已提交的内容作答。** 与 team 调用或后台启动一样,`send_message` 在作答之前只写入 journal。每条结算或恢复被中断 turn 的路由(包括停放轮的补答)都依据记录回答它:outbox 条目答为已排队投递,续跑答为已续跑;在恢复该 turn 的路由上,什么都没提交的答为从未运行。模型永远不会再次发送已提交的消息,恢复的那一轮也是完整的。 +16. **被中断的 `send_message` 按其已提交的内容作答。** 与 team 调用或后台启动一样,`send_message` 在作答之前只写入 journal。每条结算或恢复被中断 turn 的路由(包括停放轮的补答)都依据记录回答它:outbox 条目答为已排队投递,续跑答为已续跑;在恢复该 turn 的路由上,什么都没提交的答为从未运行。模型永远不会再次发送已提交的消息,恢复的那一轮也是完整的。[H4e-b2 设计](2026-10-11-managed-agent-team-mailbox.zh-CN.md)(其决策 9)细化了这些答复:run 此后被证明从未启动,或消息投递已失败的发送,答为未到达,并说明如何再次发送。 ## 流水线 diff --git a/docs/design/2026-10-11-managed-agent-team-mailbox.md b/docs/design/2026-10-11-managed-agent-team-mailbox.md index eccd9e49b14..5f3c71c01da 100644 --- a/docs/design/2026-10-11-managed-agent-team-mailbox.md +++ b/docs/design/2026-10-11-managed-agent-team-mailbox.md @@ -2,7 +2,7 @@ [English](2026-10-11-managed-agent-team-mailbox.md) | [简体中文](2026-10-11-managed-agent-team-mailbox.zh-CN.md) -Status: design. This is the second runtime slice of **H4e** 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 [#13745](https://github.com/QwenLM/qwen-code/issues/13745). It builds on H4e-a ([team record contract](2026-10-10-managed-agent-teams.md), #13811), H4e-b1 ([lead-side team runtime](2026-10-10-managed-agent-team-lead-runtime.md), #13824, enabled by #13846) and H4d-b ([Session message runtime](2026-10-10-managed-session-message-runtime.md), #13822). It answers H4e-a open questions 1, 2 and 4, and H4e-b1 open question 3. It also re-scopes H4e-b2 and H4e-b3 from the H4e-b1 design: see decisions 1, 7 and 8 below. +Status: implemented, except the physical acceptance pass on a real Hosted stack (decision 10), which runs before this change merges. This is the second runtime slice of **H4e** 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 [#13745](https://github.com/QwenLM/qwen-code/issues/13745). It builds on H4e-a ([team record contract](2026-10-10-managed-agent-teams.md), #13811), H4e-b1 ([lead-side team runtime](2026-10-10-managed-agent-team-lead-runtime.md), #13824, enabled by #13846) and H4d-b ([Session message runtime](2026-10-10-managed-session-message-runtime.md), #13822). It answers H4e-a open questions 1, 2 and 4, and H4e-b1 open question 3. It also re-scopes H4e-b2 and H4e-b3 from the H4e-b1 design: see decisions 1, 7 and 8 below. ## Problem and scope @@ -88,20 +88,21 @@ The facts below are from `main` at `66ddb151cb`, which includes #13846 (merged a `team_message` stays disabled and unproduced in this slice. What a team adds on top of H4d-b is routing and presentation: a name, a broadcast, a label and an identity. It is all derived from the roster in the lead's journal. Whether member-authored messages, assignments or the protocol kinds need `team_message` is H4e-b3's question (open question 1). 2. **A member is its continuation chain (answers H4e-a open question 4 and H4e-b1 open question 3).** - - **The chain.** The roster entry names the chain's first run. A run belongs to a member of the open team when its chain root is on that team's roster. The root is found by following `predecessorChildRunId` back through the lead's `child_run` records. A launch is never itself a continuation, so every root is a launch. The funnel indexes the lead's `child_run` records by predecessor once per call, so a roster view or a broadcast does not re-read the domain for each member. + - **The chain.** The roster entry names the chain's first run. A run belongs to a member of the open team when its chain root is on that team's roster. The root is found by following `predecessorChildRunId` back through the lead's `child_run` records. A launch is never itself a continuation, so every root is a launch. The funnels index the lead's `child_run` records by predecessor, and a roster view indexes them once for all its members. A broadcast routes each recipient through H4d-b's send, which reads the records again per recipient; there are at most ten. - **The member's state** is the state of its chain head, as H4d-b's routing defines the head. It reads: - `running`, or `running (stopping)` once a stop was requested; - `completed`, or `completed (stop requested)` for a head that settled after a stop request, which routing refuses to continue; - `failed` or `cancelled`, with the stop reason. - - **The roster view.** `task_list` shows that state, and the head's task id whenever the member can still take work, running or continuable. When the head's newest continuation, the one committed last, never started, the line also says why, and how many messages sent to it were cancelled with it (H4d-b cancels a message whose task ended without an admission). For example: `alpha: completed — task_r1 (its latest continuation never started: creation_failed; 1 message to it cancelled)`. The line does not show that continuation's task id: a send to it would route from that run, which ended, and be refused. A failed or cancelled child sends no notification, so this line is how the lead learns that new work never reached the member, as H4e-b1 decision 6 meant the roster view for failures. + - **The roster view.** `task_list` shows that state, and the head's task id whenever the member can still take work, running or continuable. When the head's newest continuation, the one committed last, never started, the line also says why, and how many messages sent to it never arrived (H4d-b cancels a message to a run that ended before its handover). For example: `alpha: completed — task_r1 (its latest continuation never started (creation_failed), so the work it carried did not arrive, nor did 1 message sent to it)`. One cancelled before its start at a user's request reads `was cancelled before it started (stop_requested)` instead. The line does not show that continuation's task id, which names no live run. A send by it still reaches the chain: routing walks back from a continuation that never started to its predecessor, for every child, where H4d-b refused such a send as ended. A failed or cancelled child sends no notification, so this line is how the lead learns that new work never reached the member, as H4e-b1 decision 6 meant the roster view for failures. - **What reads the state.** - `team_delete` refuses while any head runs. - The owner rules read the head too (decision 7). There a stopping head counts as ended and not completed, and a head that completed after a stop request counts as completed but not continuable. + - A continuation stopped before it attached a child Session may still end without starting, which hands the member back to its predecessor. Until it settles, a send to the member and an owner change are refused as stopping, with a text that says so and asks the lead to check `task_list` once it settles, instead of pointing to a new member. - **Continuing is now allowed.** `sendToChild`'s refusal to continue a member of an open team, which #13846 added until this slice, is lifted for an active team. A finished member is continued like any finished child, and the continuation joins its chain. A team that is `closing` still refuses, because it admits no new work while it drains (H4e-a decision 8), and `team_delete` commits `closing` only once no member runs. - **Worktree members.** A worktree member's continuation keeps its `workspaceMode`, as every H4d-b continuation does, so it needs a host that serves child Workspaces. - **No record changes.** A continuation never joins a roster, because it is launched by `send_message`, not by a named Agent call. H4e-a's join rule, which admits only a live run that no other team lists, still guards every join. - **Ended members.** A member whose head failed or was cancelled after it started stays ended: H4d-a decision 9 never continues such a run. So does a head that completed after a stop was requested. The lead launches a new member instead, which takes another roster slot (open question 4). - - **A continuation that never started** leaves its member as it was, because H4d-a decision 9 releases the predecessor. That covers a failed creation and an H4f cancel before the start. The next message continues the same head again, and its answer says that the previous continuation never started. + - **A continuation that never started** leaves its member as it was, because H4d-a decision 9 releases the predecessor. That covers a failed creation and an H4f cancel before the start. The next message continues the same head again. Its answer, by name or by task id, says that the previous continuation never started and how many messages sent to it were lost, and asks for that work and those messages again unless the new message carries them. A continuation a user cancelled before its start is told apart, and its work is asked for again only if it is still wanted, since the user may have cancelled it on purpose. - **Cancelling a member's work.** A user's H4f task cancel stops exactly the run its task id names. To stop a continued member's work, the user names the head's task, which is the member's newest open child task on the public task list. The first run's task answers that the task already settled. - **After `team_delete`.** Routing by name, broadcasts, the preamble and the continuation labels (decisions 3–6) concern the open team's roster only. Once its team is deleted, a former member's chain takes messages by `task_id` alone, and it continues as a plain child with no preamble and no label, as #13846 decided (open question 7). @@ -118,16 +119,16 @@ The facts below are from `main` at `66ddb151cb`, which includes #13846 (merged a - **Routing.** A name routes exactly as the task id of that member's first run does: - a running head takes a message, unless it is stopping; - a continuable head (completed, with no stop requested) is continued; - - any other head is refused, with the member's name and its state. + - any other head is refused, with the member's name and its state. An ended member's refusal points to a new member under a new name, because a plain launch would not join the team. - **Ids.** The message id and the continuation's run id are the ones the `task_id` form would mint for the same call. So H4d-b's replay and its bounds apply unchanged: - the envelope bound; - 8 messages in flight and 64 per run; - the continuation's launch admission (closing, H4b's active cap, H4c's launch budget, a worktree's need for child Workspaces). - **Replay order.** A replay looks the call's committed message or continuation up before it resolves `to`, so it answers the committed send even after the team has changed. - - **Answers.** They name the member: `Message queued for delivery to member "" (task )…`, or `Member "" had completed; continued it as task …`. When the head's newest continuation never started, the answer says so, without that continuation's task id. + - **Answers.** They name the member: `Message queued for delivery to member "" (task )…`, or `Member "" had completed; continued it as task …`. When the continuation taken just before the one the send launched never started, the answer says so (decision 2), without that continuation's task id. A send by `task_id` to a member of the open team gets the same answers, because the lead's reply hint names the task id (decision 6). - **Legacy's `summary`** is refused as an unsupported argument. An argument that would mean nothing is the dead switch H4b decision 11 refused. -4. **`to: "*"` reaches every member.** A broadcast sends to every member of the open team, in roster order. Each member is routed as in decision 3 and committed on its own. +4. **`to: "*"` reaches every member.** A broadcast sends to every member of the open team, in roster order. Each member is routed as in decision 3 and committed on its own. A Turn cancelled during a broadcast sends to no further member, since each send may launch a continuation. When the broadcast did not reach every member, its answer asks the lead to send by name to any member that did not get it and still needs it. - **Ids.** A recipient's ids live in a namespace no single send or launch can reach, whatever call ids the provider hands out. A digest of the call id alone would not do: a plain call whose provider id equals the digest would mint the same ids. - **The run id** of a recipient's continuation is `bcast_` plus the first 32 hex characters of `sha256(promptKey \0 callId \0 position)`, where `position` is the member's 1-based roster position. Every single-send or launch run id is `${promptKey}:${callId}` and so contains a colon. A broadcast run id contains none, so the two sets never meet. - **The message id** is `msgb_` plus the first 32 hex characters of `sha256(senderSessionId \0 turnId \0 callId \0 position)`. Every single-send message id starts with `msg_`, so the two sets never meet. Within broadcasts, the digest separates calls and positions. @@ -138,7 +139,7 @@ The facts below are from `main` at `66ddb151cb`, which includes #13846 (merged a - **Partial outcomes.** A refused recipient does not stop the others. A recipient is refused when its head ended or is stopping, when a message bound refuses it (`count_limit` or `budget_exhausted`), or when its continuation's launch admission refuses it (decision 3). - **Answers.** As in Legacy: - every member reached; - - `delivered to of `, with each refused member and its reason; + - `sent to of `, with each refused member and its reason; - or, when none was reached, a tool error. - **Replay.** A replayed broadcast finds each recipient's committed message or continuation and adds nothing twice. - **Scope.** The lead is never a recipient. A broadcast never leaves the team. @@ -194,14 +195,15 @@ The facts below are from `main` at `66ddb151cb`, which includes #13846 (merged a One batch runs its calls in order, so the send is committed before `task_update` runs, but nothing keeps the member running until then. The relay commits and settles a child's result while the lead's Turn runs, and the two funnels share no lock across a batch. A member that the send continued, or that was running and read the message, can therefore complete between the two calls. The owner rule covers that with one notion, work given in this Turn: - **A Turn** is one prompt id. It spans all of its model rounds, and a Turn resumed after an interruption is the same Turn. - - **Work given in this Turn.** A member was given work in this Turn when an earlier call of the same Turn committed, for its chain, either a continuation that has not been proven never to have started, or an outbound message to the chain head that was not cancelled or rejected. Both name the sending call's key as their `executionCallId`, which is one of this Turn's call keys, so the lead's records decide it. + - **Work given in this Turn.** A member was given work in this Turn when an earlier call of the same Turn committed, for its chain, either a continuation that has not been proven never to have started, or an outbound message to one of the chain's started runs that was not cancelled or rejected (a message to a continuation that never started is cancelled and never arrives). Both name the sending call's key as their `executionCallId`, which is one of this Turn's call keys, so the lead's records decide it. - **Accepted.** Such a member may be set as the owner of an open task whether its head still runs or has completed since. The send gave the work, so the board records who has it, and the lead sets the status `completed` when the report arrives. - **Still refused.** A member whose head failed, was cancelled after it started, is stopping, or completed after a stop request is refused, as decision 2 counts it ended. + - **Completed work.** On a call that leaves the task completed, a member any run of whose chain completed is accepted, even when its head has ended since: that run may have done the work, and #13846 records who did it. A member whose chain never completed a run is refused, with a text that points to the member that did the work or to `leader`, not to a new member. The texts lead the model there. None of them claims which task a past send carried, because no record ties a send to a task, and none offers to skip a send for work that never arrived: - - **The `task_update` answer.** For a member owner, it says that it notified nobody, and that if the member does not already have this work, `send_message` to its name gives it. It does not tell the model to send: a send earlier in the Turn, or the launch prompt, may already have given the work, and a second send would repeat it. + - **The `task_update` answer.** For a member owner, it says that it notified nobody, and that if the member does not already have this work, `send_message` to its name gives it. It does not tell the model to send: a send earlier in the Turn, or the launch prompt, may already have given the work, and a second send would repeat it. When a send earlier in the Turn gave the member work, the answer says so, and asks for a send only if that one did not carry this task. A call that restates its task's owner and status is not checked again, so the answer reads the member itself: when its newest continuation never started, the answer says so and asks for that work and those messages again, and for a member that has ended it says that the member takes no more work. - **A completed, continuable member not given work in this Turn.** It is refused on open work with one conditional text. If it already did this task's work, from its launch prompt or a message the lead sent it, set the status `completed` with this owner once its report is in. Otherwise give it the work with `send_message` to its name first, which continues it, and then assign it. The first branch is #13846's way out, kept. - - **A head whose newest continuation never started.** The refusal says that this continuation never started and why, and that the work it carried did not arrive. It points to sending that work again by name and then assigning the task, with no branch that skips the send. + - **A head whose newest continuation never started.** The refusal says that this continuation never started and why, and that the work it carried did not arrive, nor did the messages sent to it. It points to sending that work and those messages again by name and then assigning the task, with no branch that skips the send. For a continuation a user cancelled before its start, it asks for the work again only if it is still wanted. - **A member that cannot be continued.** One that failed, was cancelled, is stopping or completed after a stop request is refused as ended. The refusal points to launching a new member, or, for one that completed, to completing the task with it as owner if it did the work. - **The descriptions.** The `task_update`, `task_create` and `task_list` descriptions, and the team form's `send_message` description, say the same. `task_list` no longer calls the task id the way to reach a member. @@ -219,10 +221,10 @@ The facts below are from `main` at `66ddb151cb`, which includes #13846 (merged a 9. **Interrupted turns are answered by what committed (extends H4d-b decision 16).** - **Which ids to probe.** Recovery and the PostToolUse dispatch evidence look up a `send_message` call under its base ids and under the ten broadcast positions' ids, whatever the journaled arguments say. A PreToolUse or permission hook can rewrite the input the call executed, so the journaled `to` may not be the one that ran. - - **A single send.** One found under the base ids is answered with H4d-b's task-id text. It names the run without the member's name and never invites a retry. + - **A single send.** One found under the base ids is answered as decision 3 answers a live send: with the member's name while its team is open, otherwise with H4d-b's task-id text. It never invites a retry of a send that arrived. When the run the send reached has proved since that it never started, the answer says that the message did not arrive and how to send it again: a failed child sends no notification, so nothing else would tell the lead. Each such answer asks again only for its own message, so two answers never ask twice for the same content; for a launch that never started, it says that the member or child has ended. A message whose own delivery failed did not arrive either, though its run did not prove it never started: cancelled before its handover, rejected by its target, or still waiting for a handover that the relay cancels because its run ended or is being stopped (H4d-b). The answer says so. It points to a resend while the chain takes messages, to waiting while a continuation stopped before its start settles, or, once the chain has ended, to a new member (a new agent for a plain child). - **A broadcast.** - One found under position ids is answered recipient by recipient, from the committed message or continuation of each position. - - The answer lists who was reached and who was not, so the model resends only to the rest. + - The answer lists who was reached and who was not, so the model resends only to the rest. A recipient whose run never started, or whose message's delivery failed as above, counts as not reached. A member that has ended is listed apart, because a send by name would be refused; a live replay notes it on the member's line. When the team was deleted or replaced since, the answer lists only the recipients it was sent to, as a live replay does. - A route that resumes the Turn answers a call that committed nothing as never run. - **Dispatch evidence.** A committed broadcast still counts as dispatched after a restart. @@ -291,6 +293,7 @@ To a running member, step 1 is an outbound `session_message` to the head, and H4 ## Files affected +- `packages/cli/src/serve/hosted-member-chain.ts` (new): the pure reads the funnels share — the chain index (root, head, newest continuation), the open team's member of a run, the member view, work given in this Turn, the preamble and its replay form, and the broadcast ids. - `packages/cli/src/serve/hosted-child-agent-session.ts`: - lifting #13846's refusal to continue an open team's member; - the preamble on a member's continuation envelope, and the replay check that admits it; @@ -311,7 +314,6 @@ To a running member, step 1 is an outbound `session_message` to the head, and H4 - the member preamble in a named launch's admission; - the PostToolUse dispatch evidence over base and broadcast ids. - `packages/cli/src/serve/hosted-runtime-recovery.ts`: the probes and answers of decision 9. -- `packages/cli/src/serve/hosted-harness-session.ts`: the team funnel handed to the message funnel. - Tests beside each file. These #13846 tests flip on purpose: - the refusal to continue a finished member (`hosted-workspace-tool-turn.team.test.ts`); - the `to` refusal that points at task ids (`hosted-workspace-tool-turn.team.test.ts`); @@ -332,7 +334,9 @@ To a running member, step 1 is an outbound `session_message` to the head, and H4 - the broadcast batch rule. - A member launched and addressed by name in one batch. - A send by name to a member that is running, completed, failed, stopping, or completed after a stop request. - - A send by name after a continuation that never started, whose answer names it. + - A send by name after a continuation that never started, whose answer names it with the messages it lost; the same by task id; and one after a continuation a user cancelled before its start. + - A replay of a send whose continuation, or the waiting run its message was queued for, has proved since that it never started. + - A replayed or recovered send, single or broadcast, whose message was cancelled, rejected, or is still waiting for a handover to a run that ended or is being stopped, for a chain that takes messages, one that has ended and one stopping a continuation before its start. - A broadcast with mixed outcomes, including the active cap, and its replay. - A broadcast and, in the same turn, a plain call whose provider id equals the broadcast's derived `bcast_…` run id or `msgb_…` message id, or is a prefix of the broadcast's call id: every message and run id still differs, and recovery tells the two calls apart. - One approval covering a broadcast. @@ -343,6 +347,9 @@ To a running member, step 1 is an outbound `session_message` to the head, and H4 - A committed instruction whose preamble wording differs from the current one, still accepted. - Each interrupted answer of decision 9, including a call whose hook rewrote `to`. - The PostToolUse dispatch evidence of a broadcast after a restart. + - A broadcast whose Turn is cancelled part-way, which sends to no further member. + - A replayed or recovered broadcast whose recipients' runs never started, and a recovered one whose team was replaced since. + - The owner rule in a wake turn, whose call keys collapse its prompt id. - **Team funnel.** - Membership and state through a continuation chain, including stopping heads and a continuation that failed its creation or was cancelled before it started. - `team_delete` refused while a continued member runs. @@ -354,8 +361,11 @@ To a running member, step 1 is an outbound `session_message` to the head, and H4 - a `task_update` in a later model round, or in the resumed Turn, of the Turn that sent, accepted as the same Turn; - a `task_update` in a later Turn, refused with the conditional text; - a member whose continuation from this Turn never started, refused with the never-started text, which has no branch that skips the send; - - a member whose continuation from this Turn completed after a stop request, and a stopping member, refused as ended. - - The `task_update` answer for a member owner, in a batch that sent the member the work first, which does not ask for another send. + - a member whose continuation from this Turn completed after a stop request, and a stopping member, refused as ended; + - a member whose continuation is being stopped before it attached, refused with the text that waits for it to settle; + - a member whose head failed or is stopping after an earlier run completed, accepted on a task the call completes, and one whose chain never completed a run refused there; + - a message of this Turn to another member, which gives this one no work. + - The `task_update` answer for a member owner, in a batch that sent the member the work first, which does not ask for another send; and for a restated owner whose newest continuation never started or who has ended. - The refusal texts for a continuable member and for one that cannot be continued. - **Child funnel.** - A member's continuation envelope opening its instruction with the preamble, with its bound counted. diff --git a/docs/design/2026-10-11-managed-agent-team-mailbox.zh-CN.md b/docs/design/2026-10-11-managed-agent-team-mailbox.zh-CN.md index 593ff229069..17ded74bc86 100644 --- a/docs/design/2026-10-11-managed-agent-team-mailbox.zh-CN.md +++ b/docs/design/2026-10-11-managed-agent-team-mailbox.zh-CN.md @@ -2,7 +2,7 @@ [English](2026-10-11-managed-agent-team-mailbox.md) | [简体中文](2026-10-11-managed-agent-team-mailbox.zh-CN.md) -状态:设计。这是 [#12827](https://github.com/QwenLM/qwen-code/issues/12827) 的 **H4e** 中第二个运行时切片,即 Managed Agent 提案 [#12380](https://github.com/QwenLM/qwen-code/issues/12380) 的 H 阶段,由 [#13745](https://github.com/QwenLM/qwen-code/issues/13745) 跟踪。它建立在 H4e-a([团队记录契约](2026-10-10-managed-agent-teams.zh-CN.md),#13811)、H4e-b1([lead 一侧的团队运行时](2026-10-10-managed-agent-team-lead-runtime.zh-CN.md),#13824,由 #13846 启用)与 H4d-b([Session 消息运行时](2026-10-10-managed-session-message-runtime.zh-CN.md),#13822)之上。它回答 H4e-a 的开放问题 1、2 与 4,以及 H4e-b1 的开放问题 3。它还调整了 H4e-b1 设计中 H4e-b2 与 H4e-b3 的范围:见下文决策 1、7 与 8。 +状态:已实现,但在真实 Hosted 环境上的实机验收(决策 10)尚待进行,将在本变更合入前完成。这是 [#12827](https://github.com/QwenLM/qwen-code/issues/12827) 的 **H4e** 中第二个运行时切片,即 Managed Agent 提案 [#12380](https://github.com/QwenLM/qwen-code/issues/12380) 的 H 阶段,由 [#13745](https://github.com/QwenLM/qwen-code/issues/13745) 跟踪。它建立在 H4e-a([团队记录契约](2026-10-10-managed-agent-teams.zh-CN.md),#13811)、H4e-b1([lead 一侧的团队运行时](2026-10-10-managed-agent-team-lead-runtime.zh-CN.md),#13824,由 #13846 启用)与 H4d-b([Session 消息运行时](2026-10-10-managed-session-message-runtime.zh-CN.md),#13822)之上。它回答 H4e-a 的开放问题 1、2 与 4,以及 H4e-b1 的开放问题 3。它还调整了 H4e-b1 设计中 H4e-b2 与 H4e-b3 的范围:见下文决策 1、7 与 8。 ## 问题与范围 @@ -88,20 +88,21 @@ H4e-b1 设计把 H4e-b2 规划为五个部分,并启用 `team_message`: 本切片中 `team_message` 保持关闭且无人产出。团队在 H4d-b 之上增加的是路由与呈现:名字、广播、标签与身份。这些都由 lead journal 中的名册推导。成员撰写的消息、任务分配或协议类型是否需要 `team_message`,是 H4e-b3 的问题(开放问题 1)。 2. **成员就是它的续跑链(回答 H4e-a 开放问题 4 与 H4e-b1 开放问题 3)。** - - **链。** 名册条目指向链上的第一个 run。若一个 run 的链根在开启中团队的名册上,它就属于该团队的一个成员。链根沿 lead 的 `child_run` 记录回溯 `predecessorChildRunId` 找到。launch 本身从来不是续跑,所以每个链根都是一次 launch。funnel 在每次调用中只对 lead 的 `child_run` 记录按前驱建一次索引,因此名册视图或广播不会为每个成员重读整个 domain。 + - **链。** 名册条目指向链上的第一个 run。若一个 run 的链根在开启中团队的名册上,它就属于该团队的一个成员。链根沿 lead 的 `child_run` 记录回溯 `predecessorChildRunId` 找到。launch 本身从来不是续跑,所以每个链根都是一次 launch。各 funnel 按前驱为 lead 的 `child_run` 记录建索引,名册视图为全部成员只建一次。广播把每个收件方交给 H4d-b 的发送去路由,而它会为每个收件方重新读取记录;收件方至多十个。 - **成员的状态**就是其链头的状态,链头按 H4d-b 路由的定义。它读作: - `running`,请求停止后读作 `running (stopping)`; - `completed`;请求停止后才结算的链头读作 `completed (stop requested)`,路由拒绝续跑它; - `failed` 或 `cancelled`,附停止原因。 - - **名册视图。** `task_list` 显示该状态;只要成员还能接收工作(运行中或可续跑),就同时显示链头的 task id。若链头最新的续跑(即最后提交的那个)从未启动,该行还会写出原因,以及随它一起被取消的消息条数(H4d-b 会取消其任务未经准入就结束的消息)。例如:`alpha: completed — task_r1 (its latest continuation never started: creation_failed; 1 message to it cancelled)`。该行不显示那个续跑的 task id:发给它的消息会从那个已结束的 run 开始路由,并被拒绝。失败或被取消的 child 不发送通知,所以 lead 正是通过这一行得知新工作没有到达成员,这与 H4e-b1 决策 6 让名册视图承担失败可见性的用意一致。 + - **名册视图。** `task_list` 显示该状态;只要成员还能接收工作(运行中或可续跑),就同时显示链头的 task id。若链头最新的续跑(即最后提交的那个)从未启动,该行还会写出原因,以及发给它的消息中有多少条从未到达(H4d-b 会取消发往交接前就已结束的 run 的消息)。例如:`alpha: completed — task_r1 (its latest continuation never started (creation_failed), so the work it carried did not arrive, nor did 1 message sent to it)`。启动前因用户请求而被取消的续跑,则写作 `was cancelled before it started (stop_requested)`。该行不显示那个续跑的 task id,因为它不指向任何存活的 run。按它发送仍能到达这条链:对任何 child,路由都会从从未启动的续跑回溯到它的前驱,而 H4d-b 原先会把这样的发送当作发给已结束的 run 而拒绝。失败或被取消的 child 不发送通知,所以 lead 正是通过这一行得知新工作没有到达成员,这与 H4e-b1 决策 6 让名册视图承担失败可见性的用意一致。 - **读取状态的地方。** - 任一链头仍在运行时,`team_delete` 拒绝。 - owner 规则也读取链头(决策 7)。在那里,正在停止的链头算作已结束且未完成;请求停止后才完成的链头算作已完成但不可续跑。 + - 在挂接 child Session 之前被停止的续跑仍可能未启动就结束,这会把成员交还给它的前驱。在它结算之前,发给该成员的消息和 owner 变更都以"正在停止"被拒绝,拒绝文本会说明这一点,并让 lead 在它结算后查看 `task_list`,而不是指向启动新成员。 - **现在允许续跑。** #13846 在本切片之前加入的 `sendToChild` 拒绝续跑开启中团队成员的规则,对 `active` 团队撤销。已完成的成员像任何已完成的 child 一样被续跑,续跑加入它的链。处于 `closing` 的团队仍然拒绝,因为它在收尾期间不接收新工作(H4e-a 决策 8),而 `team_delete` 只在没有成员运行时才提交 `closing`。 - **worktree 成员。** worktree 成员的续跑保留其 `workspaceMode`,与每个 H4d-b 续跑一样,因此需要支持 child Workspace 的主机。 - **记录不变。** 续跑从不加入名册,因为它由 `send_message` 启动,而不是由具名 Agent 调用启动。H4e-a 的加入规则只接纳存活且未被其他团队列出的 run,它仍然守护每一次加入。 - **已结束的成员。** 链头在启动后失败或被取消的成员保持结束状态:H4d-a 决策 9 从不续跑这样的 run。请求停止后才完成的链头也一样。lead 改为启动新成员,新成员占用另一个名册名额(开放问题 4)。 - - **从未启动的续跑**让它的成员保持原样,因为 H4d-a 决策 9 会释放其前驱。这包括创建失败,以及启动前被 H4f 取消。下一条消息会再次续跑同一个链头,其答复会说明上一次续跑从未启动。 + - **从未启动的续跑**让它的成员保持原样,因为 H4d-a 决策 9 会释放其前驱。这包括创建失败,以及启动前被 H4f 取消。下一条消息会再次续跑同一个链头。无论按名还是按 task id 发送,其答复都会说明上一次续跑从未启动、发给它的消息丢了多少条,并要求再次发送那份工作和那些消息,除非新消息已经包含它们。用户在启动前取消的续跑会单独说明,只有那份工作仍然需要时才要求再次发送,因为用户可能是有意取消的。 - **取消成员的工作。** 用户的 H4f 任务取消只停止其 task id 所指的那个 run。要停止被续跑成员的工作,用户要指定链头的任务,即公开任务列表中该成员最新的未结束 child 任务。第一个 run 的任务会答复该任务已经结算。 - **`team_delete` 之后。** 按名路由、广播、前言与续跑标签(决策 3–6)只涉及开启中团队的名册。团队删除后,前成员的链只能通过 `task_id` 接收消息,并作为普通 child 续跑,不带前言也不带标签,与 #13846 的决定一致(开放问题 7)。 @@ -118,16 +119,16 @@ H4e-b1 设计把 H4e-b2 规划为五个部分,并启用 `team_message`: - **路由。** 名字的路由与该成员第一个 run 的 task id 完全相同: - 运行中的链头收到消息,正在停止的除外; - 可续跑的链头(已完成且未请求停止)被续跑; - - 其他链头被拒绝,答复写明成员名及其状态。 + - 其他链头被拒绝,答复写明成员名及其状态。已结束成员的拒绝信息指向以新名字启动新成员,因为普通 launch 不会加入团队。 - **id。** 消息 id 与续跑 run id 就是 `task_id` 形式在同一次调用中会生成的那些。因此 H4d-b 的重放与上限原样适用: - 信封上限; - 在途 8 条与每个 run 64 条; - 续跑的 launch 准入(正在关闭、H4b 的活跃上限、H4c 的 launch 预算、worktree 对 child Workspace 的需要)。 - **重放顺序。** 重放先查找该调用已提交的消息或续跑,再解析 `to`,因此即使团队已经变化,它也会答复已提交的发送。 - - **答复。** 答复写明成员:`Message queued for delivery to member "" (task )…`,或 `Member "" had completed; continued it as task …`。链头最新的续跑从未启动时,答复会说明这一点,但不写那个续跑的 task id。 + - **答复。** 答复写明成员:`Message queued for delivery to member "" (task )…`,或 `Member "" had completed; continued it as task …`。若本次发送所启动的续跑之前的那个续跑从未启动,答复会说明这一点(决策 2),但不写那个续跑的 task id。按 `task_id` 发给开启中团队的成员,得到相同的答复,因为 lead 的回复提示写的是 task id(决策 6)。 - **Legacy 的 `summary`** 作为不支持的参数被拒绝。一个没有意义的参数,正是 H4b 决策 11 拒绝的那种失效开关。 -4. **`to: "*"` 触达全体成员。** 广播按名册顺序发给开启中团队的每个成员。每个成员按决策 3 单独路由、单独提交。 +4. **`to: "*"` 触达全体成员。** 广播按名册顺序发给开启中团队的每个成员。每个成员按决策 3 单独路由、单独提交。广播进行中 Turn 被取消时,不再发给后续成员,因为每次发送都可能启动一个续跑。广播没有送达全部成员时,答复会让 lead 按名发给仍需要它、但没有收到的成员。 - **id。** 无论 provider 给出什么调用 id,收件方的 id 都位于任何单次发送或 launch 都到达不了的命名空间中。只对调用 id 取摘要是不够的:provider 给出的 id 恰好等于该摘要的普通调用会生成相同的 id。 - **续跑的 run id** 为 `bcast_` 加上 `sha256(promptKey \0 callId \0 position)` 的前 32 个十六进制字符,其中 `position` 是成员在名册中从 1 开始的位置。每个单次发送或 launch 的 run id 都是 `${promptKey}:${callId}`,因此都含冒号;广播的 run id 不含冒号,两个集合永不相交。 - **消息 id** 为 `msgb_` 加上 `sha256(senderSessionId \0 turnId \0 callId \0 position)` 的前 32 个十六进制字符。每个单次发送的消息 id 都以 `msg_` 开头,两个集合永不相交。在广播之间,摘要区分不同的调用与位置。 @@ -138,7 +139,7 @@ H4e-b1 设计把 H4e-b2 规划为五个部分,并启用 `team_message`: - **部分结果。** 被拒绝的收件方不会阻止其他收件方。收件方在链头已结束或正在停止时、被消息上限拒绝时(`count_limit` 或 `budget_exhausted`),或其续跑被 launch 准入拒绝时(决策 3)被拒绝。 - **答复。** 与 Legacy 一致: - 全部送达; - - `delivered to of `,并列出每个被拒绝的成员及原因; + - `sent to of `,并列出每个被拒绝的成员及原因; - 或者一个都未送达时,返回工具错误。 - **重放。** 重放的广播会找到每个收件方已提交的消息或续跑,不会重复添加。 - **范围。** lead 从不是收件方。广播从不离开团队。 @@ -194,14 +195,15 @@ H4e-b1 设计把 H4e-b2 规划为五个部分,并启用 `team_message`: 同一批次按顺序运行其调用,所以 `task_update` 运行前发送已经提交,但没有任何东西让成员一直运行到那时。relay 会在 lead 的 Turn 运行期间提交并结算 child 的结果,两个 funnel 也没有覆盖整个批次的共同锁。因此被这次发送续跑的成员,或正在运行并读到这条消息的成员,可能在两次调用之间就已完成。owner 规则用一个概念覆盖这种情况,即本 Turn 已交付的工作: - **Turn** 就是一个 prompt id。它覆盖其所有模型回合,中断后恢复的 Turn 也是同一个 Turn。 - - **本 Turn 已交付的工作。** 若同一 Turn 中更早的调用为某成员的链提交了一个未被证明从未启动的续跑,或一条发往链头、未被取消也未被拒绝的 outbound 消息,该成员就在本 Turn 被交付了工作。两者都以发送调用的键作为 `executionCallId`,它是本 Turn 的某个调用键,所以 lead 的记录就能判定。 + - **本 Turn 已交付的工作。** 若同一 Turn 中更早的调用为某成员的链提交了一个未被证明从未启动的续跑,或一条发往该链上已启动的某个 run、未被取消也未被拒绝的 outbound 消息(发往从未启动的续跑的消息会被取消,永远不会到达),该成员就在本 Turn 被交付了工作。两者都以发送调用的键作为 `executionCallId`,它是本 Turn 的某个调用键,所以 lead 的记录就能判定。 - **接受。** 无论这样的成员的链头仍在运行还是已经完成,它都可以被设为开放任务的 owner。发送已经交付了工作,所以任务板记录谁拥有它;汇报到达时,lead 把状态设为 `completed`。 - **仍被拒绝。** 链头失败、启动后被取消、正在停止,或请求停止后才完成的成员被拒绝,因为决策 2 把它算作已结束。 + - **已完成的工作。** 对于把任务保持为 completed 的调用,只要成员链上有任一 run 已完成,即使其链头此后已结束,也会被接受:那个 run 可能做了这项工作,而 #13846 记录的正是谁做了它。链上从未有 run 完成的成员被拒绝,拒绝文本指向做了这项工作的成员或 `leader`,而不是新成员。 这些文本引导模型这样做。它们都不断言过去某次发送承载的是哪个任务,因为没有记录把一次发送与一个任务关联起来;也都不会为从未到达的工作提供跳过发送的选项: - - **`task_update` 的答复。** owner 是成员时,答复说明它没有通知任何人,并说明若该成员还没有这份工作,按成员名 `send_message` 就能交给它。答复不要求模型发送:本 Turn 更早的发送,或 launch 提示词,可能已经交付了这份工作,再发一次会让它重复。 + - **`task_update` 的答复。** owner 是成员时,答复说明它没有通知任何人,并说明若该成员还没有这份工作,按成员名 `send_message` 就能交给它。答复不要求模型发送:本 Turn 更早的发送,或 launch 提示词,可能已经交付了这份工作,再发一次会让它重复。若本 Turn 更早的发送已给该成员交付了工作,答复会说明这一点,并且只在那次发送没有承载这个任务时才要求发送。重述任务原有 owner 与状态的调用不会再次检查,所以答复直接读取该成员:若其最新的续跑从未启动,答复会说明这一点并要求再次发送那份工作和那些消息;对已结束的成员,答复说明它不再接收工作。 - **本 Turn 未被交付工作、已完成且可续跑的成员。** 它在开放工作上以一段条件文本被拒绝:若它已经做了这个任务的工作(来自 launch 提示词或 lead 发给它的消息),就在汇报到达后以该 owner 把状态设为 `completed`;否则先按成员名 `send_message` 交付工作(这会续跑它),再指定它。第一个分支就是 #13846 保留下来的出路。 - - **链头最新的续跑从未启动。** 拒绝信息说明这次续跑从未启动及其原因,以及它承载的工作没有到达;它指向按名再次发送这份工作,然后指定任务,不提供跳过发送的分支。 + - **链头最新的续跑从未启动。** 拒绝信息说明这次续跑从未启动及其原因,以及它承载的工作和发给它的消息都没有到达;它指向按名再次发送这份工作和那些消息,然后指定任务,不提供跳过发送的分支。对于用户在启动前取消的续跑,只有那份工作仍然需要时才要求再次发送。 - **无法续跑的成员。** 失败、被取消、正在停止,或请求停止后才完成的成员,以已结束被拒绝。拒绝信息指向启动新成员;对已完成的成员,若它做了这项工作,还指向以它为 owner 完成任务。 - **描述。** `task_update`、`task_create` 与 `task_list` 的描述,以及团队形式 `send_message` 的描述,也这样说明。`task_list` 不再把 task id 说成联系成员的方式。 @@ -219,10 +221,10 @@ H4e-b1 设计把 H4e-b2 规划为五个部分,并启用 `team_message`: 9. **被中断的回合按已提交的内容作答(扩展 H4d-b 决策 16)。** - **查找哪些 id。** 无论 journal 中记录的参数是什么,恢复与 PostToolUse 派发证据都会在 `send_message` 调用的基础 id 与十个广播位置的 id 下查找它。PreToolUse 或权限 hook 可能改写调用实际执行的输入,所以 journal 中的 `to` 未必就是实际运行的那个。 - - **单次发送。** 在基础 id 下找到的,以 H4d-b 的 task id 文本作答。该文本写明 run 而不写成员名,也绝不引导重试。 + - **单次发送。** 在基础 id 下找到的,按决策 3 对实时发送的方式作答:团队开启期间写明成员名,否则用 H4d-b 的 task id 文本。对已经到达的发送,它绝不引导重试。若发送到达的 run 此后被证明从未启动,答复会说明消息没有到达以及如何再次发送:失败的 child 不发送通知,没有别的途径会告诉 lead。每个这样的答复只要求重发它自己的消息,因此两个答复不会对同一内容各要求一次;对从未启动的 launch,答复说明该成员或 child 已结束。消息自身的投递若已失败,即使其 run 并未被证明从未启动,也同样没有到达:交接前被取消、被目标拒绝,或仍在等待交接、而 relay 会因其 run 已结束或正在停止而取消它(H4d-b)。答复会说明这一点:链仍接收消息时指向重发;启动前被停止的续跑尚在结算时,指向等它结算;链已结束时,指向启动新成员(普通 child 则为新 agent)。 - **广播。** - 在位置 id 下找到的,逐个收件方作答,依据每个位置已提交的消息或续跑。 - - 答复列出哪些已送达、哪些没有,让模型只向其余成员重发。 + - 答复列出哪些已送达、哪些没有,让模型只向其余成员重发。所到达的 run 从未启动,或其消息的投递如上失败的收件方,算作未送达。已结束的成员单独列出,因为按名发送会被拒绝;实时重放则在该成员的那一行注明。若团队此后已被删除或替换,答复只列出已发送到的收件方,与实时重放相同。 - 会恢复 Turn 的路由,把一个什么也没提交的调用作答为从未运行。 - **派发证据。** 已提交的广播在重启后仍算已派发。 @@ -291,6 +293,7 @@ lead 发给成员 `alpha` 的消息,`alpha` 的第一个 run `r1` 已完成: ## 受影响的文件 +- `packages/cli/src/serve/hosted-member-chain.ts`(新增):各 funnel 共用的纯读取逻辑——链索引(链根、链头、最新续跑)、某个 run 所属的开启中团队成员、成员视图、本 Turn 已交付的工作、前言及其重放形式,以及广播 id。 - `packages/cli/src/serve/hosted-child-agent-session.ts`: - 撤销 #13846 对续跑开启中团队成员的拒绝; - 成员续跑信封上的前言,以及接纳它的重放检查; @@ -311,7 +314,6 @@ lead 发给成员 `alpha` 的消息,`alpha` 的第一个 run `r1` 已完成: - 具名 launch 准入中的成员前言; - 基于基础 id 与广播 id 的 PostToolUse 派发证据。 - `packages/cli/src/serve/hosted-runtime-recovery.ts`:决策 9 的查找与作答。 -- `packages/cli/src/serve/hosted-harness-session.ts`:把团队 funnel 交给消息 funnel。 - 各文件旁的测试。以下 #13846 的测试有意翻转: - 拒绝续跑已完成成员的测试(`hosted-workspace-tool-turn.team.test.ts`); - 指向 task id 的 `to` 拒绝(`hosted-workspace-tool-turn.team.test.ts`); @@ -332,7 +334,9 @@ lead 发给成员 `alpha` 的消息,`alpha` 的第一个 run `r1` 已完成: - 广播的批次规则。 - 在同一批次中启动一个成员并按名寻址它。 - 按名发给运行中、已完成、失败、正在停止,以及请求停止后才完成的成员。 - - 在一次从未启动的续跑之后按名发送,答复写出那次续跑。 + - 在一次从未启动的续跑之后按名发送,答复写出那次续跑及其丢失的消息;按 task id 发送时同样如此;以及用户在启动前取消的续跑之后的发送。 + - 重放一次发送,其续跑,或其消息排队等待的 run,此后已被证明从未启动。 + - 重放或恢复一次发送(单发或广播),其消息被取消、被拒绝,或仍在等待交接而其 run 已结束或正在停止;分别针对仍接收消息的链、已结束的链,以及正在停止一个尚未启动的续跑的链。 - 结果混合的广播(包括活跃上限)及其重放。 - 同一 turn 中有一次广播,以及一个普通调用,其 provider id 等于该广播派生的 `bcast_…` run id 或 `msgb_…` 消息 id,或是广播调用 id 的前缀:所有消息 id 与 run id 仍然各不相同,恢复也能区分这两个调用。 - 一次审批覆盖一次广播。 @@ -343,6 +347,9 @@ lead 发给成员 `alpha` 的消息,`alpha` 的第一个 run `r1` 已完成: - 前言措辞与当前不同的已提交指令,仍被接受。 - 决策 9 中每一种被中断的作答,包括 hook 改写了 `to` 的调用。 - 重启后广播的 PostToolUse 派发证据。 + - Turn 在广播中途被取消,不再发给后续成员。 + - 收件方的 run 从未启动的广播的重放与恢复,以及团队此后被替换的广播的恢复。 + - wake turn 中的 owner 规则,其调用键会折叠 prompt id。 - **团队 funnel。** - 经过续跑链的成员身份与状态,包括正在停止的链头,以及创建失败或启动前被取消的续跑。 - 被续跑的成员运行时 `team_delete` 被拒绝。 @@ -354,8 +361,11 @@ lead 发给成员 `alpha` 的消息,`alpha` 的第一个 run `r1` 已完成: - 发送所在 Turn 的之后模型回合中,或恢复后的同一 Turn 中的 `task_update`,作为同一 Turn 被接受; - 之后 Turn 中的 `task_update`,以条件文本被拒绝; - 本 Turn 的续跑从未启动的成员,以"从未启动"文本被拒绝,该文本没有跳过发送的分支; - - 本 Turn 的续跑在请求停止后才完成的成员,以及正在停止的成员,以已结束被拒绝。 - - 在先把工作发给成员的批次中,owner 为该成员时 `task_update` 的答复不要求再发送一次。 + - 本 Turn 的续跑在请求停止后才完成的成员,以及正在停止的成员,以已结束被拒绝; + - 续跑在挂接之前被停止的成员,以等待其结算的文本被拒绝; + - 较早的 run 已完成、链头此后失败或正在停止的成员,在本次调用置为完成的任务上被接受;链上从未有 run 完成的成员在那里被拒绝; + - 本 Turn 发给另一个成员的消息,不算给这个成员交付了工作。 + - 在先把工作发给成员的批次中,owner 为该成员时 `task_update` 的答复不要求再发送一次;以及重述 owner、而其最新续跑从未启动或已结束时的答复。 - 可续跑成员与无法续跑成员各自的拒绝文本。 - **child funnel。** - 成员的续跑信封中指令以前言开头,且上限计入前言。 diff --git a/packages/cli/src/serve/hosted-child-agent-session.ts b/packages/cli/src/serve/hosted-child-agent-session.ts index 3c356b84269..f9d0313c152 100644 --- a/packages/cli/src/serve/hosted-child-agent-session.ts +++ b/packages/cli/src/serve/hosted-child-agent-session.ts @@ -61,10 +61,15 @@ import type { DefinitionPin } from '@qwen-code/qwen-code-core/managed-runtime/ma import { isTerminalRunState } from '@qwen-code/qwen-code-core/managed-runtime/managed-extension-record.js'; import { managedChildTaskId } from '@qwen-code/qwen-code-core/managed-runtime/managed-extension-projection.js'; import { escapeXml } from '@qwen-code/qwen-code-core/utils/xml.js'; +import { hostedTeamMembership } from './hosted-team-session.js'; import { - hostedOpenTeamMembership, - hostedTeamMembership, -} from './hosted-team-session.js'; + HostedChildChains, + hostedOpenTeamChainMember, + hostedRouteDetails, + isSentInstruction, + teamMemberPreamble, + type HostedRouteDetails, +} from './hosted-member-chain.js'; import { stripDisplayControlChars, truncateNotificationLabel, @@ -392,14 +397,20 @@ export function boundedNotificationText( */ export class ChildMessagesPendingError extends Error {} -/** Where `send_message` to a child task went. */ +/** + * Where `send_message` to a child task went, with what its answer must + * tell about the run it reached (H4e-b2), read on the writes chain. + */ export type ChildMessageRoute = - | { readonly kind: 'message'; readonly childRunId: string } - | { + | ({ + readonly kind: 'message'; + readonly childRunId: string; + } & HostedRouteDetails) + | ({ readonly kind: 'continuation'; readonly childRunId: string; readonly predecessorChildRunId: string; - } + } & HostedRouteDetails) | { readonly kind: 'refused'; readonly reason: string }; export class HostedChildAgentSession { @@ -477,21 +488,20 @@ export class HostedChildAgentSession { * have started left its predecessor untouched (H4d-a decision 9), so the * chain's head stays at the predecessor until another one starts. */ - chainHead(run: ChildAgentRun): ChildAgentRun { - let head = run; - for (;;) { - const next = this.store.authority - .extensionRecordsInDomain('child_run') - .map((entry) => parseChildRun(entry.record)) - .find( - (record): record is ChildAgentRun => - record.kind === 'child_agent' && - record.predecessorChildRunId === head.childRunId && - record.run.execution !== 'not_started_proven', - ); - if (next === undefined) return head; - head = next; - } + chainHead( + run: ChildAgentRun, + chains: HostedChildChains = new HostedChildChains( + this.store.authority.extensionRecordsInDomain('child_run'), + ), + ): ChildAgentRun { + // One definition of the head serves this routing and H4e-b2's member + // views alike. A chain starts at a committed run and the authority + // keeps a continuation's kind, so its head is a child agent run. + const head = chains.head(run.childRunId); + const record = head && this.record(head.childRunId); + if (record === undefined) + throw new Error(`Child run ${run.childRunId} has no chain head.`); + return record; } /** Every message this Session sent to one child run, in any state. */ @@ -558,26 +568,32 @@ export class HostedChildAgentSession { `Session message ${params.messageId} was sent with different evidence.`, ); } - return { kind: 'message', childRunId: message.childRunId }; + return this.routed( + { kind: 'message', childRunId: message.childRunId }, + params.messageId, + ); } const continued = this.record(params.continuationRunId); if (continued !== undefined) { const envelope = decodeChildLaunchEnvelope( await this.store.resources.read(continued.inputRef), ); + // H4e-b2 decision 5: a member's continuation opens with its + // preamble, whose presence follows the team and may have changed + // since, so the committed instruction is read for its form. if ( continued.predecessorChildRunId === null || - envelope.prompt !== params.text + !isSentInstruction(envelope.prompt, params.text) ) { throw new ManagedSessionConflictError( `Child run ${params.continuationRunId} was launched with different evidence.`, ); } - return { + return this.routed({ kind: 'continuation', childRunId: continued.childRunId, predecessorChildRunId: continued.predecessorChildRunId, - }; + }); } const run = this.runForTask(params.taskId); if (run === undefined) { @@ -586,6 +602,37 @@ export class HostedChildAgentSession { reason: `No child agent task ${params.taskId} was launched by this Session.`, }; } + // A continuation that never started is no head and left its + // predecessor untouched (H4d-a decision 9), so its task id routes + // from that predecessor: an answer that named it must still reach + // the chain, never read as an ended child. + const chains = new HostedChildChains( + this.store.authority.extensionRecordsInDomain('child_run'), + ); + let from = run; + while ( + from.run.execution === 'not_started_proven' && + from.predecessorChildRunId !== null + ) { + const predecessor = this.record(from.predecessorChildRunId); + if (predecessor === undefined) break; + from = predecessor; + } + // H4e-b2 decision 2: a run of an open team member's chain is that + // member, and every continuation of it opens its instruction with + // the member preamble (decision 5). + const member = hostedOpenTeamChainMember( + this.store.authority.extensionRecordsInDomain('team_state'), + chains, + from.childRunId, + ); + const instruction = + member === undefined + ? params.text + : teamMemberPreamble({ + name: member.name, + teamName: member.team.name, + }) + params.text; // The bound is the smaller carrier: the continuation's launch // envelope, built from the chain's own description and definition, // which no later state changes — so a running and a completed task @@ -597,23 +644,40 @@ export class HostedChildAgentSession { try { envelope = encodeChildLaunchEnvelope({ description: launch.description, - prompt: params.text, + prompt: instruction, definition: launch.definition, }); } catch (cause) { if (!(cause instanceof ManagedSessionRecordError)) throw cause; return { kind: 'refused', - reason: `Message exceeds what a child task can carry (byte_limit): keep it under ${MANAGED_CHILD_LIMITS.maxEnvelopeBytes} bytes with its launch description.`, + reason: + member === undefined + ? `Message exceeds what a child task can carry (byte_limit): keep it under ${MANAGED_CHILD_LIMITS.maxEnvelopeBytes} bytes with its launch description.` + : `Message exceeds what a child task can carry (byte_limit): keep it under ${MANAGED_CHILD_LIMITS.maxEnvelopeBytes} bytes together with its launch description and the member preamble every continuation of "${member.name}" carries.`, }; } - const head = this.chainHead(run); + const head = this.chainHead(from, chains); const headTask = this.taskIdOf(head.childRunId); + // H4e-b2 decision 3: a member's refusal names the member. + const subject = + member === undefined + ? `Child agent task ${headTask}` + : `Member "${member.name}" (task ${headTask})`; if (!isTerminalRunState(head.run.state)) { if (head.stopRequested) { return { kind: 'refused', - reason: `Child agent task ${headTask} is being stopped and cannot receive messages.`, + reason: + member === undefined + ? `${subject} is being stopped and cannot receive messages.` + : head.predecessorChildRunId !== null && + head.childSessionId === null + ? // Stopped before it attached, it may end without + // starting, which hands the member back to its + // predecessor. + `Member "${member.name}" is being stopped before its latest continuation started and cannot receive messages now. If that continuation ends without starting, the member takes messages again: check task_list once it settles.` + : `Member "${member.name}" is being stopped and cannot receive messages.`, }; } if (params.closing) { @@ -627,7 +691,7 @@ export class HostedChildAgentSession { if (sentBefore.length >= limits.maxPerRun) { return { kind: 'refused', - reason: `Child agent task ${headTask} has received its ${limits.maxPerRun} messages (budget_exhausted).`, + reason: `${subject} has received its ${limits.maxPerRun} messages (budget_exhausted).`, }; } if ( @@ -636,7 +700,7 @@ export class HostedChildAgentSession { ) { return { kind: 'refused', - reason: `Child agent task ${headTask} has ${limits.maxInFlightPerRun} messages still on their way to it (count_limit); wait for them to arrive.`, + reason: `${subject} has ${limits.maxInFlightPerRun} messages still on their way to it (count_limit); wait for them to arrive.`, }; } const contentRef = await this.store.resources.publish( @@ -661,27 +725,32 @@ export class HostedChildAgentSession { { domain: 'session_message', record: body }, TRUSTED, ); - return { kind: 'message', childRunId: head.childRunId }; + return this.routed( + { kind: 'message', childRunId: head.childRunId }, + params.messageId, + ); } if (head.stopReason !== 'completed' || head.stopRequested) { + // A member is relaunched under a new name: a plain agent launch + // would not join the team. return { kind: 'refused', - reason: `Child agent task ${headTask} ended (${head.run.state}: ${head.stopReason ?? 'unknown'}) and cannot receive messages; launch a new agent instead.`, + reason: + member === undefined + ? `${subject} ended (${head.run.state}: ${head.stopReason ?? 'unknown'}) and cannot receive messages; launch a new agent instead.` + : head.stopReason === 'completed' + ? `Member "${member.name}" completed after a stop was requested and cannot receive messages; for more work, launch a new member under a new name.` + : `Member "${member.name}" ended (${head.run.state}: ${head.stopReason ?? 'unknown'}) and cannot receive messages; for more work, launch a new member under a new name.`, }; } - // H4e-b1: an open team's roster names one run of its member, so a - // continuation would run off the roster, unlabeled and unseen by - // team_delete; continued members come with H4e-b2. Only a launch - // joins, so a member's run is never itself a continuation. Once its - // team is deleted, a former member continues as a plain child. - const member = hostedOpenTeamMembership( - this.store.authority.extensionRecordsInDomain('team_state'), - head.childRunId, - ); - if (member !== undefined) { + // H4e-b2 decision 2: a member of an active team is continued like + // any child, and the roster follows its chain. A closing team admits + // no new work while it drains (H4e-a decision 8); once its team is + // deleted, a former member continues as a plain child. + if (member !== undefined && member.team.lifecycle !== 'active') { return { kind: 'refused', - reason: `Child agent task ${headTask} ran as "${member.name}" of team "${member.teamName}" and has finished; a member of an open team cannot be continued yet. For more work, launch a new member with another name while the team takes members; once every member has ended, deleting the team would let this one be continued like any other child agent, but would give up the team's board.`, + reason: `Member "${member.name}" (task ${headTask}) belongs to team "${member.team.name}", which is closing and takes no new work. Finish it with team_delete.`, }; } // A continuation is a launch: it owes the launch admission (H4d-a @@ -698,7 +767,10 @@ export class HostedChildAgentSession { if (!admission.admitted) { return { kind: 'refused', - reason: `Hosted child agent refused to continue task ${headTask} (${admission.reason}).`, + reason: + member === undefined + ? `Hosted child agent refused to continue task ${headTask} (${admission.reason}).` + : `Hosted child agent refused to continue member "${member.name}" (task ${headTask}) (${admission.reason}).`, }; } const inputRef = await this.store.resources.publish( @@ -721,21 +793,45 @@ export class HostedChildAgentSession { { domain: 'child_run', record: body }, TRUSTED, ); - return { + return this.routed({ kind: 'continuation', childRunId: params.continuationRunId, predecessorChildRunId: head.childRunId, - }; + }); }); } + /** A committed route with what its answer must tell (H4e-b2). */ + private routed< + Route extends { + readonly kind: 'message' | 'continuation'; + readonly childRunId: string; + }, + >(route: Route, messageId?: string): Route & HostedRouteDetails { + return { + ...route, + ...hostedRouteDetails(this.store.authority, route, messageId), + }; + } + /** * Revision 1 (`startChildRun`): the launch intent, before any physical * side effect. The retried launch returns its opening command, so the * creation idempotency the control plane derives from this record can * never diverge from it. */ - async admit(params: ChildAgentLaunchParams): Promise<{ + async admit( + params: ChildAgentLaunchParams & { + /** + * H4e-b2 decision 5: the prompt the model sent, when `prompt` opens + * with a member preamble. A replay accepts a committed prompt that is + * the sent one alone or behind one well-formed preamble block, since + * whether the launch carries one follows a team that may have changed + * since, and goes on with the committed envelope as it is. + */ + readonly sentPrompt?: string; + }, + ): Promise<{ readonly inputRef: ManagedSessionDurableRef; readonly envelope: ChildLaunchEnvelope; }> { @@ -744,11 +840,13 @@ export class HostedChildAgentSession { prompt: params.prompt, definition: params.definition, }; - const bytes = encodeChildLaunchEnvelope(envelope); // A restated launch names the same record: identical evidence is the // replay — anything else conflicts, never a second child chain. const existing = this.record(params.childRunId); if (existing !== undefined) { + const committedBytes = await this.store.resources.read(existing.inputRef); + const committed = decodeChildLaunchEnvelope(committedBytes); + const sentPrompt = params.sentPrompt ?? params.prompt; const sameEvidence = existing.ownerScopeId === params.ownerScopeId && existing.rootSessionId === params.rootSessionId && @@ -757,17 +855,18 @@ export class HostedChildAgentSession { existing.workingDirectory === params.workingDirectory && existing.run.executionCallId === params.executionCallId && isDeepStrictEqual(existing.run.definition, params.definition) && - isDeepStrictEqual( - await this.store.resources.read(existing.inputRef), - bytes, - ); + committed.description === params.description && + isDeepStrictEqual(committed.definition, params.definition) && + (committed.prompt === params.prompt || + isSentInstruction(committed.prompt, sentPrompt)); if (!sameEvidence) { throw new ManagedSessionConflictError( `Child run ${params.childRunId} was launched with different evidence.`, ); } - return { inputRef: existing.inputRef, envelope }; + return { inputRef: existing.inputRef, envelope: committed }; } + const bytes = encodeChildLaunchEnvelope(envelope); const inputRef = await this.store.resources.publish('managed-input', bytes); await this.commit( params.childRunId, @@ -1115,10 +1214,20 @@ export class HostedChildAgentSession { ), // A run keeps the label of the roster it joined, so a result // accepted after its team was deleted still names its member. - teammate: hostedTeamMembership( - this.store.authority.extensionRecordsInDomain('team_state'), - childRunId, - )?.name, + // H4e-b2 decision 6: a continuation is labeled through its + // chain root while that root is on the open team's roster. + teammate: + hostedTeamMembership( + this.store.authority.extensionRecordsInDomain('team_state'), + childRunId, + )?.name ?? + hostedOpenTeamChainMember( + this.store.authority.extensionRecordsInDomain('team_state'), + new HostedChildChains( + this.store.authority.extensionRecordsInDomain('child_run'), + ), + childRunId, + )?.name, ...(workspace === undefined ? {} : { workspace }), }), }), diff --git a/packages/cli/src/serve/hosted-member-chain.test.ts b/packages/cli/src/serve/hosted-member-chain.test.ts new file mode 100644 index 00000000000..6cd2c62b55a --- /dev/null +++ b/packages/cli/src/serve/hosted-member-chain.test.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { sessionMessageId } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-message-operations.js'; +import { + broadcastMessageId, + broadcastRunId, + hostedCallKey, + hostedPromptKey, + isSentInstruction, + teamMemberPreamble, +} from './hosted-member-chain.js'; + +describe('the member preamble (H4e-b2 decision 5)', () => { + const alice = { name: 'alice', teamName: 'review' }; + + it('names the member and its team and stays under its bound', () => { + const preamble = teamMemberPreamble(alice); + expect(preamble).toMatch( + /^\n[^<]+\n<\/team-member>\n\n$/, + ); + expect(preamble).toContain( + 'Your result is your answer to the last instruction you receive', + ); + expect(preamble).toContain('use send_message with to "parent"'); + const longest = teamMemberPreamble({ + name: 'a'.repeat(64), + teamName: 'b'.repeat(64), + }); + expect(Buffer.byteLength(longest, 'utf8')).toBeLessThan(750); + }); + + it('recognizes a committed instruction by its form, not its wording', () => { + const sent = 'fix the tests'; + expect(isSentInstruction(sent, sent)).toBe(true); + expect(isSentInstruction(teamMemberPreamble(alice) + sent, sent)).toBe( + true, + ); + // A release that rewords the preamble still recognizes what an earlier + // one committed. + expect( + isSentInstruction( + '\nAn older wording.\n\n\n' + + sent, + sent, + ), + ).toBe(true); + for (const committed of [ + teamMemberPreamble(alice) + 'another text', + teamMemberPreamble(alice) + teamMemberPreamble(alice) + sent, + '\nx\n\n\n' + sent, + '\nx\n\n\n' + + sent, + `${sent}\n${teamMemberPreamble(alice)}`, + ]) + expect(isSentInstruction(committed, sent)).toBe(false); + }); +}); + +describe('broadcast ids (H4e-b2 decision 4)', () => { + it('live where no single send or launch can reach them', () => { + const runId = broadcastRunId('prompt', 'call-all', 1); + expect(runId).toMatch(/^bcast_[0-9a-f]{32}$/); + // Every single-send and launch run id is a call key, with a colon. + expect(hostedCallKey('prompt', runId)).toBe(`prompt:${runId}`); + expect(runId).not.toContain(':'); + const messageId = broadcastMessageId({ + senderSessionId: 'lead', + turnId: 'prompt', + callId: 'call-all', + position: 1, + }); + expect(messageId).toMatch(/^msgb_[0-9a-f]{32}$/); + // A plain call whose provider id equals either derived value mints + // ids of its own. + for (const callId of [runId, messageId]) + expect( + sessionMessageId({ senderSessionId: 'lead', turnId: 'prompt', callId }), + ).toMatch(/^msg_[0-9a-f]{32}$/); + }); + + it('separate calls, positions and Turns', () => { + const ids = new Set([ + broadcastRunId('prompt', 'call-all', 1), + broadcastRunId('prompt', 'call-all', 2), + broadcastRunId('prompt', 'call-other', 1), + broadcastRunId('prompt-2', 'call-all', 1), + ]); + expect(ids.size).toBe(4); + // A wake turn's prompt id collapses, as its call keys do. + const wake = 'msg_x:message:accept:notify'; + expect(hostedPromptKey(wake)).toMatch(/^[0-9a-f]{16}$/); + expect(broadcastRunId(wake, 'c', 1)).toBe(broadcastRunId(wake, 'c', 1)); + }); +}); diff --git a/packages/cli/src/serve/hosted-member-chain.ts b/packages/cli/src/serve/hosted-member-chain.ts new file mode 100644 index 00000000000..8867abe2ff9 --- /dev/null +++ b/packages/cli/src/serve/hosted-member-chain.ts @@ -0,0 +1,547 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import { + isChildSessionRun, + parseChildRun, + type ChildSessionRun, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-child-run-record.js'; +import { isTerminalRunState } from '@qwen-code/qwen-code-core/managed-runtime/managed-extension-record.js'; +import { + parseSessionMessage, + type SessionMessage, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-session-message-record.js'; +import { + MANAGED_TEAM_LIMITS, + parseTeamState, + type TeamState, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-team-record.js'; +import type { ManagedSessionDomain } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js'; + +// H4e-b2 of #12827: what a team adds on top of H4d-b's Session messages — +// a member is its continuation chain, every run of that chain opens its +// instruction with who it is, and a broadcast's ids live where no single +// send or launch can reach them. Pure reads of the lead's committed +// records, shared by the team, child and message funnels and the tool +// turn. See docs/design/2026-10-11-managed-agent-team-mailbox.md. + +/** The most members a roster holds, so the most positions a broadcast has. */ +export const HOSTED_BROADCAST_POSITIONS = MANAGED_TEAM_LIMITS.maxMembers; + +/** + * The Turn half of a call key. A wake turn acknowledging a child result + * collapses to a digest, so its keys never grow across hops; every other + * Turn keys its calls by its prompt id verbatim. + */ +export function hostedPromptKey(promptId: string): string { + return promptId.endsWith(':accept:notify') + ? createHash('sha256').update(promptId).digest('hex').slice(0, 16) + : promptId; +} + +/** A call's replay-stable key: `${promptKey}:${callId}`, always with a colon. */ +export function hostedCallKey(promptId: string, callId: string): string { + return `${hostedPromptKey(promptId)}:${callId}`; +} + +/** + * Decision 4: the continuation run id of one broadcast recipient. It has + * no colon, while every single-send and launch run id is a call key, so a + * provider call id can never mint it. + */ +export function broadcastRunId( + promptId: string, + callId: string, + position: number, +): string { + return `bcast_${createHash('sha256') + .update(`${hostedPromptKey(promptId)}\0${callId}\0${position}`) + .digest('hex') + .slice(0, 32)}`; +} + +/** + * Decision 4: the message id of one broadcast recipient. Single sends + * mint `msg_` ids, so `msgb_` never meets them. + */ +export function broadcastMessageId(params: { + readonly senderSessionId: string; + readonly turnId: string; + readonly callId: string; + readonly position: number; +}): string { + return `msgb_${createHash('sha256') + .update( + `${params.senderSessionId}\0${params.turnId}\0${params.callId}\0${params.position}`, + ) + .digest('hex') + .slice(0, 32)}`; +} + +const PREAMBLE_BODY = + 'You are a member of this team. Your parent, the agent that launched you, is the team leader. Your result is your answer to the last instruction you receive: if another message from the leader arrives after you have answered, your answer to it becomes your whole result, so answer it in full and repeat anything from your earlier answers the leader still needs. To ask the leader something, use send_message with to "parent"; its reply reaches you after you have answered, as your next instruction. You cannot message other members or edit the team\'s task board.'; + +/** + * Decision 5: the fixed text every instruction of a member's chain opens + * with. Names are sanitized to `[a-z0-9-]`, so they need no escaping. + */ +export function teamMemberPreamble(member: { + readonly name: string; + readonly teamName: string; +}): string { + return `\n${PREAMBLE_BODY}\n\n\n`; +} + +const PREAMBLE_FORM = + /^\n[^<]*\n<\/team-member>\n\n/; + +/** + * Decision 5's replay evidence: whether a committed instruction is the + * sent text alone, or one well-formed preamble block followed by it. The + * block's form is read, never its wording, so a release that rewords the + * preamble still recognizes what an earlier one committed. + */ +export function isSentInstruction(committed: string, sent: string): boolean { + if (committed === sent) return true; + const block = PREAMBLE_FORM.exec(committed); + return block !== null && committed.slice(block[0].length) === sent; +} + +/** The lead's child Session runs, indexed once per call. */ +export class HostedChildChains { + private readonly byId = new Map(); + private readonly successors = new Map(); + + constructor(records: ReadonlyArray<{ readonly record: unknown }>) { + // Records come in their first commit's order, so a successor list + // ends with the continuation committed last. + for (const entry of records) { + const run = parseChildRun(entry.record); + if (!isChildSessionRun(run)) continue; + this.byId.set(run.childRunId, run); + if (run.predecessorChildRunId !== null) { + const list = this.successors.get(run.predecessorChildRunId) ?? []; + list.push(run); + this.successors.set(run.predecessorChildRunId, list); + } + } + } + + run(childRunId: string): ChildSessionRun | undefined { + return this.byId.get(childRunId); + } + + /** The launch a run's chain started from; a launch is its own root. */ + root(childRunId: string): string { + let id = childRunId; + for (;;) { + const predecessor = this.byId.get(id)?.predecessorChildRunId ?? null; + if (predecessor === null) return id; + id = predecessor; + } + } + + /** + * The chain head as H4d-b's routing defines it: the newest continuation + * that did not prove it never started. H4d-a decision 9 lets at most one + * started continuation follow a run. + */ + head(childRunId: string): ChildSessionRun | undefined { + let head = this.byId.get(childRunId); + while (head !== undefined) { + const next = (this.successors.get(head.childRunId) ?? []).find( + (run) => run.run.execution !== 'not_started_proven', + ); + if (next === undefined) return head; + head = next; + } + return undefined; + } + + /** The continuation of `childRunId` committed last, started or not. */ + newestContinuation(childRunId: string): ChildSessionRun | undefined { + const list = this.successors.get(childRunId); + return list === undefined ? undefined : list[list.length - 1]; + } + + /** + * The continuation its predecessor took just before this one. When there + * is one, it had proved it never started by then, or routing would have + * reached it instead (H4d-a decision 9). + */ + previousContinuation(childRunId: string): ChildSessionRun | undefined { + const predecessor = this.byId.get(childRunId)?.predecessorChildRunId; + if (predecessor === null || predecessor === undefined) return undefined; + const list = this.successors.get(predecessor) ?? []; + const index = list.findIndex((run) => run.childRunId === childRunId); + return index > 0 ? list[index - 1] : undefined; + } + + /** Every run of the chain a launch started, never-started ones included. */ + chain(rootChildRunId: string): readonly ChildSessionRun[] { + const runs: ChildSessionRun[] = []; + const visit = (id: string) => { + const run = this.byId.get(id); + if (run === undefined) return; + runs.push(run); + for (const next of this.successors.get(id) ?? []) visit(next.childRunId); + }; + visit(rootChildRunId); + return runs; + } +} + +export interface HostedChainMember { + readonly team: TeamState; + readonly name: string; + /** The roster entry's run: the launch the chain started from. */ + readonly rootChildRunId: string; +} + +/** + * Decision 2: the member of the lead's open team whose chain a run belongs + * to — its chain root on the open team's roster — or none. + */ +export function hostedOpenTeamChainMember( + teams: ReadonlyArray<{ readonly record: unknown }>, + chains: HostedChildChains, + childRunId: string, +): HostedChainMember | undefined { + const root = chains.root(childRunId); + for (const entry of teams) { + const team = parseTeamState(entry.record); + if (team.lifecycle === 'deleted') continue; + const member = team.members.find((each) => each.childRunId === root); + if (member !== undefined) + return { team, name: member.name, rootChildRunId: root }; + } + return undefined; +} + +/** A member's liveness and what it can still take, read from its chain head. */ +export interface HostedMemberView { + readonly head: ChildSessionRun | undefined; + readonly state: 'running' | 'completed' | 'failed' | 'cancelled'; + /** Running, with a stop requested. */ + readonly stopping: boolean; + /** + * Stopping a continuation that has not attached a child Session yet: if + * it ends without starting, the head goes back to its predecessor. + */ + readonly stoppingBeforeStart: boolean; + /** Settled after a stop was requested: completed, but never continued. */ + readonly stopRequestedCompletion: boolean; + /** Completed with no stop requested: a message continues it. */ + readonly continuable: boolean; + /** The head's newest continuation, when it proved it never started. */ + readonly neverStarted: ChildSessionRun | undefined; +} + +export function hostedMemberView( + chains: HostedChildChains, + rootChildRunId: string, +): HostedMemberView { + const head = chains.head(rootChildRunId); + const state: HostedMemberView['state'] = + head?.run.state === 'settled' + ? 'completed' + : head?.run.state === 'failed' || head?.run.state === 'cancelled' + ? head.run.state + : 'running'; + // Every continuation the head took proved it never started, or the + // newest that did not would be the head. + const newest = + head === undefined ? undefined : chains.newestContinuation(head.childRunId); + return { + head, + state, + stopping: state === 'running' && head?.stopRequested === true, + stoppingBeforeStart: + state === 'running' && + head?.stopRequested === true && + head.predecessorChildRunId !== null && + head.childSessionId === null, + stopRequestedCompletion: + state === 'completed' && head?.stopRequested === true, + continuable: + state === 'completed' && + head?.kind === 'child_agent' && + head.stopReason === 'completed' && + !head.stopRequested, + neverStarted: newest, + }; +} + +/** Why a continuation that never started ended, for the lead to read. */ +function neverStartedReason(run: ChildSessionRun): string { + return run.stopReason ?? run.run.state; +} + +/** + * A run that proved it never started, as the lead must read it: the work + * it carried did not arrive, nor did the messages sent to it while it + * waited (H4d-b cancels a message to a run that ended before its + * handover). A failed child sends no notification, so an answer or the + * roster is how the lead learns of it. One cancelled before its start, at + * a user's request (H4f), is told apart: that work may no longer be wanted. + */ +export interface HostedNeverStarted { + readonly childRunId: string; + /** Null for a launch: there is no earlier run to send to again. */ + readonly predecessorChildRunId: string | null; + readonly reason: string; + readonly cancelled: boolean; + /** The messages sent to it, none of which arrived. */ + readonly lostMessages: number; +} + +export function hostedNeverStarted( + run: ChildSessionRun, + messages: readonly SessionMessage[], +): HostedNeverStarted { + return { + childRunId: run.childRunId, + predecessorChildRunId: run.predecessorChildRunId, + reason: neverStartedReason(run), + cancelled: run.run.state === 'cancelled', + lostMessages: messages.filter( + (message) => message.childRunId === run.childRunId, + ).length, + }; +} + +/** "never started (creation_failed)", or "was cancelled before it started + * (stop_requested)". */ +export function neverStartedPhrase(neverStarted: HostedNeverStarted): string { + return neverStarted.cancelled + ? `was cancelled before it started (${neverStarted.reason})` + : `never started (${neverStarted.reason})`; +} + +/** "1 message sent to it", "2 messages sent to it". */ +export function messagesSentToIt(count: number): string { + return `${count} message${count === 1 ? '' : 's'} sent to it`; +} + +/** + * "never started (creation_failed), so the work it carried did not + * arrive, nor did 1 message sent to it" — said of a continuation. + */ +export function neverStartedClause(neverStarted: HostedNeverStarted): string { + return ( + `${neverStartedPhrase(neverStarted)}, so the work it carried did not arrive` + + (neverStarted.lostMessages > 0 + ? `, nor did ${messagesSentToIt(neverStarted.lostMessages)}` + : '') + ); +} + +/** + * H4e-b2: what a committed send reached, as its answer must tell it. A + * replay or a recovered answer reads it again, so it may find what the + * first answer could not: the run the send reached has proved since that + * it never started. + */ +export interface HostedRouteDetails { + /** The open team member whose chain the send reached. */ + readonly member?: { readonly name: string; readonly teamName: string }; + /** The run the send reached — the continuation it launched, or the + * waiting run a message was queued for — never started, so the send did + * not arrive. */ + readonly ownNeverStarted?: HostedNeverStarted; + /** The continuation taken just before the one the send launched, which + * never started. */ + readonly previousNeverStarted?: HostedNeverStarted; + /** + * The message's own delivery ended, or must end, with no receipt — + * cancelled before its handover, or rejected by its target — so it did + * not arrive, though its run did not prove it never started. `chain` + * says what a resend would meet: a chain that takes messages, one that + * has ended, or one stopping a continuation that has not started yet. + */ + readonly deliveryFailed?: { + readonly state: 'cancelled' | 'rejected'; + readonly chain: 'open' | 'ended' | 'settling'; + }; +} + +export function hostedRouteDetails( + authority: { + extensionRecordsInDomain( + domain: ManagedSessionDomain, + ): ReadonlyArray<{ readonly record: unknown }>; + }, + route: { + readonly kind: 'message' | 'continuation'; + readonly childRunId: string; + }, + /** The id a message route was committed under: its delivery is read. */ + messageId?: string, +): HostedRouteDetails { + const chains = new HostedChildChains( + authority.extensionRecordsInDomain('child_run'), + ); + const messages = outboundChildMessages( + authority.extensionRecordsInDomain('session_message'), + ); + const member = hostedOpenTeamChainMember( + authority.extensionRecordsInDomain('team_state'), + chains, + route.childRunId, + ); + const reached = chains.run(route.childRunId); + const previous = + route.kind === 'continuation' + ? chains.previousContinuation(route.childRunId) + : undefined; + // A run that never started says more than the cancel H4d-b gives its + // messages, so that note takes precedence. A message never handed over + // to a run that ended or is being stopped is one the relay cancels + // (H4d-b), so an answer written before that step already reads it so. + const sentState = + route.kind === 'message' && + messageId !== undefined && + reached !== undefined && + reached.run.execution !== 'not_started_proven' + ? messages.find((message) => message.messageId === messageId)?.run + .delivery?.state + : undefined; + const delivery = + sentState === 'planned' && + reached !== undefined && + (isTerminalRunState(reached.run.state) || reached.stopRequested) + ? 'cancelled' + : sentState; + const view = + delivery === 'cancelled' || delivery === 'rejected' + ? hostedMemberView(chains, chains.root(route.childRunId)) + : undefined; + return { + ...(member === undefined + ? {} + : { member: { name: member.name, teamName: member.team.name } }), + ...(reached?.run.execution === 'not_started_proven' + ? { ownNeverStarted: hostedNeverStarted(reached, messages) } + : {}), + ...(previous?.run.execution === 'not_started_proven' + ? { previousNeverStarted: hostedNeverStarted(previous, messages) } + : {}), + ...((delivery === 'cancelled' || delivery === 'rejected') && + view !== undefined + ? { + deliveryFailed: { + state: delivery, + chain: view.stoppingBeforeStart + ? ('settling' as const) + : isEndedMember(view) + ? ('ended' as const) + : ('open' as const), + }, + } + : {}), + }; +} + +/** The lead's outbound messages to the child runs of one Session. */ +export function outboundChildMessages( + records: ReadonlyArray<{ readonly record: unknown }>, +): readonly SessionMessage[] { + return records + .map((entry) => parseSessionMessage(entry.record)) + .filter( + (message) => + message.direction === 'outbound' && message.route === 'to_child', + ); +} + +/** + * Decision 7: whether an earlier call of the current Turn gave the member + * work — a continuation of its chain that did not prove it never started, + * or a message to one of its started runs that was not cancelled or + * rejected. Both name their sending call's key, so the lead's records + * decide it: a key belongs to the Turn when it starts with `turnPrefix`, + * `${promptKey}:`. A prefix match is exact because no other Turn's prompt + * key extends this one past a colon: a wake turn's `:accept:notify` + * prompt id collapses to a digest (`hostedPromptKey`), and every other + * prompt id is assigned whole. + */ +export function givenWorkThisTurn( + chains: HostedChildChains, + rootChildRunId: string, + messages: readonly SessionMessage[], + turnPrefix: string, +): boolean { + const chain = chains.chain(rootChildRunId); + // A message to a continuation that never started never arrives (H4d-b + // cancels it), so only the chain's runs that did start can take one. + const ids = new Set( + chain + .filter((run) => run.run.execution !== 'not_started_proven') + .map((run) => run.childRunId), + ); + const fromThisTurn = (callKey: string | null) => + callKey !== null && callKey.startsWith(turnPrefix); + return ( + chain.some( + (run) => + run.predecessorChildRunId !== null && + run.run.execution !== 'not_started_proven' && + fromThisTurn(run.run.executionCallId), + ) || + messages.some( + (message) => + ids.has(message.childRunId) && + message.run.delivery?.state !== 'cancelled' && + message.run.delivery?.state !== 'rejected' && + fromThisTurn(message.run.executionCallId), + ) + ); +} + +/** + * Whether a run of the member's chain completed: it may have done a task's + * work, so the member may be recorded on the task a call completes, as + * #13846 records a completed member, whatever later runs of the chain did. + */ +export function hasCompletedRun( + chains: HostedChildChains, + rootChildRunId: string, +): boolean { + return chains + .chain(rootChildRunId) + .some((run) => run.run.state === 'settled'); +} + +/** + * What to do about the work a never-started continuation carried, and the + * messages it lost: send them again, or, after a user's cancel, only if + * they are still wanted. + */ +export function neverStartedAction( + neverStarted: HostedNeverStarted, + owner: string, +): string { + const lost = neverStarted.lostMessages > 0; + return neverStarted.cancelled + ? `If that work${lost ? ' and those messages are' : ' is'} still wanted, send ${lost ? 'them' : 'it'} again with send_message to "${owner}"` + : `Send that work${lost ? ' and those messages' : ''} again with send_message to "${owner}"`; +} + +/** A head that cannot take more work at all: it ended or is ending. */ +export function isEndedMember(view: HostedMemberView): boolean { + return ( + view.state === 'failed' || + view.state === 'cancelled' || + view.stopping || + view.stopRequestedCompletion + ); +} + +/** Whether a head still runs, stopping or not: `team_delete` waits for it. */ +export function isRunningMember(view: HostedMemberView): boolean { + return view.state === 'running'; +} diff --git a/packages/cli/src/serve/hosted-runtime-recovery.ts b/packages/cli/src/serve/hosted-runtime-recovery.ts index 35a446f3ef8..07f220bc18a 100644 --- a/packages/cli/src/serve/hosted-runtime-recovery.ts +++ b/packages/cli/src/serve/hosted-runtime-recovery.ts @@ -44,15 +44,23 @@ import { HOSTED_AGENT_CALL_NOT_REACHED_TEXT, HOSTED_AGENT_WAIT_ABANDONED_TEXT, hostedAgentBackgroundStartedText, + hostedBroadcastCommitted, hostedChildRunIdFor, - hostedMessageContinuedText, hostedMessageToChildText, HOSTED_MESSAGE_TO_PARENT_TEXT, + hostedRouteAnswer, hostedTeamJoinedText, hostedRuntimeSessionId, journaledToolResultIds, truncateHostedGlobResponse, } from './hosted-workspace-tool-turn.js'; +import { parseTeamState } from '@qwen-code/qwen-code-core/managed-runtime/managed-team-record.js'; +import { + HostedChildChains, + hostedMemberView, + hostedRouteDetails, + isEndedMember, +} from './hosted-member-chain.js'; import { endHostedAction, readHostedActionOptions, @@ -517,7 +525,9 @@ async function answerAbandonedTurnCalls(input: { /** * H4d-b: the answer a send_message call earned when it committed — its * outbox entry, or the continuation it launched for a completed child — - * or undefined when it committed nothing. + * or undefined when it committed nothing. H4e-b2 names a member and tells + * a run that proved it never started since, and answers a broadcast by + * the recipients it committed (decision 9). */ function sentMessageText( input: { @@ -538,20 +548,86 @@ function sentMessageText( }), ); if (message?.direction === 'outbound') { - return message.route === 'to_parent' - ? HOSTED_MESSAGE_TO_PARENT_TEXT - : hostedMessageToChildText( - input.children?.taskIdOf(message.childRunId) ?? message.childRunId, - ); + if (message.route === 'to_parent') return HOSTED_MESSAGE_TO_PARENT_TEXT; + if (input.children === undefined) + return hostedMessageToChildText(message.childRunId); + const sent = { kind: 'message', childRunId: message.childRunId } as const; + return hostedRouteAnswer(input.children, { + ...sent, + ...hostedRouteDetails(input.session.authority, sent, message.messageId), + }); } const continued = input.children?.record(callKey); if (continued?.predecessorChildRunId) { - return hostedMessageContinuedText( - input.children!.taskIdOf(continued.predecessorChildRunId), - input.children!.taskIdOf(continued.childRunId), - ); + const sent = { + kind: 'continuation', + childRunId: continued.childRunId, + predecessorChildRunId: continued.predecessorChildRunId, + } as const; + return hostedRouteAnswer(input.children!, { + ...sent, + ...hostedRouteDetails(input.session.authority, sent), + }); } - return undefined; + // H4e-b2 decision 9: a broadcast commits each recipient under its own + // position's ids. Every position is probed whatever the journaled + // arguments say, since a hook may have rewritten the input that ran. + if (input.children === undefined) return undefined; + const reached = hostedBroadcastCommitted({ + authority: input.session.authority, + children: input.children, + messages: input.messages, + senderSessionId: input.session.authority.sessionHeader.sessionKey.sessionId, + promptId: input.promptId, + callId, + }); + if (reached.length === 0) return undefined; + const team = input.session.authority + .extensionRecordsInDomain('team_state') + .map((entry) => parseTeamState(entry.record)) + .find((each) => each.lifecycle !== 'deleted'); + const lines = reached + .map( + (each) => `- ${each.name ?? `position ${each.position}`}: ${each.text}`, + ) + .join('\n'); + const arrived = reached.filter((each) => each.arrived).length; + const head = `The broadcast was interrupted after it was sent to ${reached.length} member${reached.length === 1 ? '' : 's'}${arrived > 0 ? '; do not send it again to the members it reached' : ''}.`; + // The batch rule keeps the roster the broadcast read; a team deleted or + // replaced since leaves no roster to tell the rest by, as a live replay + // finds (decision 9). + if ( + team === undefined || + reached.some( + (each) => + team.members[each.position - 1]?.childRunId !== each.rootChildRunId, + ) + ) + return `${head} Its team has changed since, so nothing tells who else it was meant for.\n${lines}`; + const chains = new HostedChildChains( + input.session.authority.extensionRecordsInDomain('child_run'), + ); + // A member the call never committed a send to, or whose send reached a + // run that never started, did not get it; one that has ended since + // cannot take it by name either. + const missed: string[] = []; + const ended: string[] = []; + for (const [index, member] of team.members.entries()) { + const committed = reached.find((each) => each.position === index + 1); + if (committed?.arrived) continue; + if (isEndedMember(hostedMemberView(chains, member.childRunId))) + ended.push(member.name); + else missed.push(member.name); + } + return ( + `${head}\n${lines}` + + (missed.length > 0 + ? `\nIt did not reach: ${missed.join(', ')}. Send to those members by name if they still need it.` + : '') + + (ended.length > 0 + ? `\nIt did not reach ${ended.join(', ')}, which ${ended.length === 1 ? 'has' : 'have'} ended and cannot take it.` + : '') + ); } /** The interruption every settle or resume route below names. */ diff --git a/packages/cli/src/serve/hosted-session-message-session.test.ts b/packages/cli/src/serve/hosted-session-message-session.test.ts index ff9f7f9aeaf..d22395c2ddd 100644 --- a/packages/cli/src/serve/hosted-session-message-session.test.ts +++ b/packages/cli/src/serve/hosted-session-message-session.test.ts @@ -14,7 +14,10 @@ import { type ManagedSession, } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-assembly.js'; import { LocalManagedSessionResourceStore } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-resources.js'; -import { decodeChildLaunchEnvelope } from '@qwen-code/qwen-code-core/managed-runtime/managed-child-operations.js'; +import { + decodeChildLaunchEnvelope, + MANAGED_CHILD_LIMITS, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-child-operations.js'; import { parseChildRun } from '@qwen-code/qwen-code-core/managed-runtime/managed-child-run-record.js'; import { parseSessionMessage } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-message-record.js'; import { ManagedSessionConflictError } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-authority.js'; @@ -30,6 +33,11 @@ import { withSessionMessageConsumption, } from './hosted-session-message-session.js'; +import { HostedTeamSession } from './hosted-team-session.js'; +import { teamMemberPreamble } from './hosted-member-chain.js'; +import { teamLifecycleBody } from '@qwen-code/qwen-code-core/managed-runtime/managed-team-operations.js'; +import { parseTeamState } from '@qwen-code/qwen-code-core/managed-runtime/managed-team-record.js'; + // H4d-b: `session_message` and continuations are enabled for real, so the // suite drives both funnels with no enablement mock. @@ -399,10 +407,26 @@ describe('send_message to a child task (H4d-b)', () => { reason: null, started: false, }); + // The next continuation says the one before it never started (H4e-b2), + // and a replay of that one says its own message never arrived. + const neverStarted = { + childRunId: 'prompt:call-9', + predecessorChildRunId: 'run-1', + reason: 'creation_failed', + cancelled: false, + lostMessages: 0, + }; expect(await send(parent.children, 'second try', 'call-12')).toEqual({ kind: 'continuation', childRunId: 'prompt:call-12', predecessorChildRunId: 'run-1', + previousNeverStarted: neverStarted, + }); + expect(await send(parent.children, 'first try')).toEqual({ + kind: 'continuation', + childRunId: 'prompt:call-9', + predecessorChildRunId: 'run-1', + ownNeverStarted: neverStarted, }); }); @@ -764,3 +788,252 @@ describe('withSessionMessageConsumption', () => { expect(consumed).toEqual(['msg_1:message']); }); }); + +// H4e-b2: messages between a lead and its members are these Session +// messages; the team adds a name, a preamble and a label on top. +describe('team members over Session messages (H4e-b2)', () => { + async function lead(): Promise { + const parent = await side(PARENT); + const teams = new HostedTeamSession( + { + authority: parent.session.authority, + resources: parent.session.resources, + }, + parent.session.authority.sessionHeader.sessionKey, + ); + await teams.run('team_create', { team_name: 'review' }, 'prompt:call-team'); + return { ...parent, teams }; + } + + async function join( + parent: Side & { teams: HostedTeamSession }, + name: string, + childRunId: string, + ): Promise { + const member = parent.teams.admitMember(name); + await launch(parent.children, childRunId); + await parent.teams.join({ ...member, childRunId }); + } + + function sendTo( + children: HostedChildAgentSession, + childRunId: string, + text: string, + call: string, + ) { + return children.sendToChild({ + taskId: children.taskIdOf(childRunId), + text, + messageId: `msg_${call}`, + continuationRunId: `prompt:${call}`, + executionCallId: `prompt:${call}`, + closing: false, + }); + } + + async function promptOf(parent: Side, childRunId: string): Promise { + return decodeChildLaunchEnvelope( + await parent.session.resources.read( + parent.children.record(childRunId)!.inputRef, + ), + ).prompt; + } + + async function inputText(session: ManagedSession, inputId: string) { + const event = inputs(session).find( + (each) => each.payload['inputId'] === inputId, + )!; + return ( + JSON.parse( + ( + await session.resources.read(event.payload['contentRef'] as never) + ).toString('utf8'), + ) as { text: string } + ).text; + } + + async function finish( + parent: Side, + childRunId: string, + childSessionId: typeof CHILD, + ): Promise { + await attach(parent.children, childRunId, childSessionId); + await complete(parent.children, childRunId); + } + + it('continues a finished member behind its preamble and replays by the instruction form', async () => { + const parent = await lead(); + await join(parent, 'alice', 'run-a'); + await finish(parent, 'run-a', CHILD); + expect(await sendTo(parent.children, 'run-a', 'fix it', 'c1')).toEqual({ + kind: 'continuation', + childRunId: 'prompt:c1', + predecessorChildRunId: 'run-a', + member: { name: 'alice', teamName: 'review' }, + }); + expect(await promptOf(parent, 'prompt:c1')).toBe( + `${teamMemberPreamble({ name: 'alice', teamName: 'review' })}fix it`, + ); + // The replay finds the committed continuation by its form. + expect( + await sendTo(parent.children, 'run-a', 'fix it', 'c1'), + ).toMatchObject({ kind: 'continuation', childRunId: 'prompt:c1' }); + await expect( + sendTo(parent.children, 'run-a', 'something else', 'c1'), + ).rejects.toThrow(ManagedSessionConflictError); + // A child off every roster continues with the message alone. + await launch(parent.children, 'run-p'); + await finish(parent, 'run-p', randomUUID()); + await sendTo(parent.children, 'run-p', 'look again', 'c2'); + expect(await promptOf(parent, 'prompt:c2')).toBe('look again'); + }); + + it('refuses to continue a member of a closing team', async () => { + const parent = await lead(); + await join(parent, 'alice', 'run-a'); + await finish(parent, 'run-a', CHILD); + const team = parseTeamState( + parent.session.authority.extensionRecord( + 'team_state', + 'prompt:call-team', + )!.record, + ); + const closing = teamLifecycleBody(team, 'closing'); + await parent.session.authority.commitExtensionRecord( + { + operation: 'deleteTeam', + commandId: 'prompt:call-delete:closing', + sessionKey: parent.session.authority.sessionHeader.sessionKey, + contentDigest: sha256(JSON.stringify(closing)), + }, + { domain: 'team_state', record: closing }, + { class: 'trusted_entry' }, + ); + expect(await sendTo(parent.children, 'run-a', 'more', 'c1')).toEqual({ + kind: 'refused', + reason: `Member "alice" (task ${parent.children.taskIdOf('run-a')}) belongs to team "review", which is closing and takes no new work. Finish it with team_delete.`, + }); + expect(parent.children.record('prompt:c1')).toBeUndefined(); + }); + + it("labels a member's messages and continued results only while its team is open", async () => { + const parent = await lead(); + await join(parent, 'alice', 'run-a'); + await attach(parent.children, 'run-a', CHILD); + const receive = (childRunId: string, senderSessionId: string, id: string) => + parent.messages.receive({ + messageId: id, + route: 'to_parent', + childRunId, + senderSessionId, + content: Buffer.from('which branch?'), + contentDigest: sha256('which branch?'), + }); + await receive('run-a', CHILD, 'msg_from-alice'); + const labeled = await inputText(parent.session, 'msg_from-alice:message'); + expect(labeled).toContain( + `child agent ${parent.children.taskIdOf('run-a')}\nalice\nTo answer, use send_message with task_id "${parent.children.taskIdOf('run-a')}".`, + ); + await complete(parent.children, 'run-a'); + // The continuation is the member: its result is labeled through the + // chain root. + await sendTo(parent.children, 'run-a', 'fix it', 'c1'); + const continuedSession = randomUUID(); + await finish(parent, 'prompt:c1', continuedSession); + await parent.children.accept('prompt:c1', { + notification: { description: 'alice task' }, + }); + expect( + await inputText(parent.session, 'prompt:c1:accept:notify'), + ).toContain('alice'); + // Once the team is deleted, a former member continues as a plain + // child: no preamble, no label on its result or its messages. + await parent.teams.run('team_delete', {}, 'prompt:call-delete'); + await sendTo(parent.children, 'run-a', 'once more', 'c2'); + expect(await promptOf(parent, 'prompt:c2')).toBe('once more'); + const formerSession = randomUUID(); + await finish(parent, 'prompt:c2', formerSession); + await parent.children.accept('prompt:c2', { + notification: { description: 'alice task' }, + }); + expect( + await inputText(parent.session, 'prompt:c2:accept:notify'), + ).not.toContain(''); + await receive('prompt:c2', formerSession, 'msg_from-former'); + expect( + await inputText(parent.session, 'msg_from-former:message'), + ).not.toContain(''); + }); + + it('names the member in the bounds a send to it meets', async () => { + const parent = await lead(); + await join(parent, 'alice', 'run-a'); + for (let index = 0; index < 8; index++) + await sendTo(parent.children, 'run-a', `m${index}`, `m${index}`); + expect( + await sendTo(parent.children, 'run-a', 'one too many', 'm8'), + ).toEqual({ + kind: 'refused', + reason: `Member "alice" (task ${parent.children.taskIdOf('run-a')}) has 8 messages still on their way to it (count_limit); wait for them to arrive.`, + }); + // The envelope bound counts the preamble every continuation carries. + expect( + await sendTo( + parent.children, + 'run-a', + 'x'.repeat(MANAGED_CHILD_LIMITS.maxEnvelopeBytes), + 'big', + ), + ).toEqual({ + kind: 'refused', + reason: `Message exceeds what a child task can carry (byte_limit): keep it under ${MANAGED_CHILD_LIMITS.maxEnvelopeBytes} bytes together with its launch description and the member preamble every continuation of "alice" carries.`, + }); + }); + + it('replays a named launch whose committed prompt carries a preamble', async () => { + const parent = await side(PARENT); + const preamble = teamMemberPreamble({ name: 'alice', teamName: 'review' }); + const params = { + childRunId: 'run-a', + ownerScopeId: PARENT, + rootSessionId: PARENT, + completion: 'sent' as const, + description: 'audit the diff', + definition: DEFINITION, + workingDirectory: '.', + workspaceMode: 'shared' as const, + executionCallId: 'run-a', + }; + await parent.children.admit({ + ...params, + prompt: `${preamble}review the change`, + sentPrompt: 'review the change', + }); + // The team changed since: the replay rebuilds no preamble and goes on + // with the committed envelope. + const replayed = await parent.children.admit({ + ...params, + prompt: 'review the change', + sentPrompt: 'review the change', + }); + expect(replayed.envelope.prompt).toBe(`${preamble}review the change`); + // Only the prompt is read for its form: every other field of the + // launch must match as committed. + for (const differs of [ + { prompt: 'another change', sentPrompt: 'another change' }, + { + description: 'audit another diff', + prompt: 'review the change', + sentPrompt: 'review the change', + }, + { + definition: { ...DEFINITION, definitionRevision: 2 }, + prompt: 'review the change', + sentPrompt: 'review the change', + }, + ]) + await expect( + parent.children.admit({ ...params, ...differs }), + ).rejects.toThrow(ManagedSessionConflictError); + }); +}); diff --git a/packages/cli/src/serve/hosted-session-message-session.ts b/packages/cli/src/serve/hosted-session-message-session.ts index 6c5f064e594..9922b9125df 100644 --- a/packages/cli/src/serve/hosted-session-message-session.ts +++ b/packages/cli/src/serve/hosted-session-message-session.ts @@ -38,6 +38,10 @@ import { type HostedChildAgentStore, } from './hosted-child-agent-session.js'; import type { HostedMonitorWakeTurn } from './hosted-monitor-wake.js'; +import { + HostedChildChains, + hostedOpenTeamChainMember, +} from './hosted-member-chain.js'; // H4d-b of #12827: the hosted side of durable session messages between a // parent Session and its child Sessions. A sender commits its outbox entry; @@ -87,6 +91,8 @@ export function sessionMessageNotificationText(params: { readonly messageId: string; /** The child task the message came from, or none for the parent. */ readonly fromTaskId: string | null; + /** H4e-b2 decision 6: the member name of a child of the open team. */ + readonly teammate?: string; readonly text: string; }): string { const head = [ @@ -95,6 +101,9 @@ export function sessionMessageNotificationText(params: { params.fromTaskId === null ? 'parent' : `child agent ${escapeXml(params.fromTaskId)}`, + ...(params.teammate === undefined + ? [] + : [`${escapeXml(params.teammate)}`]), params.fromTaskId === null ? 'To answer, use send_message with to "parent".' : `To answer, use send_message with task_id "${escapeXml(params.fromTaskId)}".`, @@ -311,6 +320,7 @@ export class HostedSessionMessageSession { ); } let fromTaskId: string | null = null; + let teammate: string | undefined; if (params.route === 'to_parent') { const run = this.store.authority.extensionRecord( 'child_run', @@ -328,6 +338,17 @@ export class HostedSessionMessageSession { ); } fromTaskId = this.children.taskIdOf(params.childRunId); + // H4e-b2 decision 6: a member's message names it while its chain + // root is on the open team's roster. The reply hint stays H4d-b's + // task id: a later team may reuse both names, and the task id + // always reaches the chain that wrote. + teammate = hostedOpenTeamChainMember( + this.store.authority.extensionRecordsInDomain('team_state'), + new HostedChildChains( + this.store.authority.extensionRecordsInDomain('child_run'), + ), + params.childRunId, + )?.name; } else if ( this.lineage?.parentSessionId !== params.senderSessionId || this.lineage.parentChildRunId !== params.childRunId @@ -375,6 +396,7 @@ export class HostedSessionMessageSession { text: sessionMessageNotificationText({ messageId: params.messageId, fromTaskId, + ...(teammate === undefined ? {} : { teammate }), text: params.content.toString('utf8'), }), }), diff --git a/packages/cli/src/serve/hosted-team-session.test.ts b/packages/cli/src/serve/hosted-team-session.test.ts index 943b703ff5b..c487a940184 100644 --- a/packages/cli/src/serve/hosted-team-session.test.ts +++ b/packages/cli/src/serve/hosted-team-session.test.ts @@ -19,10 +19,15 @@ import { parseTeamTask, } from '@qwen-code/qwen-code-core/managed-runtime/managed-team-record.js'; import { HostedChildAgentSession } from './hosted-child-agent-session.js'; +import { HostedSessionMessageSession } from './hosted-session-message-session.js'; +import { + HostedChildChains, + hostedOpenTeamChainMember, +} from './hosted-member-chain.js'; +import { sessionMessageInputId } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-message-operations.js'; import { HostedTeamRefusal, HostedTeamSession, - hostedOpenTeamMembership, hostedTeamArgumentError, hostedTeamMembership, type HostedTeamStore, @@ -257,18 +262,23 @@ describe('members', () => { await settle('run-alice'); const records = () => session.authority.extensionRecordsInDomain('team_state'); + // H4e-b2: the open-team answer reads the run's chain root. + const openMember = () => + hostedOpenTeamChainMember( + records(), + new HostedChildChains( + session.authority.extensionRecordsInDomain('child_run'), + ), + 'run-alice', + ); const alice = { teamName: 'review', name: 'alice' }; - expect(hostedOpenTeamMembership(records(), 'run-alice')).toMatchObject( - alice, - ); + expect(openMember()).toMatchObject({ name: 'alice' }); await stopDeleteAtClosing('prompt:del'); expect(teams.openTeam()?.lifecycle).toBe('closing'); - expect(hostedOpenTeamMembership(records(), 'run-alice')).toMatchObject( - alice, - ); + expect(openMember()).toMatchObject({ name: 'alice' }); await teams.run('team_delete', {}, 'prompt:del'); expect(teams.openTeam()).toBeUndefined(); - expect(hostedOpenTeamMembership(records(), 'run-alice')).toBeUndefined(); + expect(openMember()).toBeUndefined(); expect(hostedTeamMembership(records(), 'run-alice')).toMatchObject(alice); expect(hostedTeamMembership(records(), 'run-bob')).toBeUndefined(); }); @@ -411,7 +421,7 @@ describe('the board', () => { teams.run('task_update', { taskId: '1', owner: 'bob' }, 'prompt:u3'), ), ).toBe( - 'Member "bob" has finished, so it can own only a completed task. If it did this task\'s work, set status "completed" with this owner; otherwise assign "leader" or a running member.', + 'Member "bob" has finished. If it already did this task\'s work, from its launch prompt or a message you sent it, set status "completed" with this owner once its report is in. Otherwise give it the work with send_message to "bob" first, which continues it, and then update the task in the same turn.', ); const assigned = await teams.run( 'task_update', @@ -422,7 +432,7 @@ describe('the board', () => { 'Task #1 updated (status: in_progress, owner: alice).', ); expect(assigned).toContain( - '"alice" was not notified: members learn of board changes only once the team mailbox lands, so give a member its work in its launch prompt, or send_message it, while it runs, with the task id that task_list shows beside its name (task_…).', + '"alice" was not notified: task_update records the board only. If alice does not already have this work, give it with send_message to "alice".', ); expect( await refusal( @@ -545,7 +555,7 @@ describe('the board', () => { ), ), ).toBe( - 'Member "alice" has finished, so it can own only a completed task. If it did this task\'s work, set status "completed" with this owner; otherwise assign "leader" or a running member.', + 'Member "alice" has finished. If it already did this task\'s work, from its launch prompt or a message you sent it, set status "completed" with this owner once its report is in. Otherwise give it the work with send_message to "alice" first, which continues it, and then update the task in the same turn.', ); expect( await teams.run( @@ -572,7 +582,7 @@ describe('the board', () => { ), ), ).toBe( - 'Member "alice" has finished and cannot keep task #1 open. Set owner "" to unassign it, or assign "leader" or a running member.', + 'Member "alice" cannot keep task #1 open: it has finished. If it already did this task\'s work, from its launch prompt or a message you sent it, set status "completed" with this owner once its report is in. Otherwise give it the work with send_message to "alice" first, which continues it, and then update the task in the same turn, or set owner "" to unassign it.', ); // Naming the same owner again is still keeping it. expect( @@ -599,7 +609,9 @@ describe('the board', () => { 'prompt:failed', ), ), - ).toBe('Member "bob" has failed and cannot own a task.'); + ).toBe( + 'Member "bob" has failed without completing a run, so it did not do this task\'s work. Record the member that did it, or "leader".', + ); await children.requestStop('run-carol'); await children.settleCancelled('run-carol', { started: false }); expect( @@ -610,7 +622,9 @@ describe('the board', () => { 'prompt:cancelled', ), ), - ).toBe('Member "carol" was cancelled and cannot own a task.'); + ).toBe( + 'Member "carol" was cancelled without completing a run, so it did not do this task\'s work. Record the member that did it, or "leader".', + ); await teams.run( 'task_update', { taskId: '3', status: 'completed' }, @@ -867,7 +881,7 @@ describe('the board', () => { ).toEqual(['prompt:call-team#1']); }); - it('lists the board with filters and the roster with run states, stop reasons and running task ids', async () => { + it('lists the board with filters and the roster with run states, stop reasons and reachable task ids', async () => { await createTeam(); await addMember('alice'); await addMember('bob'); @@ -902,9 +916,10 @@ describe('the board', () => { '#2 [pending] — fix', '', '--- Team "review" members ---', - // Only a running member shows the task id send_message takes. + // A member that can still take work, running or continuable, + // shows its chain head's task id (H4e-b2 decision 2). `alice: running — ${children.taskIdOf('run-alice')}`, - 'bob: completed', + `bob: completed — ${children.taskIdOf('run-bob')}`, // A failed run says why; a member the host never started reads // differently from one that failed its work. 'carol: failed (creation_failed)', @@ -1009,3 +1024,585 @@ it('rebuilds the team and its board from a reopened journal', async () => { '#1 [pending] — audit', ); }); + +// H4e-b2 decision 2: a member is its continuation chain; the roster names +// the chain's first run and the member reads as its head. +describe('members through their continuation chains', () => { + /** A send that continues or messages a member's chain, keyed by a call. */ + function sendTo(childRunId: string, text: string, callKey: string) { + return children.sendToChild({ + taskId: children.taskIdOf(childRunId), + text, + messageId: `msg_${callKey.replace(':', '_')}`, + continuationRunId: callKey, + executionCallId: callKey, + closing: false, + }); + } + + async function line(name: string): Promise { + const listing = await teams.run('task_list', {}, 'turn:list'); + return listing.split('\n').find((each) => each.startsWith(`${name}: `))!; + } + + it('reads a member from its chain head, through a continuation that never started', async () => { + await createTeam(); + await addMember('alice'); + await settle('run-alice'); + expect(await sendTo('run-alice', 'fix it', 'turn-1:c1')).toMatchObject({ + kind: 'continuation', + }); + expect(teams.memberView('run-alice').head?.childRunId).toBe('turn-1:c1'); + expect(await line('alice')).toBe( + `alice: running — ${children.taskIdOf('turn-1:c1')}`, + ); + expect( + await refusal(teams.run('team_delete', {}, 'turn:delete')), + ).toContain('still has running members: alice'); + await settle('turn-1:c1'); + // The next continuation is committed and messaged, then never starts. + await sendTo('run-alice', 'and the docs', 'turn-2:c2'); + expect( + await sendTo('turn-2:c2', 'also the changelog', 'turn-2:m1'), + ).toMatchObject({ kind: 'message' }); + await children.settleFailed('turn-2:c2', { + stopReason: 'creation_failed', + reason: null, + started: false, + }); + expect(teams.memberView('run-alice').head?.childRunId).toBe('turn-1:c1'); + expect(await line('alice')).toBe( + `alice: completed — ${children.taskIdOf('turn-1:c1')} (its latest continuation never started (creation_failed), so the work it carried did not arrive, nor did 1 message sent to it)`, + ); + await teams.run( + 'task_create', + { subject: 'docs', description: 'docs' }, + 'turn:create', + ); + // The work the never-started continuation carried did not arrive, so + // the refusal has no branch that skips the send — even in its Turn. + expect( + await refusal( + teams.run( + 'task_update', + { taskId: '1', status: 'in_progress', owner: 'alice' }, + 'turn-2:own', + 'turn-2:', + ), + ), + ).toBe( + 'Member "alice" has finished, and its latest continuation never started (creation_failed), so the work it carried did not arrive, nor did 1 message sent to it. Send that work and those messages again with send_message to "alice", then update the task in the same turn.', + ); + }); + + it('shows stop requests and keeps a stopped member off open work', async () => { + await createTeam(); + await addMember('bob'); + await teams.run( + 'task_create', + { subject: 'audit', description: 'audit' }, + 'turn:create', + ); + await children.requestStop('run-bob'); + expect(await line('bob')).toBe('bob: running (stopping)'); + expect( + await refusal( + teams.run( + 'task_update', + { taskId: '1', status: 'in_progress', owner: 'bob' }, + 'turn:own-stopping', + ), + ), + ).toBe( + 'Member "bob" is being stopped and cannot own a task. Launch a new member for this work.', + ); + await settle('run-bob'); + expect(await line('bob')).toBe('bob: completed (stop requested)'); + expect( + await refusal( + teams.run( + 'task_update', + { taskId: '1', status: 'in_progress', owner: 'bob' }, + 'turn:own-stopped', + ), + ), + ).toBe( + 'Member "bob" completed after a stop was requested, so it cannot be continued. If it did this task\'s work, set status "completed" with this owner; otherwise launch a new member for it.', + ); + expect( + await teams.run( + 'task_update', + { taskId: '1', status: 'completed', owner: 'bob' }, + 'turn:done', + ), + ).toBe('Task #1 updated (status: completed, owner: bob).'); + }); + + it('records a member that this Turn gave work, by a continuation or a message', async () => { + await createTeam(); + await addMember('alice'); + await addMember('bob'); + await settle('run-alice'); + for (const subject of ['fix', 'test']) + await teams.run( + 'task_create', + { subject, description: subject }, + `turn:create-${subject}`, + ); + // A continuation from turn-1 gave alice work: it is recorded whether + // the continuation still runs or has completed since. + await sendTo('run-alice', 'fix #1', 'turn-1:c1'); + await settle('turn-1:c1'); + expect( + await teams.run( + 'task_update', + { taskId: '1', status: 'in_progress', owner: 'alice' }, + 'turn-1:own', + 'turn-1:', + ), + ).toContain( + 'Task #1 updated (status: in_progress, owner: alice). "alice" was not notified: task_update records the board only. A send_message earlier in this turn gave alice work; if it did not carry this task, give it with send_message to "alice".', + ); + // A message from turn-1 to running bob, handed over and read: bob is + // recorded after it completed. + const messages = new HostedSessionMessageSession( + { authority: session.authority, resources: session.resources }, + sessionKey, + children, + undefined, + ); + await sendTo('run-bob', 'test #2', 'turn-1:m1'); + await children.dispatchStarted('run-bob', { + dispatchId: 'dispatch-run-bob', + runtime: { runtimeBindingId: 'binding-1', generation: '1' }, + }); + await children.attach('run-bob', 'session-run-bob'); + await messages.handover('msg_turn-1_m1', 'session-run-bob'); + await messages.accepted( + 'msg_turn-1_m1', + sessionMessageInputId('msg_turn-1_m1'), + ); + await children.settleCompleted('run-bob', { + result: Buffer.from('done', 'utf8'), + receipt: Buffer.from('{"outcome":"settled"}', 'utf8'), + messageCount: 1, + }); + expect( + await teams.run( + 'task_update', + { taskId: '2', status: 'in_progress', owner: 'bob' }, + 'turn-1:own-bob', + 'turn-1:', + ), + ).toContain('Task #2 updated (status: in_progress, owner: bob).'); + // A later Turn gave neither any work: one conditional refusal, which + // never claims which task a past send carried. + await teams.run( + 'task_update', + { taskId: '2', status: 'pending', owner: '' }, + 'turn-2:park', + 'turn-2:', + ); + expect( + await refusal( + teams.run( + 'task_update', + { taskId: '2', status: 'in_progress', owner: 'bob' }, + 'turn-2:own-bob', + 'turn-2:', + ), + ), + ).toBe( + 'Member "bob" has finished. If it already did this task\'s work, from its launch prompt or a message you sent it, set status "completed" with this owner once its report is in. Otherwise give it the work with send_message to "bob" first, which continues it, and then update the task in the same turn.', + ); + }); + + it('keeps a stopped member off open work even after this Turn gave it work', async () => { + await createTeam(); + await addMember('alice'); + await settle('run-alice'); + await teams.run( + 'task_create', + { subject: 'fix', description: 'fix' }, + 'turn:create', + ); + await sendTo('run-alice', 'fix #1', 'turn-1:c1'); + await children.requestStop('turn-1:c1'); + expect( + await refusal( + teams.run( + 'task_update', + { taskId: '1', status: 'in_progress', owner: 'alice' }, + 'turn-1:own', + 'turn-1:', + ), + ), + ).toBe( + // Stopped before it attached, the continuation may still end without + // starting, which hands alice back to her first run. + 'Member "alice" is being stopped before its latest continuation started. If that continuation ends without starting, the member can take work again: check task_list once it settles.', + ); + await children.dispatchStarted('turn-1:c1', { + dispatchId: 'dispatch-turn-1:c1', + runtime: { runtimeBindingId: 'binding-1', generation: '1' }, + }); + await children.attach('turn-1:c1', 'session-turn-1:c1'); + expect( + await refusal( + teams.run( + 'task_update', + { taskId: '1', status: 'in_progress', owner: 'alice' }, + 'turn-1:own-attached', + 'turn-1:', + ), + ), + ).toBe( + 'Member "alice" is being stopped and cannot own a task. Launch a new member for this work.', + ); + await children.settleCompleted('turn-1:c1', { + result: Buffer.from('done', 'utf8'), + receipt: Buffer.from('{"outcome":"settled"}', 'utf8'), + }); + expect( + await refusal( + teams.run( + 'task_update', + { taskId: '1', status: 'in_progress', owner: 'alice' }, + 'turn-1:own-again', + 'turn-1:', + ), + ), + ).toContain('completed after a stop was requested'); + }); + + it('keeps a finished owner on a task this Turn gave it work for', async () => { + await createTeam(); + await addMember('alice'); + await teams.run( + 'task_create', + { subject: 'fix', description: 'fix' }, + 'turn:create', + ); + await teams.run( + 'task_update', + { taskId: '1', status: 'in_progress', owner: 'alice' }, + 'turn:own', + ); + await settle('run-alice'); + await sendTo('run-alice', 'and the tests', 'turn-3:c1'); + await settle('turn-3:c1'); + // Moving the kept owner's task to another open status: accepted in + // the Turn that gave the work, refused in a later one. + expect( + await teams.run( + 'task_update', + { taskId: '1', status: 'pending' }, + 'turn-3:park', + 'turn-3:', + ), + ).toContain('Task #1 updated (status: pending, owner: alice).'); + expect( + await refusal( + teams.run( + 'task_update', + { taskId: '1', status: 'in_progress' }, + 'turn-4:resume', + 'turn-4:', + ), + ), + ).toContain('cannot keep task #1 open: it has finished'); + }); + + it('does not count a message of this Turn that was cancelled', async () => { + await createTeam(); + await addMember('bob'); + await teams.run( + 'task_create', + { subject: 'test', description: 'test' }, + 'turn:create', + ); + const messages = new HostedSessionMessageSession( + { authority: session.authority, resources: session.resources }, + sessionKey, + children, + undefined, + ); + await sendTo('run-bob', 'test it', 'turn-1:m1'); + await messages.settle('msg_turn-1_m1', 'cancelled'); + await children.dispatchStarted('run-bob', { + dispatchId: 'dispatch-run-bob', + runtime: { runtimeBindingId: 'binding-1', generation: '1' }, + }); + await children.attach('run-bob', 'session-run-bob'); + await children.settleCompleted('run-bob', { + result: Buffer.from('done', 'utf8'), + receipt: Buffer.from('{"outcome":"settled"}', 'utf8'), + messageCount: 1, + }); + expect( + await refusal( + teams.run( + 'task_update', + { taskId: '1', status: 'in_progress', owner: 'bob' }, + 'turn-1:own', + 'turn-1:', + ), + ), + ).toContain('Member "bob" has finished. If it already did'); + }); + + it('does not count a message of this Turn that its target rejected', async () => { + await createTeam(); + await addMember('bob'); + await teams.run( + 'task_create', + { subject: 'test', description: 'test' }, + 'turn:create', + ); + const messages = new HostedSessionMessageSession( + { authority: session.authority, resources: session.resources }, + sessionKey, + children, + undefined, + ); + await sendTo('run-bob', 'test it', 'turn-1:m1'); + await children.dispatchStarted('run-bob', { + dispatchId: 'dispatch-run-bob', + runtime: { runtimeBindingId: 'binding-1', generation: '1' }, + }); + await children.attach('run-bob', 'session-run-bob'); + await messages.handover('msg_turn-1_m1', 'session-run-bob'); + await messages.settle('msg_turn-1_m1', 'rejected'); + await children.settleCompleted('run-bob', { + result: Buffer.from('done', 'utf8'), + receipt: Buffer.from('{"outcome":"settled"}', 'utf8'), + messageCount: 1, + }); + expect( + await refusal( + teams.run( + 'task_update', + { taskId: '1', status: 'in_progress', owner: 'bob' }, + 'turn-1:own', + 'turn-1:', + ), + ), + ).toContain('Member "bob" has finished. If it already did'); + }); + + it('does not count the launch of this Turn as work given', async () => { + // Decision 7 counts sends: a member launched and finished in this Turn + // did the work its launch prompt carried, which the lead records as + // completed once the report is in. + await createTeam(); + await addMember('carol', 'turn-1:carol'); + await settle('turn-1:carol'); + await teams.run( + 'task_create', + { subject: 'test', description: 'test' }, + 'turn:create', + ); + expect( + await refusal( + teams.run( + 'task_update', + { taskId: '1', status: 'in_progress', owner: 'carol' }, + 'turn-1:own', + 'turn-1:', + ), + ), + ).toContain('Member "carol" has finished. If it already did'); + }); + + it('records an ended member on the task the call completes when a run of its chain completed', async () => { + // #13846 records who did the work: alice's first run did task #1, so a + // later continuation that failed, or one being stopped, does not take + // that away. + await createTeam(); + await addMember('alice'); + await addMember('bob'); + for (const subject of ['audit', 'fix']) + await teams.run( + 'task_create', + { subject, description: subject }, + `turn:create-${subject}`, + ); + await settle('run-alice'); + await settle('run-bob'); + await sendTo('run-alice', 'more', 'turn-1:c1'); + await children.dispatchStarted('turn-1:c1', { + dispatchId: 'dispatch-turn-1:c1', + runtime: { runtimeBindingId: 'binding-1', generation: '1' }, + }); + await children.attach('turn-1:c1', 'session-turn-1:c1'); + await children.settleFailed('turn-1:c1', { + stopReason: 'child_failed', + reason: null, + started: true, + }); + expect( + await teams.run( + 'task_update', + { taskId: '1', status: 'completed', owner: 'alice' }, + 'turn-2:done', + 'turn-2:', + ), + ).toBe('Task #1 updated (status: completed, owner: alice).'); + await sendTo('run-bob', 'more', 'turn-1:c2'); + await children.dispatchStarted('turn-1:c2', { + dispatchId: 'dispatch-turn-1:c2', + runtime: { runtimeBindingId: 'binding-1', generation: '1' }, + }); + await children.attach('turn-1:c2', 'session-turn-1:c2'); + await children.requestStop('turn-1:c2'); + expect( + await teams.run( + 'task_update', + { taskId: '2', status: 'completed', owner: 'bob' }, + 'turn-2:done-bob', + 'turn-2:', + ), + ).toBe('Task #2 updated (status: completed, owner: bob).'); + // Open work still needs a member that can take it. + expect( + await refusal( + teams.run( + 'task_update', + { taskId: '1', status: 'in_progress' }, + 'turn-2:reopen', + 'turn-2:', + ), + ), + ).toBe( + 'Member "alice" cannot keep task #1 open: it has failed. Launch a new member for this work, set owner "" to unassign it, or assign "leader".', + ); + }); + + it('answers an update that restates its owner by what the member can take', async () => { + await createTeam(); + await addMember('alice'); + await addMember('bob'); + for (const subject of ['fix', 'test']) + await teams.run( + 'task_create', + { subject, description: subject }, + `turn:create-${subject}`, + ); + await settle('run-alice'); + // The send and the owner land together, then the continuation never + // starts: restating the owner is not checked again, so the answer must + // not suggest the work may already be there. + await sendTo('run-alice', 'fix #1', 'turn-1:c1'); + await teams.run( + 'task_update', + { taskId: '1', status: 'in_progress', owner: 'alice' }, + 'turn-1:own', + 'turn-1:', + ); + await children.settleFailed('turn-1:c1', { + stopReason: 'creation_failed', + reason: null, + started: false, + }); + expect( + await teams.run( + 'task_update', + { + taskId: '1', + status: 'in_progress', + owner: 'alice', + description: 'fix it now', + }, + 'turn-1:restate', + 'turn-1:', + ), + ).toBe( + 'Task #1 updated (status: in_progress, owner: alice). "alice" was not notified: task_update records the board only. Its latest continuation never started (creation_failed), so the work it carried did not arrive. Send that work again with send_message to "alice".', + ); + // A member that ended is never pointed to a send. + await teams.run( + 'task_update', + { taskId: '2', status: 'in_progress', owner: 'bob' }, + 'turn-1:own-bob', + 'turn-1:', + ); + await children.settleFailed('run-bob', { + stopReason: 'creation_failed', + reason: null, + started: false, + }); + expect( + await teams.run( + 'task_update', + { taskId: '2', owner: 'bob', description: 'test it now' }, + 'turn-1:restate-bob', + 'turn-1:', + ), + ).toBe( + 'Task #2 updated (status: in_progress, owner: bob). "bob" was not notified: task_update records the board only. bob cannot take more work: it has failed.', + ); + }); + + it('counts only work given to the member itself', async () => { + await createTeam(); + await addMember('alice'); + await addMember('bob'); + await teams.run( + 'task_create', + { subject: 'fix', description: 'fix' }, + 'turn:create', + ); + await settle('run-alice'); + await settle('run-bob'); + // This Turn gave bob work, not alice. + await sendTo('run-bob', 'fix #1', 'turn-1:c1'); + expect( + await refusal( + teams.run( + 'task_update', + { taskId: '1', status: 'in_progress', owner: 'alice' }, + 'turn-1:own', + 'turn-1:', + ), + ), + ).toContain('Member "alice" has finished. If it already did'); + }); + + it('refuses team_delete while a member is being stopped', async () => { + await createTeam(); + await addMember('alice'); + await children.requestStop('run-alice'); + expect( + await refusal(teams.run('team_delete', {}, 'turn:delete')), + ).toContain('still has running members: alice'); + }); + + it('asks again for the work of a continuation a user cancelled before it started only if still wanted', async () => { + await createTeam(); + await addMember('alice'); + await teams.run( + 'task_create', + { subject: 'fix', description: 'fix' }, + 'turn:create', + ); + await settle('run-alice'); + await sendTo('run-alice', 'fix #1', 'turn-1:c1'); + await children.requestStop('turn-1:c1'); + await children.settleCancelled('turn-1:c1', { started: false }); + expect(await line('alice')).toBe( + `alice: completed — ${children.taskIdOf('run-alice')} (its latest continuation was cancelled before it started (stop_requested), so the work it carried did not arrive)`, + ); + expect( + await refusal( + teams.run( + 'task_update', + { taskId: '1', status: 'in_progress', owner: 'alice' }, + 'turn-2:own', + 'turn-2:', + ), + ), + ).toBe( + 'Member "alice" has finished, and its latest continuation was cancelled before it started (stop_requested), so the work it carried did not arrive. If that work is still wanted, send it again with send_message to "alice", then update the task in the same turn.', + ); + }); +}); diff --git a/packages/cli/src/serve/hosted-team-session.ts b/packages/cli/src/serve/hosted-team-session.ts index decfeb8bb97..2891b3324a9 100644 --- a/packages/cli/src/serve/hosted-team-session.ts +++ b/packages/cli/src/serve/hosted-team-session.ts @@ -43,6 +43,19 @@ import { teamTaskRevisionBody, } from '@qwen-code/qwen-code-core/managed-runtime/managed-team-operations.js'; import { stripAnsiAndControl } from '@qwen-code/qwen-code-core/utils/textUtils.js'; +import { + HostedChildChains, + givenWorkThisTurn, + hasCompletedRun, + hostedMemberView, + hostedNeverStarted, + isEndedMember, + isRunningMember, + neverStartedAction, + neverStartedClause, + outboundChildMessages, + type HostedMemberView, +} from './hosted-member-chain.js'; // H4e-b1 of #12827: the lead Session's team funnel. The lead alone writes // its team: the roster and lifecycle (team_state) and the board @@ -50,7 +63,10 @@ import { stripAnsiAndControl } from '@qwen-code/qwen-code-core/utils/textUtils.j // committing through one serial chain under command ids derived from the // tool call, so a replayed call finds what it committed and adds nothing // twice. Members are H4b child agents; this funnel only names them. See -// docs/design/2026-10-10-managed-agent-team-lead-runtime.md. +// docs/design/2026-10-10-managed-agent-team-lead-runtime.md. H4e-b2 reads +// each member through its continuation chain: the roster names the +// chain's first run, and the member is the chain's head (see +// docs/design/2026-10-11-managed-agent-team-mailbox.md). /** The narrow authority/resource pair a HostedTeamSession commits through. */ export interface HostedTeamStore { @@ -84,12 +100,6 @@ export interface HostedTeamStore { /** A model-facing refusal: the call answers with it and commits nothing. */ export class HostedTeamRefusal extends Error {} -export type HostedTeamMemberState = - | 'running' - | 'completed' - | 'failed' - | 'cancelled'; - /** The Legacy board tools' schema bounds, in characters. */ const SUBJECT_CHARS = 200; const DESCRIPTION_CHARS = 10_000; @@ -129,7 +139,7 @@ export const HOSTED_TEAM_TOOLS: readonly FunctionDeclaration[] = [ { name: 'task_create', description: - 'Create a pending task on the team board. Members are not told about board changes; give a member its work in its launch prompt, or send_message it, while it runs, with the task id that task_list shows beside its name (task_…).', + 'Create a pending task on the team board. Members are not told about board changes: give a member its work in its launch prompt or with send_message to its name, which continues a member that has finished.', parametersJsonSchema: { type: 'object', properties: { @@ -160,7 +170,7 @@ export const HOSTED_TEAM_TOOLS: readonly FunctionDeclaration[] = [ { name: 'task_update', description: - 'Update a board task: status, owner, subject, description, active form, metadata and dependencies. Set status to "deleted" to remove it. A task in progress needs an owner: "leader" or a member that is still running; a member that has finished can be newly set as the owner only of a completed task, and a task whose owner has ended moves to another open status only with a new owner. Dependencies only grow, and a completed or deleted blocker stops blocking. Members are not told about the update.', + 'Update a board task: status, owner, subject, description, active form, metadata and dependencies. Set status to "deleted" to remove it. A task in progress needs an owner: "leader", a member that is still running and not being stopped, or a member you gave work with send_message earlier in this turn. A member that completed work can otherwise be the owner only of a completed task, so a task whose owner has finished moves to another open status only with a new owner or new work sent first. Dependencies only grow, and a completed or deleted blocker stops blocking. Members are not told about the update: to give a member new work, send_message it the work by name first, then update the task in the same turn.', parametersJsonSchema: { type: 'object', properties: { @@ -215,7 +225,7 @@ export const HOSTED_TEAM_TOOLS: readonly FunctionDeclaration[] = [ { name: 'task_list', description: - 'List the team board, optionally filtered, followed by each member with the state of its run (and why a failed or cancelled run ended) and, while it runs, the task id send_message takes.', + 'List the team board, optionally filtered, followed by each member with the state of its current run, which is its newest one that did not fail to start (and why a failed or cancelled run ended), its task id while it can still take work, and a newer continuation that never started, with the messages that did not reach it. Address a member with send_message to its name.', parametersJsonSchema: { type: 'object', properties: { @@ -393,14 +403,6 @@ export function hostedTeamMembership( return membershipAmong(teams, childRunId, () => true); } -/** The same entry, among the teams not yet deleted. */ -export function hostedOpenTeamMembership( - teams: ReadonlyArray<{ readonly record: unknown }>, - childRunId: string, -): HostedTeamMembership | undefined { - return membershipAmong(teams, childRunId, isOpenTeam); -} - function membershipAmong( teams: ReadonlyArray<{ readonly record: unknown }>, childRunId: string, @@ -416,15 +418,6 @@ function membershipAmong( return undefined; } -function memberRunState( - child: ChildSessionRun | undefined, -): HostedTeamMemberState { - if (child?.run.state === 'settled') return 'completed'; - if (child?.run.state === 'failed' || child?.run.state === 'cancelled') - return child.run.state; - return 'running'; -} - export class HostedTeamSession { private writes: Promise = Promise.resolve(); @@ -454,9 +447,17 @@ export class HostedTeamSession { ); } - /** A member's liveness: the state of its child run. */ - memberState(childRunId: string): HostedTeamMemberState { - return memberRunState(this.childRun(childRunId)); + /** + * H4e-b2 decision 2: a member read through its continuation chain. The + * roster entry names the chain's first run; the member is the chain's + * head, the newest continuation that did not prove it never started. + * A caller reading several members passes one index for all of them. + */ + memberView( + rootChildRunId: string, + chains: HostedChildChains = this.chains(), + ): HostedMemberView { + return hostedMemberView(chains, rootChildRunId); } /** @@ -497,6 +498,12 @@ export class HostedTeamSession { toolName: string, args: Record, callKey: string, + /** + * H4e-b2 decision 7: the current Turn's call-key prefix, + * `${promptKey}:`. The tool turn, the one production caller, always + * passes it; a caller outside a model Turn has no work given in one. + */ + turnPrefix?: string, ): Promise { return this.serial(() => { switch (toolName) { @@ -507,7 +514,7 @@ export class HostedTeamSession { case 'task_create': return this.createTask(callKey, args); case 'task_update': - return this.updateTask(callKey, args); + return this.updateTask(callKey, args, turnPrefix); case 'task_list': return Promise.resolve(this.listTasks(args)); default: @@ -626,8 +633,12 @@ export class HostedTeamSession { throw new HostedTeamRefusal('No active team to delete.'); let closing = team; if (closed === undefined) { + // H4e-b2: a continued member runs in its chain's head. + const chains = this.chains(); const running = team.members - .filter((member) => this.memberState(member.childRunId) === 'running') + .filter((member) => + isRunningMember(hostedMemberView(chains, member.childRunId)), + ) .map((member) => member.name); if (running.length > 0) throw new HostedTeamRefusal( @@ -689,6 +700,7 @@ export class HostedTeamSession { private async updateTask( callKey: string, args: Record, + turnPrefix: string | undefined, ): Promise { const team = this.openTeam(); if (team === undefined) @@ -719,7 +731,16 @@ export class HostedTeamSession { ? null : sanitizeName(args['owner'] as string); if (!replaying) - this.validateUpdate(team, board, number, args, owner, blocks, blockedBy); + this.validateUpdate( + team, + board, + number, + args, + owner, + blocks, + blockedBy, + turnPrefix, + ); for (const place of pending) { const target = board.get(place === 0 ? number : blocks[place - 1]!)!; // Re-read: an earlier place of this call may have revised it. @@ -741,12 +762,46 @@ export class HostedTeamSession { } const task = this.task(board.get(number)!.taskId); if (deleting) return `Task #${number} deleted.`; - const notified = team.members.find((each) => each.name === owner); - const notice = - notified !== undefined && - this.memberState(notified.childRunId) === 'running' - ? ` "${owner}" was not notified: members learn of board changes only once the team mailbox lands, so give a member its work in its launch prompt, or send_message it, while it runs, with the task id that task_list shows beside its name (task_…).` - : ''; + // H4e-b2 decision 7: the board is a record, never a delivery. The + // answer never asks for a send outright: the launch prompt or a send + // earlier in the Turn may already have given this work, and when such + // a send exists it says so. A call that restates its task's owner and + // status is not checked again, so the answer reads the member itself: + // work that never arrived is asked for again, and a member that has + // ended is never pointed to a send. + let notice = ''; + const member = + owner != null && owner !== MANAGED_TEAM_LEADER + ? team.members.find((each) => each.name === owner) + : undefined; + if (member !== undefined && task.status !== 'completed') { + const chains = this.chains(); + const view = hostedMemberView(chains, member.childRunId); + const recorded = ` "${owner}" was not notified: task_update records the board only.`; + if (isEndedMember(view)) { + notice = `${recorded} ${owner} cannot take more work: it ${view.stopping ? 'is being stopped' : view.stopRequestedCompletion ? 'completed after a stop was requested' : `has ${view.state}`}.`; + } else if (view.neverStarted !== undefined) { + const neverStarted = hostedNeverStarted( + view.neverStarted, + this.messagesTo(view.neverStarted.childRunId), + ); + notice = `${recorded} Its latest continuation ${neverStartedClause(neverStarted)}. ${neverStartedAction(neverStarted, member.name)}.`; + } else if ( + turnPrefix !== undefined && + givenWorkThisTurn( + chains, + member.childRunId, + outboundChildMessages( + this.store.authority.extensionRecordsInDomain('session_message'), + ), + turnPrefix, + ) + ) { + notice = `${recorded} A send_message earlier in this turn gave ${owner} work; if it did not carry this task, give it with send_message to "${owner}".`; + } else { + notice = `${recorded} If ${owner} does not already have this work, give it with send_message to "${owner}".`; + } + } return ( `Task #${number} updated (status: ${task.status}` + (task.owner ? `, owner: ${task.owner}` : '') + @@ -763,6 +818,7 @@ export class HostedTeamSession { owner: string | null | undefined, blocks: readonly number[], blockedBy: readonly number[], + turnPrefix: string | undefined, ): void { const live = (each: number) => { const task = board.get(each); @@ -816,14 +872,14 @@ export class HostedTeamSession { // call moves the task to another open status, so a member that ended // never lands on open work. if (owner != null && owner !== task.owner) - this.assertOwner(team, owner, status === 'completed'); + this.assertOwner(team, owner, status === 'completed', turnPrefix); else if ( (owner === undefined || owner === task.owner) && task.owner !== null && status !== task.status && status !== 'completed' ) - this.assertOwner(team, task.owner, false, number); + this.assertOwner(team, task.owner, false, turnPrefix, number); if ( status === 'in_progress' && (owner === undefined ? task.owner : owner) === null @@ -911,34 +967,65 @@ export class HostedTeamSession { (open.length > 0 ? ` (blocked by ${open.join(', ')})` : '') ); }); + const chains = this.chains(); return [ ...(lines.length > 0 ? lines : ['No tasks found.']), '', `--- Team "${team.name}" members ---`, ...(team.members.length > 0 - ? team.members.map((member) => { - // A failed or cancelled run says why, so the lead can tell a - // member the host never started (an isolated member whose - // Workspace was not prepared) from one that failed its work. - // Only a running member shows its task id: once a member of an - // open team has finished, send_message refuses to continue it. - const state = this.memberState(member.childRunId); - const reason = - state === 'failed' || state === 'cancelled' - ? this.childRun(member.childRunId)?.stopReason - : undefined; - return ( - `${member.name}: ${state}` + - (reason ? ` (${reason})` : '') + - (state === 'running' - ? ` — ${managedChildTaskId(this.key.sessionId, member.childRunId)}` - : '') - ); - }) + ? team.members.map((member) => this.rosterLine(member, chains)) : ['No members yet.']), ].join('\n'); } + /** + * One member's roster line, read from its chain head (H4e-b2 decision 2). + * A failed or cancelled head says why, so the lead can tell a member the + * host never started (an isolated member whose Workspace was not + * prepared) from one that failed its work. The head's task id shows only + * while the member can still take work. A continuation that never started + * says so, with how many messages to it never arrived — a failed child + * sends no notification, so this line is how the lead learns of it — but + * not its own task id, which names no live run: the head's reaches the + * member. + */ + private rosterLine( + member: { readonly name: string; readonly childRunId: string }, + chains: HostedChildChains, + ): string { + const view = this.memberView(member.childRunId, chains); + const head = view.head; + const label = view.stopping + ? 'running (stopping)' + : view.stopRequestedCompletion + ? 'completed (stop requested)' + : view.state; + const reason = + (view.state === 'failed' || view.state === 'cancelled') && head + ? head.stopReason + : null; + const reachable = + head !== undefined && + ((view.state === 'running' && !view.stopping) || view.continuable); + const note = + view.neverStarted === undefined + ? '' + : ` (its latest continuation ${neverStartedClause( + hostedNeverStarted( + view.neverStarted, + this.messagesTo(view.neverStarted.childRunId), + ), + )})`; + return ( + `${member.name}: ${label}` + + (reason ? ` (${reason})` : '') + + (reachable + ? ` — ${managedChildTaskId(this.key.sessionId, head.childRunId)}` + : '') + + note + ); + } + private activeTeam(): TeamState { const team = this.openTeam(); if (team === undefined) @@ -951,15 +1038,29 @@ export class HostedTeamSession { } /** - * An owner is the leader or a member whose run has not ended. A member - * whose run completed may also be named on a task the call leaves - * completed: a one-shot member usually finishes before the lead's next - * call lands, and the board should still record who did the work. + * An owner is the leader or a member that can hold the work. H4e-b2 reads + * the member's chain head (decision 2) and decision 7's notion of work + * given in this Turn: + * - a running member may own any task, and so may a member that an + * earlier call of this Turn gave work — a continuation of its chain that + * did not prove it never started, or a message to it that was not + * cancelled or rejected — even when it has completed since: the relay + * settles a child while the lead's Turn runs, so one batch cannot keep + * it running until task_update lands; + * - a completed member may otherwise own only a task the call leaves + * completed, so the board records who did the work (#13846), and so + * may an ended member any run of whose chain completed; + * - a member that failed, was cancelled, is stopping, or completed after + * a stop request has ended: it never lands on open work. + * No refusal claims which task a past send carried, since no record ties + * a send to a task, and none offers to skip a send for work that never + * arrived. */ private assertOwner( team: TeamState, owner: string, completing: boolean, + turnPrefix: string | undefined, keptOn?: number, ): void { if (owner === MANAGED_TEAM_LEADER) return; @@ -968,25 +1069,93 @@ export class HostedTeamSession { throw new HostedTeamRefusal( `"${owner}" is not a member of team "${team.name}". An owner is "leader" or a member name.`, ); - const state = this.memberState(member.childRunId); - if (state === 'running' || (state === 'completed' && completing)) return; - const ended = - state === 'completed' - ? 'has finished' - : state === 'cancelled' + const chains = this.chains(); + const view = hostedMemberView(chains, member.childRunId); + const kept = keptOn !== undefined; + // "Member X has finished", or for a kept owner "Member X cannot keep + // task #N open: it has finished". + const who = (state: string) => + kept + ? `Member "${owner}" cannot keep task #${keptOn} open: it ${state}` + : `Member "${owner}" ${state}`; + const unassign = kept ? ', or set owner "" to unassign it' : ''; + if (isEndedMember(view)) { + // A run of the chain that completed may have done this task's work, + // so the member may still be recorded on the task the call completes, + // whatever a later run did — a head that settled after a stop request + // among them. + if (completing && hasCompletedRun(chains, member.childRunId)) return; + if (view.stopRequestedCompletion) + throw new HostedTeamRefusal( + `${who('completed after a stop was requested, so it cannot be continued')}. If it did this task's work, set status "completed" with this owner; otherwise launch a new member for it${unassign}.`, + ); + // A continuation stopped before it attached may end without + // starting, which hands the member back to its predecessor. + if (view.stoppingBeforeStart) + throw new HostedTeamRefusal( + `${who('is being stopped before its latest continuation started')}. If that continuation ends without starting, the member can take work again: check task_list once it settles${kept ? ', set owner "" to unassign the task, or assign "leader"' : ''}.`, + ); + const ended = view.stopping + ? 'is being stopped' + : view.state === 'cancelled' ? 'was cancelled' - : `has ${state}`; - if (keptOn !== undefined) + : `has ${view.state}`; + // No run of it completed, so it did no task's work. + if (completing) + throw new HostedTeamRefusal( + `Member "${owner}" ${ended} without completing a run, so it did not do this task's work. Record the member that did it, or "leader".`, + ); throw new HostedTeamRefusal( - `Member "${owner}" ${ended} and cannot keep task #${keptOn} open. Set owner "" to unassign it, or assign "leader" or a running member.`, + kept + ? `${who(ended)}. Launch a new member for this work, set owner "" to unassign it, or assign "leader".` + : `Member "${owner}" ${ended} and cannot own a task. Launch a new member for this work.`, ); + } + if (view.state === 'running' || completing) return; + // The head's newest continuation never started, so the newest work + // sent to the member did not arrive, whatever an earlier send of this + // Turn gave: no later started run exists, or it would be the head. + // One cancelled at a user's request is resent only if still wanted. + if (view.neverStarted !== undefined) { + const neverStarted = hostedNeverStarted( + view.neverStarted, + this.messagesTo(view.neverStarted.childRunId), + ); + throw new HostedTeamRefusal( + `${who(`has finished, and its latest continuation ${neverStartedClause(neverStarted)}`)}. ${neverStartedAction(neverStarted, owner)}, then update the task in the same turn${unassign}.`, + ); + } + if ( + turnPrefix !== undefined && + givenWorkThisTurn( + chains, + member.childRunId, + outboundChildMessages( + this.store.authority.extensionRecordsInDomain('session_message'), + ), + turnPrefix, + ) + ) + return; throw new HostedTeamRefusal( - state === 'completed' - ? `Member "${owner}" has finished, so it can own only a completed task. If it did this task's work, set status "completed" with this owner; otherwise assign "leader" or a running member.` - : `Member "${owner}" ${state === 'cancelled' ? 'was cancelled' : `has ${state}`} and cannot own a task.`, + `${who('has finished')}. If it already did this task's work, from its launch prompt or a message you sent it, set status "completed" with this owner once its report is in. Otherwise give it the work with send_message to "${owner}" first, which continues it, and then update the task in the same turn${unassign}.`, ); } + /** The lead's child Session runs, indexed for one call. */ + private chains(): HostedChildChains { + return new HostedChildChains( + this.store.authority.extensionRecordsInDomain('child_run'), + ); + } + + /** The lead's messages to one child run. */ + private messagesTo(childRunId: string) { + return outboundChildMessages( + this.store.authority.extensionRecordsInDomain('session_message'), + ).filter((message) => message.childRunId === childRunId); + } + private tasksOf(teamId: string): TeamTask[] { return this.store.authority .extensionRecordsInDomain('team_task') diff --git a/packages/cli/src/serve/hosted-workspace-tool-turn.team.test.ts b/packages/cli/src/serve/hosted-workspace-tool-turn.team.test.ts index 3039e89f0a2..91485778616 100644 --- a/packages/cli/src/serve/hosted-workspace-tool-turn.team.test.ts +++ b/packages/cli/src/serve/hosted-workspace-tool-turn.team.test.ts @@ -27,6 +27,7 @@ import { HOSTED_TEAM_AGENT_TOOL, HOSTED_TEAM_AGENT_WORKTREE_TOOL, HOSTED_TEAM_SEND_MESSAGE_TO_CHILD_TOOL, + hostedTeamMessageInput, } from './hosted-workspace-tool-turn.js'; import { HostedChildAgentSession } from './hosted-child-agent-session.js'; import { @@ -36,7 +37,18 @@ import { } from './hosted-team-session.js'; import { HostedWorkspaceBroker } from './hosted-workspace-broker.js'; import { HostedSessionMessageSession } from './hosted-session-message-session.js'; -import { settleInterruptedTurnRuntime } from './hosted-runtime-recovery.js'; +import { + broadcastMessageId, + broadcastRunId, + hostedCallKey, + teamMemberPreamble, +} from './hosted-member-chain.js'; +import { sessionMessageId } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-message-operations.js'; +import { + answerCommittedTurnCalls, + settleInterruptedTurnRuntime, +} from './hosted-runtime-recovery.js'; +import { encodeChildLaunchEnvelope } from '@qwen-code/qwen-code-core/managed-runtime/managed-child-operations.js'; import { HOSTED_APPROVAL_OPTIONS, HOSTED_TOOL_APPROVAL_POLICY, @@ -118,14 +130,17 @@ function createTurn( funnel?: HostedTeamSession; childWorkspaces?: boolean; messages?: boolean; + /** H4e-b2: another Turn of the same Session. */ + promptId?: string; } = {}, ): HostedWorkspaceToolTurn { const hookEvents = options.hookEvents; + const promptId = options.promptId ?? 'prompt'; return new HostedWorkspaceToolTurn( { baseUrl: 'http://127.0.0.1:1', token: 'test' }, session, createManagedHarnessHandle(session), - 'prompt', + promptId, async (type, messageParts, model, identity) => { const uuid = identity?.uuid ?? randomUUID(); await session.sink.write({ @@ -137,7 +152,7 @@ function createTurn( type, cwd: root, version: 'test', - daemonPromptId: 'prompt', + daemonPromptId: promptId, message: { role: type === 'assistant' ? 'model' : 'user', parts: messageParts, @@ -661,12 +676,25 @@ it('declares send_message with team addressing beside the team tools', async () expect(tools.filter((tool) => tool.name === 'send_message')).toEqual([ HOSTED_TEAM_SEND_MESSAGE_TO_CHILD_TOOL, ]); - expect(HOSTED_TEAM_SEND_MESSAGE_TO_CHILD_TOOL.description).toBe( - `${HOSTED_SEND_MESSAGE_TO_CHILD_TOOL.description} A team member is addressed, while it runs, by the task id that task_list shows beside its name (task_…); a member that has finished cannot be continued until its team is deleted.`, - ); - expect(HOSTED_TEAM_SEND_MESSAGE_TO_CHILD_TOOL.parametersJsonSchema).toBe( - HOSTED_SEND_MESSAGE_TO_CHILD_TOOL.parametersJsonSchema, + // H4e-b2 decisions 3 and 4: a member by name, every member with "*", + // or a child by task id — exactly one of to and task_id. + expect(HOSTED_TEAM_SEND_MESSAGE_TO_CHILD_TOOL.description).toContain( + 'to every member at once (to: "*")', ); + expect(HOSTED_TEAM_SEND_MESSAGE_TO_CHILD_TOOL.parametersJsonSchema).toEqual({ + type: 'object', + properties: { + to: expect.objectContaining({ type: 'string' }), + task_id: expect.objectContaining({ type: 'string' }), + message: ( + HOSTED_SEND_MESSAGE_TO_CHILD_TOOL.parametersJsonSchema as { + properties: Record; + } + ).properties['message'], + }, + required: ['message'], + additionalProperties: false, + }); }); // #13841: an isolated member's result names its merge outcome beside its @@ -734,10 +762,10 @@ it('labels an isolated member with its merge outcome and waits for its settle', ).toContain('Team \\"review\\" deleted.'); }); -// #13822 continues a finished child as a new run, which the roster would -// not name: a member hears from the lead while it runs and is never -// continued (H4e-b2). -it('messages a running member and refuses to continue a finished one', async () => { +// #13822 continues a finished child as a new run; H4e-b2 lets the roster +// follow it, so a finished member of the active team is continued and +// stays the same member. +it('messages a running member and continues a finished one as the same member', async () => { const turn = createTurn({ messages: true }); await execute(turn, [ call('team_create', { team_name: 'review' }, 'call-team'), @@ -755,13 +783,12 @@ it('messages a running member and refuses to continue a finished one', async () ), ]), ).toContain('Message queued for delivery'); + // H4e-b2 decision 3: a name routes as its first run's task id does. expect( await execute(turn, [ - call('send_message', { to: 'bob', message: 'hi' }, 'call-by-name'), + call('send_message', { to: 'Bob', message: 'hi' }, 'call-by-name'), ]), - ).toContain( - 'a team member is addressed, while it runs, by the task id that task_list shows beside its name (task_…)', - ); + ).toContain(`Message queued for delivery to member \\"bob\\" (task ${bob}).`); await children.dispatchStarted('prompt:call-alice', { dispatchId: 'dispatch-1', runtime: { runtimeBindingId: 'binding-1', generation: '1' }, @@ -771,21 +798,30 @@ it('messages a running member and refuses to continue a finished one', async () result: Buffer.from('all clean', 'utf8'), receipt: Buffer.from('{"outcome":"settled"}', 'utf8'), }); - const refused = await execute(turn, [ - call( - 'send_message', - { task_id: alice, message: 'now fix them' }, - 'call-more', - ), - ]); - expect(refused).toContain( - 'ran as \\"alice\\" of team \\"review\\" and has finished; a member of an open team cannot be continued yet. For more work, launch a new member with another name while the team takes members; once every member has ended, deleting the team would let this one be continued like any other child agent, but would give up the team\'s board.', + // H4e-b2 decision 2: a finished member of the active team is continued, + // and the continuation opens its instruction with the member preamble. + expect( + await execute(turn, [ + call( + 'send_message', + { task_id: alice, message: 'now fix them' }, + 'call-more', + ), + ]), + ).toContain('continued it as'); + const continued = children.record('prompt:call-more')!; + expect(continued.predecessorChildRunId).toBe('prompt:call-alice'); + const envelope = JSON.parse( + (await session.resources.read(continued.inputRef)).toString('utf8'), + ) as { prompt: string }; + expect(envelope.prompt).toBe( + `${teamMemberPreamble({ name: 'alice', teamName: 'review' })}now fix them`, ); - expect(children.record('prompt:call-more')).toBeUndefined(); const listing = await execute(turn, [call('task_list', {}, 'call-list')]); - // Only a running member shows its task id on the roster. - expect(listing).toContain(`alice: completed\\nbob: running — ${bob}`); - expect(listing).not.toContain(alice); + // The member is its chain head: running, with the head's task id. + expect(listing).toContain( + `alice: running — ${children.taskIdOf('prompt:call-more')}\\nbob: running — ${bob}`, + ); // A plain child beside the team continues as before. await execute(turn, [ call( @@ -1021,3 +1057,1397 @@ it('fires PostToolUse for a team tool that ran, never for one it refused', async expect(events).not.toContain('PostToolUse'); expect(events).not.toContain('PostToolUseFailure'); }); + +// --------------------------------------------------------------------- +// H4e-b2: the lead's mailbox over Session messages +// (docs/design/2026-10-11-managed-agent-team-mailbox.md). + +/** A child run that dispatched, attached and completed. */ +async function finish(childRunId: string, result = 'done'): Promise { + await children.dispatchStarted(childRunId, { + dispatchId: `dispatch-${childRunId}`, + runtime: { runtimeBindingId: 'binding-1', generation: '1' }, + }); + await children.attach(childRunId, `session-${childRunId}`); + await children.settleCompleted(childRunId, { + result: Buffer.from(result, 'utf8'), + receipt: Buffer.from('{"outcome":"settled"}', 'utf8'), + }); +} + +function recordCount(domain: 'child_run' | 'session_message'): number { + return session.authority.extensionRecordsInDomain(domain).length; +} + +async function launchPromptOf(childRunId: string): Promise { + const run = children.record(childRunId)!; + return ( + JSON.parse( + (await session.resources.read(run.inputRef)).toString('utf8'), + ) as { + prompt: string; + } + ).prompt; +} + +it('admits the team form of send_message with exactly one recipient', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + const cases: Array<[Record, string]> = [ + [{ message: 'hi' }, 'takes exactly one of to'], + [ + { to: 'alice', task_id: 'task_x', message: 'hi' }, + 'takes exactly one of to', + ], + [{ to: ' ', message: 'hi' }, '\\"to\\" must be a member name'], + [ + { to: 'alice', summary: 'greeting', message: 'hi' }, + 'unsupported argument \\"summary\\"', + ], + ]; + for (const [args, text] of cases) + expect( + await execute(turn, [call('send_message', args, `call-${text.length}`)]), + ).toContain(text); + // A broadcast reads the roster its batch leaves, so it never shares a + // batch with a change to that roster. + expect( + await execute(turn, [ + member('alice', 'call-alice'), + call('send_message', { to: ' * ', message: 'hi all' }, 'call-all'), + ]), + ).toContain( + 'cannot share a batch with team_create, team_delete or a member launch', + ); + // The launch is refused on its own side too, whatever the order. + expect( + await execute(turn, [ + call('send_message', { to: '*', message: 'hi all' }, 'call-all-2'), + member('alice', 'call-alice-2'), + ]), + ).toContain( + 'Hosted team member cannot launch in the same batch as send_message to \\"*\\"', + ); + expect(roster()).toEqual([]); +}); + +it('encodes the input of a team send with its recipients first', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + // The encoded input is what an approval card and its 8 KiB preview show: + // the recipient comes before a long message (H4e-b2 decision 3). + const encode = vi.spyOn( + turn as unknown as { + encodeToolInput(payload: { toolName: string; input: unknown }): unknown; + }, + 'encodeToolInput', + ); + await execute(turn, [ + call('send_message', { message: 'start', to: 'alice' }, 'call-hi'), + ]); + expect( + encode.mock.calls + .map(([payload]) => payload) + .filter((payload) => payload.toolName === 'send_message') + .map((payload) => Object.keys(payload.input as object)), + ).toEqual([['to', 'message']]); +}); + +it('answers a broadcast that reached no member as an error', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await children.settleFailed('prompt:call-alice', { + stopReason: 'creation_failed', + reason: null, + started: false, + }); + const answer = await execute(turn, [ + call('send_message', { to: '*', message: 'hi all' }, 'call-all'), + ]); + expect(answer).toContain('"error":"The message reached no member:'); + expect(answer).toContain('- alice: not reached: Member \\"alice\\" ended'); +}); + +it('addresses a member by name and refuses the leader, a stranger and no team', async () => { + const turn = createTurn({ messages: true }); + expect( + await execute(turn, [ + call('send_message', { to: 'alice', message: 'hi' }, 'call-none'), + ]), + ).toContain('No open team'); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + // A member launched earlier in the batch is resolved when the send runs. + const answer = await execute(turn, [ + member('alice', 'call-alice'), + call( + 'send_message', + { to: 'Alice', message: 'start with docs' }, + 'call-hi', + ), + ]); + expect(answer).toContain( + `Message queued for delivery to member \\"alice\\" (task ${children.taskIdOf('prompt:call-alice')})`, + ); + expect( + await execute(turn, [ + call('send_message', { to: 'leader', message: 'hi' }, 'call-self'), + ]), + ).toContain('You are the team leader'); + expect( + await execute(turn, [ + call('send_message', { to: 'zed', message: 'hi' }, 'call-zed'), + ]), + ).toContain( + '\\"zed\\" is not a member of team \\"review\\". Members: alice.', + ); +}); + +it('broadcasts to every member with mixed outcomes and replays without sending twice', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await execute(turn, [member('bob', 'call-bob')]); + await execute(turn, [member('carol', 'call-carol')]); + await finish('prompt:call-bob'); + await children.settleFailed('prompt:call-carol', { + stopReason: 'creation_failed', + reason: null, + started: false, + }); + const answer = await execute(turn, [ + call('send_message', { to: '*', message: 'wrap up' }, 'call-all'), + ]); + expect(answer).toContain( + 'Message sent to 2 of 3 members of team \\"review\\"', + ); + expect(answer).toContain( + `- alice: queued for its running task ${children.taskIdOf('prompt:call-alice')}.`, + ); + const bobRun = broadcastRunId('prompt', 'call-all', 2); + expect(answer).toContain( + `- bob: had completed; continued as task ${children.taskIdOf(bobRun)}`, + ); + // A member's refusal names it and how to replace it (decision 3). + expect(answer).toContain( + '- carol: not reached: Member \\"carol\\" ended (failed: creation_failed) and cannot receive messages; for more work, launch a new member under a new name.', + ); + // Each recipient has its own ids, in their own namespace. + const aliceMessage = session.authority.extensionRecord( + 'session_message', + broadcastMessageId({ + senderSessionId: sessionKey.sessionId, + turnId: 'prompt', + callId: 'call-all', + position: 1, + }), + ); + expect(aliceMessage).toBeDefined(); + const continued = children.record(bobRun)!; + expect(continued.predecessorChildRunId).toBe('prompt:call-bob'); + expect(continued.run.executionCallId).toBe('prompt:call-all'); + expect(await launchPromptOf(bobRun)).toBe( + `${teamMemberPreamble({ name: 'bob', teamName: 'review' })}wrap up`, + ); + // The replay finds every recipient's committed send and adds nothing. + const before = [recordCount('child_run'), recordCount('session_message')]; + expect( + await execute(createTurn({ messages: true }), [ + call('send_message', { to: '*', message: 'wrap up' }, 'call-all'), + ]), + ).toContain('Message sent to 2 of 3 members'); + expect([recordCount('child_run'), recordCount('session_message')]).toEqual( + before, + ); +}); + +it('keeps broadcast ids apart from a plain call whose provider id equals them', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await execute(turn, [ + call('send_message', { to: '*', message: 'one' }, 'call-all'), + ]); + const derivedRun = broadcastRunId('prompt', 'call-all', 1); + const derivedMessage = broadcastMessageId({ + senderSessionId: sessionKey.sessionId, + turnId: 'prompt', + callId: 'call-all', + position: 1, + }); + const alice = children.taskIdOf('prompt:call-alice'); + const before = recordCount('session_message'); + for (const callId of [derivedRun, derivedMessage]) + expect( + await execute(turn, [ + call('send_message', { task_id: alice, message: 'one' }, callId), + ]), + ).toContain('Message queued for delivery'); + expect(recordCount('session_message')).toBe(before + 2); +}); + +it('opens a member launch with its preamble inside the envelope bound', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + expect(await launchPromptOf('prompt:call-alice')).toBe( + `${teamMemberPreamble({ name: 'alice', teamName: 'review' })}review the change`, + ); + // The longest prompt that fits alone is refused once the preamble is in. + const definition = { + definitionId: 'hosted-agent/hosted-workspace-shell/1', + definitionRevision: 1, + definitionDigest: session.authority.sessionHeader.definitionRef.digest, + }; + const fits = (length: number) => { + try { + encodeChildLaunchEnvelope({ + description: 'bob task', + prompt: 'x'.repeat(length), + definition, + }); + return true; + } catch { + return false; + } + }; + let low = 0; + let high = 64 * 1024; + while (low < high) { + const middle = (low + high + 1) >> 1; + if (fits(middle)) low = middle; + else high = middle - 1; + } + expect( + await execute(turn, [ + call( + 'agent', + { description: 'bob task', prompt: 'x'.repeat(low), name: 'bob' }, + 'call-bob', + ), + ]), + ).toContain('byte_limit'); + expect(children.record('prompt:call-bob')).toBeUndefined(); +}); + +it('answers a replayed send by name after its team was deleted', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await finish('prompt:call-alice'); + expect( + await execute(turn, [ + call('send_message', { to: 'alice', message: 'more' }, 'call-more'), + ]), + ).toContain('Member \\"alice\\" had completed; continued it as task'); + await finish('prompt:call-more'); + await execute(turn, [call('team_delete', {}, 'call-delete')]); + // The committed send answers, as a plain child's: a former member's + // continuation carries no label (decision 6). + const replayed = await execute(createTurn({ messages: true }), [ + call('send_message', { to: 'alice', message: 'more' }, 'call-more'), + ]); + expect(replayed).toContain( + `Child agent ${children.taskIdOf('prompt:call-alice')} had completed; continued it as ${children.taskIdOf('prompt:call-more')}`, + ); + expect(replayed).not.toContain('labeled'); +}); + +it('replays a broadcast after its team was deleted or replaced without sending again', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await finish('prompt:call-alice'); + await execute(turn, [ + call('send_message', { to: '*', message: 'wrap up' }, 'call-all'), + ]); + await finish(broadcastRunId('prompt', 'call-all', 1)); + await execute(turn, [call('team_delete', {}, 'call-delete')]); + const replay = () => + execute(createTurn({ messages: true }), [ + call('send_message', { to: '*', message: 'wrap up' }, 'call-all'), + ]); + const before = [recordCount('child_run'), recordCount('session_message')]; + expect(await replay()).toContain( + 'The message was sent to 1 member before its team changed; nothing more was sent.\\n- alice: had completed; continued as task', + ); + // A new team under the same name, whose roster the broadcast never read. + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team-2'), + ]); + await execute(turn, [member('bob', 'call-bob')]); + const withBob = [recordCount('child_run'), recordCount('session_message')]; + expect(await replay()).toContain( + 'The message was sent to 1 member before its team changed; nothing more was sent.', + ); + expect([recordCount('child_run'), recordCount('session_message')]).toEqual( + withBob, + ); + expect(withBob[1]).toBe(before[1]); +}); + +it('tells a replayed broadcast only of a continuation that came before its own', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await finish('prompt:call-alice'); + await execute(turn, [ + call('send_message', { to: '*', message: 'all' }, 'call-all'), + ]); + await finish(broadcastRunId('prompt', 'call-all', 1)); + await execute(turn, [ + call('send_message', { to: 'alice', message: 'later' }, 'call-x'), + ]); + await children.settleFailed('prompt:call-x', { + stopReason: 'creation_failed', + reason: null, + started: false, + }); + // The later continuation never started, but it followed this broadcast's. + const replayed = await execute(createTurn({ messages: true }), [ + call('send_message', { to: '*', message: 'all' }, 'call-all'), + ]); + expect(replayed).toContain('Message sent to every member'); + expect(replayed).not.toContain('previous continuation'); +}); + +it('records the owner a send of this Turn gave work, even once it completed', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await execute(turn, [ + call('task_create', { subject: 'fix', description: 'fix it' }, 'call-task'), + ]); + await finish('prompt:call-alice'); + // Two steps in one batch: the send continues alice, then the board + // records her on the open task. + const answer = await execute(turn, [ + call('send_message', { to: 'alice', message: 'fix #1' }, 'call-give'), + call( + 'task_update', + { taskId: '1', status: 'in_progress', owner: 'alice' }, + 'call-own', + ), + ]); + expect(answer).toContain( + 'Task #1 updated (status: in_progress, owner: alice).', + ); + expect(answer).not.toContain('send_message to \\"alice\\" first'); + // The continuation can complete before the owner update lands; the + // same Turn still records the work it gave. + await finish('prompt:call-give'); + await execute(turn, [ + call( + 'task_update', + { taskId: '1', status: 'pending', owner: '' }, + 'call-park', + ), + ]); + expect( + await execute(turn, [ + call( + 'task_update', + { taskId: '1', status: 'in_progress', owner: 'alice' }, + 'call-own-again', + ), + ]), + ).toContain('Task #1 updated (status: in_progress, owner: alice).'); + // A later Turn gave no work: the refusal is a condition, never a + // claim about which task a past send carried. + await execute(turn, [ + call( + 'task_update', + { taskId: '1', status: 'pending', owner: '' }, + 'call-park-2', + ), + ]); + const later = await execute( + createTurn({ messages: true, promptId: 'prompt-2' }), + [ + call( + 'task_update', + { taskId: '1', status: 'in_progress', owner: 'alice' }, + 'call-later', + ), + ], + ); + expect(later).toContain( + 'If it already did this task\'s work, from its launch prompt or a message you sent it, set status \\"completed\\" with this owner once its report is in. Otherwise give it the work with send_message to \\"alice\\" first', + ); +}); + +it('answers an interrupted broadcast recipient by recipient', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await execute(turn, [member('bob', 'call-bob')]); + await execute(turn, [member('carol', 'call-carol')]); + await children.settleFailed('prompt:call-carol', { + stopReason: 'creation_failed', + reason: null, + started: false, + }); + // The broadcast reached alice, then its Harness died before bob and + // before the answer: the journal holds the call, alice's message, and + // no tool result. Carol had ended, so no send would reach her. + await session.sink.write({ + uuid: randomUUID(), + parentUuid: null, + sessionId: sessionKey.sessionId, + timestamp: new Date().toISOString(), + model: 'model', + type: 'assistant', + cwd: root, + version: 'test', + daemonPromptId: 'prompt', + message: { + role: 'model', + // A hook rewrote the input that ran: recovery probes every position + // whatever the journaled arguments say. + parts: [ + { + functionCall: { + id: 'call-all', + name: 'send_message', + args: { to: 'alice', message: 'wrap up' }, + }, + }, + ], + }, + }); + await children.sendToChild({ + taskId: children.taskIdOf('prompt:call-alice'), + text: 'wrap up', + messageId: broadcastMessageId({ + senderSessionId: sessionKey.sessionId, + turnId: 'prompt', + callId: 'call-all', + position: 1, + }), + continuationRunId: broadcastRunId('prompt', 'call-all', 1), + executionCallId: 'prompt:call-all', + closing: false, + }); + await answerCommittedTurnCalls({ + session, + sessionId: sessionKey.sessionId, + cwd: root, + promptId: 'prompt', + children, + teams, + messages: new HostedSessionMessageSession( + { authority: session.authority, resources: session.resources }, + sessionKey, + children, + undefined, + ), + }); + const answer = JSON.stringify( + (await session.sink.project()) + .filter((entry) => entry.type === 'tool_result') + .flatMap((entry) => entry.message?.parts ?? []) + .find((part) => part.functionResponse?.id === 'call-all') + ?.functionResponse?.response, + ); + expect(answer).toContain( + 'The broadcast was interrupted after it was sent to 1 member; do not send it again to the members it reached.', + ); + expect(answer).toContain('- alice: queued for its running task'); + expect(answer).toContain( + 'It did not reach: bob. Send to those members by name if they still need it.', + ); + expect(answer).toContain( + 'It did not reach carol, which has ended and cannot take it.', + ); +}); + +it('answers an interrupted send by name whose continuation never started', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await finish('prompt:call-alice'); + await session.sink.write({ + uuid: randomUUID(), + parentUuid: null, + sessionId: sessionKey.sessionId, + timestamp: new Date().toISOString(), + model: 'model', + type: 'assistant', + cwd: root, + version: 'test', + daemonPromptId: 'prompt', + message: { + role: 'model', + parts: [ + { + functionCall: { + id: 'call-more', + name: 'send_message', + args: { to: 'alice', message: 'more' }, + }, + }, + ], + }, + }); + // The send continued alice, then the Harness died before its answer, and + // the continuation proved it never started: a failed child sends no + // notification, so the recovered answer must say the message is lost. + await children.sendToChild({ + taskId: children.taskIdOf('prompt:call-alice'), + text: 'more', + messageId: 'msg_unused', + continuationRunId: 'prompt:call-more', + executionCallId: 'prompt:call-more', + closing: false, + }); + await children.settleFailed('prompt:call-more', { + stopReason: 'creation_failed', + reason: null, + started: false, + }); + await answerCommittedTurnCalls({ + session, + sessionId: sessionKey.sessionId, + cwd: root, + promptId: 'prompt', + children, + teams, + messages: new HostedSessionMessageSession( + { authority: session.authority, resources: session.resources }, + sessionKey, + children, + undefined, + ), + }); + const answer = JSON.stringify( + (await session.sink.project()) + .filter((entry) => entry.type === 'tool_result') + .flatMap((entry) => entry.message?.parts ?? []) + .find((part) => part.functionResponse?.id === 'call-more') + ?.functionResponse?.response, + ); + expect(answer).toContain( + `Member \\"alice\\" had completed; continued it as task ${children.taskIdOf('prompt:call-more')}, which never started (creation_failed), so this message did not arrive. Send it again with send_message to \\"alice\\".`, + ); +}); + +it('names a continuation that never started and routes its task id from the member', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await finish('prompt:call-alice'); + await execute(turn, [ + call('send_message', { to: 'alice', message: 'first' }, 'call-1'), + ]); + await children.settleFailed('prompt:call-1', { + stopReason: 'creation_failed', + reason: null, + started: false, + }); + // The next send continues the member again and says the previous + // continuation never started. + expect( + await execute(turn, [ + call('send_message', { to: 'alice', message: 'again' }, 'call-2'), + ]), + ).toContain( + 'Its previous continuation never started (creation_failed), so the work it carried did not arrive; send that work again unless this message carries it.', + ); + await children.settleFailed('prompt:call-2', { + stopReason: 'creation_failed', + reason: null, + started: false, + }); + expect( + await execute(turn, [ + call('send_message', { to: '*', message: 'all' }, 'call-all'), + ]), + ).toContain('Its previous continuation never started (creation_failed)'); + await children.settleFailed(broadcastRunId('prompt', 'call-all', 1), { + stopReason: 'creation_failed', + reason: null, + started: false, + }); + // The never-started continuation's own task id still reaches the chain, + // and a member addressed by task id is answered as one. + const byId = await execute(turn, [ + call( + 'send_message', + { task_id: children.taskIdOf('prompt:call-1'), message: 'by id' }, + 'call-3', + ), + ]); + expect(byId).toContain( + 'Member \\"alice\\" had completed; continued it as task', + ); + expect(byId).toContain('Its result arrives labeled \\"alice\\".'); + expect(byId).toContain( + 'Its previous continuation never started (creation_failed)', + ); + expect(children.record('prompt:call-3')?.predecessorChildRunId).toBe( + 'prompt:call-alice', + ); + // A replay of the first send finds that its own continuation never + // started, so it says the message did not arrive. + expect( + await execute(createTurn({ messages: true }), [ + call('send_message', { to: 'alice', message: 'first' }, 'call-1'), + ]), + ).toContain( + `continued it as task ${children.taskIdOf('prompt:call-1')}, which never started (creation_failed), so this message did not arrive. Send it again with send_message to \\"alice\\".`, + ); +}); + +it('counts the messages a never-started continuation lost, and tells a cancelled one apart', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await finish('prompt:call-alice'); + await execute(turn, [ + call('send_message', { to: 'alice', message: 'one' }, 'call-1'), + ]); + // The continuation has not started yet, so the next send is a message + // queued for it. + expect( + await execute(turn, [ + call('send_message', { to: 'alice', message: 'two' }, 'call-2'), + ]), + ).toContain( + `Message queued for delivery to member \\"alice\\" (task ${children.taskIdOf('prompt:call-1')}).`, + ); + await children.settleFailed('prompt:call-1', { + stopReason: 'creation_failed', + reason: null, + started: false, + }); + expect( + await execute(turn, [ + call('send_message', { to: 'alice', message: 'three' }, 'call-3'), + ]), + ).toContain( + 'Its previous continuation never started (creation_failed), so the work it carried did not arrive, nor did 1 message sent to it; send that work and those messages again unless this message carries them.', + ); + // A replay of the queued message says it never arrived. + expect( + await execute(createTurn({ messages: true }), [ + call('send_message', { to: 'alice', message: 'two' }, 'call-2'), + ]), + ).toContain( + `Message was queued for member \\"alice\\" as task ${children.taskIdOf('prompt:call-1')}, which never started (creation_failed), so this message did not arrive. Send it again with send_message to \\"alice\\".`, + ); + // A continuation a user cancelled before it started is resent only if + // its work is still wanted. + await children.requestStop('prompt:call-3'); + await children.settleCancelled('prompt:call-3', { started: false }); + expect( + await execute(turn, [ + call('send_message', { to: 'alice', message: 'four' }, 'call-4'), + ]), + ).toContain( + 'Its previous continuation was cancelled before it started (stop_requested), so the work it carried did not arrive; send that work again only if it is still wanted and this message does not carry it.', + ); +}); + +it('refuses a send by name to a stopping member and one stopped on request', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await children.requestStop('prompt:call-alice'); + expect( + await execute(turn, [ + call('send_message', { to: 'alice', message: 'hi' }, 'call-1'), + ]), + ).toContain( + 'The message to member \\"alice\\" was refused: Member \\"alice\\" is being stopped and cannot receive messages.', + ); + await finish('prompt:call-alice'); + expect( + await execute(turn, [ + call('send_message', { to: 'alice', message: 'hi' }, 'call-2'), + ]), + ).toContain( + 'Member \\"alice\\" completed after a stop was requested and cannot receive messages; for more work, launch a new member under a new name.', + ); + expect(children.record('prompt:call-2')).toBeUndefined(); +}); + +it('reports a broadcast recipient the active cap refuses', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await finish('prompt:call-alice'); + for (const name of ['bob', 'carol', 'dave', 'erin']) + await execute(turn, [member(name, `call-${name}`)]); + const answer = await execute(turn, [ + call('send_message', { to: '*', message: 'status?' }, 'call-all'), + ]); + expect(answer).toContain('Message sent to 4 of 5 members'); + expect(answer).toContain( + '- alice: not reached: Hosted child agent refused to continue member \\"alice\\" (task', + ); + expect(answer).toContain('(count_limit)'); +}); + +it('puts the recipients of a team send first in its input', () => { + expect( + Object.keys( + hostedTeamMessageInput({ message: 'x'.repeat(10_000), to: '*' }), + ), + ).toEqual(['to', 'message']); + expect( + Object.keys(hostedTeamMessageInput({ message: 'x', task_id: 'task_1' })), + ).toEqual(['task_id', 'message']); +}); + +it('counts a committed broadcast as dispatched after a restart', async () => { + const setup = createTurn({ messages: true }); + await execute(setup, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(setup, [member('alice', 'call-alice')]); + const events: string[] = []; + const broadcast = call( + 'send_message', + { to: '*', message: 'hi' }, + 'call-all', + ); + const responses = await createTurn({ + messages: true, + hookEvents: events, + }).execute( + [broadcast], + [ + { + functionCall: { + id: 'call-all', + name: 'send_message', + args: broadcast.args, + }, + }, + ], + 'model', + new AbortController().signal, + ); + expect(events).toContain('PostToolUse'); + events.length = 0; + // A new turn object has no in-memory dispatch record: the committed + // recipients are the evidence. + await createTurn({ messages: true, hookEvents: events }).resumeHookResults( + responses.filter((part) => part.functionResponse?.id === 'call-all'), + 'model', + new AbortController().signal, + ); + expect(events).toContain('PostToolUse'); +}); + +/** A call the journal holds with no answer, as a dead Harness left it. */ +async function journalCall( + callId: string, + args: Record, +): Promise { + await session.sink.write({ + uuid: randomUUID(), + parentUuid: null, + sessionId: sessionKey.sessionId, + timestamp: new Date().toISOString(), + model: 'model', + type: 'assistant', + cwd: root, + version: 'test', + daemonPromptId: 'prompt', + message: { + role: 'model', + parts: [{ functionCall: { id: callId, name: 'send_message', args } }], + }, + }); +} + +/** The answer recovery gives a journal-only call of the turn "prompt". */ +async function recoveredAnswer(callId: string): Promise { + await answerCommittedTurnCalls({ + session, + sessionId: sessionKey.sessionId, + cwd: root, + promptId: 'prompt', + children, + teams, + messages: new HostedSessionMessageSession( + { authority: session.authority, resources: session.resources }, + sessionKey, + children, + undefined, + ), + }); + return JSON.stringify( + (await session.sink.project()) + .filter((entry) => entry.type === 'tool_result') + .flatMap((entry) => entry.message?.parts ?? []) + .find((part) => part.functionResponse?.id === callId)?.functionResponse + ?.response, + ); +} + +const neverStarted = (childRunId: string) => + children.settleFailed(childRunId, { + stopReason: 'creation_failed', + reason: null, + started: false, + }); + +it('records the owner a send of a wake turn gave work', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await execute(turn, [ + call('task_create', { subject: 'fix', description: 'fix it' }, 'call-task'), + ]); + await finish('prompt:call-alice'); + // The lead reads a result in a wake turn, whose call keys collapse its + // prompt id to a digest; the owner rule reads the same keys. + const wakePrompt = 'msg_result:message:accept:notify'; + const wake = createTurn({ messages: true, promptId: wakePrompt }); + expect( + await execute(wake, [ + call('send_message', { to: 'alice', message: 'fix #1' }, 'call-give'), + call( + 'task_update', + { taskId: '1', status: 'in_progress', owner: 'alice' }, + 'call-own', + ), + ]), + ).toContain('Task #1 updated (status: in_progress, owner: alice).'); + const continued = hostedCallKey(wakePrompt, 'call-give'); + expect(children.record(continued)?.predecessorChildRunId).toBe( + 'prompt:call-alice', + ); + await finish(continued); + expect( + await execute(wake, [ + call('task_update', { taskId: '1', status: 'pending' }, 'call-park'), + ]), + ).toContain('Task #1 updated (status: pending, owner: alice).'); + expect( + await execute(createTurn({ messages: true, promptId: 'prompt-2' }), [ + call('task_update', { taskId: '1', status: 'in_progress' }, 'call-back'), + ]), + ).toContain('cannot keep task #1 open: it has finished'); +}); + +it('replays a broadcast whose recipients never started as not reached', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await execute(turn, [member('bob', 'call-bob')]); + await finish('prompt:call-alice'); + await finish('prompt:call-bob'); + // alice waits in a continuation, so the broadcast queues a message for + // it; bob is continued. Neither run ever starts. + await execute(turn, [ + call('send_message', { to: 'alice', message: 'first' }, 'call-a'), + ]); + expect( + await execute(turn, [ + call('send_message', { to: '*', message: 'all' }, 'call-all'), + ]), + ).toContain('Message sent to every member'); + const bobRun = broadcastRunId('prompt', 'call-all', 2); + await neverStarted('prompt:call-a'); + await neverStarted(bobRun); + const before = [recordCount('child_run'), recordCount('session_message')]; + const replayed = await execute(createTurn({ messages: true }), [ + call('send_message', { to: '*', message: 'all' }, 'call-all'), + ]); + expect(replayed).toContain( + 'Message sent to 2 of 2 members of team \\"review\\", but it did not arrive at 2 of them. Send it by name to any member below that did not get it and still needs it.', + ); + expect(replayed).toContain( + `- alice: queued for task ${children.taskIdOf('prompt:call-a')}, which never started (creation_failed), so the message did not arrive.`, + ); + expect(replayed).toContain( + `- bob: had completed; continued as task ${children.taskIdOf(bobRun)}, which never started (creation_failed), so the message did not arrive.`, + ); + expect([recordCount('child_run'), recordCount('session_message')]).toEqual( + before, + ); +}); + +it('recovers an interrupted broadcast whose recipient never started as not reached', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await execute(turn, [member('bob', 'call-bob')]); + await finish('prompt:call-alice'); + await journalCall('call-all', { to: '*', message: 'wrap up' }); + await children.sendToChild({ + taskId: children.taskIdOf('prompt:call-alice'), + text: 'wrap up', + messageId: broadcastMessageId({ + senderSessionId: sessionKey.sessionId, + turnId: 'prompt', + callId: 'call-all', + position: 1, + }), + continuationRunId: broadcastRunId('prompt', 'call-all', 1), + executionCallId: 'prompt:call-all', + closing: false, + }); + await neverStarted(broadcastRunId('prompt', 'call-all', 1)); + const answer = await recoveredAnswer('call-all'); + expect(answer).toContain( + 'The broadcast was interrupted after it was sent to 1 member.\\n- alice: had completed; continued as task', + ); + expect(answer).not.toContain('do not send it again'); + expect(answer).toContain( + 'It did not reach: alice, bob. Send to those members by name if they still need it.', + ); +}); + +it('recovers an interrupted broadcast after its team changed without naming the new roster', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await finish('prompt:call-alice'); + await children.sendToChild({ + taskId: children.taskIdOf('prompt:call-alice'), + text: 'wrap up', + messageId: broadcastMessageId({ + senderSessionId: sessionKey.sessionId, + turnId: 'prompt', + callId: 'call-all', + position: 1, + }), + continuationRunId: broadcastRunId('prompt', 'call-all', 1), + executionCallId: 'prompt:call-all', + closing: false, + }); + await finish(broadcastRunId('prompt', 'call-all', 1)); + await execute(turn, [call('team_delete', {}, 'call-delete')]); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team-2'), + ]); + await execute(turn, [member('carol', 'call-carol')]); + await journalCall('call-all', { to: '*', message: 'wrap up' }); + const answer = await recoveredAnswer('call-all'); + expect(answer).toContain( + 'The broadcast was interrupted after it was sent to 1 member; do not send it again to the members it reached. Its team has changed since, so nothing tells who else it was meant for.', + ); + expect(answer).not.toContain('carol'); +}); + +it('recovers an interrupted send by name to a running member with its name', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await journalCall('call-hi', { to: 'alice', message: 'hi' }); + await children.sendToChild({ + taskId: children.taskIdOf('prompt:call-alice'), + text: 'hi', + messageId: sessionMessageId({ + senderSessionId: sessionKey.sessionId, + turnId: 'prompt', + callId: 'call-hi', + }), + continuationRunId: 'prompt:call-hi', + executionCallId: 'prompt:call-hi', + closing: false, + }); + expect(await recoveredAnswer('call-hi')).toContain( + `Message queued for delivery to member \\"alice\\" (task ${children.taskIdOf('prompt:call-alice')}).`, + ); +}); + +it('sends a broadcast to no further member once its turn is cancelled', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + for (const name of ['alice', 'bob', 'carol']) + await execute(turn, [member(name, `call-${name}`)]); + const controller = new AbortController(); + const sendToChild = children.sendToChild.bind(children); + const spy = vi + .spyOn(children, 'sendToChild') + .mockImplementation(async (params) => { + const route = await sendToChild(params); + controller.abort(); + return route; + }); + const calls = [ + call('send_message', { to: '*', message: 'stop soon' }, 'call-all'), + ]; + const answer = JSON.stringify( + await createTurn({ messages: true }).execute( + calls, + calls.map((each) => ({ + functionCall: { id: each.callId, name: each.name, args: each.args }, + })), + 'model', + controller.signal, + ), + ); + spy.mockRestore(); + expect(answer).toContain( + 'Message sent to 1 of 3 members of team \\"review\\"; the turn was cancelled before the rest.', + ); + expect(answer).toContain('- bob: not sent: the turn was cancelled.'); + expect(answer).toContain('- carol: not sent: the turn was cancelled.'); + expect(recordCount('session_message')).toBe(1); +}); + +it('answers a replayed send whose continuation was cancelled before it started', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await finish('prompt:call-alice'); + await execute(turn, [ + call('send_message', { to: 'alice', message: 'first' }, 'call-1'), + ]); + await neverStarted('prompt:call-1'); + await execute(turn, [ + call('send_message', { to: 'alice', message: 'second' }, 'call-2'), + ]); + await children.requestStop('prompt:call-2'); + await children.settleCancelled('prompt:call-2', { started: false }); + // Neither arrived: the resend asked for carries the earlier work too. + expect( + await execute(createTurn({ messages: true }), [ + call('send_message', { to: 'alice', message: 'second' }, 'call-2'), + ]), + ).toContain( + `continued it as task ${children.taskIdOf('prompt:call-2')}, which was cancelled before it started (stop_requested), so this message did not arrive. If it is still wanted, send it again with send_message to \\"alice\\". Its previous continuation never started (creation_failed), so the work it carried did not arrive; send that work again unless your resend of this message carries it.`, + ); +}); + +it('notes a never-started continuation only on the continuation after it', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await finish('prompt:call-alice'); + await execute(turn, [ + call('send_message', { to: 'alice', message: 'one' }, 'call-1'), + ]); + await neverStarted('prompt:call-1'); + expect( + await execute(turn, [ + call('send_message', { to: 'alice', message: 'two' }, 'call-2'), + ]), + ).toContain('Its previous continuation never started'); + // A message to that newer continuation follows no never-started run. + const queued = await execute(turn, [ + call('send_message', { to: 'alice', message: 'three' }, 'call-3'), + ]); + expect(queued).toContain( + `Message queued for delivery to member \\"alice\\" (task ${children.taskIdOf('prompt:call-2')}).`, + ); + expect(queued).not.toContain('previous continuation'); +}); + +it('answers a replayed message to a member that never started as ended', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await execute(turn, [ + call('send_message', { to: 'alice', message: 'hi' }, 'call-hi'), + ]); + await neverStarted('prompt:call-alice'); + expect( + await execute(createTurn({ messages: true }), [ + call('send_message', { to: 'alice', message: 'hi' }, 'call-hi'), + ]), + ).toContain( + `Message was queued for member \\"alice\\" as task ${children.taskIdOf('prompt:call-alice')}, which never started (creation_failed), so this message did not arrive. Member \\"alice\\" has ended; for more work, launch a new member under a new name.`, + ); +}); + +/** A member that took a message and then was stopped, as H4f does it. */ +async function stopAttached(childRunId: string): Promise { + await children.dispatchStarted(childRunId, { + dispatchId: `dispatch-${childRunId}`, + runtime: { runtimeBindingId: 'binding-1', generation: '1' }, + }); + await children.attach(childRunId, `session-${childRunId}`); + await children.requestStop(childRunId); + await children.settleCancelled(childRunId, { + started: true, + childSessionId: `session-${childRunId}`, + }); +} + +const relay = () => + new HostedSessionMessageSession( + { authority: session.authority, resources: session.resources }, + sessionKey, + children, + undefined, + ); + +for (const outcome of ['cancelled', 'rejected'] as const) + it(`recovers a broadcast whose message to a started member was ${outcome} as not reached`, async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await execute(turn, [member('bob', 'call-bob')]); + await journalCall('call-all', { to: '*', message: 'wrap up' }); + // The broadcast queued its message for running alice, then the Harness + // died before bob and before the answer. + const messageId = broadcastMessageId({ + senderSessionId: sessionKey.sessionId, + turnId: 'prompt', + callId: 'call-all', + position: 1, + }); + await children.sendToChild({ + taskId: children.taskIdOf('prompt:call-alice'), + text: 'wrap up', + messageId, + continuationRunId: broadcastRunId('prompt', 'call-all', 1), + executionCallId: 'prompt:call-all', + closing: false, + }); + // alice was stopped before the message was read, so the relay ended + // its delivery with no receipt. + if (outcome === 'rejected') { + await children.dispatchStarted('prompt:call-alice', { + dispatchId: 'dispatch-prompt:call-alice', + runtime: { runtimeBindingId: 'binding-1', generation: '1' }, + }); + await children.attach('prompt:call-alice', 'session-prompt:call-alice'); + await relay().handover(messageId, 'session-prompt:call-alice'); + await relay().settle(messageId, 'rejected'); + await children.requestStop('prompt:call-alice'); + await children.settleCancelled('prompt:call-alice', { + started: true, + childSessionId: 'session-prompt:call-alice', + }); + } else { + await relay().settle(messageId, 'cancelled'); + await stopAttached('prompt:call-alice'); + } + const answer = await recoveredAnswer('call-all'); + expect(answer).toContain( + `The broadcast was interrupted after it was sent to 1 member.\\n- alice: queued for task ${children.taskIdOf('prompt:call-alice')}, but its delivery was ${outcome}, so the message did not arrive.`, + ); + expect(answer).not.toContain('do not send it again'); + expect(answer).toContain( + 'It did not reach: bob. Send to those members by name if they still need it.', + ); + expect(answer).toContain( + 'It did not reach alice, which has ended and cannot take it.', + ); + }); + +it('answers a replayed send by name whose message was cancelled', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await execute(turn, [ + call('send_message', { to: 'alice', message: 'hi' }, 'call-hi'), + ]); + await relay().settle( + sessionMessageId({ + senderSessionId: sessionKey.sessionId, + turnId: 'prompt', + callId: 'call-hi', + }), + 'cancelled', + ); + await stopAttached('prompt:call-alice'); + expect( + await execute(createTurn({ messages: true }), [ + call('send_message', { to: 'alice', message: 'hi' }, 'call-hi'), + ]), + ).toContain( + `Message was queued for member \\"alice\\" (task ${children.taskIdOf('prompt:call-alice')}), but its delivery was cancelled, so it did not arrive. Member \\"alice\\" has ended; for more work, launch a new member under a new name.`, + ); +}); + +it('recovers a send by task id to a plain child whose message was cancelled', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call( + 'agent', + { description: 'plain task', prompt: 'review the change' }, + 'call-plain', + ), + ]); + await journalCall('call-hi', { + task_id: children.taskIdOf('prompt:call-plain'), + message: 'hi', + }); + const messageId = sessionMessageId({ + senderSessionId: sessionKey.sessionId, + turnId: 'prompt', + callId: 'call-hi', + }); + await children.sendToChild({ + taskId: children.taskIdOf('prompt:call-plain'), + text: 'hi', + messageId, + continuationRunId: 'prompt:call-hi', + executionCallId: 'prompt:call-hi', + closing: false, + }); + await relay().settle(messageId, 'cancelled'); + await stopAttached('prompt:call-plain'); + expect(await recoveredAnswer('call-hi')).toContain( + `Message was queued for child agent ${children.taskIdOf('prompt:call-plain')}, but its delivery was cancelled, so it did not arrive. That child agent has ended; launch a new agent for this work if it is still needed.`, + ); +}); + +const sendToKey = (callId: string) => + sessionMessageId({ + senderSessionId: sessionKey.sessionId, + turnId: 'prompt', + callId, + }); + +it('answers a replayed send whose message failed by what a resend would meet', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await execute(turn, [member('bob', 'call-bob')]); + const replay = (callId: string, to: string, message: string) => + execute(createTurn({ messages: true }), [ + call('send_message', { to, message }, callId), + ]); + // alice still runs: her message's delivery failed, so it is sent again. + await execute(turn, [ + call('send_message', { to: 'alice', message: 'one' }, 'call-a'), + ]); + await relay().settle(sendToKey('call-a'), 'cancelled'); + expect(await replay('call-a', 'alice', 'one')).toContain( + 'but its delivery was cancelled, so it did not arrive. Send it again with send_message to \\"alice\\" if it is still needed.', + ); + // bob failed while his message was still waiting for its handover: the + // relay will cancel it, so the answer already reads it so. + await execute(turn, [ + call('send_message', { to: 'bob', message: 'two' }, 'call-b'), + ]); + await children.dispatchStarted('prompt:call-bob', { + dispatchId: 'dispatch-prompt:call-bob', + runtime: { runtimeBindingId: 'binding-1', generation: '1' }, + }); + await children.attach('prompt:call-bob', 'session-prompt:call-bob'); + await children.settleFailed('prompt:call-bob', { + stopReason: 'child_failed', + reason: null, + started: true, + }); + expect(await replay('call-b', 'bob', 'two')).toContain( + 'but its delivery was cancelled, so it did not arrive. Member \\"bob\\" has ended; for more work, launch a new member under a new name.', + ); +}); + +it('answers a message to a continuation stopped before it started as settling', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await finish('prompt:call-alice'); + await execute(turn, [ + call('send_message', { to: 'alice', message: 'work' }, 'call-1'), + ]); + await execute(turn, [ + call('send_message', { to: 'alice', message: 'more' }, 'call-2'), + ]); + await children.requestStop('prompt:call-1'); + expect( + await execute(createTurn({ messages: true }), [ + call('send_message', { to: 'alice', message: 'more' }, 'call-2'), + ]), + ).toContain( + 'but its delivery was cancelled, so it did not arrive. Its latest continuation is being stopped before it started; if that continuation ends without starting, send it again with send_message to \\"alice\\" if it is still needed.', + ); +}); + +it('replays a broadcast whose message to a member failed as not arrived there', async () => { + const turn = createTurn({ messages: true }); + await execute(turn, [ + call('team_create', { team_name: 'review' }, 'call-team'), + ]); + await execute(turn, [member('alice', 'call-alice')]); + await execute(turn, [member('bob', 'call-bob')]); + await execute(turn, [ + call('send_message', { to: '*', message: 'all' }, 'call-all'), + ]); + await relay().settle( + broadcastMessageId({ + senderSessionId: sessionKey.sessionId, + turnId: 'prompt', + callId: 'call-all', + position: 1, + }), + 'cancelled', + ); + await stopAttached('prompt:call-alice'); + const replayed = await execute(createTurn({ messages: true }), [ + call('send_message', { to: '*', message: 'all' }, 'call-all'), + ]); + expect(replayed).toContain( + 'Message sent to 2 of 2 members of team \\"review\\", but it did not arrive at 1 of them.', + ); + expect(replayed).toContain( + `- alice: queued for task ${children.taskIdOf('prompt:call-alice')}, but its delivery was cancelled, so the message did not arrive. It has ended and takes no messages.`, + ); + expect(replayed).toContain( + `- bob: queued for its running task ${children.taskIdOf('prompt:call-bob')}.`, + ); +}); diff --git a/packages/cli/src/serve/hosted-workspace-tool-turn.ts b/packages/cli/src/serve/hosted-workspace-tool-turn.ts index 93bf4392971..d93370df58e 100644 --- a/packages/cli/src/serve/hosted-workspace-tool-turn.ts +++ b/packages/cli/src/serve/hosted-workspace-tool-turn.ts @@ -52,6 +52,7 @@ import { assertManagedSessionDurableRef, assertManagedSessionStableId, ManagedSessionWritesStoppedError, + type ManagedSessionDomain, type ManagedSessionDurableRef, } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js'; import type { ManagedHarnessHandle } from '@qwen-code/qwen-code-core/managed-runtime/managed-harness-factory.js'; @@ -95,6 +96,7 @@ import { HOSTED_TEAM_TOOL_NAMES, HostedTeamRefusal, hostedTeamArgumentError, + hostedTeamMembership, type HostedTeamMembership, type HostedTeamSession, } from './hosted-team-session.js'; @@ -106,6 +108,19 @@ import { ManagedSessionRecordError } from '@qwen-code/qwen-code-core/managed-run import { ManagedSessionConflictError } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-authority.js'; import { managedChildTaskId } from '@qwen-code/qwen-code-core/managed-runtime/managed-extension-projection.js'; import type { HostedMonitorSession } from './hosted-monitor-session.js'; +import { + HOSTED_BROADCAST_POSITIONS, + HostedChildChains, + broadcastMessageId, + broadcastRunId, + hostedCallKey, + hostedPromptKey, + hostedRouteDetails, + neverStartedClause, + neverStartedPhrase, + teamMemberPreamble, +} from './hosted-member-chain.js'; +import { MANAGED_TEAM_LEADER } from '@qwen-code/qwen-code-core/managed-runtime/managed-team-record.js'; import { HostedMonitorLoop } from './hosted-monitor-loop.js'; import { HostedMonitorRemoteExecutor } from './hosted-monitor-remote-executor.js'; import { @@ -511,15 +526,289 @@ export function hostedMessageContinuedText( return `Child agent ${predecessorTaskId} had completed; continued it as ${taskId} with your message as its next instruction. Its result arrives as a durable notification input; either task id reaches the newest run.`; } +type HostedChildMessageRoute = Awaited< + ReturnType +>; +type HostedSentRoute = Exclude; + /** - * H4e-b1: the same tool beside the team tools. A member is addressed while - * it runs; one that has finished is not continued while its team is open. + * H4e-b2: the continuation taken before this one never started, so the + * work it carried and the messages it lost are asked for again — unless + * the send carries them, or, when this send did not arrive either, the + * resend it asks for. + */ +function previousNote(route: HostedSentRoute): string { + const previous = route.previousNeverStarted; + if (previous === undefined) return ''; + const lost = previous.lostMessages > 0; + const what = `that work${lost ? ' and those messages' : ''}`; + const them = lost ? 'them' : 'it'; + const carrier = + route.ownNeverStarted === undefined + ? 'this message' + : 'your resend of this message'; + return ` Its previous continuation ${neverStartedClause(previous)}; ${ + previous.cancelled + ? `send ${what} again only if ${lost ? 'they are' : 'it is'} still wanted and ${carrier} does not carry ${them}` + : `send ${what} again unless ${carrier} carries ${them}` + }.`; +} + +/** + * The answer of one send, to a member by name or to a task id: a member + * is named and its result labeled (H4e-b2 decisions 3 and 6), and a run + * that never started is told, since a failed child sends no notification. + * A replay or a recovered answer may find the run it reached proved since + * that it never started. + */ +export function hostedRouteAnswer( + children: HostedChildAgentSession, + route: HostedSentRoute, +): string { + const task = children.taskIdOf(route.childRunId); + const member = route.member; + const own = route.ownNeverStarted; + if (own !== undefined) { + const told = + route.kind === 'message' + ? `Message was queued for ${member === undefined ? `child agent ${task}` : `member "${member.name}" as task ${task}`}, which ${neverStartedPhrase(own)}, so this message did not arrive.` + : `${member === undefined ? `Child agent ${children.taskIdOf(route.predecessorChildRunId)}` : `Member "${member.name}"`} had completed; continued it as task ${task}, which ${neverStartedPhrase(own)}, so this message did not arrive.`; + // Each answer asks again only for its own message: what else that run + // carried or lost belongs to its own calls' answers and to the next + // send's note. A launch that never started ended its member or child. + const resend = + own.predecessorChildRunId !== null + ? ` ${own.cancelled ? 'If it is still wanted, send it' : 'Send it'} again with send_message to ${member === undefined ? `task_id "${children.taskIdOf(own.predecessorChildRunId)}"` : `"${member.name}"`}.` + : member === undefined + ? ` That child agent has ended; ${own.cancelled ? 'if this work is still wanted, launch a new agent for it' : 'launch a new agent for this work'}.` + : ` Member "${member.name}" has ended; ${own.cancelled ? 'if this work is still wanted, launch a new member under a new name for it' : 'for more work, launch a new member under a new name'}.`; + return told + resend + previousNote(route); + } + // A message whose delivery ended with no receipt did not arrive; what a + // resend meets depends on the chain now. + if (route.deliveryFailed !== undefined) { + const { state, chain } = route.deliveryFailed; + const told = `Message was queued for ${member === undefined ? `child agent ${task}` : `member "${member.name}" (task ${task})`}, but its delivery was ${state}, so it did not arrive.`; + const address = + member === undefined ? `task_id "${task}"` : `"${member.name}"`; + if (chain === 'open') + return `${told} Send it again with send_message to ${address} if it is still needed.`; + if (chain === 'settling') + return `${told} Its latest continuation is being stopped before it started; if that continuation ends without starting, send it again with send_message to ${address} if it is still needed.`; + return member === undefined + ? `${told} That child agent has ended; launch a new agent for this work if it is still needed.` + : `${told} Member "${member.name}" has ended; for more work, launch a new member under a new name.`; + } + if (route.kind === 'message') + return member === undefined + ? hostedMessageToChildText(task) + : `Message queued for delivery to member "${member.name}" (task ${task}). It arrives as its next input once its current turn ends. There is no inline reply: the member answers through its result notification or a message to you.`; + return ( + (member === undefined + ? hostedMessageContinuedText( + children.taskIdOf(route.predecessorChildRunId), + task, + ) + : `Member "${member.name}" had completed; continued it as task ${task} with your message as its next instruction. Its result arrives labeled "${member.name}".`) + + previousNote(route) + ); +} + +/** Whether a committed send reached its recipient as far as is known. */ +function hostedRouteArrived(route: HostedSentRoute): boolean { + return ( + route.ownNeverStarted === undefined && route.deliveryFailed === undefined + ); +} + +/** H4e-b2: one broadcast recipient's line in the call's answer. */ +function hostedRouteLine( + children: HostedChildAgentSession, + route: HostedChildMessageRoute, +): string { + if (route.kind === 'refused') return `not reached: ${route.reason}`; + const task = children.taskIdOf(route.childRunId); + const own = route.ownNeverStarted; + if (route.kind === 'message') + return own !== undefined + ? `queued for task ${task}, which ${neverStartedPhrase(own)}, so the message did not arrive.` + : route.deliveryFailed !== undefined + ? `queued for task ${task}, but its delivery was ${route.deliveryFailed.state}, so the message did not arrive.${route.deliveryFailed.chain === 'ended' ? ' It has ended and takes no messages.' : ''}` + : `queued for its running task ${task}.`; + return ( + (own === undefined + ? `had completed; continued as task ${task}.` + : `had completed; continued as task ${task}, which ${neverStartedPhrase(own)}, so the message did not arrive.`) + + previousNote(route) + ); +} + +/** One recipient a broadcast call committed a send to. */ +export interface HostedBroadcastRecipient { + readonly position: number; + /** The member name, while a team still lists the chain. */ + readonly name: string | undefined; + /** The roster entry's run the recipient's chain started from. */ + readonly rootChildRunId: string; + /** False when the run it reached proved it never started, or the + * message's delivery ended with no receipt. */ + readonly arrived: boolean; + readonly text: string; +} + +/** + * H4e-b2 decision 9: what one broadcast call committed, recipient by + * recipient, found under each position's ids. Recovery and the PostToolUse + * dispatch evidence probe all positions whatever the journaled arguments + * say, since a hook may have rewritten the input the call executed. + */ +export function hostedBroadcastCommitted(input: { + readonly authority: { + extensionRecordsInDomain( + domain: ManagedSessionDomain, + ): ReadonlyArray<{ readonly record: unknown }>; + }; + readonly children: HostedChildAgentSession; + readonly messages: HostedSessionMessageSession | undefined; + readonly senderSessionId: string; + readonly promptId: string; + readonly callId: string; +}): HostedBroadcastRecipient[] { + const chains = new HostedChildChains( + input.authority.extensionRecordsInDomain('child_run'), + ); + const teams = input.authority.extensionRecordsInDomain('team_state'); + const found: HostedBroadcastRecipient[] = []; + for (let position = 1; position <= HOSTED_BROADCAST_POSITIONS; position++) { + const message = input.messages?.message( + broadcastMessageId({ + senderSessionId: input.senderSessionId, + turnId: input.promptId, + callId: input.callId, + position, + }), + ); + const continued = input.children.record( + broadcastRunId(input.promptId, input.callId, position), + ); + let sent: + | { kind: 'message'; childRunId: string; messageId: string } + | { + kind: 'continuation'; + childRunId: string; + predecessorChildRunId: string; + }; + if (message?.direction === 'outbound') + sent = { + kind: 'message', + childRunId: message.childRunId, + messageId: message.messageId, + }; + else if (continued?.predecessorChildRunId) + sent = { + kind: 'continuation', + childRunId: continued.childRunId, + predecessorChildRunId: continued.predecessorChildRunId, + }; + else continue; + const route = { + ...sent, + ...hostedRouteDetails( + input.authority, + sent, + sent.kind === 'message' ? sent.messageId : undefined, + ), + }; + const rootChildRunId = chains.root(route.childRunId); + found.push({ + position, + name: hostedTeamMembership(teams, rootChildRunId)?.name, + rootChildRunId, + arrived: hostedRouteArrived(route), + text: hostedRouteLine(input.children, route), + }); + } + return found; +} + +/** + * Whether one call committed any broadcast recipient: the same ids as + * `hostedBroadcastCommitted`, probed without indexing the lead's runs, so + * the PostToolUse dispatch evidence stays cheap for every send. + */ +export function hostedBroadcastAnyCommitted(input: { + readonly children: HostedChildAgentSession; + readonly messages: HostedSessionMessageSession | undefined; + readonly senderSessionId: string; + readonly promptId: string; + readonly callId: string; +}): boolean { + for (let position = 1; position <= HOSTED_BROADCAST_POSITIONS; position++) { + if ( + input.messages?.message( + broadcastMessageId({ + senderSessionId: input.senderSessionId, + turnId: input.promptId, + callId: input.callId, + position, + }), + )?.direction === 'outbound' || + input.children.record( + broadcastRunId(input.promptId, input.callId, position), + )?.predecessorChildRunId + ) + return true; + } + return false; +} + +/** + * H4e-b2: the same tool beside the team tools, which also addresses a + * member by name and every member at once (decisions 3 and 4). */ export const HOSTED_TEAM_SEND_MESSAGE_TO_CHILD_TOOL: FunctionDeclaration = { - ...HOSTED_SEND_MESSAGE_TO_CHILD_TOOL, - description: `${HOSTED_SEND_MESSAGE_TO_CHILD_TOOL.description} A team member is addressed, while it runs, by the task id that task_list shows beside its name (task_…); a member that has finished cannot be continued until its team is deleted.`, + name: 'send_message', + description: + 'Send a message to a member of your team by name (to), to every member at once (to: "*"), or to a child agent this Session launched by the task_id its launch returned; give exactly one of to and task_id. A running member or child receives it as its next input once its current turn ends. One that completed is continued as a new background task with your message as its next instruction, together with its earlier instructions and results; its result arrives as a notification, and a member\'s result is labeled with its name. A failed, cancelled or stopping one, or one that completed after a stop was requested, cannot receive messages. There is no inline reply: the recipient answers through its result notification or by messaging you. A message is not a board change: record the work with task_update in the same turn.', + parametersJsonSchema: { + type: 'object', + properties: { + to: { + type: 'string', + description: + 'A member name of your team, or "*" for every member. Use task_id instead for a child agent that is not a member.', + }, + task_id: { + type: 'string', + description: 'The task id a child agent launch returned.', + }, + message: SEND_MESSAGE_TEXT_SCHEMA, + }, + required: ['message'], + additionalProperties: false, + }, }; +/** H4e-b2 decision 4: `to` names every member at once. */ +function isBroadcast(to: unknown): boolean { + return typeof to === 'string' && to.trim() === '*'; +} + +/** + * H4e-b2 decision 4: the team form's input with its recipients first. The + * approval card previews only the input's first 8 KiB, so a long message + * must never push `to` or `task_id` out of it. + */ +export function hostedTeamMessageInput( + args: Record, +): Record { + return { + ...(args['to'] === undefined ? {} : { to: args['to'] }), + ...(args['task_id'] === undefined ? {} : { task_id: args['task_id'] }), + ...args, + }; +} + /** H4d-b: a child Session's durable message to the parent that launched it. */ export const HOSTED_SEND_MESSAGE_TO_PARENT_TOOL: FunctionDeclaration = { name: 'send_message', @@ -706,10 +995,7 @@ export async function journaledToolResultIds( * resumed Hook results both name the same run again and again. */ export function hostedChildRunIdFor(promptId: string, callId: string): string { - const promptKey = promptId.endsWith(':accept:notify') - ? createHash('sha256').update(promptId).digest('hex').slice(0, 16) - : promptId; - return `${promptKey}:${callId}`; + return hostedCallKey(promptId, callId); } /** @@ -999,6 +1285,13 @@ export class HostedWorkspaceToolTurn { ) ) return 'Hosted team member cannot launch in the same batch as team_create or team_delete; let the team change land first.'; + if ( + batch.some( + (other) => + other.name === 'send_message' && isBroadcast(other.args['to']), + ) + ) + return 'Hosted team member cannot launch in the same batch as send_message to "*"; let the join land first.'; // A recovered foreground wait (#13708) answers the batch's other // launches as plain background starts, which would hide the join. if ( @@ -1346,6 +1639,17 @@ export class HostedWorkspaceToolTurn { this.childAgents?.record(this.childRunIdFor(call.callId)) !== undefined || this.messages?.message(this.messageIdFor(call.callId)) !== undefined || + // H4e-b2: a broadcast's recipients commit under their own ids. + (call.name === 'send_message' && + this.childAgents !== undefined && + hostedBroadcastAnyCommitted({ + children: this.childAgents, + messages: this.messages, + senderSessionId: + this.session.authority.sessionHeader.sessionKey.sessionId, + promptId: this.promptId, + callId: call.callId, + })) || intents.some((entry) => entry.payload['ordinal'] === ordinal) || // A team tool commits only to the journal and answers a refusal as // its error, so its committed answer is its own dispatch evidence. @@ -1825,8 +2129,15 @@ export class HostedWorkspaceToolTurn { const args = call.args; const role = this.sendMessageRole(); messageAdmitted = role !== undefined; + // H4e-b2: beside the team tools, the lead also addresses a member + // by name, or every member with "*". + const teamForm = role === 'parent' && this.teamsAdmitted(); const keys = - role === 'child' ? ['to', 'message'] : ['task_id', 'message']; + role === 'child' + ? ['to', 'message'] + : teamForm + ? ['to', 'task_id', 'message'] + : ['task_id', 'message']; const unsupportedKey = Object.keys(args).find( (key) => !keys.includes(key), ); @@ -1838,12 +2149,42 @@ export class HostedWorkspaceToolTurn { validationError = role === 'child' ? `Hosted send_message received unsupported argument ${JSON.stringify(unsupportedKey)}. A child Session messages only its parent: pass to "parent" and message.` - : `Hosted send_message received unsupported argument ${JSON.stringify(unsupportedKey)}. This Session messages only the child agents it launched: pass task_id and message; ${this.teamsAdmitted() ? 'a team member is addressed, while it runs, by the task id that task_list shows beside its name (task_…), and other Sessions belong' : 'teams and other Sessions belong'} to the legacy tool.`; + : teamForm + ? `Hosted send_message received unsupported argument ${JSON.stringify(unsupportedKey)}. Pass to (a member name, or "*" for every member) or task_id, and message; other Sessions belong to the legacy tool.` + : `Hosted send_message received unsupported argument ${JSON.stringify(unsupportedKey)}. This Session messages only the child agents it launched: pass task_id and message; teams and other Sessions belong to the legacy tool.`; } else if (role === 'child' && args['to'] !== 'parent') { validationError = 'Hosted send_message from a child Session addresses only to "parent".'; + } else if ( + teamForm && + (args['to'] === undefined) === (args['task_id'] === undefined) + ) { + validationError = + 'Hosted send_message takes exactly one of to (a member name, or "*" for every member) and task_id.'; + } else if ( + teamForm && + args['to'] !== undefined && + (typeof args['to'] !== 'string' || !args['to'].trim()) + ) { + validationError = + 'Hosted send_message "to" must be a member name, or "*" for every member.'; + } else if ( + teamForm && + isBroadcast(args['to']) && + source.some( + (other) => + other !== call && + (other.name === 'team_create' || + other.name === 'team_delete' || + (other.name === 'agent' && other.args['name'] !== undefined)), + ) + ) { + // A re-driven batch then reads the roster the broadcast read. + validationError = + 'Hosted send_message to "*" cannot share a batch with team_create, team_delete or a member launch; let the roster change land first.'; } else if ( role === 'parent' && + args['to'] === undefined && (typeof args['task_id'] !== 'string' || !args['task_id'].trim()) ) { validationError = @@ -1857,7 +2198,7 @@ export class HostedWorkspaceToolTurn { ) { validationError = `Hosted send_message message exceeds ${MANAGED_SESSION_MESSAGE_LIMITS.maxContentBytes} bytes.`; } - input = { ...args }; + input = teamForm ? hostedTeamMessageInput(args) : { ...args }; } else if (team) { validationError = hostedTeamArgumentError(call.name, call.args); input = { ...call.args }; @@ -3229,6 +3570,15 @@ export class HostedWorkspaceToolTurn { this.agentDispatched.add(request.call.callId); return answer(HOSTED_MESSAGE_TO_PARENT_TEXT, false); } + if (typeof request.call.args['to'] === 'string') { + return this.acceptTeamMessage( + request, + request.call.args['to'], + text, + answer, + signal, + ); + } const taskId = (request.call.args['task_id'] as string).trim(); let route: Awaited>; try { @@ -3247,19 +3597,191 @@ export class HostedWorkspaceToolTurn { } if (route.kind === 'refused') return answer(route.reason, true); this.agentDispatched.add(request.call.callId); - if (route.kind === 'message') { + // A member addressed by its task id is answered as one (H4e-b2). + return answer(hostedRouteAnswer(this.childAgents!, route), false); + } + + /** + * H4e-b2 decisions 3 and 4: the lead's `send_message` to a member by + * name, or to every member with "*". A name routes exactly as the task + * id of the member's first run does, under the ids the task_id form + * mints for the call; each broadcast recipient has its own ids, where no + * single send or launch can reach them. A replay finds what the call + * committed before it resolves `to`, so it answers the committed send + * even after the team has changed. + */ + private async acceptTeamMessage( + request: { call: ToolCallRequestInfo }, + to: string, + text: string, + answer: (text: string, failed: boolean) => Promise, + signal: AbortSignal, + ): Promise { + const children = this.childAgents!; + const teams = this.teams!; + const { callId } = request.call; + const callKey = this.childRunIdFor(callId); + const closing = + this.session.authority.currentActivation?.phase !== 'active'; + const send = async ( + taskId: string, + messageId: string, + continuationRunId: string, + ): Promise => { + try { + return await children.sendToChild({ + taskId, + text, + messageId, + continuationRunId, + executionCallId: callKey, + closing, + childWorkspaces: this.childWorkspaces, + }); + } catch (cause) { + if (!isMessageRefusal(cause)) throw cause; + return { kind: 'refused', reason: cause.message }; + } + }; + if (isBroadcast(to)) { + const senderSessionId = + this.session.authority.sessionHeader.sessionKey.sessionId; + const team = teams.openTeam(); + const committed = hostedBroadcastAnyCommitted({ + children, + messages: this.messages, + senderSessionId, + promptId: this.promptId, + callId, + }) + ? hostedBroadcastCommitted({ + authority: this.session.authority, + children, + messages: this.messages, + senderSessionId, + promptId: this.promptId, + callId, + }) + : []; + // A replay sends only to the roster the first run read: the batch + // rule keeps it unchanged, so a team deleted or replaced since means + // the broadcast answers for the recipients it reached and sends to + // nobody new. + if ( + committed.length > 0 && + (team === undefined || + committed.some( + (each) => + team.members[each.position - 1]?.childRunId !== + each.rootChildRunId, + )) + ) { + this.agentDispatched.add(callId); + return answer( + `The message was sent to ${committed.length} member${committed.length === 1 ? '' : 's'} before its team changed; nothing more was sent.\n${committed + .map( + (each) => + `- ${each.name ?? `position ${each.position}`}: ${each.text}`, + ) + .join('\n')}`, + false, + ); + } + if (team === undefined) + return answer( + 'No open team: create one with team_create, or address a child agent by task_id.', + true, + ); + if (team.members.length === 0) + return answer(`Team "${team.name}" has no members yet.`, true); + const lines: string[] = []; + // Sent: the call committed a send to the member. Arrived: that send + // did not reach a run that proved it never started, nor did its + // delivery fail, which only a replay can find. + let sent = 0; + let arrived = 0; + // A cancelled Turn sends to no further member: each send may launch + // a continuation, which spends the Session's launches. + let cancelled = false; + for (const [index, member] of team.members.entries()) { + const position = index + 1; + if (signal.aborted) { + cancelled = true; + lines.push(`- ${member.name}: not sent: the turn was cancelled.`); + continue; + } + const route = await send( + children.taskIdOf(member.childRunId), + broadcastMessageId({ + senderSessionId, + turnId: this.promptId, + callId, + position, + }), + broadcastRunId(this.promptId, callId, position), + ); + if (route.kind !== 'refused') { + sent += 1; + if (hostedRouteArrived(route)) arrived += 1; + } + lines.push(`- ${member.name}: ${hostedRouteLine(children, route)}`); + } + if (sent === 0) + return answer( + `The message reached no member:\n${lines.join('\n')}`, + true, + ); + this.agentDispatched.add(callId); return answer( - hostedMessageToChildText(this.childAgents!.taskIdOf(route.childRunId)), + arrived === team.members.length + ? `Message sent to every member of team "${team.name}". There is no inline reply: each member answers through its result notification or a message to you.\n${lines.join('\n')}` + : `Message sent to ${sent} of ${team.members.length} members of team "${team.name}"${arrived < sent ? `, but it did not arrive at ${sent - arrived} of them` : ''}${cancelled ? `; the turn was cancelled before the rest` : ''}. Send it by name to any member below that did not get it and still needs it.\n${lines.join('\n')}`, false, ); } - return answer( - hostedMessageContinuedText( - this.childAgents!.taskIdOf(route.predecessorChildRunId), - this.childAgents!.taskIdOf(route.childRunId), - ), - false, + const messageId = this.messageIdFor(callId); + if ( + this.messages!.message(messageId) !== undefined || + children.record(callKey) !== undefined + ) { + // The replay: sendToChild answers the committed send by its ids. + // The name and its label hold only while the member's team is open; + // a former member's continuation is a plain child (decision 6). + const route = await send('', messageId, callKey); + if (route.kind === 'refused') return answer(route.reason, true); + this.agentDispatched.add(callId); + return answer(hostedRouteAnswer(children, route), false); + } + const team = teams.openTeam(); + if (team === undefined) + return answer( + 'No open team: create one with team_create, or address a child agent by task_id.', + true, + ); + const name = sanitizeName(to); + if (name === MANAGED_TEAM_LEADER) + return answer( + 'You are the team leader: "to" names a member, or "*" for every member.', + true, + ); + const member = team.members.find((each) => each.name === name); + if (member === undefined) + return answer( + `"${to}" is not a member of team "${team.name}". ${team.members.length > 0 ? `Members: ${team.members.map((each) => each.name).join(', ')}.` : 'It has no members yet.'}`, + true, + ); + const route = await send( + children.taskIdOf(member.childRunId), + messageId, + callKey, ); + if (route.kind === 'refused') + return answer( + `The message to member "${name}" was refused: ${route.reason}`, + true, + ); + this.agentDispatched.add(callId); + return answer(hostedRouteAnswer(children, route), false); } /** @@ -3306,22 +3828,6 @@ export class HostedWorkspaceToolTurn { definitionRevision: 1, definitionDigest: authority.sessionHeader.definitionRef.digest, }; - // The envelope bound is enforced by the admission's byte_limit - // refusal — but the encoder throws a size error before that branch - // can ever answer. Translate the throw into the same refusal: an - // over-size prompt is a model-correctable argument error, never a - // recovery-blocked Turn. - let envelopeBytes: number; - try { - envelopeBytes = encodeChildLaunchEnvelope({ - description, - prompt, - definition, - }).byteLength; - } catch (cause) { - if (!(cause instanceof ManagedSessionRecordError)) throw cause; - envelopeBytes = Number.POSITIVE_INFINITY; - } // H4e-b1: a named launch is a team member. Its team checks run before // the launch commits anything; a replay whose run is already on the // roster has nothing left to join, and one whose team changed since @@ -3352,6 +3858,27 @@ export class HostedWorkspaceToolTurn { } } } + // H4e-b2 decision 5: a member's launch prompt opens with its preamble, + // part of the envelope and so of its bound. A replay goes on with the + // committed envelope, whose preamble may follow a team changed since. + const launchPrompt = + member === undefined ? prompt : teamMemberPreamble(member) + prompt; + // The envelope bound is enforced by the admission's byte_limit + // refusal — but the encoder throws a size error before that branch + // can ever answer. Translate the throw into the same refusal: an + // over-size prompt is a model-correctable argument error, never a + // recovery-blocked Turn. + let envelopeBytes: number; + try { + envelopeBytes = encodeChildLaunchEnvelope({ + description, + prompt: launchPrompt, + definition, + }).byteLength; + } catch (cause) { + if (!(cause instanceof ManagedSessionRecordError)) throw cause; + envelopeBytes = Number.POSITIVE_INFINITY; + } // 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` or the launch @@ -3390,7 +3917,8 @@ export class HostedWorkspaceToolTurn { rootSessionId: key.sessionId, completion: request.agentBackground ? 'sent' : 'tool', description, - prompt, + prompt: launchPrompt, + sentPrompt: prompt, definition, workspaceMode, workingDirectory: '.', @@ -3524,7 +4052,12 @@ export class HostedWorkspaceToolTurn { let answer: (text: string) => Part[]; let text: string; try { - text = await this.teams!.run(name, args, this.childRunIdFor(callId)); + text = await this.teams!.run( + name, + args, + this.childRunIdFor(callId), + `${hostedPromptKey(this.promptId)}:`, + ); answer = (each) => convertToFunctionResponse(name, callId, [{ text: each }]); } catch (cause) { diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedTeamLeadMySqlIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedTeamLeadMySqlIT.java index 3dbd18090cf..531ad3bbaed 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedTeamLeadMySqlIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedTeamLeadMySqlIT.java @@ -143,7 +143,7 @@ class HostedTeamLeadMySqlIT { .contains("#2 [pending] — Audit B").contains("alice: running").contains("bob: running"); assertThat(toolAnswer("lead-delete-running")).contains("Team \"review\" still has running members:") .contains("alice").contains("bob"); - assertThat(toolAnswer("lead-own-bob-open")).contains("has finished, so it can own only a completed task"); + assertThat(toolAnswer("lead-own-bob-open")).contains("Member \"bob\" has finished. If it already did this task's work"); assertThat(toolAnswer("lead-list-final")).contains("#1 [completed] @alice — Audit A") .contains("#2 [completed] @bob — Audit B").contains("alice: completed").contains("bob: completed"); assertThat(toolAnswer("lead-delete-final")).contains("Team \"review\" deleted."); @@ -396,7 +396,8 @@ class HostedTeamLeadMySqlIT { plan.add(Step.calls(call("lead-done-alice", "task_update", Map.of("taskId", "1", "status", "completed")))); } else { - // A member that finished may own its task only as completed. + // A member that finished, and was given no work in this + // turn, may own its task only as completed (H4e-b2). plan.add(Step.calls(call("lead-own-bob-open", "task_update", Map.of("taskId", "2", "owner", "bob", "status", "in_progress")))); plan.add(Step.calls(call("lead-own-bob-done", "task_update",