test(subagent): close continuable coverage and drop unreachable guards

Restores the one-shot settleRun coverage in its own file beside the helper,
covers fork's seed contribution, the post-transfer rollback, the descriptor
model route on cold resume, manager-unload drain, and a failing teardown branch.

Removes three redundant checks the surrounding contracts already own: the
duplicate-Activation and live-id pre-checks (AgentRegistry.enter is the
authoritative collision boundary) and a rollback lifecycle edge that could never
publish because the epoch had no start edge.
This commit is contained in:
Dudu-0223
2026-07-30 14:01:13 +08:00
committed by Tianyi Cui
parent 55f86367ad
commit bc504195df
7 changed files with 446 additions and 179 deletions

View File

@@ -211,6 +211,35 @@ describe('dsh-subagent-fork', () => {
expect(ctx.subagents.list()).toEqual([])
})
it('contributes the completed-turn prefix as a continuable child\'s seed', async () => {
const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child answer')])
const provider = ctx.subagents.getProvider('fork')!
const signal = new AbortController().signal
// Before any completed parent turn there is nothing to inherit, so the
// child starts fresh rather than carrying an empty seed.
const fresh = await provider.prepareContinuable!({
sessionId: SessionId('continuable-fresh'),
parent,
signal,
})
expect(fresh.seed).toBeUndefined()
// Complete one parent turn, then the prefix is captured once at creation.
parent.followup({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })
await parent.whenIdle()
const seeded = await provider.prepareContinuable!({
sessionId: SessionId('continuable-seeded'),
parent,
signal,
})
expect(seeded.seed).toBeDefined()
const lastSeeded = seeded.seed!.at(-1)
// The seed ends at a completed turn, so it replays as a valid child log.
expect(lastSeeded?.type).toBe('turn/end')
expect(seeded.seed!.map(event => event.seq)).toEqual(seeded.seed!.map((_event, index) => index))
})
it('has the namespace-plugin export shape (no stray default)', () => {
expect('default' in fork).toBe(false)
expect(fork.name).toBe('subagent-fork')

View File

@@ -113,10 +113,10 @@ export interface ActivationObserver {
/**
* Publish the terminal edge exactly once. An epoch that never became resident
* emits nothing, because it has no start edge to pair.
* @param child - the child agent whose final output the edge reports, if any.
* @param child - the child agent whose final output the edge reports.
* @param failure - the teardown or durability failure, or `undefined` on success.
*/
settle(child: Agent | undefined, failure: unknown): void
settle(child: Agent, failure: unknown): void
}
/** Hooks the manager needs from the owning service. */
@@ -243,24 +243,6 @@ export class SubagentContinuationManager {
}.bind(this), 'subagents.continuations()')
}
/**
* Whether this manager still admits new materialization and delivery. Host
* teardown closes admission synchronously through {@link enterDraining}.
* @returns true once draining began.
*/
get isDraining(): boolean {
return this.draining
}
/**
* Close admission synchronously: reject new creation, cold resume, and
* delivery so a host can drain the live Activation forest without racing new
* work. Idempotent.
*/
enterDraining(): void {
this.draining = true
}
/**
* Read one durable child's live residency state.
* @param childId - the durable child session id.
@@ -384,7 +366,9 @@ export class SubagentContinuationManager {
* @throws an aggregate error when any branch failed to release.
*/
async drain(): Promise<void> {
this.enterDraining()
// Close admission synchronously before the first await, so no new creation,
// cold resume, or delivery can race the snapshot below.
this.draining = true
// Snapshot roots after closing admission: a root is an Activation no live
// Activation owns, so disposing roots recurses child-first into the forest.
const owned = new Set<SessionId>()
@@ -500,18 +484,10 @@ export class SubagentContinuationManager {
signal: AbortSignal
}): Promise<Activation> {
const { childId, provider, parent } = inputs
if (this.activations.has(childId)) {
throw new SubagentError(
`subagent "${childId}" already has a live activation; the message was not delivered`,
'ACTIVATION_CONFLICT',
)
}
if (this.ctx.agents.get(childId) !== undefined) {
throw new SubagentError(
`subagent "${childId}" has a live agent outside continuation ownership; the message was not delivered`,
'OWNERSHIP_CONFLICT',
)
}
// No id pre-check here: the child lock serializes each durable child, both
// callers reach this only after confirming no Activation exists, and
// `AgentRegistry.enter()` is the authoritative collision boundary for an id
// some other owner holds — a duplicate would reject there with rollback.
inputs.signal.throwIfAborted()
const setup = (childCtx: Context): void => { applyChildComposition(childCtx, inputs.composition) }
const observer = this.host.observeActivation(provider, childId, parent)
@@ -535,7 +511,7 @@ export class SubagentContinuationManager {
} catch (error: unknown) {
// Agent creation provides rollback before handle transfer, so nothing
// outlives this rejection; report the epoch that never became resident.
observer.settle(undefined, error)
// No start edge was published, so this epoch has no lifecycle to close.
throw error
}
@@ -558,16 +534,11 @@ export class SubagentContinuationManager {
} catch (error: unknown) {
// Roll the transfer back completely: the Activation leaves the map, the
// parent's ownership membership is released, and the created handle is
// disposed before this rejection surfaces.
// disposed before this rejection surfaces. No lifecycle edge is published,
// because `observer.start()` below has not run for this epoch.
this.activations.delete(childId)
this.releaseOwnership(childId)
activation.disposal = (async () => {
try {
await handle.dispose()
} finally {
observer.settle(handle.agent, error)
}
})()
activation.disposal = handle.dispose()
await activation.disposal.catch(() => undefined)
throw error
}

View File

@@ -369,7 +369,7 @@ export class SubagentService extends Service {
started = true
this.emitLifecycle('subagent/start', identity, parent)
},
settle: (child: Agent | undefined, failure: unknown): void => {
settle: (child: Agent, failure: unknown): void => {
// A failure before residency has no start edge to pair, and inventing
// one would report a lifecycle the child never had.
if (settled || !started) return
@@ -462,9 +462,10 @@ export class SubagentService extends Service {
/**
* The child's last assistant message content, for one Activation's terminal
* lifecycle edge. Absent when no assistant message reached the log.
* @param child - the settling child agent whose log is read.
* @returns its final assistant content, or `undefined` when it produced none.
*/
function lastAssistantOutput(child: Agent | undefined): ContentBlock[] | undefined {
if (child === undefined) return undefined
function lastAssistantOutput(child: Agent): ContentBlock[] | undefined {
const message = child.session.events.findLast(
(event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message',
)

View File

@@ -630,13 +630,181 @@ describe('continuable public surface', () => {
})
describe('continuable errors', () => {
it('rejects a second live Activation for the same durable child', async () => {
const { ctx, parent } = await setup([textResponse('unused')])
// Occupy the id with an unmanaged live Agent.
const squatter = ctx.agentLoop.create(SessionId('squatted'), { provider: 'mock', model: 'mock' })
await ctx.sessions.flush(squatter.session)
await expect(followup(ctx, { kind: 'user' }, SessionId('squatted'), message('hello')))
it('rejects a duplicate Activation at the agent registry collision boundary', async () => {
const hold = Promise.withResolvers<void>()
const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable(startSpec(parent))
const child = await vi.waitFor(() => {
const found = ctx.agents.get(started.childId)
expect(found).toBeDefined()
return found!
})
// Drop the Activation without disposing the Agent, leaving the id live but
// unmanaged. Materialization must not adopt it.
const manager = (ctx.subagents as unknown as {
continuations: { activations: Map<SessionId, unknown> }
}).continuations
manager.activations.delete(started.childId)
await expect(followup(ctx, { kind: 'user' }, started.childId, message('hello')))
.rejects.toThrow(SubagentError)
void parent
expect(ctx.agents.get(started.childId)).toBe(child)
hold.resolve()
})
it('rejects parent authority whose agent is no longer the live registry entry', async () => {
const { ctx, parent } = await setup([textResponse('first')])
const started = await ctx.subagents.startContinuable(startSpec(parent))
const child = await vi.waitFor(() => {
const found = ctx.agents.get(started.childId)
expect(found).toBeDefined()
return found!
})
// A stale parent reference: same id, not the exact live entry.
const stale = { ...parent, id: parent.id } as unknown as Agent
await expect(followup(ctx, { kind: 'parent', agent: stale }, started.childId, message('stale')))
.rejects.toMatchObject({ code: 'UNAUTHORIZED' })
void child
})
it('rejects establishing a child under a parent whose disposal already began', async () => {
const hold = Promise.withResolvers<void>()
const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable(startSpec(parent))
const child = await vi.waitFor(() => {
const found = ctx.agents.get(started.childId)
expect(found).toBeDefined()
return found!
})
// Begin the parent Activation's teardown, then try to give it a child.
const drained = ctx.subagents.drainContinuable()
await expect(ctx.subagents.startContinuable(startSpec(child)))
.rejects.toMatchObject({ code: 'DRAINING' })
hold.resolve()
await drained
})
it('reports a failing branch after every branch settles, without pinning the rest', async () => {
const hold = Promise.withResolvers<void>()
const adapter = new GatedAdapter([
{ chunks: textResponse('child done') },
{ chunks: textResponse('grandchild'), gate: hold.promise },
])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable(startSpec(parent))
const child = await vi.waitFor(() => {
const found = ctx.agents.get(started.childId)
expect(found).toBeDefined()
return found!
})
const grandchild = await ctx.subagents.startContinuable(startSpec(child))
await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() })
// Make the grandchild's own handle disposal reject: scope teardown failure
// propagates, unlike a contained `agent/disposed` listener throw.
const manager = (ctx.subagents as unknown as {
continuations: { activations: Map<SessionId, { handle: { dispose: () => Promise<void> } }> }
}).continuations
const branch = manager.activations.get(grandchild.childId)!
const realDispose = branch.handle.dispose.bind(branch.handle)
branch.handle.dispose = async () => {
await realDispose()
throw new Error('grandchild reap failed')
}
const drained = ctx.subagents.drainContinuable()
hold.resolve()
await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' })
// The other branch still released, and durable sessions survive.
expect(ctx.agents.get(started.childId)).toBeUndefined()
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(loaded.meta.id).toBe(started.childId)
})
it('rolls the transfer back when ownership registration fails after handle transfer', async () => {
const hold = Promise.withResolvers<void>()
const adapter = new GatedAdapter([
{ chunks: textResponse('parent child'), gate: hold.promise },
{ chunks: textResponse('unused') },
])
const { ctx, parent } = await setupWith(adapter)
const outer = await ctx.subagents.startContinuable(startSpec(parent))
const child = await vi.waitFor(() => {
const found = ctx.agents.get(outer.childId)
expect(found).toBeDefined()
return found!
})
// Begin the would-be parent's disposal, then race a grandchild into it. The
// handle transfers before ownership registration rejects, so the rollback
// must leave no Activation and no live Agent behind.
const manager = (ctx.subagents as unknown as {
continuations: { activations: Map<SessionId, { disposal: Promise<void> | undefined }> }
}).continuations
const before = new Set(ctx.agents.list().map(agent => agent.id))
manager.activations.get(outer.childId)!.disposal = Promise.resolve()
await expect(ctx.subagents.startContinuable(startSpec(child)))
.rejects.toMatchObject({ code: 'ACTIVATION_CLOSING' })
await vi.waitFor(() => {
expect(ctx.agents.list().map(agent => agent.id).filter(id => !before.has(id))).toEqual([])
})
hold.resolve()
})
it('reapplies the descriptor model route on cold resume', async () => {
const { ctx, parent } = await setup([textResponse('first'), textResponse('resumed')])
const started = await ctx.subagents.startContinuable({
...startSpec(parent),
request: {
prompt: message('routed work'),
parent,
agentOptions: { provider: 'mock', model: 'child-model' },
},
})
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(loaded.events.find(event => event.type === 'subagent/descriptor')?.data)
.toMatchObject({ agentProvider: 'mock', agentModel: 'child-model' })
// The resumed Activation runs on the declared route, not the parent's.
await followup(ctx, { kind: 'user' }, started.childId, message('again'))
await vi.waitFor(() => {
expect(ctx.agents.get(started.childId)?.options.model).toBe('child-model')
})
await waitNoActivation(ctx, started.childId)
})
it('drains without continuation services as a no-op', async () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SubagentService)
// No `ctx.agents`, so no manager was ever bound and nothing was materialized.
await expect(ctx.subagents.drainContinuable()).resolves.toBeUndefined()
})
it('unloading the manager drains its live activations', async () => {
const hold = Promise.withResolvers<void>()
const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }])
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-'))
roots.push(root)
await ctx.plugin(JsonlSessionPersistence, { root })
await ctx.plugin(AgentLoop, { agents: [] })
const serviceFiber = await ctx.plugin(SubagentService)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
const started = await ctx.subagents.startContinuable(startSpec(parent))
await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeDefined() })
// Manager unload uses the same drain, so no child outlives its runtime.
const disposal = serviceFiber.dispose()
hold.resolve()
await disposal
expect(ctx.agents.get(started.childId)).toBeUndefined()
})
})

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { settleRun } from '../src/index.ts'
describe('outcome mapping helpers', () => {
it.each([
['completed', { status: 'completed', output: 'partial' }],
['aborted', { status: 'killed' }],
['error', { status: 'failed', detail: 'error' }],
['max-tokens', { status: 'failed', detail: 'max-tokens' }],
['refusal', { status: 'failed', detail: 'refusal' }],
['paused', { status: 'failed', detail: 'paused' }],
] as const)('settleRun maps the %s stop reason onto its Task outcome', async (stopReason, expected) => {
const output = [{ type: 'text' as const, text: 'partial' }]
await expect(settleRun({
id: SessionId('child'),
localAgent: undefined,
result: Promise.resolve({ output, stopReason: stopReason as never }),
dispose: () => Promise.resolve(),
})).resolves.toEqual(expected)
})
it('settleRun disposes the run before reporting, on both result paths', async () => {
const order: string[] = []
const completed = await settleRun({
id: SessionId('child-1'),
localAgent: undefined,
result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
dispose() { order.push('dispose'); return Promise.resolve() },
})
order.push('reported')
expect(completed).toEqual({ status: 'completed', output: 'ok' })
expect(order).toEqual(['dispose', 'reported'])
// An infrastructure rejection still disposes and reports failed.
let disposed = false
const failed = await settleRun({
id: SessionId('child-2'),
localAgent: undefined,
result: Promise.reject(new Error('transport gone')),
dispose() { disposed = true; return Promise.resolve() },
})
expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' })
expect(disposed).toBe(true)
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-5'),
localAgent: undefined,
result: Promise.reject(new Error('result failed')),
dispose: () => Promise.reject(new Error('reap failed')),
})
expect(bothFailed).toEqual({
status: 'failed',
detail: 'Error: result failed; dispose failed: Error: reap failed',
})
})
})