refactor(agent): complete inbox lifecycle migration

This commit is contained in:
_Kerman
2026-08-03 12:25:33 +08:00
parent dc1d542092
commit 49e90695cc
214 changed files with 6019 additions and 4235 deletions

View File

@@ -847,16 +847,13 @@ export class SubagentContinuationManager {
// quiet Agent from one whose accepted turn has not been admitted yet.
// Registered through the child's own scoped context, so scope filtering
// already restricts both listeners to this exact agent.
handle.agent.ctx.on('agent/inbox/dequeue', (_agent, item) => {
/* v8 ignore next -- a dequeue of an id this manager never admitted needs
handle.agent.ctx.on('agent/inbox/claimed', (_agent, { message }) => {
/* v8 ignore next -- a claim of an id this manager never admitted needs
* another sender on the same child, which no current path allows. */
if (activation.accepted.delete(item.message.id)) this.wake(activation)
if (activation.accepted.delete(message.id)) this.wake(activation)
})
handle.agent.ctx.on('agent/inbox/discard', (_agent, items) => {
// Deleting every id in the batch is unconditional; waking once afterwards
// costs nothing and avoids branching on which ids this manager admitted.
for (const item of items) activation.accepted.delete(item.message.id)
this.wake(activation)
handle.agent.ctx.on('agent/inbox/discarded', (_agent, { message }) => {
if (activation.accepted.delete(message.id)) this.wake(activation)
})
// Agent creation committed setup at its publication boundary;
// revocations from here on are immediate live revocation.

View File

@@ -207,7 +207,6 @@ function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopR
return 'max-tokens'
case 'aborted':
case 'interrupted':
case 'disposed':
return 'aborted'
case 'error':
return 'error'

View File

@@ -142,11 +142,24 @@ async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void>
}, { timeout: 5_000 })
}
/** Observe calls at the Agent cancellation boundary without a production event. */
function observeCancel(agent: Agent, callback: () => void): void {
const cancel = agent.cancel.bind(agent)
let observed = false
vi.spyOn(agent, 'cancel').mockImplementation((cause, options) => {
if (!observed) {
observed = true
callback()
}
cancel(cause, options)
})
}
describe('SubagentService.startContinuable', () => {
it('returns both identities at inbox acceptance, without waiting for the turn or the log', async () => {
const { ctx, parent, adapter } = await setup([textResponse('first answer')])
const enqueued: { id: MessageId; loggedYet: boolean }[] = []
ctx.on('agent/inbox/enqueue', (agent, accepted) => {
ctx.on('agent/inbox/inserted', (agent, accepted) => {
// Acceptance is the boundary `startContinuable` resolves at, so observe
// the log state exactly there rather than after later microtasks.
enqueued.push({ id: accepted.message.id, loggedYet: hasUserText(agent.session.events, 'child task') })
@@ -737,7 +750,9 @@ describe('continuable durability and teardown', () => {
const grandchild = await ctx.subagents.startContinuable(startSpec(targetChild))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(3) })
const cancellations: SessionId[] = []
ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) })
observeCancel(targetChild, () => { cancellations.push(targetChild.id) })
const grandchildAgent = ctx.agents.get(grandchild.childId)!
observeCancel(grandchildAgent, () => { cancellations.push(grandchildAgent.id) })
const drained = ctx.subagents.drainContinuableDescendants([parent])
const convergedDrain = ctx.subagents.drainContinuableDescendants([parent])
@@ -784,7 +799,8 @@ describe('continuable durability and teardown', () => {
const grandchild = await ctx.subagents.startContinuable(startSpec(child))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
const cancellations: SessionId[] = []
ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) })
const grandchildAgent = ctx.agents.get(grandchild.childId)!
observeCancel(grandchildAgent, () => { cancellations.push(grandchildAgent.id) })
const drained = ctx.subagents.drainContinuableDescendants([child])
@@ -828,7 +844,8 @@ describe('continuable durability and teardown', () => {
expect(ctx.agents.get(intermediateId)).toBeUndefined()
expect(ctx.agents.get(descendant.childId)).toBeDefined()
const cancellations: SessionId[] = []
ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) })
const descendantAgent = ctx.agents.get(descendant.childId)!
observeCancel(descendantAgent, () => { cancellations.push(descendantAgent.id) })
const drained = ctx.subagents.drainContinuableDescendants([parent])
@@ -926,7 +943,7 @@ describe('continuable durability and teardown', () => {
const drains: Promise<void>[] = []
const accepted: MessageId[] = []
ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) })
ctx.on('agent/inbox/enqueue', (_agent, item) => { accepted.push(item.message.id) })
ctx.on('agent/inbox/inserted', (_agent, item) => { accepted.push(item.message.id) })
await expect(ctx.subagents.startContinuable(startSpec(parent)))
.rejects.toMatchObject({ code: 'DRAINING' })
@@ -967,12 +984,12 @@ describe('continuable durability and teardown', () => {
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const order: string[] = []
child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
child.ctx.on('agent/inbox/inserted', (_agent, accepted) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'before drain')) {
order.push('enqueue')
}
})
child.ctx.on('agent/cancel-requested', () => { order.push('cancel') })
observeCancel(child, () => { order.push('cancel') })
const delivery = followup(ctx, parent, started.childId, message('before drain'))
// Let the child-lock operation reach the live admission cutoff. Admission
@@ -1150,9 +1167,9 @@ describe('continuable review regressions', () => {
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', (info) => { ends.push(info) })
// Block the resumed prompt so this epoch produces nothing of its own.
ctx.on('agent/prompt-submit', async (subject, _message, _signal, next) => {
ctx.on('agent/pre-step', async (subject, _messages, _context, next) => {
if (subject === parent) return next()
return { kind: 'block', reason: 'blocked by policy' }
return { kind: 'reject' }
})
await followup(ctx, parent, started.childId, message('again'))
await waitNoActivation(ctx, started.childId)
@@ -1258,7 +1275,7 @@ describe('continuable review regressions', () => {
expect(found).toBeDefined()
return found!
})
child.ctx.on('agent/cancel-requested', () => { order.push('cancel') })
observeCancel(child, () => { order.push('cancel') })
const drained = drainManager(ctx)
hold.resolve(undefined)
@@ -1298,7 +1315,7 @@ describe('continuable review regressions', () => {
// Cancel from the synchronous enqueue observer: the discard fires after the
// id is recorded but before `followup()` returns.
const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
child.cancel({ kind: 'user' })
}
@@ -1330,7 +1347,7 @@ describe('continuable review regressions', () => {
await followup(ctx, parent, started.childId, message('queued'))
expect(activation.accepted.size).toBe(1)
const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
child.cancel({ kind: 'user' })
}
@@ -1348,9 +1365,9 @@ describe('continuable review regressions', () => {
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', (info) => { ends.push(info) })
// Block admission so the child's only turn never opens.
ctx.on('agent/prompt-submit', async (subject, _message, _signal, next) => {
ctx.on('agent/pre-step', async (subject, _messages, _context, next) => {
if (subject === parent) return next()
return { kind: 'block', reason: 'blocked by policy' }
return { kind: 'reject' }
})
const started = await ctx.subagents.startContinuable(startSpec(parent))
@@ -1370,7 +1387,7 @@ describe('continuable review regressions', () => {
const registeredAtEnqueue: boolean[] = []
// A synchronous inbox observer runs before the admitting microtask, the
// exact window where `Agent.status` is still idle.
ctx.on('agent/inbox/enqueue', (agent) => {
ctx.on('agent/inbox/inserted', (agent) => {
if (agent.session.header.parentSession !== undefined) {
registeredAtEnqueue.push(ctx.agents.get(agent.id) === agent)
}

View File

@@ -116,7 +116,6 @@ describe('SubagentService.listChildren', () => {
const child = ctx.sessions.create(childId, { meta: { parentSession: parentId } })
child.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
child.append('subagent/descriptor', descriptorPayload('query-only child'))
@@ -234,7 +233,7 @@ describe('SubagentService.listChildren', () => {
// descriptor and the parent lineage, without starting an Activation.
const liveId = SessionId('live-child')
const live = ctx.sessions.create(liveId, { meta: { parentSession: parent.id } })
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
live.append('turn/start', { turn: 1 })
live.append('subagent/descriptor', descriptorPayload('live child'))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toContainEqual({