feat(subagent): let ancestors interrupt descendants

interrupt_agent(agent_id) passes the calling agent as the ancestor
authority for ctx.subagents.interrupt(); the service verifies live
registry identity and recorded lineage, so a direct child or deeper
descendant stops with the same generic parameter while send_message keeps
its exact-direct-parent authority.

Discovery: list_agents gains an optional scope. descendants walks the new
SubagentService.listDescendants() — one lineage trace flattened in stable
pre-order across ordinary and one-shot intermediates, each entry carrying
its verified parentId and depth — and every status now comes from the
live Agent registry (running/idle/complete).

Refs #1535
This commit is contained in:
Hypatia May
2026-08-06 13:46:55 +08:00
committed by Tianyi Cui
parent 4b29f9ca7a
commit d769d3cbb7
41 changed files with 1325 additions and 162 deletions
@@ -972,3 +972,197 @@ describe('SubagentService.listChildren', () => {
expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE')
})
})
describe('SubagentService.listDescendants', () => {
it('flattens the complete tree in stable pre-order with verified parent and depth', async () => {
const { ctx, parent } = await setup([])
const childA = await authorChild(ctx, '00000000-0000-4000-8000-00000000aaa1', {
parentSession: parent.id,
createdAt: 1,
origin: 'subagent',
}, childEvents(descriptorPayload('branch a')))
const grandchild = await authorChild(ctx, '00000000-0000-4000-8000-00000000aaa2', {
parentSession: childA,
createdAt: 2,
origin: 'subagent',
}, childEvents(descriptorPayload('under a')))
const childB = await authorChild(ctx, '00000000-0000-4000-8000-00000000aaa3', {
parentSession: parent.id,
createdAt: 3,
origin: 'subagent',
}, childEvents(descriptorPayload('branch b')))
const entries = await ctx.subagents.listDescendants(parent.id)
expect(entries).toEqual([
{
kind: 'child', id: childA, label: 'branch a', mode: 'continuable',
activity: 'inactive', hasChildren: true, parentId: parent.id, depth: 1,
},
{
kind: 'child', id: grandchild, label: 'under a', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: childA, depth: 2,
},
{
kind: 'child', id: childB, label: 'branch b', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: parent.id, depth: 1,
},
])
})
it('walks a deeply nested ordinary-session chain without consuming the call stack', async () => {
const { ctx, parent } = await setup([])
const depth = 10_000
let parentId = parent.id
for (let level = 1; level < depth; level += 1) {
const session = ctx.sessions.create(SessionId(`deep-ordinary-${level}`), {
meta: { createdAt: level, parentSession: parentId },
})
parentId = session.id
}
const leafId = SessionId('deep-subagent-leaf')
const leaf = ctx.sessions.create(leafId, {
meta: { createdAt: depth, parentSession: parentId, origin: 'subagent' },
})
leaf.append('turn/start', { turn: 1 })
leaf.append('subagent/descriptor', descriptorPayload('deep leaf'))
await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([{
kind: 'child', id: leafId, label: 'deep leaf', mode: 'continuable',
activity: 'running', hasChildren: false, parentId, depth,
}])
})
it('discovers continuable descendants below ordinary and one-shot intermediates', async () => {
const { ctx, parent } = await setup([textResponse('one shot')])
// An ordinary fork has no descriptor: omitted itself, subtree still walked.
const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork'))
await ctx.sessions.flush(fork)
const underFork = await authorChild(ctx, '00000000-0000-4000-8000-00000000bbb1', {
parentSession: fork.header.id,
createdAt: 2,
origin: 'subagent',
}, childEvents(descriptorPayload('under the fork')))
// A real one-shot child, then a continuable authored below it.
const oneShot = await ctx.subagents.start('spawn', {
label: 'one-shot intermediate',
prompt: [{ type: 'text', text: 'one-shot task' }],
parent,
signal: testSignal,
})
await oneShot.result
await ctx.sessions.flush(oneShot.localAgent!.session)
const oneShotId = oneShot.id
await oneShot.dispose()
const underOneShot = await authorChild(ctx, '00000000-0000-4000-8000-00000000bbb2', {
parentSession: oneShotId,
createdAt: 9_999_999_999_999,
origin: 'subagent',
}, childEvents(descriptorPayload('under the one-shot')))
const entries = await ctx.subagents.listDescendants(parent.id)
// The fork is absent (descriptor-less); the one-shot is present with its
// mode so a caller can see the lineage it walked through.
expect(entries.map(entry => entry.id)).not.toContain(fork.header.id)
expect(entries).toContainEqual({
kind: 'child', id: underFork, label: 'under the fork', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: fork.header.id, depth: 2,
})
expect(entries).toContainEqual(expect.objectContaining({
kind: 'child', id: oneShotId, mode: 'one-shot', parentId: parent.id, depth: 1,
}))
expect(entries).toContainEqual({
kind: 'child', id: underOneShot, label: 'under the one-shot', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: oneShotId, depth: 2,
})
// Pre-order: every child appears after its own parent entry.
const position = new Map(entries.map((entry, index) => [entry.id, index]))
expect(position.get(underOneShot)!).toBeGreaterThan(position.get(oneShotId)!)
})
it('diagnoses a settled descriptor-less node while walking its subtree', async () => {
const { ctx, parent } = await setup([])
// A settled origin-marked candidate without an identity is corrupt under
// the projection contract, but its subtree remains independently visible.
const bare = await authorChild(ctx, '00000000-0000-4000-8000-00000000eee1', {
parentSession: parent.id,
createdAt: 1,
origin: 'subagent',
}, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[])
const below = await authorChild(ctx, '00000000-0000-4000-8000-00000000eee2', {
parentSession: bare,
createdAt: 2,
origin: 'subagent',
}, childEvents(descriptorPayload('below the bare node')))
await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([
{ kind: 'diagnostic', id: bare, reason: 'corrupt', parentId: parent.id, depth: 1 },
{
kind: 'child', id: below, label: 'below the bare node', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: bare, depth: 2,
},
])
})
it('keeps traversing below a corrupt intermediate and positions its diagnostic', async () => {
const { ctx, parent } = await setup([])
const corrupt = await authorChild(ctx, '00000000-0000-4000-8000-00000000ccc1', {
parentSession: parent.id,
createdAt: 1,
origin: 'subagent',
}, childEvents(descriptorPayload('unsupported descriptor', 999)))
const below = await authorChild(ctx, '00000000-0000-4000-8000-00000000ccc2', {
parentSession: corrupt,
createdAt: 2,
origin: 'subagent',
}, childEvents(descriptorPayload('below the corrupt node')))
const entries = await ctx.subagents.listDescendants(parent.id)
expect(entries).toEqual([
{ kind: 'diagnostic', id: corrupt, reason: 'corrupt', parentId: parent.id, depth: 1 },
{
kind: 'child', id: below, label: 'below the corrupt node', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: corrupt, depth: 2,
},
])
})
it('verifies a cold candidate still belongs to its enumerated lifecycle', async () => {
const { ctx, parent } = await setup([])
const childId = await authorChild(ctx, '00000000-0000-4000-8000-00000000ddd1', {
parentSession: parent.id,
createdAt: 1,
origin: 'subagent',
}, childEvents(descriptorPayload('lineage checked')))
const realInspect = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
ctx.sessionPersistence.inspect = async (sessionId, signal) => {
const inspected = await realInspect(sessionId, signal)
// The exact read reports a different durable parent than enumeration did.
return { ...inspected, meta: { ...inspected.meta, parentSession: SessionId('someone-else') } }
}
await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([
{ kind: 'diagnostic', id: childId, reason: 'corrupt', parentId: parent.id, depth: 1 },
])
})
it('a pre-aborted signal stops the descendant scan before persistence reads', async () => {
const { ctx, parent } = await setup([textResponse('done')])
await startChild(ctx, parent, 'never read')
const list = vi.spyOn(ctx.sessionPersistence, 'list')
const controller = new AbortController()
controller.abort()
await expect(ctx.subagents.listDescendants(parent.id, controller.signal)).rejects.toThrow(
expect.objectContaining({ code: 'CANCELLED' }) as Error,
)
expect(list).not.toHaveBeenCalled()
})
it('fails loud when the projection registry is not mounted', async () => {
const { ctx, parent } = await setup([], { sessionProjections: false })
await expect(ctx.subagents.listDescendants(parent.id)).rejects.toThrow(
expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error,
)
})
})