fix(subagent): harden continuable persistence
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')),
|
||||
|
||||
Reference in New Issue
Block a user