From a1b3bebb61fe2f9cc1157496d5115476e7835ed9 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sat, 1 Aug 2026 21:49:27 +0800 Subject: [PATCH] fix(subagent): scope drains and soften final flush --- ...ntinuable-subagent-conversations.i18n.yaml | 4 +- ...7-28-continuable-subagent-conversations.md | 10 +- ...8-continuable-subagent-conversations.zh.md | 10 +- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 4 +- docs/core-data-structures/subagent.zh.md | 4 +- packages/acp/acp/README.i18n.yaml | 4 +- packages/acp/acp/README.md | 2 +- packages/acp/acp/README.zh.md | 2 +- packages/acp/acp/src/index.ts | 9 +- packages/acp/acp/tests/dispose.spec.ts | 46 +++++++++ packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 4 +- packages/subagent/subagent/README.zh.md | 4 +- .../subagent/subagent/src/continuation.ts | 98 ++++++++++--------- .../subagent/subagent/src/run-settlement.ts | 10 +- .../subagent/tests/continuation.spec.ts | 81 ++++++++++++--- .../subagent/tests/run-settlement.spec.ts | 14 --- 18 files changed, 203 insertions(+), 111 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index ee9124f314..1ce5810024 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md -2026-07-28-continuable-subagent-conversations.md: 43abcd88d172104ee5bc55e5a99b4e0306d6dd12 -2026-07-28-continuable-subagent-conversations.zh.md: 57d09e176362c05791cdd6317d95b6a3f5082f40 +2026-07-28-continuable-subagent-conversations.md: e3feee395d3e2f9229a76b6b33b4909b406166ee +2026-07-28-continuable-subagent-conversations.zh.md: f1c8f4cdb093a07946b1eeb24e71ac1cf92e75e4 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index 43abcd88d1..e3feee395d 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -103,7 +103,7 @@ Every Activation owns its `AgentHandle` and an `ownedChildren: Set`. When the authenticated parent is itself a continuation-managed Activation, starting a child or submitting parent-originated work adds the child Session id to that parent's `ownedChildren` before the child can run or the message can enter its inbox. That parent cannot settle or dispose while this set is non-empty. A top-level or other non-continuation Agent has no Activation and does not join this waiting graph. -Child release occurs only after the child Agent is quiescent, every child of that child is disposed, the final durability checkpoint settles, and the child's `AgentHandle` completes disposal. The manager calls `ctx.sessions.flush(child.session)`: `true` confirms durability, while `false` or rejection is normalized to `DURABILITY_FAILED`. A failed checkpoint is reported but does not prevent handle disposal or ownership release, because retaining a failed child would permanently pin its ancestors in `waiting`. If the child is owned, the manager then resolves the live parent through `SessionHeader.parentSession` and removes the child Session id from its `ownedChildren`. Manager teardown uses the same child-first order. +Child release occurs only after the child Agent is quiescent, every child of that child is disposed, the best-effort final session flush settles, and the child's `AgentHandle` completes disposal. The manager awaits `ctx.sessions.flush(child.session)` but does not interpret its participation boolean: an arbitrary listener cannot prove that the selected persistence backend stored the state. A rejection is logged without preventing handle disposal or ownership release, because retaining a child would permanently pin its ancestors in `waiting`. If the child is owned, the manager then resolves the live parent through `SessionHeader.parentSession` and removes the child Session id from its `ownedChildren`. Manager teardown uses the same child-first order. Ownership is retained until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free proposal deliberately does not add. @@ -137,7 +137,7 @@ Without Tasks there is no `task_output`, `task_kill`, Task status, per-message r Host and manager teardown remains the lifecycle stop path. Manager unload applies it globally; a host applies it only below the exact top-level Agents it owns. Each form closes the applicable admission scope, stops the selected visible Activations, awaits admitted materializations in that scope, releases child-first, and preserves the durable Sessions. -Each turn requests the Session durability checkpoint, and final Activation settlement requires the manager to inspect `ctx.sessions.flush()` rather than ignore its boolean result. `true` confirms that at least one durability listener participated and every listener settled successfully. `false` or rejection reports `DURABILITY_FAILED`; normal background settlement logs the lifecycle failure, while an explicit host or manager drain includes it in the aggregate rejection after all branches settle. Either way, the manager still disposes the handle and releases ownership, and the persisted child state may be missing or stale on a later resume. +Each turn requests the Session durability checkpoint, while final Activation settlement additionally awaits `ctx.sessions.flush()` as a best-effort barrier. The manager deliberately ignores the boolean result because listener participation cannot identify a persistence backend. A rejection is logged without changing the lifecycle result or host-drain outcome; the manager still disposes the handle and releases ownership, and the persisted child state may be missing or stale on a later resume. Only messages written to the child Session log are reconstructable with their admitted provenance; inbox acceptance alone provides no restart guarantee. @@ -193,13 +193,13 @@ The implementation pins these behaviors: - An idle Agent with live owned children yields a `waiting` Activation whose `AgentHandle` remains retained. - A `next-turn` delivered to `waiting` wakes the same Activation; delivery after completed disposal cold-resumes a new Activation. - Every continuation-managed parent Activation disposes only after all directly owned child Activations complete `AgentHandle` disposal; top-level Agents do not join the waiting graph. -- Final Activation settlement treats only `ctx.sessions.flush(child.session) === true` as durability confirmation; `false` and rejection report `DURABILITY_FAILED`, still dispose the child handle, and still release parent ownership so durability failure cannot leak a `waiting` Activation. +- Final Activation settlement awaits `ctx.sessions.flush(child.session)` as a best-effort barrier, logs rejection without interpreting listener participation as durability proof, then disposes the child handle and releases parent ownership so a flush failure cannot leak a `waiting` Activation. - Manager teardown closes admission globally; a host owning selected top-level Agents instead closes admission only below their exact identities until those roots leave the registry. Both track admitted materializations by exact ancestry, install one memoized disposal cutoff per selected visible Activation, propagate cancellation top-down, release handles child-first, await every selected branch despite individual failures, and only then dispose the corresponding top-level Agents or manager scope. - This version exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup. - Session logs reconstruct only messages that were actually written, with their admitted provenance; inbox-accepted but unlogged messages have no restart guarantee. - No continuable-subagent path creates or depends on a Task, `TaskId`, Task completion notice, Task cancellation, or intermediate result-bearing execution wrapper. - Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance and lifecycle-publication failure, global and parent-scoped drain quiescence for materialization caught between Agent publication and Activation registration, sibling-forest isolation, exact ancestry after an intermediate Agent leaves the registry, provider-independent cold resume, final exact-parent reauthorization after cold-resume materialization, caller-signal and teardown ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. -- Unit coverage pins the residency-only routing table, single-inbox ordering, `MessageId` correlation through inbox events, follow-up during an open turn, waiting wakeup, cold resume, ownership registration and release, child-first disposal, send-versus-dispose races, both `false` and rejection from the final durability checkpoint without ownership leaks, and the absence of public subagent cancellation, steering, and report tools. +- Unit coverage pins the residency-only routing table, single-inbox ordering, `MessageId` correlation through inbox events, follow-up during an open turn, waiting wakeup, cold resume, ownership registration and release, child-first disposal, send-versus-dispose races, best-effort final flush with absent and failing listeners, and the absence of public subagent cancellation, steering, and report tools. - A keyless assembled-app snapshot covers parent delegation and follow-up queueing, the absence of subagent steering, report delivery, and automatic parent wakeup, retained waiting `AgentHandle`, and child-first disposal. ### Accepted costs @@ -214,4 +214,4 @@ Without report delivery, completing a child turn neither sends its content to no Queueing every continuation message means a parent cannot correct an in-progress child turn immediately; the correction runs as the next turn. A later UI steering action may reduce that latency without changing follow-up ordering. -A failed final durability checkpoint allows the runtime ownership graph to drain but leaves the persisted child state missing or stale. The failure is observable as `DURABILITY_FAILED`; retry and repair require a separate recovery design. +A failed best-effort final flush is logged while the runtime ownership graph continues draining; the persisted child state may be missing or stale. Retry and repair require a separate recovery design. diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index 57d09e1763..f1c8f4cdb0 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -103,7 +103,7 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup( 当经过身份认证的 parent 自身是由继续执行管理器管理的激活时,启动 child 或提交由 parent 发起的工作,会在 child 可以运行或消息可以进入其 inbox 前,将 child 会话 id 加入该 parent 的 `ownedChildren`。该集合非空时,这个 parent 不能结算或 dispose。顶层 Agent 或其他非继续执行 Agent 没有激活,也不会加入该等待图。 -只有在 child Agent 完全停稳、该 child 持有的每个 child 都已 dispose、最终持久性检查点结算且 child 的 `AgentHandle` 完成 dispose 后,系统才释放 child。管理器会调用 `ctx.sessions.flush(child.session)`:只有 `true` 确认持久性,`false` 或 rejection 则统一报告为 `DURABILITY_FAILED`。检查点失败会被报告,但不会阻止 handle dispose 或释放所有权,因为保留失败的 child 会让其祖先永久固定在 `waiting`。如果 child 归 parent 所有,管理器随后会通过 `SessionHeader.parentSession` 解析在线 parent,并从其 `ownedChildren` 中移除 child 会话 id。管理器拆卸使用相同的 child-first 顺序。 +只有在 child Agent 完全停稳、该 child 持有的每个 child 都已 dispose、best-effort 的最终会话 flush 结算且 child 的 `AgentHandle` 完成 dispose 后,系统才释放 child。管理器会等待 `ctx.sessions.flush(child.session)`,但不解释其参与布尔值:任意 listener 都无法证明所选持久化后端已存储该状态。rejection 会被记录,但不会阻止 handle dispose 或释放所有权,因为保留 child 会让其祖先永久固定在 `waiting`。如果 child 归 parent 所有,管理器随后会通过 `SessionHeader.parentSession` 解析在线 parent,并从其 `ownedChildren` 中移除 child 会话 id。管理器拆卸使用相同的 child-first 顺序。 系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。 @@ -137,7 +137,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 宿主和管理器拆卸仍是生命周期停止路径。管理器卸载会全局应用它;宿主只会在自己确切拥有的顶层 Agent 之下应用它。两种形式都会关闭适用的准入作用域,停止选中的可见 Activation,等待该作用域中已获准的物化过程,按 child-first 顺序释放,并保留持久化 Session。 -每个轮次都会请求执行会话持久性检查点,激活最终结算时,管理器必须检查 `ctx.sessions.flush()`,而不能忽略其布尔结果。`true` 确认至少有一个持久性 listener 参与,且所有 listener 都成功结算。`false` 或 rejection 会报告 `DURABILITY_FAILED`;普通后台结算会记录该生命周期失败,显式的宿主或管理器 drain 则会在所有分支结算后,将其纳入聚合 rejection。无论结果如何,管理器仍会 dispose handle 并释放所有权,后续恢复时持久化 child 状态可能缺失或陈旧。 +每个轮次都会请求执行会话持久性检查点,而 Activation 最终结算还会等待 `ctx.sessions.flush()`,将其作为 best-effort 屏障。管理器特意忽略布尔结果,因为 listener 是否参与无法标识持久化后端。rejection 会被记录,但不会改变生命周期结果或宿主 drain 的结果;管理器仍会 dispose handle 并释放所有权,后续恢复时持久化 child 状态可能缺失或陈旧。 只有实际写入 child 会话日志的消息,才能根据其准入来源重建;仅被 inbox 接受并不提供重启保证。 @@ -193,13 +193,13 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect - 带有在线所持 child 的空闲 Agent 会产生 `waiting` 激活,其 `AgentHandle` 继续保留。 - 向 `waiting` 投递 `next-turn` 会唤醒同一个激活;完成 dispose 后投递消息会冷恢复新激活。 - 每个由继续执行管理器管理的 parent 激活只会在直接持有的所有 child 激活完成 `AgentHandle` dispose 后进行 dispose;顶层 Agent 不加入等待图。 -- 激活最终结算时,只有 `ctx.sessions.flush(child.session) === true` 才确认持久性;`false` 和 rejection 会报告 `DURABILITY_FAILED`,但仍会 dispose child handle 并释放 parent 所有权,使持久性失败不会泄漏 `waiting` 激活。 +- Activation 最终结算会等待 `ctx.sessions.flush(child.session)`,将其作为 best-effort 屏障;它会记录 rejection,但不会把 listener 参与解释为持久性证明,然后 dispose child handle 并释放 parent 所有权,使 flush 失败不会泄漏 `waiting` Activation。 - 管理器拆卸会全局关闭准入;拥有选定顶层 Agent 的宿主则只关闭这些确切身份之下的准入,直到这些根离开注册表。两者都会按确切祖先关系跟踪已获准的物化过程,为每个选中的可见 Activation 安装一个记忆化 dispose 截止点,自顶向下传播取消,按 child-first 顺序释放 handle,即使个别分支失败也会等待所有选中分支,之后才 dispose 对应的顶层 Agent 或管理器作用域。 - 本版本不暴露 `report` 工具,不提供从 child 到 parent 的内容投递,也不自动唤醒 parent。 - 会话日志只能根据准入来源重建实际写入的消息;已被 inbox 接受但未写入日志的消息没有重启保证。 - 可继续 subagent 路径不创建或依赖 Task、`TaskId`、Task 完成通知、Task 取消或中间的带结果执行包装层。 - 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前和生命周期发布失败路径的完整回滚、全局和限定到 parent 作用域的 drain 都会等待夹在 Agent 发布与 Activation 注册之间的物化过程完全停稳、同级森林隔离、中间 Agent 离开注册表后的确切祖先关系、不依赖提供方的冷恢复、冷恢复物化后的最终确切 parent 再授权、接受前后两个阶段的调用方 signal 与拆卸所有权,以及已接受但未写入日志的消息不会自动回放。 -- 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `MessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、最终持久性检查点返回 `false` 和 rejection 时都不泄漏所有权,以及不存在公开 subagent 取消、steering 和报告工具这一事实。 +- 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `MessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、没有 listener 和 listener 失败时的 best-effort 最终 flush,以及不存在公开 subagent 取消、steering 和报告工具这一事实。 - 一项无密钥整套应用快照覆盖 parent 委派和 follow-up 排队、不存在 subagent steering、报告投递和自动唤醒 parent、保留等待中的 `AgentHandle` 以及 child-first dispose。 ### 已接受的代价 @@ -214,4 +214,4 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 将每条继续执行消息排队,意味着 parent 无法立即纠正正在进行的 child 轮次;纠正操作会在下一个轮次执行。后续 UI steering 操作可以缩短该延迟,而不改变 follow-up 排序。 -最终持久性检查点失败时,运行时所有权图仍可完成 drain,但持久化 child 状态会缺失或陈旧。该失败会以 `DURABILITY_FAILED` 的形式被观测到;重试与修复需要单独的恢复设计。 +best-effort 最终 flush 失败时会记录日志,同时运行时所有权图继续 drain;持久化 child 状态可能缺失或陈旧。重试与修复需要单独的恢复设计。 diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index 3f95ba136e..37956ff300 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/subagent.md -subagent.md: 4d389c106133114781ef0a3128032433fd0ed7ec -subagent.zh.md: cb374d03bbd9716ee53a1cecf938ff1dc1f8ce66 +subagent.md: ce81eaf748ce4ce99b18cd218a99317998eeb45b +subagent.zh.md: cac1aa68614314fbad6a459a7eb877e6537a783d diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 4d389c1061..ce81eaf748 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -141,9 +141,9 @@ Follow-up authority comes from an exact live Agent tool context. The authenticat For both operations the caller signal owns lookup, materialization, and admission only until inbox acceptance. Afterwards the manager owns the Activation independently: later caller cancellation neither cancels the accepted turn nor disposes the child, and the seam exposes no public subagent cancellation or steering operation. -Every Activation owns its `AgentHandle` and an `ownedChildren: Set`; because one Session has at most one live Activation, the child Session id identifies the live child without another runtime-incarnation reference. Starting a child or submitting parent-originated work registers the child in a continuation-managed parent's set before the child can run, and that parent cannot settle while the set is non-empty. A top-level or other non-continuation Agent has no Activation and stays outside the waiting graph. Child release happens only after the child Agent is quiescent, every child of that child is disposed, the final durability checkpoint settles, and the child's `AgentHandle` completes disposal. +Every Activation owns its `AgentHandle` and an `ownedChildren: Set`; because one Session has at most one live Activation, the child Session id identifies the live child without another runtime-incarnation reference. Starting a child or submitting parent-originated work registers the child in a continuation-managed parent's set before the child can run, and that parent cannot settle while the set is non-empty. A top-level or other non-continuation Agent has no Activation and stays outside the waiting graph. Child release happens only after the child Agent is quiescent, every child of that child is disposed, the best-effort final session flush settles, and the child's `AgentHandle` completes disposal. -Only `ctx.sessions.flush(session) === true` confirms durability; `false` or rejection reports `DURABILITY_FAILED`. Either way the manager still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting` — the persisted child state may then be missing or stale on a later resume. Manager unload invokes an internal manager-wide drain that closes admission and disposes every live forest; `drainContinuableDescendants(parents)` closes admission only below exact live host-owned Agents and disposes their continuable descendants while unrelated forests remain live. Both await already-admitted materializations in their scope, propagate cancellation top-down, release handles child-first, and await every selected branch despite individual failures. Durable child Sessions survive that process-local teardown. +Final settlement awaits `ctx.sessions.flush(session)` but ignores its participation boolean because an arbitrary listener cannot prove that a persistence backend stored the state. Rejection is logged without failing the Activation, and the manager still disposes the handle and releases ownership; the persisted child state may then be missing or stale on a later resume. Manager unload invokes an internal manager-wide drain that closes admission and disposes every live forest; `drainContinuableDescendants(parents)` closes admission only below exact live host-owned Agents and disposes their continuable descendants while unrelated forests remain live. Both await already-admitted materializations in their scope, propagate cancellation top-down, release handles child-first, and await every selected branch despite individual failures. Durable child Sessions survive that process-local teardown. ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index cb374d03bb..cac1aa6861 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -141,9 +141,9 @@ Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 ` 对于这两种操作,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent,并且该 seam 不对外暴露任何 subagent 取消或 steering(中途引导)操作。 -每个 Activation 都拥有自己的 `AgentHandle` 和一个 `ownedChildren: Set`;由于一份会话至多有一个存活 Activation,子会话 id 无需另一个运行时化身引用即可标识存活的子 agent。启动子 agent 或提交源自 parent 的工作,会在子 agent 能够运行之前将其注册到受继续执行管理的父级集合中;只要该集合非空,该父级就无法 settle。顶层或其他非继续执行的 Agent 没有 Activation,处于 waiting 图之外。只有当子 Agent 已停稳、该子 agent 的每个子级都已 dispose、最终的持久性检查点结算完毕,且子 agent 的 `AgentHandle` 完成 dispose 之后,才会释放子 agent。 +每个 Activation 都拥有自己的 `AgentHandle` 和一个 `ownedChildren: Set`;由于一份会话至多有一个存活 Activation,子会话 id 无需另一个运行时化身引用即可标识存活的子 agent。启动子 agent 或提交源自 parent 的工作,会在子 agent 能够运行之前将其注册到受继续执行管理的父级集合中;只要该集合非空,该父级就无法 settle。顶层或其他非继续执行的 Agent 没有 Activation,处于 waiting 图之外。只有当子 Agent 已停稳、该子 agent 的每个子级都已 dispose、best-effort 的最终会话 flush 结算完毕,且子 agent 的 `AgentHandle` 完成 dispose 之后,才会释放子 agent。 -只有 `ctx.sessions.flush(session) === true` 才确认持久性;`false` 或 rejection 会报告 `DURABILITY_FAILED`。无论哪种情况,管理器仍会 dispose 该 handle 并释放所有权,因为保留一个失败的子 agent 会将其祖先永久钉在 `waiting`——此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。管理器卸载会调用内部的管理器全局 drain,关闭准入并 dispose 每片在线森林;`drainContinuableDescendants(parents)` 只关闭由 host 确切拥有的在线 Agent 之下的准入,并 dispose 其可继续后代,而无关森林保持在线。两者都会等待各自作用域内已获准的物化过程,自顶向下传播取消,按 child-first 顺序释放 handle,并且即使个别分支失败也会等待所有选中分支。持久化子会话不受该进程内拆卸的影响。 +最终结算会等待 `ctx.sessions.flush(session)`,但会忽略其参与布尔值,因为任意 listener 都无法证明某个持久化后端已存储该状态。rejection 会被记录,但不会使 Activation 失败;管理器仍会 dispose 该 handle 并释放所有权,此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。管理器卸载会调用内部的管理器全局 drain,关闭准入并 dispose 每片在线森林;`drainContinuableDescendants(parents)` 只关闭由 host 确切拥有的在线 Agent 之下的准入,并 dispose 其可继续后代,而无关森林保持在线。两者都会等待各自作用域内已获准的物化过程,自顶向下传播取消,按 child-first 顺序释放 handle,并且即使个别分支失败也会等待所有选中分支。持久化子会话不受该进程内拆卸的影响。 ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ diff --git a/packages/acp/acp/README.i18n.yaml b/packages/acp/acp/README.i18n.yaml index 7c8be3dc18..898a23ffd2 100644 --- a/packages/acp/acp/README.i18n.yaml +++ b/packages/acp/acp/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/acp/acp/README.md -README.md: 9a48fdec3330cd364c1ab6de4c117b20af0f443f -README.zh.md: 65732f41277a8760bfd2824aea12b0f240ae8025 +README.md: 583025e94d72c1ab03d282f8f4eb101c4e6f4740 +README.zh.md: 3a082b423c1e4ab7e236179a3f502cd450b4904c diff --git a/packages/acp/acp/README.md b/packages/acp/acp/README.md index 9a48fdec33..583025e94d 100644 --- a/packages/acp/acp/README.md +++ b/packages/acp/acp/README.md @@ -35,7 +35,7 @@ Committed-message output intentionally trades token-by-token latency for a clean ## Lifecycle -Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting their loop/session cleanup. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent. +Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting every result before reporting any failure. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent. ## Running diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md index 65732f4127..3a082b423c 100644 --- a/packages/acp/acp/README.zh.md +++ b/packages/acp/acp/README.zh.md @@ -35,7 +35,7 @@ ## 生命周期 -客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待它们的循环/会话清理完成。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。 +客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。 ## 运行 diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index 58823ff407..0a2f7f7f68 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -362,7 +362,14 @@ export function apply(ctx: Context, config: AcpConfig): void { logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`) } } - await Promise.all(records.map(record => record.dispose())) + const disposals = await Promise.allSettled(records.map(record => record.dispose())) + const failures: unknown[] = [] + for (const result of disposals) { + if (result.status === 'rejected') failures.push(result.reason as unknown) + } + if (failures.length > 0) { + throw new AggregateError(failures, `ACP agent teardown failed for ${failures.length} session(s)`) + } })() return quiescing } diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index 7638c4bd9e..5f6b5babd5 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -96,6 +96,52 @@ describe('ACP connection ownership', () => { expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) + it('awaits every owned session disposal before reporting one failure', async () => { + harness = await makeBridgeHarness() + const create = harness.ctx.agents.create.bind(harness.ctx.agents) + const releaseSecond = Promise.withResolvers() + const warnings: string[] = [] + let created = 0 + let secondStarted = false + harness.ctx.logger.warn = (message: string) => { warnings.push(message) } + const createSpy = vi.spyOn(harness.ctx.agents, 'create').mockImplementation(async (options) => { + const handle = await create(options) + const originalDispose = handle.dispose.bind(handle) + if (created++ === 0) { + handle.dispose = async () => { + await originalDispose() + throw new Error('first session cleanup failed') + } + } else { + handle.dispose = async () => { + secondStarted = true + await releaseSecond.promise + await originalDispose() + } + } + return handle + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const first = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const second = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await harness.closeClientTransport() + await vi.waitFor(() => { expect(secondStarted).toBe(true) }) + expect(warnings.some(warning => warning.includes('connection-close teardown failed'))).toBe(false) + + releaseSecond.resolve(undefined) + await vi.waitFor(() => { + expect(warnings.some(warning => warning.includes('ACP agent teardown failed for 1 session(s)'))).toBe(true) + expect(harness!.ctx.agents.get(SessionId(first.sessionId))).toBeUndefined() + expect(harness!.ctx.agents.get(SessionId(second.sessionId))).toBeUndefined() + }) + + createSpy.mockRestore() + const disposed = harness + harness = undefined + await disposed.dispose().catch(() => undefined) + }) + it('an ACP-only reload rejects new sessions before creating an orphan', async () => { harness = await makeBridgeHarness() await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 7b5f318996..793a9851bb 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md -README.md: a27c64ad3b7c4260d769f030db5dacd20e772ac6 -README.zh.md: d18e1159336515e9184dbc8f07dc92811a93d4e9 +README.md: 5b7c0376367a942d91700739ad445cf2b7a4455a +README.zh.md: 30c5e0b501d0cf3cac749a788bb92a0353b466e9 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index a27c64ad3b..5b7c037636 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -77,7 +77,7 @@ The manager derives three internal residency conditions from Agent quiescence an The manager reserves the child identity, resolves the durable descriptor, calls `ctx.agents.create()` (or `ctx.agents.resume()` for cold resume) through a private activation-owner scope, installs the returned `AgentHandle` in the Activation, establishes any continuable-parent ownership, and then submits the prompt. Cold resume never dispatches through a provider because the persisted Session already holds the initial prefix and the folded descriptor is the whole reconstruction input. -A continuation-managed parent Activation records each child Session id in an `ownedChildren` set before the child can run and disposes only after every owned child Activation completes `AgentHandle` disposal (child-first). Teardown propagates Agent cancellation top-down before awaiting slow descendants, while handle release remains child-first. Top-level and other non-continuation Agents have no Activation and stay outside this waiting graph. Final settlement treats only `ctx.sessions.flush(child.session) === true` as durability confirmation; `false` or rejection reports `DURABILITY_FAILED` and still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting`. +A continuation-managed parent Activation records each child Session id in an `ownedChildren` set before the child can run and disposes only after every owned child Activation completes `AgentHandle` disposal (child-first). Teardown propagates Agent cancellation top-down before awaiting slow descendants, while handle release remains child-first. Top-level and other non-continuation Agents have no Activation and stay outside this waiting graph. Final settlement awaits a best-effort `ctx.sessions.flush(child.session)` before handle disposal. A listener rejection is logged without failing the Activation because listener participation does not identify a persistence backend; the persisted state may therefore be missing or stale on resume. ## Lifecycle events @@ -91,7 +91,7 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` resolves session query and dynamically imports its optional runtime only when called, then interprets a read-only live-preferred scan of all descriptor-bearing direct children without consulting the continuation manager, Agent registrations, Activations, or providers. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The scan forwards the caller's signal to cancellable trace and exact-read operations, checks cancellation around the remaining event-list read, and reports every observed abort as `SubagentError` code `CANCELLED`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. -Continuable Activations require final durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent. +Continuable Activations await a best-effort final session flush without treating listener participation as durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent. ## Model Experience diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index d18e115933..30c5e0b501 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -77,7 +77,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 管理器预留子 agent 身份、解析持久化描述符,通过私有的 activation-owner 作用域调用 `ctx.agents.create()`(冷恢复时为 `ctx.agents.resume()`),把返回的 `AgentHandle` 安装到 Activation 中,建立任何可继续父级所有权,然后提交提示词。冷恢复绝不通过提供方分发,因为持久化 Session 已持有初始前缀,折叠后的描述符即是全部重建输入。 -受继续执行管理的父级 Activation 会在子 agent 能够运行之前,把每个子 agent 的 Session id 记录到 `ownedChildren` 集合中,并且只有在每个所拥有的子 agent Activation 完成 `AgentHandle` dispose 之后才会 dispose(子先于父)。拆卸会先自顶向下传播 Agent 取消,再等待缓慢的后代,而 handle 释放仍保持 child-first。顶层及其他非继续执行的 Agent 没有 Activation,处于该等待图之外。最终结算只把 `ctx.sessions.flush(child.session) === true` 视为持久性确认;`false` 或拒绝会报告 `DURABILITY_FAILED`,但仍会 dispose 句柄并释放所有权,因为保留失败的子 agent 会使其祖先永久停留在 `waiting`。 +受继续执行管理的父级 Activation 会在子 agent 能够运行之前,把每个子 agent 的 Session id 记录到 `ownedChildren` 集合中,并且只有在每个所拥有的子 agent Activation 完成 `AgentHandle` dispose 之后才会 dispose(子先于父)。拆卸会先自顶向下传播 Agent 取消,再等待缓慢的后代,而 handle 释放仍保持 child-first。顶层及其他非继续执行的 Agent 没有 Activation,处于该等待图之外。最终结算会在 dispose handle 前等待 best-effort 的 `ctx.sessions.flush(child.session)`。listener rejection 会被记录,但不会使 Activation 失败,因为 listener 是否参与无法标识持久化后端;因此,恢复时持久化状态可能缺失或陈旧。 ## 生命周期事件 @@ -91,7 +91,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 只在被调用时解析会话查询并动态导入其可选运行时,然后解释对所有带描述符的直接 child 所作的只读、实时优先扫描,且不查询继续执行管理器、Agent 注册信息、Activation 或提供方。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。扫描会把调用方的取消信号转发到可取消的追踪与精确读取操作,在其余事件列表读取的前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 -可继续 Activation 要求最终持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。 +可继续 Activation 会等待 best-effort 的最终会话 flush,但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。 ## 模型体验 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index b1f8186d1c..f4abb67fe3 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -917,9 +917,9 @@ export class SubagentContinuationManager { * transaction is installed before cancellation or recursive callbacks, so * admission and reentrant teardown converge on the same owner. * - * A failed final checkpoint is reported but never prevents handle disposal or - * ownership release, because retaining a failed child would permanently pin - * its ancestors in `waiting`. + * The final session flush is best effort and never prevents handle disposal + * or ownership release, because retaining a child would permanently pin its + * ancestors in `waiting`. * @param activation - the residency epoch to stop and release. * @returns the one disposal transaction owned by this Activation. */ @@ -951,7 +951,7 @@ export class SubagentContinuationManager { .filter((child): child is Activation => child !== undefined) const childDisposals = children.map(child => this.dispose(child)) - let failure: Error | undefined + const failures: SubagentError[] = [] try { // Release remains child-first even though cancellation propagated // top-down: every owned child completes before this handle is removed. @@ -965,71 +965,73 @@ export class SubagentContinuationManager { })) const reasons = childFailures.filter(reason => reason !== undefined) if (reasons.length > 0) { - failure = new SubagentError( + failures.push(new SubagentError( `subagent "${childId}" child teardown failed: ${reasons.map(reason => errorChain(reason)).join('; ')}`, 'ACTIVATION_TEARDOWN_FAILED', - ) + )) } - // Quiesce before the checkpoint: a turn still running would keep + // Quiesce before the flush: a turn still running would keep // appending events the flush cannot cover. await idle - const durability = await this.checkpoint(activation) - failure ??= durability + await this.flushFinalState(activation) // Capture the child-dependent edge data while the child is still live: // handle disposal unregisters it, and consumers read its log and scope. activation.observer.capture(activation.handle.agent) } catch (error: unknown) { - failure ??= new SubagentError( + failures.push(new SubagentError( `subagent "${childId}" activation teardown failed: ${errorChain(error)}`, 'ACTIVATION_TEARDOWN_FAILED', { cause: error }, - ) - } finally { - try { - await activation.handle.dispose() - } catch (error: unknown) { - failure ??= new SubagentError( - `subagent "${childId}" activation handle disposal failed: ${errorChain(error)}`, - 'ACTIVATION_TEARDOWN_FAILED', - { cause: error }, - ) - } finally { - // Only now is the Activation gone: keeping the entry until disposal - // settles makes a racing delivery wait for release rather than - // cold-resume into the still-registered agent. - this.activations.delete(childId) - // Release ownership even on failure: a retained failed child would pin - // its ancestors in `waiting` forever. - this.releaseOwnership(childId) - // Emit once the disposal outcome is known, so a rejecting scoped cleanup - // cannot be reported as a successful epoch. - activation.observer.settle(failure) - } + )) } + try { + await activation.handle.dispose() + } catch (error: unknown) { + failures.push(new SubagentError( + `subagent "${childId}" activation handle disposal failed: ${errorChain(error)}`, + 'ACTIVATION_TEARDOWN_FAILED', + { cause: error }, + )) + } + + let failure: SubagentError | undefined + if (failures.length === 1) { + failure = failures[0] + } else if (failures.length > 1) { + failure = new SubagentError( + `subagent "${childId}" activation teardown failed at ${failures.length} boundaries: ` + + failures.map(item => errorChain(item)).join('; '), + 'ACTIVATION_TEARDOWN_FAILED', + { cause: new AggregateError(failures) }, + ) + } + // Only now is the Activation gone: keeping the entry until disposal settles + // makes a racing delivery wait for release rather than cold-resume into the + // still-registered agent. + this.activations.delete(childId) + // Release ownership even on failure: a retained failed child would pin its + // ancestors in `waiting` forever. + this.releaseOwnership(childId) + // Emit once the disposal outcome is known, so a rejecting scoped cleanup + // cannot be reported as a successful epoch. + activation.observer.settle(failure) if (failure !== undefined) throw failure } /** - * Request the final durability checkpoint. Only `true` confirms durability; - * `false` and rejection both report `DURABILITY_FAILED` so the persisted - * child state is known to be possibly missing or stale on a later resume. + * Request a best-effort final session flush after the child is quiescent. + * Listener failure is logged because flush participation cannot identify a + * particular persistence backend, and teardown must still release ownership. + * @param activation - the Activation whose final events should be flushed. */ - private async checkpoint(activation: Activation): Promise { + private async flushFinalState(activation: Activation): Promise { const child = activation.handle.agent try { - const participated = await child.ctx.sessions.flush(child.session) - if (participated) return undefined - return new SubagentError( - `subagent "${activation.childId}" required durability checkpoint has no registered listener; ` - + 'the latest child state was not confirmed persisted and may be unavailable or stale on resume', - 'DURABILITY_FAILED', - ) + await child.ctx.sessions.flush(child.session) } catch (error: unknown) { - return new SubagentError( - `subagent "${activation.childId}" durability checkpoint failed; the latest child state was not ` - + `confirmed persisted and may be unavailable or stale on resume: ${errorChain(error)}`, - 'DURABILITY_FAILED', - { cause: error }, + this.ctx.logger.warn( + `subagent "${activation.childId}" best-effort final session flush failed; ` + + `the persisted state may be unavailable or stale on resume: ${errorChain(error)}`, ) } } diff --git a/packages/subagent/subagent/src/run-settlement.ts b/packages/subagent/subagent/src/run-settlement.ts index 92d0986bcd..ba00a944b5 100644 --- a/packages/subagent/subagent/src/run-settlement.ts +++ b/packages/subagent/subagent/src/run-settlement.ts @@ -6,7 +6,6 @@ * @module @deepseek-ai/dsh-subagent/run-settlement */ -import { HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { TaskOutcome } from '@deepseek-ai/dsh-tasks' import type { SubagentResult, SubagentRun } from './types.ts' @@ -41,13 +40,6 @@ function runOutcome(result: SubagentResult): TaskOutcome { } } -/** Render infrastructure failure detail without hiding a durability diagnosis. */ -function runFailureDetail(error: unknown): string { - return error instanceof HarnessError && error.code === 'DURABILITY_FAILED' - ? error.message - : String(error) -} - /** * Await the child result, dispose the run, then return its task outcome. Result * and disposal failures become `failed`; when both fail, both details survive. @@ -59,7 +51,7 @@ export async function settleRun(run: SubagentRun): Promise { try { outcome = runOutcome(await run.result) } catch (error: unknown) { - outcome = { status: 'failed', detail: runFailureDetail(error) } + outcome = { status: 'failed', detail: String(error) } } try { await run.dispose() diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index ecfe40cec8..abf71bb1aa 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -615,7 +615,7 @@ describe('continuable child ownership', () => { }) describe('continuable durability and teardown', () => { - it('reports DURABILITY_FAILED without leaking a waiting Activation', async () => { + it('settles when the best-effort final flush has no listeners', async () => { const releaseResponse = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('unconfirmed answer'), gate: releaseResponse.promise }, @@ -626,32 +626,60 @@ describe('continuable durability and teardown', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - // Remove every durability listener, so the final checkpoint cannot confirm. + // Remove every persistence listener; the final flush is advisory. await disposePersistence!() releaseResponse.resolve(undefined) - // The handle is still disposed and ownership released, so nothing is pinned. await waitNoActivation(ctx, started.childId) - await vi.waitFor(() => { - expect(warnings.some(warning => warning.includes('durability'))).toBe(true) - }) + expect(warnings.some(warning => warning.includes('final session flush'))).toBe(false) }) - it('reports DURABILITY_FAILED when the final checkpoint rejects', async () => { + it('logs a failed final flush after every listener settles without failing the Activation', async () => { const { ctx, parent } = await setup([textResponse('answer')]) const warnings: string[] = [] + const ends: SubagentRunEndInfo[] = [] + let peerFlushed = false ctx.logger.warn = (message: string) => { warnings.push(message) } - // A listener that throws makes flush reject rather than return false. + ctx.on('subagent/end', info => void ends.push(info)) ctx.on('session/flush', (session) => { if (session.header.parentSession !== undefined) throw new Error('disk full') }) + ctx.on('session/flush', (session) => { + if (session.header.parentSession !== undefined) peerFlushed = true + }) const started = await ctx.subagents.startContinuable(startSpec(parent)) - // The handle is still disposed and ownership released, so nothing is pinned. await waitNoActivation(ctx, started.childId) + expect(peerFlushed).toBe(true) + expect(warnings.some(warning => warning.includes('best-effort final session flush failed'))).toBe(true) + expect(ends.at(-1)?.stopReason).toBe('completed') + }) + + it('logs a teardown failure reached through normal settlement', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const warnings: string[] = [] + ctx.logger.warn = (message: string) => { warnings.push(message) } + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const manager = (ctx.subagents as unknown as { + continuations: { activations: Map Promise } }> } + }).continuations + const activation = manager.activations.get(started.childId)! + const realDispose = activation.handle.dispose.bind(activation.handle) + activation.handle.dispose = async () => { + await realDispose() + throw new Error('normal settlement cleanup failed') + } + + hold.resolve(undefined) + await vi.waitFor(() => { - expect(warnings.some(warning => warning.includes('durability checkpoint failed'))).toBe(true) + expect(warnings.some(warning => warning.includes('normal settlement cleanup failed'))).toBe(true) }) + expect(ctx.agents.get(started.childId)).toBeUndefined() }) it('disposes every live Activation forest child-first on manager teardown', async () => { @@ -1184,7 +1212,38 @@ describe('continuable review regressions', () => { expect(ends[0]!.stopReason).toBe('error') }) - it('cancels a running turn before the final durability checkpoint', async () => { + it('preserves independent pre-disposal and handle-disposal failures', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const manager = (ctx.subagents as unknown as { + continuations: { + activations: Map Promise } + observer: { capture: (child: Agent) => void } + }> + } + }).continuations + const activation = manager.activations.get(started.childId)! + const realDispose = activation.handle.dispose.bind(activation.handle) + activation.observer.capture = () => { throw new Error('capture failed') } + activation.handle.dispose = async () => { + await realDispose() + throw new Error('scoped cleanup failed') + } + + const drained = drainManager(ctx) + hold.resolve(undefined) + const failure = await drained.catch((error: unknown) => error) + + expect(failure).toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) + expect(String(failure)).toContain('capture failed') + expect(String(failure)).toContain('scoped cleanup failed') + expect(ctx.agents.get(started.childId)).toBeUndefined() + }) + + it('cancels a running turn before the best-effort final flush', async () => { const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('slow'), gate: hold.promise }]) const { ctx, parent } = await setupWith(adapter) diff --git a/packages/subagent/subagent/tests/run-settlement.spec.ts b/packages/subagent/subagent/tests/run-settlement.spec.ts index 576eaa17b5..9e13d55507 100644 --- a/packages/subagent/subagent/tests/run-settlement.spec.ts +++ b/packages/subagent/subagent/tests/run-settlement.spec.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import { HarnessError } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { settleRun } from '../src/index.ts' @@ -44,19 +43,6 @@ describe('outcome mapping helpers', () => { expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' }) expect(disposed).toBe(true) - const durabilityMessage = 'subagent "child-3" durability checkpoint failed; latest state unavailable: disk full' - const durabilityFailed = await settleRun({ - id: SessionId('child-3'), - localAgent: undefined, - result: Promise.reject(new HarnessError( - durabilityMessage, - 'DURABILITY_FAILED', - { cause: new Error('disk full') }, - )), - dispose: () => Promise.resolve(), - }) - expect(durabilityFailed).toEqual({ status: 'failed', detail: durabilityMessage }) - const disposeFailed = await settleRun({ id: SessionId('child-4'), localAgent: undefined,