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:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
79
packages/subagent/subagent/tests/run-settlement.spec.ts
Normal file
79
packages/subagent/subagent/tests/run-settlement.spec.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user