fix(subagent): retain local failures without a durable ending
Local activation results were derived only from committed Session events. When the first turn/end append failed, no ending existed and the result defaulted to completed. When the first turn/start append failed, the initial message remained unclaimed and kept the idle activation resident forever. Both paths emitted agent/error but had no durable terminal to project. Retain the log offset of the latest exact-Agent live failure on the local activation. Clear it only when the same Session commits a turn/end at or after that offset, so a later recovery can supply its own outcome and a reentrant older terminal notification cannot erase a newer failure. The child scope owns both observers and removes them with its Agent handle. Capture error while that live failure remains. Let the failed local activation settle despite its own unclaimed inbox, while preserving the existing requirement to wait for owned descendants. Wake its settlement observer when a live failure changes readiness. No public API, raw Agent idle semantics, or descendant teardown policy changes. Add regressions through real local activation creation for first start/end append failures, recovery by later durable endings, exact-Agent isolation, and an error emitted reentrantly during an older terminal notification. Update the bilingual package contract and generated event consumer graph. Validation: - Both first-append regressions reproduced before the fix: turn/start remained pending; turn/end returned completed instead of error. - Six focused behavior cases passed after the fix. - 423 subagent package tests passed; activation.ts and manager.ts reached 100% statements, branches, functions, and lines under scoped coverage. - pnpm exec tsc -b packages/subagent/subagent/tsconfig.json passed. - Focused source/test oxlint and git diff --check passed. - doc-sync passed 41 gates; its sole stale event-graph failure was corrected with gen-doc-graphs, then verify-doc-graphs passed for all seven graphs. - README translation pairing regenerated and passed the doc-sync check.
This commit is contained in:
1 parent
5b9dd96963
commit
1dbbc59d3e
8 files changed
+143
-14
No files matched your search
@@ -13,7 +13,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
|
||||
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) |
|
||||
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
|
||||
@@ -63,7 +63,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
|
||||
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) |
|
||||
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
|
||||
@@ -65,7 +65,7 @@
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
|
||||
@@ -48,8 +48,8 @@
|
||||
en: f78c5e931f3a3676
|
||||
zh: 3c63f637dbaa8101
|
||||
/deepseek-ai-dsh-subagent/understand-the-implementation/activation-settlement:
|
||||
en: 964a7caf81c0ed20
|
||||
zh: 094cb601091410f5
|
||||
en: fb7d48efddd13411
|
||||
zh: db2cff76cdd9d61f
|
||||
/deepseek-ai-dsh-subagent/understand-the-implementation/ownership-and-invariants:
|
||||
en: b74886a091a327d4
|
||||
zh: 2124546878ba1aa2
|
||||
|
||||
@@ -112,6 +112,8 @@ External providers mint a fresh `SubagentRun.id` for each execution in the runti
|
||||
|
||||
The manager reserves child identity and capacity, materializes a local Agent or external execution, and accepts the initial task. Local admission closes after Agent activity, pending input, and owned descendants finish, with activity revalidated after the final Session flush. Both local and external activations deliver their result after handle cleanup, capacity release, and settlement notifications. Parent waits observe the same completion promise even when execution fails. Closing admission and disposing the handle prevent late work from entering a released Agent. Parent delivery emits its notice after settlement; caller delivery leaves collection to the awaiting workflow. Headless hosts repeat `agent.whenIdle()` and `waitForChildren(agent)` until no child work remains and the parent stays idle with an unchanged Session sequence across the child check, so completion notices can produce the final parent answer.
|
||||
|
||||
A local Agent failure without a subsequently committed `turn/end` settles as `error` after its owned descendants finish. Unclaimed input cannot keep that failed activation resident. A later committed turn ending supersedes the live failure and supplies the recovered outcome.
|
||||
|
||||
Successful local creation appends a `subagent/catalog` fact to the parent Session regardless of result delivery. External creation appends a catalog fact only with `delivery: 'parent'`; caller-owned external executions retain their caller's membership records. External entries use `mode: 'external'` to identify children without a local Session. Catalog entries record membership once at creation and carry no execution status. Complete results go to the caller or the parent completion notice. Projections exclude inherited facts. Direct lists read one parent projection, so completed local workflow children remain discoverable and can be opened or continued. Catalog payload v0 records local modes; v1 also accepts unknown mode; v2 records external executions. Readers support all three versions. Historical migration appends a v1 `subagent/catalog` from a readable child header when its descriptor is unavailable; local creation retains v0. Its `mode: 'unknown'` projection keeps the child visible without claiming continuation support; an existing complete entry remains authoritative.
|
||||
|
||||
### Ownership and invariants
|
||||
|
||||
@@ -112,6 +112,8 @@ kind: "package-reference"
|
||||
|
||||
管理器预留子级身份与容量,创建本地 Agent 或外部执行,并接受初始任务。本地 Agent 活动、待处理输入及所拥有的后代均结束后,管理器在最终 Session flush 后重新验证活动状态并关闭准入。本地与外部 activation 都在句柄清理、容量归还及结算通知结束后交付结果。父级通过同一个完成 promise 等待,执行失败也不会使等待提前结束。关闭准入并释放句柄可防止迟到工作进入已释放的 Agent。父级投递在结算后发送通知;调用方投递由等待中的工作流收集。Headless 宿主交替等待 `agent.whenIdle()` 与 `waitForChildren(agent)`,直至没有子级工作,且父级在检查子级期间保持空闲、Session 序号不变,让完成通知可驱动父级生成最终答案。
|
||||
|
||||
本地 Agent 发生失败且后续没有成功提交 `turn/end` 时,会在所拥有的后代结束后以 `error` 结算。未认领的输入不会使该失败 activation 一直驻留。后续成功提交的轮次结束记录会取代实时失败,提供恢复后的结果。
|
||||
|
||||
本地创建成功后,无论结果交付给谁,都会向父 Session 追加 `subagent/catalog` 事实。外部创建仅在使用 `delivery: 'parent'` 时追加目录事实;调用方拥有的外部执行保留调用方自己的成员记录。外部条目以 `mode: 'external'` 标记没有本地 Session 的子级。目录仅在创建时记录一次成员关系,不携带执行状态。完整结果交给调用方或父级完成通知。投影排除继承事实。直接列表读取一个父级投影,因此已完成的本地工作流子级仍可被发现、打开或继续。Catalog 载荷 v0 记录本地模式,v1 还接受未知模式,v2 记录外部执行,读取器支持三版。历史迁移在 descriptor 不可用时根据可读子 header 追加 v1 `subagent/catalog`;本地创建保留 v0。其 `mode: 'unknown'` 投影让子会话保持可见,但不表示支持继续执行;已有完整条目仍具有权威性。
|
||||
|
||||
### 所有权与不变式
|
||||
|
||||
@@ -80,6 +80,8 @@ export type ActivationResource =
|
||||
readonly handle: AgentHandle
|
||||
readonly outputStart: SessionLogOffset
|
||||
readonly structured: StructuredAttachment | undefined
|
||||
/** Latest live failure not superseded by a later committed turn ending. */
|
||||
failureAt: SessionLogOffset | undefined
|
||||
/** Renewed when local settlement must recheck input or owned children. */
|
||||
poke: PromiseWithResolvers<void>
|
||||
}
|
||||
@@ -121,7 +123,27 @@ export function requireLocalActivation(activation: Activation): LocalActivation
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture output produced during the local residency period.
|
||||
* Retain local failures whose turn ending could not be committed.
|
||||
* @param activation - exact local Agent and epoch receiving the scoped observers.
|
||||
* @param wake - notify its settlement watcher when failure changes readiness.
|
||||
*/
|
||||
export function observeLocalFailure(activation: LocalActivation, wake: () => void): void {
|
||||
const child = activation.handle.agent
|
||||
child.ctx.on('agent/error', ({ agent }) => {
|
||||
if (agent !== child) return
|
||||
activation.failureAt = child.session.seq
|
||||
wake()
|
||||
})
|
||||
child.ctx.on('session/event', (session, event) => {
|
||||
if (session === child.session && event.type === 'turn/end'
|
||||
&& activation.failureAt !== undefined && event.seq >= activation.failureAt) {
|
||||
activation.failureAt = undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture local output and retain failures without a later committed turn ending.
|
||||
* @param activation - the settled local execution and any structured result.
|
||||
* @returns final output, stop reason, and committed structured value.
|
||||
*/
|
||||
@@ -129,7 +151,7 @@ export function captureLocalResult(activation: LocalActivation): SubagentResult
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
|
||||
const events = activation.handle.agent.session.snapshotEvents(activation.outputStart)
|
||||
const output = finalAssistantOutput(events) ?? []
|
||||
const stopReason = epochStopReason(events)
|
||||
const stopReason = activation.failureAt === undefined ? epochStopReason(events) : 'error'
|
||||
const structured = activation.structured
|
||||
if (structured !== undefined) {
|
||||
const captured = structured.captured()
|
||||
|
||||
@@ -38,7 +38,7 @@ import type {
|
||||
import { attachStructuredRuntime } from './structured.ts'
|
||||
import type { StructuredAttachment } from './structured.ts'
|
||||
import type { SubagentDelivery } from './control-types.ts'
|
||||
import { ActivationPool, ChildLock, activationAgent, captureLocalResult, requireLocalActivation } from './activation.ts'
|
||||
import { ActivationPool, ChildLock, activationAgent, captureLocalResult, observeLocalFailure, requireLocalActivation } from './activation.ts'
|
||||
import type { Activation, ActivationResource, LocalActivation } from './activation.ts'
|
||||
|
||||
/** Inputs shared by model steering and human prompt delivery. */
|
||||
@@ -868,7 +868,7 @@ export class SubagentManager {
|
||||
setup,
|
||||
})
|
||||
|
||||
resource = { kind: 'local', handle, outputStart: handle.agent.session.seq, structured, poke: Promise.withResolvers<void>() }
|
||||
resource = { kind: 'local', handle, outputStart: handle.agent.session.seq, structured, failureAt: undefined, poke: Promise.withResolvers<void>() }
|
||||
}
|
||||
const observer = this.host.observeActivation(provider, childId, parent)
|
||||
|
||||
@@ -893,10 +893,11 @@ export class SubagentManager {
|
||||
inputs.signal.throwIfAborted()
|
||||
if (this.ctx.agents.get(parent.id) !== parent) throw new SubagentError('subagent parent is no longer live', 'UNAUTHORIZED')
|
||||
this.acquireOwnership(parent, childId)
|
||||
const wakeOnInboxRemoval = (): void => { this.wake(activation) }
|
||||
const wakeSettlement = (): void => { this.wake(activation) }
|
||||
if (activation.kind === 'local') {
|
||||
activation.handle.agent.ctx.on('agent/inbox/claimed', wakeOnInboxRemoval)
|
||||
activation.handle.agent.ctx.on('agent/inbox/discarded', wakeOnInboxRemoval)
|
||||
observeLocalFailure(activation, wakeSettlement)
|
||||
activation.handle.agent.ctx.on('agent/inbox/claimed', wakeSettlement)
|
||||
activation.handle.agent.ctx.on('agent/inbox/discarded', wakeSettlement)
|
||||
}
|
||||
observer.start(activationAgent(activation))
|
||||
} catch (error: unknown) {
|
||||
@@ -1031,7 +1032,8 @@ export class SubagentManager {
|
||||
if (activation.closing !== undefined) return 'closed'
|
||||
if (activation.poke !== observation) return 'retry'
|
||||
const inbox = activation.handle.agent.inbox
|
||||
if (inbox.nextTurn.length > 0 || inbox.nextStep.length > 0 || activation.ownedChildren.size > 0) return 'wait'
|
||||
if (activation.ownedChildren.size > 0) return 'wait'
|
||||
if (activation.failureAt === undefined && (inbox.nextTurn.length > 0 || inbox.nextStep.length > 0)) return 'wait'
|
||||
return 'ready'
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { liveConfig } from '../../../settings/settings/tests/live-config.ts'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -2138,6 +2139,106 @@ describe('continuable review regressions', () => {
|
||||
expect(ends[0]!.stopReason).toBe('max-tokens')
|
||||
})
|
||||
|
||||
it.each(['turn/start', 'turn/end'] as const)('settles a local child whose first %s append fails', async (failedEvent) => {
|
||||
const { ctx, parent, adapter } = await setup([textResponse('child answer')])
|
||||
const ends: SubagentRunEndInfo[] = []
|
||||
ctx.on('subagent/end', (info) => { ends.push(info) })
|
||||
ctx.on('subagent/start', ({ id }) => {
|
||||
const child = ctx.agents.get(id)!
|
||||
const append = child.session.append.bind(child.session)
|
||||
const spy = vi.spyOn(child.session, 'append')
|
||||
spy.mockImplementation(((type: string, ...rest: never[]) => {
|
||||
if (type === failedEvent) {
|
||||
spy.mockRestore()
|
||||
throw new Error(`first ${failedEvent} unavailable`)
|
||||
}
|
||||
return (append as (...args: never[]) => unknown)(type as never, ...rest)
|
||||
}) as never)
|
||||
})
|
||||
const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'caller' })
|
||||
try {
|
||||
let settled = false
|
||||
const result = started.result.then((value) => { settled = true; return value })
|
||||
await vi.waitFor(() => { expect(settled).toBe(true) })
|
||||
await expect(result).resolves.toMatchObject({ stopReason: 'error' })
|
||||
expect(ends).toHaveLength(1)
|
||||
expect(ends[0]!.stopReason).toBe('error')
|
||||
expect(adapter.requests).toHaveLength(failedEvent === 'turn/start' ? 0 : 1)
|
||||
expect(ctx.agents.get(started.childId)).toBeUndefined()
|
||||
} finally {
|
||||
await started.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['turn/start', 'turn/end'] as const)('accepts recovery after a local %s append failure', async (failedEvent) => {
|
||||
const { ctx, parent } = await setup(failedEvent === 'turn/start'
|
||||
? [textResponse('recovered')]
|
||||
: [textResponse('first answer'), textResponse('recovered')])
|
||||
let failed = false
|
||||
ctx.on('subagent/start', ({ id }) => {
|
||||
const child = ctx.agents.get(id)!
|
||||
const append = child.session.append.bind(child.session)
|
||||
const spy = vi.spyOn(child.session, 'append')
|
||||
spy.mockImplementation(((type: string, ...rest: never[]) => {
|
||||
if (type === failedEvent) {
|
||||
spy.mockRestore()
|
||||
failed = true
|
||||
throw new Error('transient local append failure')
|
||||
}
|
||||
return (append as (...args: never[]) => unknown)(type as never, ...rest)
|
||||
}) as never)
|
||||
})
|
||||
ctx.on('agent/status', ({ agent, status }) => {
|
||||
if (agent === parent || status !== 'idle' || !failed) return
|
||||
failed = false
|
||||
agent.cancel({ kind: 'hook', reason: 'replace failed task with its retry' })
|
||||
agent.followup(createUserMessage({ content: message('retry'), source: { kind: 'user' } }))
|
||||
})
|
||||
const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'caller' })
|
||||
try {
|
||||
await expect(started.result).resolves.toMatchObject({
|
||||
stopReason: 'completed', output: [{ type: 'text', text: 'recovered' }],
|
||||
})
|
||||
} finally {
|
||||
await started.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('retains a local failure emitted during an earlier terminal notification', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('child answer')])
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === parent.session || event.type !== 'turn/end') return
|
||||
const child = ctx.agents.get(session.id)!
|
||||
agentEvents(ctx, child).emit('agent/error', {
|
||||
turn: event.data.turn, step: 0, error: new Error('terminal observer failure'),
|
||||
})
|
||||
}, { prepend: true })
|
||||
const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'caller' })
|
||||
try {
|
||||
await expect(started.result).resolves.toMatchObject({ stopReason: 'error' })
|
||||
} finally {
|
||||
await started.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores another Agent failure delivered to a local activation observer', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('child answer')])
|
||||
ctx.on('subagent/start', ({ id }) => {
|
||||
const child = ctx.agents.get(id)!
|
||||
child.ctx.emit('agent/error', { agent: parent, turn: 0, step: 0, error: new Error('unrelated failure') })
|
||||
})
|
||||
const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'caller' })
|
||||
try {
|
||||
await expect(started.result).resolves.toMatchObject({ stopReason: 'completed' })
|
||||
} finally {
|
||||
await started.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a live delivery whose caller signal aborted before admission', async () => {
|
||||
const releaseFirst = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: releaseFirst.promise }])
|
||||
|
||||
Reference in new issue
Block a user