fix(subagent): harden continuable persistence

This commit is contained in:
Dudu-0223
2026-07-24 12:39:07 +08:00
committed by imccyu
parent bb8ea2be51
commit 1ab3cbf673
23 changed files with 412 additions and 56 deletions

View File

@@ -4,7 +4,7 @@ The continuable-subagent control service (`ctx.subagentControl`): the one orches
## Activation lifecycle
A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent.
A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the provider's durability-confirmed child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent. A provider rejection with `DURABILITY_FAILED` settles the Task as `failed` and copies the error message into `detail`, so `task_output` reports the failed checkpoint and resumability risk without exposing unconfirmed output.
`sendMessage(parent, childId, message)` owns steer-or-resume routing. A running activation receives live delivery through the run's strict `steer` capability and returns the existing Task id (`steered`); an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` (`started`). Failure throws and means the message was not delivered: losing a strict-steering race with Task settlement never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target.

View File

@@ -116,6 +116,13 @@ export 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.
@@ -127,7 +134,7 @@ export async function settleRun(run: SubagentRun): Promise<TaskOutcome> {
try {
outcome = runOutcome(await run.result)
} catch (error: unknown) {
outcome = { status: 'failed', detail: String(error) }
outcome = { status: 'failed', detail: runFailureDetail(error) }
}
try {
await run.dispose()

View File

@@ -16,7 +16,7 @@ import { TaskId } from '@deepseek-ai/dsh-tasks'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { createUserMessage, HarnessError, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import SubagentControlService, { runOutcome, settleRun, SubagentControlError } from '../src/index.ts'
@@ -685,16 +685,29 @@ describe('outcome mapping helpers', () => {
expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' })
expect(disposed).toBe(true)
const disposeFailed = await settleRun({
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,
result: Promise.resolve({ output: [], stopReason: 'completed' }),
dispose: () => Promise.reject(new Error('reap failed')),
})
expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' })
const bothFailed = await settleRun({
id: SessionId('child-4'),
id: SessionId('child-5'),
localAgent: undefined,
result: Promise.reject(new Error('result failed')),
dispose: () => Promise.reject(new Error('reap failed')),