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:
Tianyi Cui committed 2026-10-06 02:46:21 +08:00
1 parent 5b9dd96963
commit 1dbbc59d3e
8 files changed
+143 -14

No files matched your search

+2 -2
View File
@@ -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`) | - |
+2 -2
View File
@@ -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`) | - |
+2 -2
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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'` 投影让子会话保持可见,但不表示支持继续执行;已有完整条目仍具有权威性。
### 所有权与不变式
+24 -2
View File
@@ -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()
+8 -6
View File
@@ -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 }])