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:
Yichen Jiang
2026-07-09 21:53:48 +08:00
parent f858c647a6
commit bd59fddacd
22 changed files with 281 additions and 240 deletions

View File

@@ -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)
})
})