refactor(tasks): declare-then-execute — ctx.tasks.start() replaces register()
start({ kind, label, owner, run }) preflights everything that can fail
(the attachSurface fence, validation, the owner-cleanup attach) BEFORE
invoking the producer's run() starter, then commits atomically —
'work started but never got a collectable id' is now structurally
impossible instead of a producer try/catch rollback obligation (the
P1 review fix, rebuilt on #185's declare/execute split). Producers
lose their catch-wraps; the leak tests now pin the stronger property
that a failed preflight never spawns anything. TaskRegistration splits
into TaskStart (identity + run) and TaskHooks (cancel/done/readOutput);
docs, type-equiv manifest, catalogs, and both RFCs move with it.
This commit is contained in:
@@ -264,32 +264,27 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// (the child outlives this step; cancellation belongs to task_kill
|
||||
// and owner-disposal cleanup), so the request carries NO signal.
|
||||
if (exec.signal?.aborted) throw new Error('subagent delegation aborted')
|
||||
const run = ctx.subagents.start(config.provider, {
|
||||
prompt: [{ type: 'text', text: args.prompt }],
|
||||
parent,
|
||||
...config.agentOptions ? { agentOptions: config.agentOptions } : {},
|
||||
// tasks.start preflights (surface fence, owner cleanup) BEFORE run()
|
||||
// spawns the child, and cannot fail after — a child can never start
|
||||
// without a collectable id.
|
||||
const id = tasks.start({
|
||||
kind: 'subagent',
|
||||
label: args.description,
|
||||
owner: parent,
|
||||
run: () => {
|
||||
const run = ctx.subagents.start(config.provider, {
|
||||
prompt: [{ type: 'text', text: args.prompt }],
|
||||
parent,
|
||||
...config.agentOptions ? { agentOptions: config.agentOptions } : {},
|
||||
})
|
||||
return {
|
||||
cancel: (reason?: string) => { run.cancel(reason ?? 'background subagent task killed') },
|
||||
done: settleRun(run),
|
||||
// No readOutput: a subagent task is final-output-only — the
|
||||
// child session remains the detailed trace.
|
||||
}
|
||||
},
|
||||
})
|
||||
const done = settleRun(run)
|
||||
let id: string
|
||||
try {
|
||||
id = tasks.register({
|
||||
kind: 'subagent',
|
||||
label: args.description,
|
||||
owner: parent,
|
||||
cancel: (reason) => { run.cancel(reason ?? 'background subagent task killed') },
|
||||
done,
|
||||
// No readOutput: a subagent task is final-output-only — the child
|
||||
// session remains the detailed trace.
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
// A failed registration must not leak the just-started child: the
|
||||
// model never received an id, so nothing could ever task_kill it.
|
||||
// Cancel, await `done` (which settles only after run.dispose() —
|
||||
// child quiescence), then fail the call with the real cause.
|
||||
run.cancel('background task registration failed')
|
||||
await done
|
||||
throw error
|
||||
}
|
||||
return [{ type: 'text', text: `started background subagent task ${id}` }]
|
||||
}
|
||||
|
||||
|
||||
@@ -602,28 +602,29 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('background registration failure (no orphaned child)', () => {
|
||||
it('cancels and disposes the just-started run when register() throws', async () => {
|
||||
// TaskService is loaded but NO control surface is attached, so
|
||||
// ctx.tasks.register throws AFTER the provider run already started.
|
||||
describe('background preflight failure (no orphaned child, by construction)', () => {
|
||||
it('never starts the child when tasks.start preflight throws', async () => {
|
||||
// TaskService is loaded but NO control surface is attached: tasks.start
|
||||
// preflights that fence BEFORE invoking the producer's run(), so the
|
||||
// provider is never asked to spawn — there is no orphan to roll back.
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
const parent = { id: AgentId('agent-sess-p'), inject: () => {}, session: { header: { version: 0, id: 'sess-p', createdAt: 0 } } } as unknown as Agent
|
||||
ctx.agents.register(parent)
|
||||
|
||||
const events: string[] = []
|
||||
let starts = 0
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'probe',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => {
|
||||
let settle!: (value: { output: never[]; stopReason: 'aborted' }) => void
|
||||
starts += 1
|
||||
return {
|
||||
id: AgentId('probe-child'),
|
||||
result: new Promise((res) => { settle = res }),
|
||||
cancel(reason?: string) { events.push(`cancel:${reason}`); settle({ output: [], stopReason: 'aborted' }) },
|
||||
dispose() { events.push('dispose'); return Promise.resolve() },
|
||||
result: Promise.resolve({ output: [], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose: () => Promise.resolve(),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -637,8 +638,7 @@ describe('background registration failure (no orphaned child)', () => {
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no control surface is attached')
|
||||
// The child was cancelled AND disposed before the call settled — the
|
||||
// model never got an id, so nothing else could ever collect or kill it.
|
||||
expect(events).toEqual(['cancel:background task registration failed', 'dispose'])
|
||||
// Declare-then-execute: the failed preflight means no child ever existed.
|
||||
expect(starts).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user