fix(subagent): skip ordinary forks during child listing

This commit is contained in:
imccyu
2026-08-06 10:40:31 +08:00
parent 02c61d4481
commit 456d4319bf
2 changed files with 38 additions and 6 deletions
@@ -103,6 +103,7 @@ export async function listChildren(
)
const entries: SubagentListEntry[] = []
for (const node of trace.descendants) {
if (node.session.header.origin !== 'subagent') continue
const hasChildren = node.descendants.some(
descendant => descendant.session.header.origin === 'subagent',
)
@@ -113,7 +113,9 @@ describe('SubagentService.listChildren', () => {
const parentId = SessionId('query-only-parent')
ctx.sessions.create(parentId)
const childId = SessionId('query-only-child')
const child = ctx.sessions.create(childId, { meta: { parentSession: parentId } })
const child = ctx.sessions.create(childId, {
meta: { parentSession: parentId, origin: 'subagent' },
})
child.append('turn/start', {
turn: 1,
})
@@ -192,6 +194,7 @@ describe('SubagentService.listChildren', () => {
] as SessionEvent[])
const childId = await authorChild(ctx, '00000000-0000-4000-8000-00000000cdcd', {
parentSession: coldParent,
origin: 'subagent',
}, childEvents(descriptorPayload('persisted parent case')))
const entries = await ctx.subagents.listChildren(coldParent)
expect(entries).toEqual([
@@ -202,28 +205,33 @@ describe('SubagentService.listChildren', () => {
])
})
it('orders children by createdAt then id and omits ordinary forks without a diagnostic', async () => {
it('orders children by createdAt then id without inspecting ordinary forks', async () => {
const { ctx, parent } = await setup([])
// Authored headers pin the ordering key deterministically: same createdAt
// ties break on id, different createdAt orders ascending.
const late = await authorChild(ctx, '00000000-0000-4000-8000-000000000003', {
parentSession: parent.id,
createdAt: 9,
origin: 'subagent',
}, childEvents(descriptorPayload('late child')))
const tieB = await authorChild(ctx, '00000000-0000-4000-8000-000000000002', {
parentSession: parent.id,
createdAt: 5,
origin: 'subagent',
}, childEvents(descriptorPayload('tie b')))
const tieA = await authorChild(ctx, '00000000-0000-4000-8000-000000000001', {
parentSession: parent.id,
createdAt: 5,
origin: 'subagent',
}, childEvents(descriptorPayload('tie a')))
// An ordinary session fork shares parentSession but has no descriptor.
// An ordinary session fork shares parentSession but has no subagent origin.
const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork'))
await ctx.sessions.flush(fork)
const listEvents = vi.spyOn(ctx.sessionQuery, 'listEvents')
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries.map(entry => entry.id)).toEqual([tieA, tieB, late])
expect(entries.every(entry => entry.kind === 'child')).toBe(true)
expect(listEvents).not.toHaveBeenCalledWith(fork.id)
})
it('reports a live child as running while keeping settled siblings complete', async () => {
@@ -232,7 +240,9 @@ describe('SubagentService.listChildren', () => {
// A live child session outside persistence: publish a live session with a
// descriptor and the parent lineage, without starting an Activation.
const liveId = SessionId('live-child')
const live = ctx.sessions.create(liveId, { meta: { parentSession: parent.id } })
const live = ctx.sessions.create(liveId, {
meta: { parentSession: parent.id, origin: 'subagent' },
})
live.append('turn/start', { turn: 1 })
live.append('subagent/descriptor', descriptorPayload('live child'))
const entries = await ctx.subagents.listChildren(parent.id)
@@ -259,6 +269,7 @@ describe('SubagentService.listChildren', () => {
events[4] = { ...events[4]!, seq: 4 }
const corrupt = await authorChild(ctx, '00000000-0000-4000-8000-00000000dupe', {
parentSession: parent.id,
origin: 'subagent',
}, events)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toContainEqual({ kind: 'diagnostic', id: corrupt, reason: 'corrupt' })
@@ -274,6 +285,7 @@ describe('SubagentService.listChildren', () => {
// first-party persistence inspection rejects before session-query can fold it.
const invalid = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ee', {
parentSession: parent.id,
origin: 'subagent',
}, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{
@@ -292,6 +304,7 @@ describe('SubagentService.listChildren', () => {
const { ctx, parent } = await setup([])
const malformed = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ff', {
parentSession: parent.id,
origin: 'subagent',
}, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 }))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: malformed, reason: 'corrupt' }])
@@ -301,6 +314,7 @@ describe('SubagentService.listChildren', () => {
const { ctx, parent } = await setup([])
const future = await authorChild(ctx, '00000000-0000-4000-8000-0000000000aa', {
parentSession: parent.id,
origin: 'subagent',
}, childEvents(descriptorPayload('from the future', SUBAGENT_DESCRIPTOR_VERSION + 1)))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: future, reason: 'unsupported' }])
@@ -314,6 +328,7 @@ describe('SubagentService.listChildren', () => {
await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', {
parentSession: parent.id,
seedLength: seed.length,
origin: 'subagent',
}, seed)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([])
@@ -323,6 +338,7 @@ describe('SubagentService.listChildren', () => {
const { ctx, parent } = await setup([])
const foreign = await authorChild(ctx, '00000000-0000-4000-8000-0000000000bb', {
parentSession: parent.id,
origin: 'subagent',
}, childEvents({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
@@ -353,12 +369,15 @@ describe('SubagentService.listChildren', () => {
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }])
})
it('maps a mid-scan disappearance to unavailable', async () => {
it.each([
['session', 'SESSION_QUERY_SESSION_NOT_FOUND'],
['descriptor event', 'SESSION_QUERY_EVENT_NOT_FOUND'],
] as const)('maps a missing child %s to unavailable', async (_target, code) => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'vanishing child')
const query = ctx.get('sessionQuery')!
query.listEvents = () =>
Promise.reject(new SessionQueryError('gone', 'SESSION_QUERY_SESSION_NOT_FOUND'))
Promise.reject(new SessionQueryError('gone', code))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }])
})
@@ -391,6 +410,16 @@ describe('SubagentService.listChildren', () => {
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
})
it('maps an invalid child surface to corrupt', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'invalid surface')
const query = ctx.get('sessionQuery')!
query.listEvents = () =>
Promise.reject(new SessionQueryError('invalid surface', 'SESSION_QUERY_INVALID_SURFACE'))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
})
it('diagnoses a read whose target is no longer the descriptor event as corrupt', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'shifted log')
@@ -439,6 +468,7 @@ describe('SubagentService.listChildren', () => {
const plain = await authorChild(ctx, '00000000-0000-4000-8000-00000000c0de', {
parentSession: parent.id,
createdAt: 1,
origin: 'subagent',
}, childEvents(descriptorPayload('twin child')))
// The compacted twin: a compaction checkpoint replaces the whole surface,
// while the append-only log retains the model-hidden descriptor event.
@@ -457,6 +487,7 @@ describe('SubagentService.listChildren', () => {
const compacted = await authorChild(ctx, '00000000-0000-4000-8000-00000000c1de', {
parentSession: parent.id,
createdAt: 2,
origin: 'subagent',
}, compactedEvents)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([