diff --git a/src/main/acp/__snapshots__/acp-subagent-timeline-characterization.test.ts.snap b/src/main/acp/__snapshots__/acp-subagent-timeline-characterization.test.ts.snap new file mode 100644 index 00000000000..b76651581b5 --- /dev/null +++ b/src/main/acp/__snapshots__/acp-subagent-timeline-characterization.test.ts.snap @@ -0,0 +1,538 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ACP subagent timeline rows (characterization) > a group past 64 children drops the rest 1`] = ` +{ + "events": 1, + "has": [ + true, + false, + ], + "lastLabels": "l":"X 60","state":"working","startedAt":1010},{"id":"c60","label":"X 61","state":"working","startedAt":1010},{"id":"c61","label":"X 62","state":"working","startedAt":1010},{"id":"c62","label":"X 63","state":"working","startedAt":1010},{"id":"c63","label":"X 64","state":"working","startedAt":1010}]}]", +} +`; + +exports[`ACP subagent timeline rows (characterization) > children sharing a label, a provider-named ordinal, and outcomes in one batch 1`] = ` +[ + [ + { + "body": { + "blocks": [ + { + "text": "Kicked off 4 subagents", + "type": "text", + }, + { + "agents": [ + { + "id": "a1", + "label": "Audit", + "startedAt": 1010, + "state": "working", + }, + { + "id": "a2", + "label": "Audit 2", + "startedAt": 1010, + "state": "working", + }, + { + "id": "a3", + "label": "Audit 2 2", + "startedAt": 1010, + "state": "working", + }, + { + "id": "a4", + "label": "subagent", + "startedAt": 1010, + "state": "working", + }, + ], + "groupId": "turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "item": "subagents:turn-1", + "join": { + "thread": "parent", + "turn": "turn-1", + }, + "subagentIdentities": [ + "a1", + "a2", + "a3", + "a4", + ], + "type": "item.update", + }, + ], + [ + { + "body": { + "blocks": [ + { + "text": "Kicked off 4 subagents (1 failed)", + "type": "text", + }, + { + "agents": [ + { + "id": "a1", + "label": "Audit", + "settledAt": 1020, + "startedAt": 1010, + "state": "stopped", + }, + { + "id": "a2", + "label": "Audit 2", + "settledAt": 1020, + "startedAt": 1010, + "state": "completed", + }, + { + "id": "a3", + "label": "Audit 2 2", + "settledAt": 1020, + "startedAt": 1010, + "state": "failed", + }, + { + "id": "a4", + "label": "subagent", + "startedAt": 1010, + "state": "working", + }, + ], + "groupId": "turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "item": "subagents:turn-1", + "join": { + "thread": "parent", + "turn": "turn-1", + }, + "subagentIdentities": [ + "a1", + "a2", + "a3", + "a4", + ], + "type": "item.update", + }, + { + "body": { + "blocks": [ + { + "text": "late", + "type": "text", + }, + ], + "kind": "message", + "role": "assistant", + }, + "item": "subagent-result:a2", + "join": { + "thread": "parent", + "turn": "turn-1", + }, + "producer": { + "agentId": "a2", + "producerKind": "agent", + }, + "type": "item.update", + }, + ], +] +`; + +exports[`ACP subagent timeline rows (characterization) > knownOnly updates, turn from the update, and the no-turn group 1`] = ` +{ + "has": [ + false, + true, + true, + ], + "log": [ + [], + [ + { + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "a1", + "label": "Spawned", + "startedAt": 1020, + "state": "working", + }, + ], + "groupId": "turn-0", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "item": "subagents:turn-0", + "join": { + "thread": "parent", + "turn": "turn-0", + }, + "subagentIdentities": [ + "a1", + ], + "type": "item.update", + }, + ], + [ + { + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "a2", + "label": "Loose", + "startedAt": 1030, + "state": "working", + }, + ], + "groupId": "thread", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "item": "subagents:thread", + "join": { + "thread": "parent", + }, + "subagentIdentities": [ + "a2", + ], + "type": "item.update", + }, + ], + [ + { + "body": { + "blocks": [ + { + "text": "Ran 1 subagent (1 stopped)", + "type": "text", + }, + { + "agents": [ + { + "id": "a1", + "label": "Spawned", + "settledAt": 1040, + "startedAt": 1020, + "state": "stopped", + }, + ], + "groupId": "turn-0", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "item": "subagents:turn-0", + "join": { + "thread": "parent", + "turn": "turn-0", + }, + "subagentIdentities": [ + "a1", + ], + "type": "item.update", + }, + ], + ], +} +`; + +exports[`ACP subagent timeline rows (characterization) > more than 32 settled groups, then a late update for an evicted child 1`] = ` +{ + "has": [ + true, + true, + true, + ], + "tail": [ + [ + { + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "live", + "label": "Live", + "startedAt": 1350, + "state": "working", + }, + ], + "groupId": "turn-live", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "item": "subagents:turn-live", + "join": { + "thread": "parent", + "turn": "turn-live", + }, + "subagentIdentities": [ + "live", + ], + "type": "item.update", + }, + ], + [], + [], + ], +} +`; + +exports[`ACP subagent timeline rows (characterization) > one child: working, tokens, completed with its reply, replayed 1`] = ` +[ + [ + { + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "a1", + "label": "Explore", + "startedAt": 1010, + "state": "working", + }, + ], + "groupId": "turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "item": "subagents:turn-1", + "join": { + "thread": "parent", + "turn": "turn-1", + }, + "subagentIdentities": [ + "a1", + ], + "type": "item.update", + }, + ], + [ + { + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "a1", + "label": "Explore", + "startedAt": 1010, + "state": "working", + "tokens": 0, + }, + ], + "groupId": "turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "item": "subagents:turn-1", + "join": { + "thread": "parent", + "turn": "turn-1", + }, + "subagentIdentities": [ + "a1", + ], + "type": "item.update", + }, + ], + [ + { + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "a1", + "label": "Explore", + "startedAt": 1010, + "state": "working", + "tokens": 200, + }, + ], + "groupId": "turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "item": "subagents:turn-1", + "join": { + "thread": "parent", + "turn": "turn-1", + }, + "subagentIdentities": [ + "a1", + ], + "type": "item.update", + }, + ], + [ + { + "body": { + "blocks": [ + { + "text": "Ran 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "a1", + "label": "Explore", + "settledAt": 1040, + "startedAt": 1010, + "state": "completed", + "tokens": 200, + }, + ], + "groupId": "turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "item": "subagents:turn-1", + "join": { + "thread": "parent", + "turn": "turn-1", + }, + "subagentIdentities": [ + "a1", + ], + "type": "item.update", + }, + { + "body": { + "blocks": [ + { + "text": "Found it", + "type": "text", + }, + ], + "kind": "message", + "role": "assistant", + }, + "item": "subagent-result:a1", + "join": { + "thread": "parent", + "turn": "turn-1", + }, + "producer": { + "agentId": "a1", + "producerKind": "agent", + }, + "type": "item.update", + }, + ], + [], + [], +] +`; + +exports[`ACP subagent timeline rows (characterization) > writes nothing after dispose 1`] = ` +{ + "has": false, + "log": [ + [ + { + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "a1", + "label": "A", + "startedAt": 1010, + "state": "working", + }, + ], + "groupId": "turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "item": "subagents:turn-1", + "join": { + "thread": "parent", + "turn": "turn-1", + }, + "subagentIdentities": [ + "a1", + ], + "type": "item.update", + }, + ], + [], + ], +} +`; diff --git a/src/main/acp/acp-structured-host-subagent-stop.test.ts b/src/main/acp/acp-structured-host-subagent-stop.test.ts index 509af927d02..e851df7f931 100644 --- a/src/main/acp/acp-structured-host-subagent-stop.test.ts +++ b/src/main/acp/acp-structured-host-subagent-stop.test.ts @@ -157,8 +157,9 @@ describe('Grok child outcome during a full host Stop', () => { const [unavailable] = await fixture.rosters() expect(fixture.childWork.views()).toMatchObject([{ membership: 'settled', outcome: 'unknown' }]) expect(unavailable.row.itemId).toBe(fixture.original.row.itemId) + // Contact was lost when the session ended, a moment the host observed. expect(unavailable.group.agents).toEqual([ - { ...fixture.original.group.agents[0], state: 'unverifiable' } + { ...fixture.original.group.agents[0], state: 'unverifiable', settledAt: expect.any(Number) } ]) fixture.agent.notify(fixture.finished.method, fixture.finished.params) expect((await fixture.rosters())[0].group).toEqual(unavailable.group) diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts index c27179fa9b8..88242d5a7c0 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -88,7 +88,8 @@ export function asReattachHistory(params: unknown): unknown { export function closeAcpSessionJournal( session: AcpStructuredSession, reason: string, - verdict: StructuredAgentSessionTurnVerdict = UNVERIFIABLE_TURN_VERDICT + verdict: StructuredAgentSessionTurnVerdict = UNVERIFIABLE_TURN_VERDICT, + endedAt: number = Date.now() ): void { if (session.journalClosed !== null) { return @@ -96,7 +97,7 @@ export function closeAcpSessionJournal( session.journalClosed = reason session.prompts.clear() session.turns.end(reason) - session.lane.apply([{ type: 'session.ended', verdict }]) + session.lane.apply([{ type: 'session.ended', verdict, endedAt }]) session.lane.flush() session.lane.dispose() session.unbindReadingControl?.() @@ -129,7 +130,8 @@ export function endAcpStructuredSession( reason, session.closeRequested ? UNVERIFIABLE_TURN_VERDICT - : { state: 'interrupted', completedAt: observedAt } + : { state: 'interrupted', completedAt: observedAt }, + observedAt ) const detail = stderr ? providerDiagnostic(stderr, 'person') : undefined onEvent?.({ diff --git a/src/main/acp/acp-subagent-timeline-characterization.test.ts b/src/main/acp/acp-subagent-timeline-characterization.test.ts new file mode 100644 index 00000000000..f28c8346b4c --- /dev/null +++ b/src/main/acp/acp-subagent-timeline-characterization.test.ts @@ -0,0 +1,115 @@ +// Golden timeline events from the ACP subagent roster for scripted update sequences, so moving it +// onto shared code cannot change a saved byte. + +import { describe, expect, it } from 'vitest' +import type { AcpSubagentUpdate } from './acp-dialects/acp-dialect' +import { AcpSubagentTimeline } from './acp-subagent-timeline' +import type { AcpTimelineEvent } from './acp-timeline-event' + +function harness() { + const timeline = new AcpSubagentTimeline() + const log: AcpTimelineEvent[][] = [] + let at = 1_000 + return { + timeline, + log, + send: (updates: AcpSubagentUpdate[], join: { thread?: string; turn?: string }) => { + log.push(timeline.translate(updates, join, (at += 10))) + } + } +} + +const JOIN = { thread: 'parent', turn: 'turn-1' } + +describe('ACP subagent timeline rows (characterization)', () => { + it('one child: working, tokens, completed with its reply, replayed', () => { + const h = harness() + h.send([{ id: 'a1', label: 'Explore', state: 'working' }], JOIN) + h.send([{ id: 'a1', tokens: 0 }], JOIN) + h.send([{ id: 'a1', tokens: 200 }], JOIN) + h.send([{ id: 'a1', state: 'completed', result: 'Found it' }], JOIN) + h.send([{ id: 'a1', state: 'completed', result: 'Found it' }], JOIN) + h.send([{ id: 'a1', state: 'failed' }], JOIN) + expect(h.log).toMatchSnapshot() + }) + + it('children sharing a label, a provider-named ordinal, and outcomes in one batch', () => { + const h = harness() + h.send( + [ + { id: 'a1', label: 'Audit' }, + { id: 'a2', label: 'Audit' }, + { id: 'a3', label: 'Audit 2' }, + { id: 'a4' } + ], + JOIN + ) + h.send( + [ + { id: 'a1', state: 'stopped' }, + { id: 'a2', state: 'unverifiable' }, + { id: 'a2', state: 'completed', result: 'late' }, + { id: 'a3', state: 'failed' } + ], + JOIN + ) + expect(h.log).toMatchSnapshot() + }) + + it('knownOnly updates, turn from the update, and the no-turn group', () => { + const h = harness() + h.send([{ id: 'unknown', state: 'completed', knownOnly: true }], JOIN) + h.send([{ id: 'a1', label: 'Spawned', turn: 'turn-0' }], JOIN) + h.send([{ id: 'a2', label: 'Loose' }], { thread: 'parent' }) + h.send([{ id: 'a1', state: 'stopped', knownOnly: true }], JOIN) + expect({ + log: h.log, + has: ['unknown', 'a1', 'a2'].map((id) => h.timeline.has(id)) + }).toMatchSnapshot() + }) + + it('a group past 64 children drops the rest', () => { + const h = harness() + h.send( + Array.from({ length: 66 }, (_, index): AcpSubagentUpdate => ({ + id: `c${index}`, + label: 'X' + })), + JOIN + ) + const last = h.log.at(-1)?.at(-1) + expect({ + events: h.log.at(-1)?.length, + lastLabels: + last && 'body' in last && last.body.kind === 'message' + ? JSON.stringify(last.body.blocks).slice(-300) + : null, + has: [h.timeline.has('c63'), h.timeline.has('c64')] + }).toMatchSnapshot() + }) + + it('more than 32 settled groups, then a late update for an evicted child', () => { + const h = harness() + for (let index = 0; index < 34; index++) { + h.send([{ id: `c${index}`, label: `Job ${index}`, state: 'completed' }], { + thread: 'parent', + turn: `turn-${index}` + }) + } + h.send([{ id: 'live', label: 'Live' }], { thread: 'parent', turn: 'turn-live' }) + h.send([{ id: 'c0', state: 'failed' }], JOIN) + h.send([{ id: 'c0', label: 'Job 0' }], JOIN) + expect({ + tail: h.log.slice(-3), + has: [h.timeline.has('c0'), h.timeline.has('c1'), h.timeline.has('c33')] + }).toMatchSnapshot() + }) + + it('writes nothing after dispose', () => { + const h = harness() + h.send([{ id: 'a1', label: 'A' }], JOIN) + h.timeline.dispose() + h.send([{ id: 'a1', state: 'completed' }], JOIN) + expect({ log: h.log, has: h.timeline.has('a1') }).toMatchSnapshot() + }) +}) diff --git a/src/main/acp/acp-subagent-timeline.test.ts b/src/main/acp/acp-subagent-timeline.test.ts index a3848842816..86e9864b795 100644 --- a/src/main/acp/acp-subagent-timeline.test.ts +++ b/src/main/acp/acp-subagent-timeline.test.ts @@ -1,24 +1,10 @@ import { describe, expect, it } from 'vitest' -import { z } from 'zod' import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary' import { isSubagentGroupBlock, type NativeChatSubagentState } from '../../shared/native-chat-types' import type { AcpSubagentUpdate } from './acp-dialects/acp-dialect' import { grokSubagentNotification } from './acp-dialects/grok-subagents' import { AcpSubagentTimeline } from './acp-subagent-timeline' -const retainedSchema = z.object({ - groups: z.map( - z.string(), - z.object({ - entries: z.map(z.string(), z.object({ label: z.string() })), - labelCounts: z.map(z.string(), z.number()), - results: z.map(z.string(), z.string()) - }) - ), - groupOf: z.map(z.string(), z.string()), - retention: z.object({ settledIdentities: z.object({ size: z.number() }) }) -}) - function spawnGroups( timeline: AcpSubagentTimeline, count: number, @@ -49,7 +35,7 @@ describe('ACP subagent retention', () => { } expect(timeline.has('0:0')).toBe(true) expect(timeline.has('32:63')).toBe(true) - expect(retainedSchema.parse(timeline).groupOf.size).toBe(33 * 64) + expect(timeline.retentionSizes().groups).toBe(33) timeline.translate( Array.from({ length: 63 }, (_, child): AcpSubagentUpdate => ({ id: `0:${child}`, @@ -59,8 +45,7 @@ describe('ACP subagent retention', () => { { thread: 'parent' }, 100 ) - expect(retainedSchema.parse(timeline).groups.size).toBe(33) - expect(retainedSchema.parse(timeline).groups.get('turn-0')?.results.size).toBe(63) + expect(timeline.retentionSizes()).toMatchObject({ groups: 33, replies: 63 }) const events = timeline.translate( [{ id: '0:63', state: 'completed', result: 'Last sibling' }], { thread: 'parent', turn: 'later' }, @@ -70,10 +55,8 @@ describe('ACP subagent retention', () => { 'turn-0', 'turn-0' ]) - const retained = retainedSchema.parse(timeline) - expect(retained.groups.size).toBe(32) - expect(retained.groups.has('turn-0')).toBe(false) - expect(retained.groupOf.has('0:0')).toBe(false) + // The settled group is released with its replies; its children stay known as settled. + expect(timeline.retentionSizes()).toMatchObject({ groups: 32, replies: 0 }) expect(timeline.has('0:0')).toBe(true) expect(timeline.translate([{ id: '0:0', state: 'working' }], {}, 102)).toEqual([]) }) @@ -93,20 +76,18 @@ describe('ACP subagent retention', () => { group + 1 ) } - const retained = retainedSchema.parse(timeline) - expect(retained.groups.size).toBe(32) - expect(retained.groupOf.size).toBe(31 * 64 + 1) - expect( - [...retained.groups.values()].reduce((total, group) => total + group.results.size, 0) - ).toBe(31 * 64) - expect(retained.retention.settledIdentities.size).toBe(32 * 64) + expect(timeline.retentionSizes()).toEqual({ + groups: 32, + replies: 31 * 64, + settledIdentities: 32 * 64 + }) expect(timeline.has('live')).toBe(true) expect(timeline.has('0:0')).toBe(false) expect(timeline.has('33:0')).toBe(true) expect(timeline.translate([{ id: '33:0', state: 'working' }], {}, 2200)).toEqual([]) }) - it('bounds retained descriptions and count keys, preserving ordinary duplicate labels and clipped distinctions', () => { + it('bounds retained descriptions, preserving ordinary duplicate labels and clipped distinctions', () => { const timeline = new AcpSubagentTimeline() const description = 'A'.repeat(1024 * 1024) const updates = ['large-1', 'large-2'].flatMap((id) => { @@ -122,23 +103,16 @@ describe('ACP subagent retention', () => { { turn: 'turn' }, 0 ) - const retained = retainedSchema.parse(timeline).groups.get('turn') - expect(retained).toBeDefined() - const labels = [...(retained?.entries.values() ?? [])].map((entry) => entry.label) - expect(labels.every((label) => label.length <= MAX_SUBAGENT_FIELD_CHARS)).toBe(true) - expect( - [...(retained?.labelCounts.keys() ?? [])].every( - (key) => key.length <= MAX_SUBAGENT_FIELD_CHARS + const labels = events + .flatMap((event) => + event.type === 'item.update' && event.body.kind === 'message' + ? event.body.blocks.filter(isSubagentGroupBlock).flatMap((group) => group.agents) + : [] ) - ).toBe(true) + .map((entry) => entry.label) + expect(labels.every((label) => label.length <= MAX_SUBAGENT_FIELD_CHARS)).toBe(true) expect(new Set(labels).size).toBe(4) expect(labels.slice(2)).toEqual(['Review', 'Review 2']) - const emitted = events.flatMap((event) => - event.type === 'item.update' && event.body.kind === 'message' - ? event.body.blocks.filter(isSubagentGroupBlock).flatMap((group) => group.agents) - : [] - ) - expect(emitted.map((entry) => entry.label)).toEqual(labels) }) it('drops ownership and cached replies on disposal even if the producer continues', () => { @@ -146,11 +120,9 @@ describe('ACP subagent retention', () => { spawnGroups(timeline, 33) timeline.translate([{ id: 'child-0', state: 'completed', result: 'Answer' }], {}, 100) timeline.dispose() - const retained = retainedSchema.parse(timeline) - expect(retained.groups.size).toBe(0) - expect(retained.groupOf.size).toBe(0) - expect(retained.retention.settledIdentities.size).toBe(0) + const empty = { groups: 0, settledIdentities: 0, replies: 0 } + expect(timeline.retentionSizes()).toEqual(empty) expect(timeline.translate([{ id: 'child-1', tokens: 9 }, { id: 'new' }], {}, 101)).toEqual([]) - expect(retainedSchema.parse(timeline).groups.size).toBe(0) + expect(timeline.retentionSizes()).toEqual(empty) }) }) diff --git a/src/main/acp/acp-subagent-timeline.ts b/src/main/acp/acp-subagent-timeline.ts index 532f782210c..33b651f2b42 100644 --- a/src/main/acp/acp-subagent-timeline.ts +++ b/src/main/acp/acp-subagent-timeline.ts @@ -1,49 +1,47 @@ -import { - canReplaceSubagentState, - isTerminalSubagentState -} from '../../shared/native-chat-subagent-summary' -import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' -import { - boundSubagentField, - subagentGroupJournalBody -} from '../native-chat/agent-session-journal/journal-subagent-group-body' import type { ProviderTimelineJoin } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { SubagentTracker } from '../native-chat/subagent-tracker/subagent-tracker' +import type { SubagentGroup } from '../native-chat/subagent-tracker/subagent-tracker-types' import { acpSubagentIdentity, type AcpTimelineEvent } from './acp-timeline-event' import type { AcpSubagentUpdate } from './acp-dialects/acp-dialect' -import { SubagentRosterRetention } from '../native-chat/subagent-roster-retention' -/** Settled history budget; live ownership outlives it until an outcome or session close. */ -const MAX_GROUPS = 32 -const MAX_SUBAGENTS_PER_GROUP = 64 -const UNLABELLED = 'subagent' - -type RosterGroup = { - groupId: string - turn?: string - entries: Map - labelCounts: Map - lastSerialized: string | null - results: Map -} +/** The group a subagent reported outside any turn belongs to. */ +const OUTSIDE_TURN = 'thread' /** One roster row per spawning turn, revised in place, plus each completed subagent's reply as its - * own row, filed under its id so it opens beneath its roster entry. Lives as long as the provider - * child: a subagent an earlier run spawned is not known here, and its row is that run's. */ + * own row, filed under its id so it opens beneath its roster entry. The rows follow the shared + * subagent tracker's rules; this module only reads the dialect's updates. Lives as long as the + * provider child: a subagent an earlier run spawned is not known here, and its row is that run's. */ export class AcpSubagentTimeline { - private readonly groups = new Map() - private readonly groupOf = new Map() - private readonly retention = new SubagentRosterRetention(this.groups, { - maxGroups: MAX_GROUPS, - maxSettledIdentities: MAX_GROUPS * MAX_SUBAGENTS_PER_GROUP, - entries: (group) => group.entries.values(), - identities: (group) => group.entries.keys(), + /** Row events the tracker wrote during the current `translate`. */ + private written: AcpTimelineEvent[] = [] + private readonly results = new Map() + /** The time of the updates being translated: when they were observed. */ + private at = 0 + private readonly tracker = new SubagentTracker({ + // The host admits the events later and retries a refused one itself. + outsideTurn: (groupId) => groupId === OUTSIDE_TURN, + port: { + write: (group, { body }) => { + if (body) { + this.written.push({ + type: 'item.update', + item: `subagents:${group.groupId}`, + body, + subagentIdentities: [...group.entries.keys()].map(acpSubagentIdentity), + join: group.placement + }) + } + return { accepted: true } + } + }, + now: () => this.at, onEvict: (group) => { for (const id of group.entries.keys()) { - this.groupOf.delete(id) + this.results.delete(id) } } }) @@ -51,15 +49,20 @@ export class AcpSubagentTimeline { /** Known children include recently evicted outcomes, so they cannot become background tasks. */ has(id: string): boolean { - return this.groupOf.has(id) || this.retention.hasSettled(id) + return this.tracker.has(id) + } + + /** Retention is not observable through the update API, so expose what it holds. */ + retentionSizes(): { groups: number; settledIdentities: number; replies: number } { + return { ...this.tracker.sizes(), replies: this.results.size } } /** The journal owns session-end settlement; no later frame may reacquire working ownership. */ dispose(): void { this.disposed = true - this.groups.clear() - this.groupOf.clear() - this.retention.clear() + this.tracker.dispose() + this.results.clear() + this.written = [] } translate( @@ -70,135 +73,61 @@ export class AcpSubagentTimeline { if (this.disposed) { return [] } - const changed = new Set() - const replies: { id: string; group: RosterGroup; text: string }[] = [] - for (const update of updates) { - const group = this.apply(update, join, at) - if (!group) { - continue - } - changed.add(group) - const entry = group.entries.get(update.id) - if (update.result && entry?.state === 'completed') { - replies.push({ id: update.id, group, text: update.result }) + const replies: AcpTimelineEvent[] = [] + this.written = [] + // One revision per changed row; replies are cached before the batch releases settled rows, so a + // released row's replies go with it. + this.tracker.batch(() => { + for (const update of updates) { + this.apply(update, join, at) + const located = this.tracker.locate(update.id) + if (update.result && located?.tracked.entry.state === 'completed') { + replies.push(...this.reply(update.id, located.group, update.result)) + } } + }) + const events = [...this.written, ...replies] + this.written = [] + return events + } + + private reply( + id: string, + group: SubagentGroup, + text: string + ): AcpTimelineEvent[] { + const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + if (this.results.get(id) === bounded) { + return [] } - const events = [...changed].flatMap((group) => this.groupEvent(group, join)) - for (const { id, group, text } of replies) { - const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text - if (group.results.get(id) === bounded) { - continue - } - group.results.set(id, bounded) - events.push({ + this.results.set(id, bounded) + return [ + { type: 'item.update', item: `subagent-result:${id}`, body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: bounded }] }, producer: { agentId: id, producerKind: 'agent' }, - join: groupJoin(group, join) - }) - } - // Emit the final roster and reply before releasing their cached ownership together. - this.retention.trim(changed) - return events - } - - private apply( - update: AcpSubagentUpdate, - join: ProviderTimelineJoin, - at: number - ): RosterGroup | undefined { - if (this.retention.hasSettled(update.id)) { - return undefined - } - const known = this.groups.get(this.groupOf.get(update.id) ?? '') - const current = known?.entries.get(update.id) - if (known && current) { - const state = - update.state && canReplaceSubagentState(current.state, update.state) - ? update.state - : current.state - known.entries.set(update.id, { - ...current, - state, - ...(state !== current.state && isTerminalSubagentState(state) ? { settledAt: at } : {}), - ...(update.tokens ? { tokens: update.tokens } : {}) - }) - return known - } - if (update.knownOnly) { - return undefined - } - const group = this.groupFor(update.turn ?? join.turn) - if (group.entries.size >= MAX_SUBAGENTS_PER_GROUP) { - return undefined - } - const state = update.state ?? 'working' - group.entries.set(update.id, { - id: update.id, - label: this.claimLabel(group, update.label ?? UNLABELLED), - state, - startedAt: at, - ...(isTerminalSubagentState(state) ? { settledAt: at } : {}), - ...(update.tokens ? { tokens: update.tokens } : {}) - }) - this.groupOf.set(update.id, group.groupId) - return group - } - - private groupFor(turn: string | undefined): RosterGroup { - const groupId = turn ?? 'thread' - const existing = this.groups.get(groupId) - if (existing) { - return existing - } - const group: RosterGroup = { - groupId, - ...(turn === undefined ? {} : { turn }), - entries: new Map(), - labelCounts: new Map(), - lastSerialized: null, - results: new Map() - } - this.groups.set(groupId, group) - return group - } - - /** Two subagents with one description stay apart by ordinal, not by an invented name. */ - private claimLabel(group: RosterGroup, label: string): string { - const index = group.entries.size - const clipped = boundSubagentField(label, index) - // Copy clipped text so a slice cannot keep the full provider description alive. - const bounded = clipped === label ? label : [...clipped].join('') - const seen = group.labelCounts.get(bounded) ?? 0 - group.labelCounts.set(bounded, seen + 1) - return boundSubagentField(seen === 0 ? bounded : `${bounded} ${seen + 1}`, index) - } - - private groupEvent(group: RosterGroup, join: ProviderTimelineJoin): AcpTimelineEvent[] { - const entries = [...group.entries.values()] - const body = subagentGroupJournalBody(group.groupId, entries) - const serialized = JSON.stringify(body) - if (serialized === group.lastSerialized) { - return [] - } - // A repeated report must not burn a revision; the host retries a refused event itself. - group.lastSerialized = serialized - return [ - { - type: 'item.update', - item: `subagents:${group.groupId}`, - body, - subagentIdentities: entries.map((entry) => acpSubagentIdentity(entry.id)), - join: groupJoin(group, join) + join: group.placement } ] } -} -function groupJoin(group: RosterGroup, join: ProviderTimelineJoin): ProviderTimelineJoin { - return { - ...(join.thread === undefined ? {} : { thread: join.thread }), - ...(group.turn ? { turn: group.turn } : {}) + private apply(update: AcpSubagentUpdate, join: ProviderTimelineJoin, at: number): void { + const turn = update.turn ?? join.turn + const placement: ProviderTimelineJoin = { + ...(join.thread === undefined ? {} : { thread: join.thread }), + ...(turn === undefined ? {} : { turn }) + } + this.at = at + this.tracker.report({ + id: update.id, + group: { id: turn ?? OUTSIDE_TURN, placement: () => placement }, + announces: update.knownOnly !== true, + ...(update.label !== undefined ? { label: update.label } : {}), + ...(update.state !== undefined ? { state: update.state } : {}), + ...(update.tokens !== undefined ? { tokens: update.tokens } : {}), + // A subagent the provider runs outlives the prompt turn that spawned it. + backgrounded: true + }) } } diff --git a/src/main/claude/__snapshots__/claude-subagent-roster-characterization.test.ts.snap b/src/main/claude/__snapshots__/claude-subagent-roster-characterization.test.ts.snap new file mode 100644 index 00000000000..e894e027798 --- /dev/null +++ b/src/main/claude/__snapshots__/claude-subagent-roster-characterization.test.ts.snap @@ -0,0 +1,1734 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Claude subagent roster rows (characterization) > a child announced outside any turn is not swept by a turn end 1`] = ` +[ + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "Loose", + "startedAt": 1010, + "state": "working", + }, + ], + "groupId": "outside-turn", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:outside-turn", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "thread", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 1 subagent (1 unverifiable)", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "Loose", + "settledAt": 1030, + "startedAt": 1010, + "state": "unverifiable", + }, + ], + "groupId": "outside-turn", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:outside-turn", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "thread", + }, + }, + { + "accepted": true, + "op": "publish", + }, +] +`; + +exports[`Claude subagent roster rows (characterization) > a later run inherits the row an earlier run journaled and revises it 1`] = ` +[ + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 2 subagents", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "Audit", + "startedAt": 5, + "state": "completed", + }, + { + "id": "t2", + "label": "Audit 2", + "settledAt": 9, + "startedAt": 6, + "state": "completed", + }, + ], + "groupId": "s:turn-0", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-0", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-0", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 2 subagents", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "Audit", + "startedAt": 1020, + "state": "working", + }, + { + "id": "t2", + "label": "Audit 2", + "settledAt": 9, + "startedAt": 6, + "state": "completed", + }, + ], + "groupId": "s:turn-0", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-0", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-0", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "t4", + "label": "Audit", + "startedAt": 1030, + "state": "working", + }, + ], + "groupId": "s:turn-3", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-3", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-3", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 2 subagents (1 unverifiable)", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "Audit", + "settledAt": 1040, + "startedAt": 1020, + "state": "unverifiable", + }, + { + "id": "t2", + "label": "Audit 2", + "settledAt": 9, + "startedAt": 6, + "state": "completed", + }, + ], + "groupId": "s:turn-0", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-0", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-0", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 1 subagent (1 unverifiable)", + "type": "text", + }, + { + "agents": [ + { + "id": "t4", + "label": "Audit", + "settledAt": 1040, + "startedAt": 1030, + "state": "unverifiable", + }, + ], + "groupId": "s:turn-3", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-3", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-3", + }, + }, + { + "accepted": true, + "op": "publish", + }, +] +`; + +exports[`Claude subagent roster rows (characterization) > a provisional row from child traffic takes the announced name and canonical id 1`] = ` +[ + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "u1", + "label": "subagent", + "startedAt": 1010, + "state": "working", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "Named", + "startedAt": 1010, + "state": "working", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "Named", + "settledAt": 1050, + "startedAt": 1010, + "state": "completed", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, +] +`; + +exports[`Claude subagent roster rows (characterization) > a refused append or publish is retried by the next write of that row 1`] = ` +[ + { + "accepted": false, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "A", + "startedAt": 1010, + "state": "working", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "A", + "startedAt": 1010, + "state": "working", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 2 subagents", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "A", + "startedAt": 1010, + "state": "working", + }, + { + "id": "t2", + "label": "B", + "startedAt": 1030, + "state": "working", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 2 subagents", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "A", + "startedAt": 1010, + "state": "working", + }, + { + "id": "t2", + "label": "B", + "settledAt": 1040, + "startedAt": 1030, + "state": "completed", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": false, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 2 subagents", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "A", + "startedAt": 1010, + "state": "working", + }, + { + "id": "t2", + "label": "B", + "settledAt": 1040, + "startedAt": 1030, + "state": "completed", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 2 subagents (1 unverifiable)", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "A", + "settledAt": 1060, + "startedAt": 1010, + "state": "unverifiable", + }, + { + "id": "t2", + "label": "B", + "settledAt": 1040, + "startedAt": 1030, + "state": "completed", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, +] +`; + +exports[`Claude subagent roster rows (characterization) > a resume under a new tool id in a later turn reopens the original row 1`] = ` +[ + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "Audit", + "startedAt": 1010, + "state": "working", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "Audit", + "settledAt": 1020, + "startedAt": 1010, + "state": "completed", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "Audit", + "startedAt": 1040, + "state": "working", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "Audit", + "settledAt": 1060, + "startedAt": 1040, + "state": "completed", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, +] +`; + +exports[`Claude subagent roster rows (characterization) > an announcement that is not a subagent tombstones the provisional row 1`] = ` +[ + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "u1", + "label": "subagent", + "startedAt": 1010, + "state": "working", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "tombstone", + }, + { + "accepted": true, + "op": "publish", + }, +] +`; + +exports[`Claude subagent roster rows (characterization) > more than 32 settled groups, then a late frame for an evicted child 1`] = `138`; + +exports[`Claude subagent roster rows (characterization) > more than 32 settled groups, then a late frame for an evicted child 2`] = ` +[ + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "t33", + "label": "Job 33", + "startedAt": 1670, + "state": "working", + }, + ], + "groupId": "s:turn-33", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-33", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-33", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "t33", + "label": "Job 33", + "settledAt": 1680, + "startedAt": 1670, + "state": "completed", + }, + ], + "groupId": "s:turn-33", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-33", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-33", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "live", + "label": "Live", + "startedAt": 1690, + "state": "working", + }, + ], + "groupId": "s:turn-live", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-live", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-live", + }, + }, + { + "accepted": true, + "op": "publish", + }, +] +`; + +exports[`Claude subagent roster rows (characterization) > one foreground child settled by its spawn call result 1`] = ` +[ + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "Audit", + "startedAt": 1010, + "state": "working", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "Audit", + "settledAt": 1030, + "startedAt": 1010, + "state": "completed", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, +] +`; + +exports[`Claude subagent roster rows (characterization) > two children sharing a label, one backgrounded, through turn end and session end 1`] = ` +[ + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "Audit", + "startedAt": 1010, + "state": "working", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 2 subagents", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "Audit", + "startedAt": 1010, + "state": "working", + }, + { + "id": "t2", + "label": "Audit 2", + "startedAt": 1020, + "state": "working", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 3 subagents", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "Audit", + "startedAt": 1010, + "state": "working", + }, + { + "id": "t2", + "label": "Audit 2", + "startedAt": 1020, + "state": "working", + }, + { + "id": "t3", + "label": "Audit 2 2", + "startedAt": 1030, + "state": "working", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 3 subagents (2 unverifiable)", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "Audit", + "settledAt": 1040, + "startedAt": 1010, + "state": "unverifiable", + }, + { + "id": "t2", + "label": "Audit 2", + "startedAt": 1020, + "state": "working", + }, + { + "id": "t3", + "label": "Audit 2 2", + "settledAt": 1040, + "startedAt": 1030, + "state": "unverifiable", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 3 subagents (1 unverifiable)", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "Audit", + "settledAt": 1040, + "startedAt": 1010, + "state": "unverifiable", + }, + { + "id": "t2", + "label": "Audit 2", + "startedAt": 1020, + "state": "working", + }, + { + "id": "t3", + "label": "Audit 2 2", + "settledAt": 1050, + "startedAt": 1030, + "state": "completed", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 3 subagents (2 unverifiable)", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "Audit", + "settledAt": 1040, + "startedAt": 1010, + "state": "unverifiable", + }, + { + "id": "t2", + "label": "Audit 2", + "settledAt": 1060, + "startedAt": 1020, + "state": "unverifiable", + }, + { + "id": "t3", + "label": "Audit 2 2", + "settledAt": 1050, + "startedAt": 1030, + "state": "completed", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, +] +`; + +exports[`Claude subagent roster rows (characterization) > verdicts after lost contact, failures and stops 1`] = ` +[ + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "A", + "startedAt": 1010, + "state": "working", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 2 subagents", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "A", + "startedAt": 1010, + "state": "working", + }, + { + "id": "t2", + "label": "B", + "startedAt": 1020, + "state": "working", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 3 subagents", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "A", + "startedAt": 1010, + "state": "working", + }, + { + "id": "t2", + "label": "B", + "startedAt": 1020, + "state": "working", + }, + { + "id": "t3", + "label": "C", + "startedAt": 1030, + "state": "working", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 3 subagents (1 stopped)", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "A", + "startedAt": 1010, + "state": "working", + }, + { + "id": "t2", + "label": "B", + "settledAt": 1040, + "startedAt": 1020, + "state": "stopped", + }, + { + "id": "t3", + "label": "C", + "startedAt": 1030, + "state": "working", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 3 subagents (1 failed)", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "A", + "startedAt": 1010, + "state": "working", + }, + { + "id": "t2", + "label": "B", + "settledAt": 1040, + "startedAt": 1020, + "state": "stopped", + }, + { + "id": "t3", + "label": "C", + "settledAt": 1050, + "startedAt": 1030, + "state": "failed", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 3 subagents (1 failed)", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "A", + "settledAt": 1060, + "startedAt": 1010, + "state": "unverifiable", + }, + { + "id": "t2", + "label": "B", + "settledAt": 1040, + "startedAt": 1020, + "state": "stopped", + }, + { + "id": "t3", + "label": "C", + "settledAt": 1050, + "startedAt": 1030, + "state": "failed", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 3 subagents (1 failed)", + "type": "text", + }, + { + "agents": [ + { + "id": "t1", + "label": "A", + "settledAt": 1070, + "startedAt": 1010, + "state": "completed", + }, + { + "id": "t2", + "label": "B", + "settledAt": 1040, + "startedAt": 1020, + "state": "stopped", + }, + { + "id": "t3", + "label": "C", + "settledAt": 1050, + "startedAt": 1030, + "state": "failed", + }, + ], + "groupId": "s:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "claude-subagents:s:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "turn-row:s:turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, +] +`; diff --git a/src/main/claude/claude-subagent-group-row.ts b/src/main/claude/claude-subagent-group-row.ts deleted file mode 100644 index be51effa2cc..00000000000 --- a/src/main/claude/claude-subagent-group-row.ts +++ /dev/null @@ -1,144 +0,0 @@ -// The journal row one Claude spawn group writes: its durable identity and the -// body it revises in place. - -import type { - AgentJournalItemBody, - AgentJournalItemIdentity, - AgentJournalTurnScope -} from '../../shared/agent-session-journal-types' -import { subagentGroupFallbackText } from '../../shared/native-chat-subagent-summary' -import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { RosterGroup } from './claude-subagent-roster-state' - -type GroupRowAttempts = (entryId: string) => number - -/** A group row an earlier run journaled: its children and the turn it was written beside. */ -export type JournaledClaudeSubagentGroup = { - entries: readonly NativeChatSubagentEntry[] - turnScope: AgentJournalTurnScope -} - -/** Durable journal identity for the group's row — stable across revisions and - * across a restart, so replay finds the same row instead of appending a new one. */ -export function claudeSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity { - return { provider: 'orca', clientMessageId: `claude-subagents:${groupId}` } -} - -/** - * A group row an earlier provider run journaled, as this run's roster state. - * - * Inheriting writes nothing: the row is revised only when this run's own events - * change the group, and then it keeps every child the earlier run listed. Each - * child outlived the run that spawned it, so none is tied to this run's turns. - */ -export function inheritedClaudeSubagentGroup( - groupId: string, - { entries, turnScope }: JournaledClaudeSubagentGroup, - attemptOf: GroupRowAttempts -): RosterGroup { - const group: RosterGroup = { - groupId, - identity: claudeSubagentGroupIdentity(groupId), - // The row keeps the turn it was created beside; a later run's writes never move it. - turnScope, - entries: new Map(), - admittedEntries: entries.length, - claimedLabels: new Set(), - lastSerialized: JSON.stringify(claudeSubagentGroupBody(groupId, entries)) - } - for (const entry of entries) { - group.claimedLabels.add(entry.label) - group.entries.set(entry.id, { - entry: { ...entry }, - backgrounded: true, - toolUseId: null, - invocationIds: new Set(), - labelBase: entry.label, - attempt: attemptOf(entry.id), - invokedInEarlierRun: true - }) - } - return group -} - -/** The roster row: the structured block plus the plain sentence an older client - * renders in its place. A message whose only block is the new variant would - * reach such a client with nothing it can draw. */ -export function claudeSubagentGroupBody( - groupId: string, - agents: readonly NativeChatSubagentEntry[] -): AgentJournalItemBody { - return { - kind: 'message', - role: 'system', - blocks: [ - { type: 'text', text: subagentGroupFallbackText(agents) }, - { type: 'subagent-group', groupId, agents: [...agents] } - ] - } -} - -/** - * Revise one group's row to match its current children. - * - * Deliberately ROOT, and it is the one row in this lane where that needs saying: - * it is written from a child's frame but describes the PARENT's children, so it - * is the session's own agent reporting what it spawned. Stamping it as a child's - * would hide the roster from the very row that owns it. - */ -export function writeClaudeSubagentGroupRow( - sink: StructuredAgentSessionEventSink, - group: RosterGroup -): void { - const agents = [...group.entries.values()].map((tracked) => tracked.entry) - if (agents.length === 0) { - const serialized = JSON.stringify(claudeSubagentGroupBody(group.groupId, [])) - if (serialized === group.lastSerialized) { - return - } - // Reclassification removes the row; refused tombstones remain retryable. - const admission = sink.tryAppendTombstone?.(group.identity) - if (admission && !admission.accepted) { - group.lastSerialized = null - return - } - if (!admission) { - sink.appendTombstone(group.identity) - } - const publication = sink.tryPublish?.() - if (publication && !publication.accepted) { - group.lastSerialized = null - return - } - if (!publication) { - sink.publish() - } - group.lastSerialized = serialized - return - } - const body = claudeSubagentGroupBody(group.groupId, agents) - const serialized = JSON.stringify(body) - if (serialized === group.lastSerialized) { - // Nothing changed — a duplicate delivery must not burn a revision. - return - } - const options = { turnScope: group.turnScope } - const admission = sink.tryAppendItem?.(group.identity, body, options) - if (admission && !admission.accepted) { - group.lastSerialized = null - return - } - if (!admission) { - sink.appendItem(group.identity, body, options) - } - const publication = sink.tryPublish?.() - if (publication && !publication.accepted) { - group.lastSerialized = null - return - } - if (!publication) { - sink.publish() - } - group.lastSerialized = serialized -} diff --git a/src/main/claude/claude-subagent-journaled-roster.test.ts b/src/main/claude/claude-subagent-journaled-roster.test.ts index d99bf28b2ad..b6f806fbe89 100644 --- a/src/main/claude/claude-subagent-journaled-roster.test.ts +++ b/src/main/claude/claude-subagent-journaled-roster.test.ts @@ -7,8 +7,11 @@ import type { } from '../../shared/agent-session-journal-types' import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' import type { StructuredAgentSessionLinkageJournal } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { claudeSubagentGroupBody, claudeSubagentGroupIdentity } from './claude-subagent-group-row' -import { ClaudeJournaledRoster } from './claude-subagent-journaled-roster' +import { subagentGroupJournalBody } from '../native-chat/agent-session-journal/journal-subagent-group-body' +import { + ClaudeJournaledRoster, + claudeSubagentGroupIdentity +} from './claude-subagent-journaled-roster' type Row = { itemId: string @@ -39,7 +42,7 @@ const groupRow = ( ): Row => ({ itemId, sequence, - body: claudeSubagentGroupBody(groupId, agents), + body: subagentGroupJournalBody(groupId, agents), linkage: turnScope ? { turnScope } : {} }) @@ -134,7 +137,7 @@ describe('ClaudeJournaledRoster', () => { const claimed = journaled.claimGroup('turn-a') expect(claimed?.entries.map((agent) => agent.id)).toEqual(['task-a', 'task-b']) // The row keeps the turn it was created beside. - expect(claimed?.turnScope).toEqual(turnA) + expect(claimed?.placement).toEqual(turnA) // After that the roster's own copy is the newer one. expect(journaled.claimGroup('turn-a')).toBeNull() }) diff --git a/src/main/claude/claude-subagent-journaled-roster.ts b/src/main/claude/claude-subagent-journaled-roster.ts index 8c84e6f2e5c..3d5d5df6b4f 100644 --- a/src/main/claude/claude-subagent-journaled-roster.ts +++ b/src/main/claude/claude-subagent-journaled-roster.ts @@ -1,120 +1,60 @@ // What earlier runs of this session left of the Claude subagent roster, re-derived // from the rows they journaled. // -// The roster lives in one provider process, but everything it writes outlives that -// process: a group row keeps a restart-stable identity, and every agent row names its -// canonical id beside the call its frames arrived under. A run that started from -// nothing re-rostered a resumed child in a second row, restarted its attempts, lost -// the alias its resumed frames still carry, and rewrote the one group row no turn -// owns from empty. So a run reads what earlier runs left instead: derived, never -// stored, so it cannot disagree with the rows it came from. +// Beyond the group rows every agent's tracker inherits, a Claude run needs two more +// facts earlier runs recorded on their child rows: the canonical task id each spawn +// call resolved to (a resumed child's frames still carry the old call), and the +// latest run of each child. Both come from the same read of the journal. -import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalProducerLinkage +import type { + AgentJournalItemIdentity, + AgentJournalProducerLinkage, + AgentJournalTurnScope } from '../../shared/agent-session-journal-types' -import { isSubagentGroupBlock } from '../../shared/native-chat-types' import type { StructuredAgentSessionLinkageJournal } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { JournaledSubagentGroups } from '../native-chat/subagent-tracker/journaled-subagent-groups' +import type { JournaledSubagentSource } from '../native-chat/subagent-tracker/subagent-tracker-types' import { isBoundedClaudeTaskId } from './claude-background-task-tracker' -import { - claudeSubagentGroupIdentity, - type JournaledClaudeSubagentGroup -} from './claude-subagent-group-row' + +/** Durable journal identity for a group's row — stable across revisions and + * across a restart, so replay finds the same row instead of appending a new one. */ +export function claudeSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `claude-subagents:${groupId}` } +} /** What the roster asks of earlier runs. */ -export type ClaudeJournaledRosterSource = { +export type ClaudeJournaledRosterSource = JournaledSubagentSource & { /** The task id an earlier run resolved this spawn call to. */ canonical: (toolUseId: string) => string | null - /** The group row an earlier run last listed this child in. */ - groupOf: (entryId: string) => string | null - /** An earlier run's row for one group, handed over once: after that the - * roster's own copy is the newer one. */ - claimGroup: (groupId: string) => JournaledClaudeSubagentGroup | null - /** The latest run of this child any row records; 1 when none says more. */ - attempt: (agentId: string) => number } -type JournaledRosterReading = { +type ClaudeAgentRows = { canonicalByToolUse: Map attemptByAgent: Map - rowsByGroup: Map - groupByEntry: Map } -/** - * Read once per bound journal epoch. Before the journal is bound nothing is read and - * nothing is kept, so the first read after bind still sees every row. - */ export class ClaudeJournaledRoster implements ClaudeJournaledRosterSource { - private journal: StructuredAgentSessionLinkageJournal | null = null - private epoch: string | null = null - private reading: JournaledRosterReading | null = null + private readonly rows: JournaledSubagentGroups - constructor(private readonly bound: () => StructuredAgentSessionLinkageJournal | null) {} + constructor(bound: () => StructuredAgentSessionLinkageJournal | null) { + this.rows = new JournaledSubagentGroups(bound, claudeSubagentGroupIdentity, { + create: () => ({ canonicalByToolUse: new Map(), attemptByAgent: new Map() }), + visit: readAgentRow + }) + } canonical = (toolUseId: string): string | null => - this.current()?.canonicalByToolUse.get(toolUseId) ?? null + this.rows.extra()?.canonicalByToolUse.get(toolUseId) ?? null - groupOf = (entryId: string): string | null => this.current()?.groupByEntry.get(entryId) ?? null + groupOf = (entryId: string): string | null => this.rows.groupOf(entryId) - claimGroup = (groupId: string): JournaledClaudeSubagentGroup | null => { - const reading = this.current() - const row = reading?.rowsByGroup.get(groupId) ?? null - reading?.rowsByGroup.delete(groupId) - return row - } + claimGroup = (groupId: string) => this.rows.claimGroup(groupId) - attempt = (agentId: string): number => this.current()?.attemptByAgent.get(agentId) ?? 1 - - private current(): JournaledRosterReading | null { - const journal = this.bound() - if (!journal) { - return null - } - if (!this.reading || journal !== this.journal || journal.epoch !== this.epoch) { - this.journal = journal - this.epoch = journal.epoch - this.reading = readJournaledRoster(journal) - } - return this.reading - } -} - -function readJournaledRoster( - journal: StructuredAgentSessionLinkageJournal -): JournaledRosterReading { - const reading: JournaledRosterReading = { - canonicalByToolUse: new Map(), - attemptByAgent: new Map(), - rowsByGroup: new Map(), - groupByEntry: new Map() - } - // A child two rows list (only an older build wrote that) is the later-created row's: a turn's - // row is created with its turn, so that is where it last ran. - const listedAt = new Map() - journal.visitItemsWithLinkage((itemId, sequence, body, attribution) => { - readAgentRow(reading, attribution) - const group = body.kind === 'message' ? body.blocks.find(isSubagentGroupBlock) : undefined - if (!group || itemId !== agentJournalItemKey(claudeSubagentGroupIdentity(group.groupId))) { - return - } - reading.rowsByGroup.set(group.groupId, { - entries: group.agents, - turnScope: attribution.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE - }) - for (const entry of group.agents) { - if ((listedAt.get(entry.id) ?? -1) < sequence) { - listedAt.set(entry.id, sequence) - reading.groupByEntry.set(entry.id, group.groupId) - } - } - }) - return reading + attempt = (agentId: string): number => this.rows.extra()?.attemptByAgent.get(agentId) ?? 1 } function readAgentRow( - reading: JournaledRosterReading, + reading: ClaudeAgentRows, { agentId, providerParentRef, producerKind, attempt }: AgentJournalProducerLinkage ): void { if (producerKind !== 'agent' || agentId === undefined) { diff --git a/src/main/claude/claude-subagent-resume-after-restart.test.ts b/src/main/claude/claude-subagent-resume-after-restart.test.ts index d95dd3a48ff..1a02e576984 100644 --- a/src/main/claude/claude-subagent-resume-after-restart.test.ts +++ b/src/main/claude/claude-subagent-resume-after-restart.test.ts @@ -15,7 +15,8 @@ import type { AgentSessionJournal } from '../native-chat/agent-session-journal/j import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' -import { claudeSubagentGroupBody, claudeSubagentGroupIdentity } from './claude-subagent-group-row' +import { subagentGroupJournalBody } from '../native-chat/agent-session-journal/journal-subagent-group-body' +import { claudeSubagentGroupIdentity } from './claude-subagent-journaled-roster' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' @@ -210,7 +211,7 @@ async function journalAnOlderBuildListedTwice(): Promise { ] as const) { older.sink.appendItem( claudeSubagentGroupIdentity(groupId), - claudeSubagentGroupBody(groupId, agents), + subagentGroupJournalBody(groupId, agents), { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } diff --git a/src/main/claude/claude-subagent-roster-characterization.test.ts b/src/main/claude/claude-subagent-roster-characterization.test.ts new file mode 100644 index 00000000000..d48ca7f1ae4 --- /dev/null +++ b/src/main/claude/claude-subagent-roster-characterization.test.ts @@ -0,0 +1,196 @@ +// Golden row writes for the Claude subagent roster: every append, tombstone and publish it makes +// for scripted frame sequences, so moving the roster onto shared code cannot change a saved byte. + +import { describe, expect, it } from 'vitest' +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' +import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' +import { recordingSubagentRowSink } from '../native-chat/subagent-row-sink-recorder-test-support' +import type { ClaudeJournaledRosterSource } from './claude-subagent-journaled-roster' +import { ClaudeSubagentRoster } from './claude-subagent-roster' + +/** The clock moves once per delivered frame, never per read, so a row's times do not depend on how + * often the roster happens to read it. */ +function harness(journaled?: ClaudeJournaledRosterSource) { + const recorder = recordingSubagentRowSink() + let clock = 1_000 + let key: string | null = 's:turn-1' + const scope = (): AgentJournalTurnScope => + key === null ? { kind: 'thread' } : { kind: 'turn', turnItemId: `turn-row:${key}` } + const inner = new ClaudeSubagentRoster({ + sink: recorder.sink, + currentGroupKey: () => key, + currentTurnScope: scope, + ...(journaled ? { journaled } : {}), + now: () => clock + }) + const tick = (deliver: () => T): T => { + clock += 10 + return deliver() + } + const roster = { + observeSystemFrame: (message: Record) => + tick(() => inner.observeSystemFrame(message)), + observeChildActivity: (toolUseId: string) => tick(() => inner.observeChildActivity(toolUseId)), + observeToolResult: (toolUseId: string, failed: boolean) => + tick(() => inner.observeToolResult(toolUseId, failed)), + settleTurn: (groupKey: string | null) => tick(() => inner.settleTurn(groupKey)), + settleSession: () => tick(() => inner.settleSession()), + dispose: () => tick(() => inner.dispose()) + } + return { + ...recorder, + roster, + turn: (next: string | null) => { + key = next + } + } +} + +function frame(subtype: string, fields: Record): Record { + return { type: 'system', subtype, session_id: 's', ...fields } +} + +const started = (fields: Record) => + frame('task_started', { task_type: 'local_agent', ...fields }) +const notified = (fields: Record) => frame('task_notification', fields) + +describe('Claude subagent roster rows (characterization)', () => { + it('one foreground child settled by its spawn call result', () => { + const { roster, log } = harness() + roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Audit' })) + roster.observeSystemFrame(frame('task_progress', { task_id: 't1', tool_use_id: 'u1' })) + roster.observeToolResult('u1', false) + roster.observeToolResult('u1', false) + roster.settleTurn('s:turn-1') + expect(log).toMatchSnapshot() + }) + + it('two children sharing a label, one backgrounded, through turn end and session end', () => { + const { roster, log } = harness() + roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Audit' })) + roster.observeSystemFrame( + started({ task_id: 't2', tool_use_id: 'u2', description: 'Audit', is_backgrounded: true }) + ) + roster.observeSystemFrame(started({ task_id: 't3', tool_use_id: 'u3', description: 'Audit 2' })) + roster.settleTurn('s:turn-1') + roster.observeSystemFrame(notified({ task_id: 't3', tool_use_id: 'u3', status: 'completed' })) + roster.settleSession() + roster.dispose() + expect(log).toMatchSnapshot() + }) + + it('verdicts after lost contact, failures and stops', () => { + const { roster, log } = harness() + roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'A' })) + roster.observeSystemFrame(started({ task_id: 't2', tool_use_id: 'u2', description: 'B' })) + roster.observeSystemFrame(started({ task_id: 't3', tool_use_id: 'u3', description: 'C' })) + roster.observeSystemFrame(notified({ task_id: 't2', tool_use_id: 'u2', status: 'killed' })) + roster.observeToolResult('u3', true) + roster.settleTurn('s:turn-1') + roster.observeSystemFrame(notified({ task_id: 't1', tool_use_id: 'u1', status: 'completed' })) + roster.observeSystemFrame(notified({ task_id: 't2', tool_use_id: 'u2', status: 'completed' })) + expect(log).toMatchSnapshot() + }) + + it('more than 32 settled groups, then a late frame for an evicted child', () => { + const { roster, log, turn } = harness() + for (let index = 0; index < 34; index++) { + turn(`s:turn-${index}`) + roster.observeSystemFrame( + started({ task_id: `t${index}`, tool_use_id: `u${index}`, description: `Job ${index}` }) + ) + roster.observeSystemFrame( + notified({ task_id: `t${index}`, tool_use_id: `u${index}`, status: 'completed' }) + ) + } + turn('s:turn-live') + roster.observeSystemFrame(started({ task_id: 'live', tool_use_id: 'ul', description: 'Live' })) + roster.observeSystemFrame(notified({ task_id: 't0', tool_use_id: 'u0', status: 'failed' })) + roster.observeSystemFrame(started({ task_id: 't0', tool_use_id: 'u0', description: 'Job 0' })) + expect(log.length).toMatchSnapshot() + expect(log.slice(-6)).toMatchSnapshot() + }) + + it('a provisional row from child traffic takes the announced name and canonical id', () => { + const { roster, log } = harness() + roster.observeChildActivity('u1') + roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Named' })) + roster.observeChildActivity('u1') + roster.observeChildActivity('u-unknown') + roster.observeSystemFrame(notified({ task_id: 't1', tool_use_id: 'u1', status: 'completed' })) + expect(log).toMatchSnapshot() + }) + + it('an announcement that is not a subagent tombstones the provisional row', () => { + const { roster, log } = harness() + roster.observeChildActivity('u1') + roster.observeSystemFrame( + frame('task_started', { task_id: 't1', tool_use_id: 'u1', task_type: 'local_bash' }) + ) + roster.observeChildActivity('u1') + expect(log).toMatchSnapshot() + }) + + it('a resume under a new tool id in a later turn reopens the original row', () => { + const { roster, log, turn } = harness() + roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Audit' })) + roster.observeToolResult('u1', false) + roster.settleTurn('s:turn-1') + turn('s:turn-2') + roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1b', description: 'Audit' })) + roster.observeSystemFrame(notified({ task_id: 't1', tool_use_id: 'u1', status: 'failed' })) + roster.observeToolResult('u1b', false) + roster.settleTurn('s:turn-2') + expect(log).toMatchSnapshot() + }) + + it('a child announced outside any turn is not swept by a turn end', () => { + const { roster, log, turn } = harness() + turn(null) + roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Loose' })) + turn('s:turn-9') + roster.settleTurn('s:turn-9') + roster.settleTurn(null) + expect(log).toMatchSnapshot() + }) + + it('a refused append or publish is retried by the next write of that row', () => { + const { roster, log, refuseNextAppend, refuseNextPublish } = harness() + refuseNextAppend() + roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'A' })) + roster.observeSystemFrame(frame('task_progress', { task_id: 't1', tool_use_id: 'u1' })) + roster.observeSystemFrame(started({ task_id: 't2', tool_use_id: 'u2', description: 'B' })) + refuseNextPublish() + roster.observeToolResult('u2', false) + roster.observeToolResult('u2', false) + roster.settleTurn('s:turn-1') + expect(log).toMatchSnapshot() + }) + + it('a later run inherits the row an earlier run journaled and revises it', () => { + const earlier: NativeChatSubagentEntry[] = [ + { id: 't1', label: 'Audit', state: 'working', startedAt: 5 }, + { id: 't2', label: 'Audit 2', state: 'completed', startedAt: 6, settledAt: 9 } + ] + let claimed = false + const journaled: ClaudeJournaledRosterSource = { + canonical: (toolUseId) => (toolUseId === 'u1' ? 't1' : null), + groupOf: (id) => (id === 't1' || id === 't2' ? 's:turn-0' : null), + claimGroup: (groupId) => { + if (groupId !== 's:turn-0' || claimed) { + return null + } + claimed = true + return { entries: earlier, placement: { kind: 'turn', turnItemId: 'turn-row:s:turn-0' } } + }, + attempt: (id) => (id === 't1' ? 2 : 1) + } + const { roster, log, turn } = harness(journaled) + turn('s:turn-3') + roster.observeSystemFrame(notified({ task_id: 't1', status: 'completed' })) + roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u9', description: 'Audit' })) + roster.observeSystemFrame(started({ task_id: 't4', tool_use_id: 'u4', description: 'Audit' })) + roster.settleSession() + expect(log).toMatchSnapshot() + }) +}) diff --git a/src/main/claude/claude-subagent-roster-groups.test.ts b/src/main/claude/claude-subagent-roster-groups.test.ts deleted file mode 100644 index eee98ae3eaf..00000000000 --- a/src/main/claude/claude-subagent-roster-groups.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalTurnScope -} from '../../shared/agent-session-journal-types' -import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' -import type { JournaledClaudeSubagentGroup } from './claude-subagent-group-row' -import type { ClaudeJournaledRosterSource } from './claude-subagent-journaled-roster' -import { ClaudeSubagentRosterGroups } from './claude-subagent-roster-groups' -import { inheritedClaudeSubagentGroup } from './claude-subagent-group-row' - -const entry = (id: string): NativeChatSubagentEntry => ({ - id, - label: `Child ${id}`, - state: 'unverifiable', - startedAt: 1, - settledAt: 2 -}) - -const turnScope = (groupId: string): AgentJournalTurnScope => ({ - kind: 'turn', - turnItemId: `claude:claude-session:${groupId}` -}) - -/** An older build listed `agent-a` in both rows; the reading places it in the later one. */ -function journaledTwiceListed(): ClaudeJournaledRosterSource { - const rows = new Map([ - ['turn-a', { entries: [entry('agent-a'), entry('agent-b')], turnScope: turnScope('turn-a') }], - ['turn-b', { entries: [entry('agent-a')], turnScope: turnScope('turn-b') }] - ]) - const groupOf = new Map([ - ['agent-a', 'turn-b'], - ['agent-b', 'turn-a'] - ]) - return { - canonical: () => null, - groupOf: (id) => groupOf.get(id) ?? null, - claimGroup: (groupId) => { - const row = rows.get(groupId) ?? null - rows.delete(groupId) - return row - }, - attempt: () => 1 - } -} - -describe('ClaudeSubagentRosterGroups', () => { - it('places a twice-listed child where the reading does, whichever row is reached first', () => { - for (const order of [ - ['agent-a', 'agent-b'], - ['agent-b', 'agent-a'] - ]) { - const groups = new ClaudeSubagentRosterGroups({ - journaled: journaledTwiceListed(), - currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE - }) - for (const id of order) { - groups.locateOrInherit(id) - } - expect(groups.locate('agent-a')?.group.groupId).toBe('turn-b') - expect(groups.locate('agent-b')?.group.groupId).toBe('turn-a') - } - }) - - it('keeps a child placed in a held row when another row listing it is evicted', () => { - const groups = new ClaudeSubagentRosterGroups({ - journaled: journaledTwiceListed(), - currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE - }) - const inherited = groups.locateOrInherit('agent-b') - groups.locateOrInherit('agent-a') - if (inherited) { - groups.trim(inherited.group) - } - // Enough of this run's own rows to push the older inherited row out. - for (let turn = 0; turn < 31; turn += 1) { - groups.trim(groups.groupFor(`turn-${turn}-new`)) - } - expect(groups.get('turn-a')).toBeUndefined() - expect(groups.locate('agent-b')).toBeNull() - expect(groups.locate('agent-a')?.group.groupId).toBe('turn-b') - }) - - it('keeps an inherited row beside the turn that wrote it, and puts a new row beside the open turn', () => { - const live = turnScope('turn-live') - const groups = new ClaudeSubagentRosterGroups({ - journaled: journaledTwiceListed(), - currentTurnScope: () => live - }) - expect(groups.locateOrInherit('agent-b')?.group.turnScope).toEqual(turnScope('turn-a')) - expect(groups.groupFor('turn-live').turnScope).toEqual(live) - }) -}) - -it('bounds settled groups and invocation identities without evicting live groups', () => { - const groups = new ClaudeSubagentRosterGroups({ - currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE - }) - for (let index = 0; index < 35; index++) { - const group = groups.groupFor(`live-${index}`) - const tracked = inheritedClaudeSubagentGroup( - 'seed', - { - entries: [{ ...entry(`live-child-${index}`), state: 'working' }], - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }, - () => 1 - ).entries.get(`live-child-${index}`) - if (!tracked) { - throw new Error('missing fixture child') - } - group.entries.set(tracked.entry.id, tracked) - group.lastSerialized = 'written' - groups.place(tracked.entry.id, group.groupId) - groups.trim(group) - } - for (let index = 0; index < 1100; index++) { - const group = groups.groupFor(`settled-${index}`) - const id = `settled-child-${index}` - const tracked = inheritedClaudeSubagentGroup( - 'seed', - { - entries: [entry(id)], - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }, - () => 1 - ).entries.get(id) - if (!tracked) { - throw new Error('missing fixture child') - } - tracked.invocationIds = new Set([`tool-${index}`]) - group.entries.set(id, tracked) - group.lastSerialized = 'written' - groups.place(id, group.groupId) - groups.trim(group) - } - expect([...groups.values()]).toHaveLength(35) - expect(groups.locate('live-child-0')?.tracked.entry.state).toBe('working') - expect(groups.locate('settled-child-1099')).toBeNull() - expect(groups.hasSettled('settled-child-1099', 'tool-1099')).toBe(true) - expect(groups.hasSettled('settled-child-0', 'tool-0')).toBe(false) - for (const group of groups.values()) { - for (const tracked of group.entries.values()) { - tracked.entry.state = 'completed' - } - groups.trim(group) - } - expect([...groups.values()]).toHaveLength(32) - groups.clear() - expect([...groups.values()]).toHaveLength(0) - expect(groups.hasSettled('settled-child-1099', 'tool-1099')).toBe(false) -}) diff --git a/src/main/claude/claude-subagent-roster-groups.ts b/src/main/claude/claude-subagent-roster-groups.ts deleted file mode 100644 index 757ceb44c61..00000000000 --- a/src/main/claude/claude-subagent-roster-groups.ts +++ /dev/null @@ -1,150 +0,0 @@ -// Which spawn group holds each Claude subagent: the groups this provider run -// wrote, and the ones earlier runs of the session journaled, inherited one at a -// time as this run's own events reach them. - -import { SubagentRosterRetention } from '../native-chat/subagent-roster-retention' -import { - claudeSubagentGroupIdentity, - inheritedClaudeSubagentGroup -} from './claude-subagent-group-row' -import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' -import type { ClaudeJournaledRosterSource } from './claude-subagent-journaled-roster' -import type { RosterGroup, TrackedEntry } from './claude-subagent-roster-state' - -/** Settled history target; groups with unfinished children remain owned. */ -const MAX_SUBAGENT_GROUPS = 32 - -export type LocatedClaudeSubagent = { group: RosterGroup; tracked: TrackedEntry } - -export class ClaudeSubagentRosterGroups { - private readonly groups = new Map() - /** Canonical id → the group holding its entry, so a late update for a child - * from an earlier turn revises that turn's row instead of the live one. */ - private readonly groupIdByEntry = new Map() - - private readonly retention = new SubagentRosterRetention(this.groups, { - maxGroups: MAX_SUBAGENT_GROUPS, - maxSettledIdentities: 2048, - entries: (group) => [...group.entries.values()].map((tracked) => tracked.entry), - identities: (group) => - [...group.entries] - .filter(([id]) => this.groupIdByEntry.get(id) === group.groupId) - .flatMap(([id, tracked]) => [ - claudeSubagentRetentionIdentity(id, null), - ...[...new Set([...(tracked.invocationIds ?? []), tracked.toolUseId])].map((toolUseId) => - claudeSubagentRetentionIdentity(id, toolUseId) - ) - ]), - onEvict: (group) => { - for (const id of group.entries.keys()) { - if (this.groupIdByEntry.get(id) === group.groupId) { - this.groupIdByEntry.delete(id) - } - } - } - }) - - constructor( - private readonly deps: { - journaled?: ClaudeJournaledRosterSource - /** The open turn's scope, which a group this run creates belongs to. */ - currentTurnScope: () => AgentJournalTurnScope - } - ) {} - - get(groupId: string): RosterGroup | undefined { - return this.groups.get(groupId) - } - - values(): Iterable { - return this.groups.values() - } - - locate(id: string): LocatedClaudeSubagent | null { - const groupId = this.groupIdByEntry.get(id) - const group = groupId === undefined ? undefined : this.groups.get(groupId) - const tracked = group?.entries.get(id) - return group && tracked ? { group, tracked } : null - } - - /** Finds a child, inheriting the group an earlier run last listed it in. */ - locateOrInherit(id: string): LocatedClaudeSubagent | null { - const located = this.locate(id) - if (located) { - return located - } - const groupId = this.deps.journaled?.groupOf(id) ?? null - return groupId !== null && !this.groups.has(groupId) && this.inherit(groupId) - ? this.locate(id) - : null - } - - /** This run's group for a key, else the earlier run's row it continues — the - * key no turn owns is reused across runs — else a new one. */ - groupFor(groupId: string): RosterGroup { - const existing = this.groups.get(groupId) ?? this.inherit(groupId) - if (existing) { - return existing - } - const group: RosterGroup = { - groupId, - identity: claudeSubagentGroupIdentity(groupId), - turnScope: this.deps.currentTurnScope(), - entries: new Map(), - admittedEntries: 0, - claimedLabels: new Set(), - lastSerialized: null - } - this.admit(group) - return group - } - - place(id: string, groupId: string): void { - this.groupIdByEntry.set(id, groupId) - } - - forget(id: string): void { - this.groupIdByEntry.delete(id) - } - - hasSettled(id: string, toolUseId: string | null): boolean { - return this.retention.hasSettled(claudeSubagentRetentionIdentity(id, toolUseId)) - } - - trim(group: RosterGroup, retainAccessed = false): void { - this.retention.trim([group], retainAccessed ? group.groupId : undefined) - } - - clear(): void { - this.retention.clear() - this.groups.clear() - this.groupIdByEntry.clear() - } - - /** Once per group: after that this run's copy is the newer one. */ - private inherit(groupId: string): RosterGroup | null { - const journaled = this.deps.journaled - const row = journaled?.claimGroup(groupId) ?? null - if (!journaled || !row) { - return null - } - const group = inheritedClaudeSubagentGroup(groupId, row, journaled.attempt) - this.admit(group) - for (const id of group.entries.keys()) { - // A child an older build listed in two rows lives only in the one the reading chose, whichever - // row this run's frames reach first; the other copy is history. - if (journaled.groupOf(id) === groupId) { - this.groupIdByEntry.set(id, groupId) - } - } - return group - } - - private admit(group: RosterGroup): void { - this.groups.set(group.groupId, group) - } -} - -function claudeSubagentRetentionIdentity(id: string, toolUseId: string | null): string { - return JSON.stringify([id, toolUseId]) -} diff --git a/src/main/claude/claude-subagent-roster-retention.test.ts b/src/main/claude/claude-subagent-roster-retention.test.ts index 17776d48b27..1549b2778b2 100644 --- a/src/main/claude/claude-subagent-roster-retention.test.ts +++ b/src/main/claude/claude-subagent-roster-retention.test.ts @@ -159,7 +159,7 @@ describe('Claude shared settled-only roster retention', () => { startedAt: 1, settledAt: 2 })), - turnScope: AGENT_JOURNAL_THREAD_SCOPE + placement: AGENT_JOURNAL_THREAD_SCOPE } }, attempt: () => 4 diff --git a/src/main/claude/claude-subagent-roster-state.ts b/src/main/claude/claude-subagent-roster-state.ts deleted file mode 100644 index 3e4b2fd6fde..00000000000 --- a/src/main/claude/claude-subagent-roster-state.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { - AgentJournalItemIdentity, - AgentJournalTurnScope -} from '../../shared/agent-session-journal-types' -import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' -import type { ClaudeSubagentTaskFrame } from './claude-subagent-task-frames' - -const MAX_INVOCATIONS_PER_SUBAGENT = 16 - -export type TrackedEntry = { - entry: NativeChatSubagentEntry - /** The only signal separating a child that dies with its turn from one told to - * outlive it. A turn-end sweep must leave a backgrounded child alone. */ - backgrounded: boolean - toolUseId: string | null - invocationIds: Set | null - /** Label before its ordinal suffix, so a later announcement can tell a - * provisional row from one that already carries the provider's own name. */ - labelBase: string - /** Which run of this child. Identity survives a resume by design, so without - * this the retained rows of two runs read as one uninterrupted timeline. */ - attempt: number - /** Inherited from a row an earlier provider run journaled, and not announced in - * this run yet. That run's calls are unknown here, so any announcement is a new - * invocation, and only an announcement reopens what that run settled. Any other - * frame can still give that run's outcome, such as Claude's "didn't finish before - * the previous session ended". */ - invokedInEarlierRun: boolean -} - -export type RosterGroup = { - groupId: string - identity: AgentJournalItemIdentity - /** The spawning turn's scope: the row reports its children beside that turn's work. */ - turnScope: AgentJournalTurnScope - /** Insertion order is the display order; the map holds the state. */ - entries: Map - /** Lifetime admissions bound retained labels even when entries are removed. */ - admittedEntries: number - /** Labels remain reserved after removal or provisional-name replacement. */ - claimedLabels: Set - /** Last body written, so an idempotent replay writes no new revision. */ - lastSerialized: string | null -} - -// Invocation history stays with the entry, independent of the evicting alias cache. -export function applyClaudeSubagentInvocation( - tracked: TrackedEntry, - frame: ClaudeSubagentTaskFrame, - now: () => number -): boolean { - if (tracked.invocationIds === null) { - return false - } - if (tracked.invokedInEarlierRun) { - if (!frame.announcement) { - // A verdict on that run, not an invocation of this one; the latch still guards it. - return true - } - reopen(tracked, frame, now) - if (frame.toolUseId) { - tracked.invocationIds.add(frame.toolUseId) - tracked.toolUseId = frame.toolUseId - } - return true - } - const newInvocation = - frame.announcement && frame.toolUseId !== null && !tracked.invocationIds.has(frame.toolUseId) - if (newInvocation && frame.toolUseId) { - if (tracked.invocationIds.size >= MAX_INVOCATIONS_PER_SUBAGENT) { - tracked.invocationIds = null - tracked.entry = { ...tracked.entry, state: 'unverifiable', settledAt: now() } - return true - } - tracked.invocationIds.add(frame.toolUseId) - if (tracked.toolUseId !== null && tracked.toolUseId !== frame.toolUseId) { - // THE reactivation: a new spawn alias reopening this entry. Gated on the - // observed alias change rather than on the counter, so a late duplicate - // cannot advance a settled run. - reopen(tracked, frame, now) - } - tracked.toolUseId = frame.toolUseId - } else if (tracked.toolUseId && frame.toolUseId && tracked.toolUseId !== frame.toolUseId) { - return false - } - if (tracked.toolUseId === null) { - tracked.toolUseId = frame.toolUseId - } - return true -} - -/** The one place the attempt moves. A new run starts its own clock, so the - * idle time since the last run never reads as run time. */ -function reopen(tracked: TrackedEntry, frame: ClaudeSubagentTaskFrame, now: () => number): void { - tracked.invokedInEarlierRun = false - tracked.attempt += 1 - tracked.backgrounded = frame.backgrounded ?? false - tracked.entry = { - ...tracked.entry, - state: frame.state ?? 'working', - startedAt: now(), - settledAt: undefined - } -} - -/** Two children can share a description; the ordinal keeps their rows apart - * without inventing a name the provider never sent. The probe is over the - * labels actually rendered, not a per-base counter: a generated `Audit 2` - * must not collide with a provider that names its own child `Audit 2`. */ -export function claimClaudeSubagentLabel(group: RosterGroup, base: string): string { - let candidate = base - for (let ordinal = 2; group.claimedLabels.has(candidate); ordinal++) { - candidate = `${base} ${ordinal}` - } - group.claimedLabels.add(candidate) - return candidate -} diff --git a/src/main/claude/claude-subagent-roster.test.ts b/src/main/claude/claude-subagent-roster.test.ts index 1547f4bd330..54b1db9750d 100644 --- a/src/main/claude/claude-subagent-roster.test.ts +++ b/src/main/claude/claude-subagent-roster.test.ts @@ -589,17 +589,20 @@ describe('ClaudeSubagentRoster — invocation fences', () => { expect(rolesIn(TURN_1)[0].state).toBe('completed') }) - it('bounds invocation history and refuses to reopen beyond the retained budget', () => { + it('keeps reopening a child resumed many times, and never revives a run it has forgotten', () => { const { roster, roles } = harness() for (let i = 0; i < 20; i++) { roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: `tool-${i}` })) - if (i >= 16) { - expect(roles()[0].state).toBe('unverifiable') - } + expect(roles()[0].state).toBe('working') roster.observeToolResult(`tool-${i}`, false) + expect(roles()[0].state).toBe('completed') } roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'tool-0' })) - expect(roles()[0].state).toBe('unverifiable') + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'tool-19' })) + expect(roles()[0].state).toBe('completed') + expect(roster.linkage.settledLinkageFor('tool-19')).toMatchObject({ + linkage: { agentId: 'task-1', attempt: 20 } + }) }) }) diff --git a/src/main/claude/claude-subagent-roster.ts b/src/main/claude/claude-subagent-roster.ts index 548613136d0..f59347e835a 100644 --- a/src/main/claude/claude-subagent-roster.ts +++ b/src/main/claude/claude-subagent-roster.ts @@ -7,48 +7,31 @@ // no task frames. // // Claude re-announces a resumed task under a NEW `tool_use_id`, so `task_id` is -// the key and tool ids are aliases; keying on the tool id would duplicate the -// child on every resume. Outcomes latch within an invocation; a new spawn -// alias can reopen it, and authoritative evidence can correct lost contact. -// -// The state is one provider run's, but its rows are the session's. A run -// inherits what earlier runs journaled, one group at a time as its own events -// reach it, so a restart neither splits a child nor erases a row's children. +// the key and each tool id is one run of it; keying on the tool id would +// duplicate the child on every resume. Everything the row says follows the +// shared subagent tracker's rules; this module only reads Claude's frames. import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' -import { - canReplaceSubagentState, - isTerminalSubagentState -} from '../../shared/native-chat-subagent-summary' -import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { subagentRowSinkPort } from '../native-chat/subagent-tracker/subagent-row-sink-port' +import { SubagentTracker } from '../native-chat/subagent-tracker/subagent-tracker' +import type { SubagentReport } from '../native-chat/subagent-tracker/subagent-tracker-types' import { isBoundedClaudeTaskId } from './claude-background-task-tracker' -import { writeClaudeSubagentGroupRow } from './claude-subagent-group-row' import { ClaudeSubagentIds } from './claude-subagent-id-aliases' -import type { ClaudeJournaledRosterSource } from './claude-subagent-journaled-roster' -import { ClaudeSubagentRosterGroups } from './claude-subagent-roster-groups' +import { + claudeSubagentGroupIdentity, + type ClaudeJournaledRosterSource +} from './claude-subagent-journaled-roster' import { ClaudeSubagentLinkage, type ClaudeAgentLinkageSource, type ClaudeSubagentLinkageSource } from './claude-subagent-linkage' import { readClaudeSubagentTaskFrame } from './claude-subagent-task-frames' -import { - applyClaudeSubagentInvocation, - claimClaudeSubagentLabel, - type RosterGroup, - type TrackedEntry -} from './claude-subagent-roster-state' - -/** Children per spawn-group row. Bounds an event-accumulated map that no - * provider snapshot ever prunes. */ -const MAX_SUBAGENTS_PER_GROUP = 64 /** The turn a group belongs to when Claude reports a task outside any turn. */ const OUTSIDE_TURN = 'outside-turn' -const UNLABELLED_AGENT = 'subagent' - export type ClaudeSubagentRosterDeps = { sink: StructuredAgentSessionEventSink /** The turn that owns children spawned right now; null outside any turn. */ @@ -73,7 +56,7 @@ export type ClaudeSubagentRosterDeps = { } export class ClaudeSubagentRoster { - private readonly groups: ClaudeSubagentRosterGroups + private readonly tracker: SubagentTracker private readonly ids: ClaudeSubagentIds /** Who produced a row, for every write site journaling this session. */ readonly linkage: ClaudeSubagentLinkageSource & ClaudeAgentLinkageSource @@ -81,21 +64,22 @@ export class ClaudeSubagentRoster { * this CLI has proven it declares its tasks, child traffic for an id it never * announced is a nested tool or a grandchild, not a subagent. */ private announcesTasks = false - private readonly now: () => number constructor(private readonly deps: ClaudeSubagentRosterDeps) { - this.now = deps.now ?? (() => Date.now()) - this.groups = new ClaudeSubagentRosterGroups({ - journaled: deps.journaled, - currentTurnScope: deps.currentTurnScope + this.tracker = new SubagentTracker({ + port: subagentRowSinkPort(deps.sink, claudeSubagentGroupIdentity), + outsideTurn: (groupId) => groupId === OUTSIDE_TURN, + ...(deps.journaled ? { journaled: deps.journaled } : {}), + ...(deps.now ? { now: deps.now } : {}) }) this.ids = new ClaudeSubagentIds(deps.journaled?.canonical) this.linkage = new ClaudeSubagentLinkage({ ids: this.ids, - trackedFor: (canonicalId) => this.groups.locate(canonicalId)?.tracked ?? null, + trackedFor: (canonicalId) => + this.tracker.locate(canonicalId) ? { attempt: this.tracker.attempt(canonicalId) } : null, isForwardedParentTool: deps.isForwardedParentTool, childOwnerRefOf: deps.childOwnerRefOf, - spawnRefOf: (canonicalId) => this.groups.locate(canonicalId)?.tracked.toolUseId ?? null + spawnRefOf: (canonicalId) => this.tracker.locate(canonicalId)?.tracked.run ?? null }) } @@ -112,7 +96,7 @@ export class ClaudeSubagentRoster { for (const id of [frame.taskId, frame.toolUseId]) { if (id !== null) { this.ids.exclude(id) - this.remove(id) + this.tracker.remove(id) } } return true @@ -122,36 +106,19 @@ export class ClaudeSubagentRoster { } if (frame.toolUseId) { this.ids.alias(frame.toolUseId, frame.taskId) - } - if ( - this.groups.hasSettled(frame.taskId, frame.toolUseId) && - (frame.toolUseId !== null || !this.groups.locate(frame.taskId)) - ) { - return true - } - const located = - this.groups.locateOrInherit(frame.taskId) ?? - (frame.toolUseId ? this.adopt(frame.toolUseId, frame.taskId) : null) - if (!located) { - if (frame.announcesSubagent) { - this.create( - frame.taskId, - frame.label, - frame.state ?? 'working', - frame.backgrounded ?? false, - frame.toolUseId - ) + if (!this.tracker.locate(frame.taskId)) { + // A provisional row built under the tool id takes the canonical id the announcement names. + this.tracker.rekey(frame.toolUseId, frame.taskId) } - return true } - const tracked = located.group.entries.get(frame.taskId) - if (tracked && !applyClaudeSubagentInvocation(tracked, frame, this.now)) { - return true - } - this.revise(located.group, frame.taskId, { + this.tracker.report({ + id: frame.taskId, + ...(frame.toolUseId ? { run: frame.toolUseId } : {}), + group: this.currentGroup(), + announces: frame.announcesSubagent, label: frame.label, state: frame.state, - backgrounded: frame.backgrounded + ...(frame.backgrounded !== null ? { backgrounded: frame.backgrounded } : {}) }) return true } @@ -165,35 +132,37 @@ export class ClaudeSubagentRoster { const canonical = this.ids.canonical(parentToolUseId) if ( this.ids.isExcluded(parentToolUseId, canonical) || - this.groups.hasSettled(canonical, parentToolUseId) || - (!this.groups.locate(canonical) && this.groups.hasSettled(canonical, null)) + this.tracker.hasSettled(canonical, parentToolUseId) ) { return } - if (this.groups.locate(canonical)) { - return - } - const inherited = this.groups.locateOrInherit(canonical) - if (inherited) { + const located = this.tracker.locate(canonical) + if (located) { // Child traffic can precede the announcement that reopens an inherited row. - this.groups.trim(inherited.group, true) + if (located.tracked.inherited) { + this.tracker.retain(located.group) + } return } - if (this.announcesTasks) { - // A nested Task, a workflow child, or a grandchild parented to a tool id - // inside the sidechain all reach here. This CLI announces what it spawns, - // so an id it never declared cannot be a subagent — and a row invented for - // one is unlabelled forever and can only ever end `unverifiable`. The - // bounded exclusion set cannot cover an id that was never announced. + if ( + this.tracker.hasSettled(canonical, null) || + // This CLI announces what it spawns, so an id it never declared is a nested + // Task, a workflow child, or a grandchild — not a subagent. A row invented + // for one is unlabelled forever and can only ever end `unverifiable`. + this.announcesTasks || + // A provisional id becomes the same durable entry key, so it takes the + // bound an announced id does. + !isBoundedClaudeTaskId(canonical) + ) { return } - if (!isBoundedClaudeTaskId(canonical)) { - // `claudeTaskId` rejects an over-long announced id rather than truncating - // it; a provisional id becomes the same durable entry key, so it cannot - // enter under a looser rule. - return - } - this.create(canonical, null, 'working', false, parentToolUseId) + this.tracker.report({ + id: canonical, + run: parentToolUseId, + group: this.currentGroup(), + announces: true, + state: 'working' + }) } /** @@ -204,181 +173,51 @@ export class ClaudeSubagentRoster { */ observeToolResult(toolUseId: string, failed: boolean): void { const canonical = this.ids.canonical(toolUseId) - const located = this.groups.locate(canonical) + const located = this.tracker.locate(canonical) if ( !located || - located.tracked.invocationIds === null || located.tracked.backgrounded || - (located.tracked.toolUseId !== null && located.tracked.toolUseId !== toolUseId) + (located.tracked.run !== null && located.tracked.run !== toolUseId) ) { return } - this.revise(located.group, canonical, { - label: null, - state: failed ? 'failed' : 'completed', - backgrounded: false + this.tracker.report({ + id: canonical, + run: toolUseId, + group: this.currentGroup(), + announces: false, + state: failed ? 'failed' : 'completed' }) } - /** - * The parent turn ended. A foreground child still reported as working will - * never be settled by an event, so it becomes `unverifiable`: contact was - * lost, which is NOT evidence the child exited. A backgrounded child was - * explicitly told to outlive the turn and is left alone. - */ + /** The parent turn ended. A foreground child still working loses contact with + * it; a backgrounded one was told to outlive the turn. Only the group this key + * names: `OUTSIDE_TURN` belongs to no turn, so no turn's end says anything about it. */ settleTurn(groupKey: string | null): void { - // Only the group this key names. `OUTSIDE_TURN` belongs to no turn, so an - // unrelated turn ending is no evidence about a child announced outside it. - // `settleSession` reaches what no turn does. - this.sweep(this.groups.get(groupKey ?? OUTSIDE_TURN), false) + this.tracker.settleTurn(groupKey ?? OUTSIDE_TURN) this.deps.onIdentitiesFinal?.() } /** The provider is gone. Nothing more will arrive for any child, backgrounded * or not, so every one of them loses contact at once. */ settleSession(): void { - for (const group of this.groups.values()) { - this.sweep(group, true) - } + this.tracker.settleSession() this.deps.onIdentitiesFinal?.() } dispose(): void { - // Teardown paths reach here without an `ended` event, so a row still - // reporting `working` would have nothing left to revise it. A session that - // did settle first leaves every child terminal, so this writes nothing. + // Teardown paths reach here without an `ended` event; a session that did + // settle first leaves every child terminal, so this writes nothing. this.settleSession() - this.groups.clear() + this.tracker.dispose() this.ids.clear() this.announcesTasks = false } - private sweep(group: RosterGroup | undefined, includeBackgrounded: boolean): void { - if (!group) { - return + private currentGroup(): SubagentReport['group'] { + return { + id: this.deps.currentGroupKey() ?? OUTSIDE_TURN, + placement: this.deps.currentTurnScope } - for (const [id, tracked] of group.entries) { - if (isTerminalSubagentState(tracked.entry.state)) { - continue - } - if (tracked.backgrounded && !includeBackgrounded) { - continue - } - group.entries.set(id, { - ...tracked, - entry: { ...tracked.entry, state: 'unverifiable', settledAt: this.now() } - }) - } - this.write(group) - } - - private write(group: RosterGroup): void { - writeClaudeSubagentGroupRow(this.deps.sink, group) - this.groups.trim(group) - } - - private create( - id: string, - label: string | null, - state: NativeChatSubagentEntry['state'], - backgrounded: boolean, - toolUseId: string | null - ): void { - const group = this.groups.groupFor(this.deps.currentGroupKey() ?? OUTSIDE_TURN) - if (group.admittedEntries >= MAX_SUBAGENTS_PER_GROUP) { - this.write(group) - return - } - group.admittedEntries += 1 - const now = this.now() - const labelBase = label ?? UNLABELLED_AGENT - group.entries.set(id, { - backgrounded, - toolUseId, - invocationIds: new Set(toolUseId ? [toolUseId] : []), - labelBase, - attempt: 1, - invokedInEarlierRun: false, - entry: { - id, - label: claimClaudeSubagentLabel(group, labelBase), - state, - startedAt: now, - ...(isTerminalSubagentState(state) ? { settledAt: now } : {}) - } - }) - this.groups.place(id, group.groupId) - this.write(group) - } - - private revise( - group: RosterGroup, - id: string, - change: { - label: string | null - state: NativeChatSubagentEntry['state'] | null - backgrounded: boolean | null - } - ): void { - const tracked = group.entries.get(id) - if (!tracked) { - return - } - const next: TrackedEntry = { - ...tracked, - backgrounded: change.backgrounded ?? tracked.backgrounded, - entry: { ...tracked.entry } - } - // A provisional row built from child traffic takes the real name the first - // announcement carries; an announced row keeps the name it was given. - if ( - change.label && - tracked.labelBase === UNLABELLED_AGENT && - change.label !== UNLABELLED_AGENT - ) { - next.labelBase = change.label - next.entry.label = claimClaudeSubagentLabel(group, change.label) - } - // Proven outcomes latch; lost contact can still receive a later verdict. - if (change.state && canReplaceSubagentState(tracked.entry.state, change.state)) { - next.entry.state = change.state - if (isTerminalSubagentState(change.state)) { - // An earlier run's child ended with that run, so this verdict says how, not - // when; a run that died unswept left no time, and now is not it. - next.entry.settledAt = tracked.invokedInEarlierRun ? tracked.entry.settledAt : this.now() - } - } - group.entries.set(id, next) - this.write(group) - } - - /** Re-key a provisional entry from its tool id onto the canonical task id the - * announcement finally named, so the child does not appear twice. */ - private adopt(toolUseId: string, taskId: string): { group: RosterGroup } | null { - if (toolUseId === taskId) { - return null - } - const located = this.groups.locate(toolUseId) - if (!located) { - return null - } - located.group.entries.delete(toolUseId) - located.group.entries.set(taskId, { - ...located.tracked, - entry: { ...located.tracked.entry, id: taskId } - }) - this.groups.forget(toolUseId) - this.groups.place(taskId, located.group.groupId) - return { group: located.group } - } - - private remove(id: string): void { - const located = this.groups.locate(id) - if (!located) { - return - } - located.group.entries.delete(id) - this.groups.forget(id) - this.write(located.group) } } diff --git a/src/main/codex/__snapshots__/codex-subagent-roster-characterization.test.ts.snap b/src/main/codex/__snapshots__/codex-subagent-roster-characterization.test.ts.snap new file mode 100644 index 00000000000..1f759ef11af --- /dev/null +++ b/src/main/codex/__snapshots__/codex-subagent-roster-characterization.test.ts.snap @@ -0,0 +1,1425 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Codex subagent roster rows (characterization) > a child announced outside any parent turn 1`] = ` +[ + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "loose", + "startedAt": 1020, + "state": "working", + }, + ], + "groupId": "thread-parent:outside-turn", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:outside-turn", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "thread", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "loose", + "settledAt": 1030, + "startedAt": 1020, + "state": "completed", + }, + ], + "groupId": "thread-parent:outside-turn", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:outside-turn", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "thread", + }, + }, + { + "accepted": true, + "op": "publish", + }, +] +`; + +exports[`Codex subagent roster rows (characterization) > a child given new work in a later parent turn, and a late end of its earlier run 1`] = ` +[ + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "reader", + "startedAt": 1020, + "state": "working", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "reader", + "settledAt": 1030, + "startedAt": 1020, + "state": "completed", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "reader", + "startedAt": 1050, + "state": "working", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "reader", + "settledAt": 1080, + "startedAt": 1050, + "state": "completed", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, +] +`; + +exports[`Codex subagent roster rows (characterization) > a child running when the session ends, then a late verdict 1`] = ` +[ + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "a", + "startedAt": 1020, + "state": "working", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 2 subagents", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "a", + "startedAt": 1020, + "state": "working", + }, + { + "id": "c2", + "label": "b", + "startedAt": 1040, + "state": "working", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 2 subagents", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "a", + "startedAt": 1020, + "state": "working", + }, + { + "id": "c2", + "label": "b", + "settledAt": 1050, + "startedAt": 1040, + "state": "completed", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 2 subagents (1 unverifiable)", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "a", + "settledAt": 1060, + "startedAt": 1020, + "state": "unverifiable", + }, + { + "id": "c2", + "label": "b", + "settledAt": 1050, + "startedAt": 1040, + "state": "completed", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 2 subagents", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "a", + "settledAt": 1070, + "startedAt": 1020, + "state": "completed", + }, + { + "id": "c2", + "label": "b", + "settledAt": 1050, + "startedAt": 1040, + "state": "completed", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, +] +`; + +exports[`Codex subagent roster rows (characterization) > a child thread closing mid-turn, then its turn completing 1`] = ` +[ + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "a", + "startedAt": 1020, + "state": "working", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 1 subagent (1 unverifiable)", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "a", + "settledAt": 1030, + "startedAt": 1020, + "state": "unverifiable", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "a", + "settledAt": 1040, + "startedAt": 1020, + "state": "completed", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, +] +`; + +exports[`Codex subagent roster rows (characterization) > a refused append or publish is handed back, and the redelivered frame writes it 1`] = ` +{ + "admissions": [ + true, + false, + true, + true, + true, + true, + ], + "log": [ + { + "accepted": false, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "a", + "startedAt": 1020, + "state": "working", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "a", + "startedAt": 1020, + "state": "working", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "a", + "settledAt": 1040, + "startedAt": 1020, + "state": "completed", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": false, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "a", + "settledAt": 1040, + "startedAt": 1020, + "state": "completed", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "a", + "settledAt": 1040, + "startedAt": 1020, + "state": "completed", + "tokens": 50, + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + ], +} +`; + +exports[`Codex subagent roster rows (characterization) > more than 32 settled groups, then a late frame for an evicted run 1`] = `138`; + +exports[`Codex subagent roster rows (characterization) > more than 32 settled groups, then a late frame for an evicted run 2`] = ` +[ + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c33", + "label": "job33", + "startedAt": 2010, + "state": "working", + }, + ], + "groupId": "thread-parent:turn-33", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-33", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-33", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c33", + "label": "job33", + "settledAt": 2020, + "startedAt": 2010, + "state": "completed", + }, + ], + "groupId": "thread-parent:turn-33", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-33", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-33", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "live", + "label": "live", + "startedAt": 2040, + "state": "working", + }, + ], + "groupId": "thread-parent:turn-live", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-live", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-live", + }, + }, + { + "accepted": true, + "op": "publish", + }, +] +`; + +exports[`Codex subagent roster rows (characterization) > more than 32 settled groups, then a late frame for an evicted run 3`] = ` +{ + "groups": 32, + "settledIdentities": 6, +} +`; + +exports[`Codex subagent roster rows (characterization) > one child: announced, runs a turn, reports tokens, completes 1`] = ` +{ + "admissions": [ + true, + true, + true, + true, + true, + true, + true, + true, + ], + "log": [ + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "reader", + "startedAt": 1020, + "state": "working", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "reader", + "startedAt": 1020, + "state": "working", + "tokens": 120, + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "reader", + "startedAt": 1020, + "state": "working", + "tokens": 340, + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "reader", + "settledAt": 1070, + "startedAt": 1020, + "state": "completed", + "tokens": 340, + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "reader", + "settledAt": 1070, + "startedAt": 1020, + "state": "completed", + "tokens": 400, + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + ], +} +`; + +exports[`Codex subagent roster rows (characterization) > two children sharing a path label, one failing and one interrupted 1`] = ` +[ + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 1 subagent", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "worker", + "startedAt": 1020, + "state": "working", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 2 subagents", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "worker", + "startedAt": 1020, + "state": "working", + }, + { + "id": "c2", + "label": "worker 2", + "startedAt": 1040, + "state": "working", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 3 subagents", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "worker", + "startedAt": 1020, + "state": "working", + }, + { + "id": "c2", + "label": "worker 2", + "startedAt": 1040, + "state": "working", + }, + { + "id": "c3", + "label": "worker 2 2", + "startedAt": 1060, + "state": "working", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 3 subagents (1 failed)", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "worker", + "settledAt": 1070, + "startedAt": 1020, + "state": "failed", + }, + { + "id": "c2", + "label": "worker 2", + "startedAt": 1040, + "state": "working", + }, + { + "id": "c3", + "label": "worker 2 2", + "startedAt": 1060, + "state": "working", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Kicked off 3 subagents (1 failed)", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "worker", + "settledAt": 1070, + "startedAt": 1020, + "state": "failed", + }, + { + "id": "c2", + "label": "worker 2", + "settledAt": 1080, + "startedAt": 1040, + "state": "stopped", + }, + { + "id": "c3", + "label": "worker 2 2", + "startedAt": 1060, + "state": "working", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, + { + "accepted": true, + "body": { + "blocks": [ + { + "text": "Ran 3 subagents (1 failed)", + "type": "text", + }, + { + "agents": [ + { + "id": "c1", + "label": "worker", + "settledAt": 1070, + "startedAt": 1020, + "state": "failed", + }, + { + "id": "c2", + "label": "worker 2", + "settledAt": 1080, + "startedAt": 1040, + "state": "stopped", + }, + { + "id": "c3", + "label": "worker 2 2", + "settledAt": 1090, + "startedAt": 1060, + "state": "unverifiable", + }, + ], + "groupId": "thread-parent:turn-1", + "type": "subagent-group", + }, + ], + "kind": "message", + "role": "system", + }, + "identity": { + "clientMessageId": "codex-subagents:thread-parent:turn-1", + "provider": "orca", + }, + "op": "append", + "turnScope": { + "kind": "turn", + "turnItemId": "thread-parent/turn-1", + }, + }, + { + "accepted": true, + "op": "publish", + }, +] +`; diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts index c8b3dfb43c4..9ac082784ac 100644 --- a/src/main/codex/codex-child-work-evidence.test.ts +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -190,9 +190,11 @@ describe('Codex child-work evidence', () => { expect.objectContaining({ membership: 'settled', state: 'done', outcome: 'unknown' }) ]) expect(tracker.state).toBeNull() - // The first ending a turn gets stands. + // Lost contact is no verdict: the turn's own ending still says how it went. send(turn('turn/completed', CHILD, 'c1', 'completed')) - expect(records()).toEqual([expect.objectContaining({ outcome: 'unknown' })]) + expect(records()).toEqual([expect.objectContaining({ outcome: 'succeeded' })]) + send(turn('turn/completed', CHILD, 'c1', 'failed')) + expect(records()).toEqual([expect.objectContaining({ outcome: 'succeeded' })]) }) it('keeps a child working through a retried error and a systemError status: its turn runs on', () => { diff --git a/src/main/codex/codex-default-mode-subagents.test.ts b/src/main/codex/codex-default-mode-subagents.test.ts index 9f72f13512f..61c99be2f19 100644 --- a/src/main/codex/codex-default-mode-subagents.test.ts +++ b/src/main/codex/codex-default-mode-subagents.test.ts @@ -554,7 +554,7 @@ describe('a helper whose spawn was never seen', () => { ]) }) - it("puts a helper's run in the turn that sent it work, even after that turn ended", async () => { + it("reopens a helper's one entry when a later turn sends it work, even after that turn ended", async () => { const run = await session() run.send( turn('turn/started', THREAD_ID, PARENT_TURN), @@ -567,8 +567,9 @@ describe('a helper whose spawn was never seen', () => { turn('turn/completed', THREAD_ID, 'parent-turn-2'), turn('turn/started', HELPER, 'helper-turn-2') ) + // One entry per helper: a second row naming it would show it without its own section. const groups = new Set(run.rosterRows().map((row) => row.groupId)) - expect(groups.size).toBe(2) + expect(groups.size).toBe(1) expect(run.rosterRows().at(-1)?.agents).toEqual([ expect.objectContaining({ id: HELPER, state: 'working' }) ]) diff --git a/src/main/codex/codex-structured-journal-limits.ts b/src/main/codex/codex-structured-journal-limits.ts index 82ad7a38235..5d5d4c1c7f8 100644 --- a/src/main/codex/codex-structured-journal-limits.ts +++ b/src/main/codex/codex-structured-journal-limits.ts @@ -11,10 +11,9 @@ export const MAX_CODEX_PENDING_PROMPTS = 128 export const MAX_CODEX_IDENTITY_ENTRIES = 512 export const MAX_CODEX_DETAIL_ENTRIES = 512 export const MAX_CODEX_DETAIL_BYTES = 64 * 1024 -/** Settled spawn-group history budget; live groups remain until their final write succeeds. */ -export const MAX_CODEX_SUBAGENT_GROUPS = 32 -/** Children admitted per spawn-group row. */ -export const MAX_CODEX_SUBAGENTS_PER_GROUP = 64 +/** The shared subagent tracker's bounds, which Codex's spawn-group rows follow. */ +export { MAX_SUBAGENT_GROUPS as MAX_CODEX_SUBAGENT_GROUPS } from '../native-chat/subagent-tracker/subagent-tracker-groups' +export { MAX_SUBAGENTS_PER_GROUP as MAX_CODEX_SUBAGENTS_PER_GROUP } from '../native-chat/subagent-tracker/subagent-tracker' /** Threads whose latest token total is retained. Usage frames arrive for * threads that are not yet (or never become) roster children. */ export const MAX_CODEX_TOKEN_USAGE_THREADS = 256 diff --git a/src/main/codex/codex-subagent-execution-projection.test.ts b/src/main/codex/codex-subagent-execution-projection.test.ts index 21d39f4f195..c3564b5e3c7 100644 --- a/src/main/codex/codex-subagent-execution-projection.test.ts +++ b/src/main/codex/codex-subagent-execution-projection.test.ts @@ -118,7 +118,7 @@ describe('shared child execution projection', () => { }) it.each(['parent-1', 'parent-2'])( - 'reopens a child for real follow-up in %s and fences old execution events', + 'reopens a child for real follow-up in %s in its own row and fences old execution events', (parent) => { const h = harness() firstRun(h) @@ -130,18 +130,19 @@ describe('shared child execution projection', () => { expect(h.tracker.state).toBeNull() h.send(turn('turn/started', CHILD, 'child-2')) h.send(turn('turn/completed', PRIMARY, parent)) - expect(h.state(parent)).toBe('working') + // One entry per child: the follow-up reopens the row that spawned it. + expect(h.state('parent-1')).toBe('working') expect(h.tracker.state?.tasks).toHaveLength(1) h.send(turn('turn/completed', CHILD, 'child-1')) h.send(turn('turn/started', CHILD, 'child-1')) h.send(activity('parent-1', 'completed')) - expect(h.state(parent)).toBe('working') + expect(h.state('parent-1')).toBe('working') expect(h.tracker.state?.tasks).toHaveLength(1) if (parent === 'parent-2') { - expect(h.state('parent-1')).toBe('completed') + expect(h.state('parent-2')).toBeUndefined() } h.send(turn('turn/completed', CHILD, 'child-2')) - expect(h.state(parent)).toBe('completed') + expect(h.state('parent-1')).toBe('completed') expect(h.tracker.state).toBeNull() h.dispose() } @@ -166,8 +167,8 @@ describe('shared child execution projection', () => { h.send(activity('parent-2', 'interacted')) h.send(activity('parent-1', 'completed')) h.send(turn('turn/started', CHILD, 'child-2')) - expect(h.state('parent-1')).toBe('completed') - expect(h.state('parent-2')).toBe('working') + expect(h.state('parent-1')).toBe('working') + expect(h.state('parent-2')).toBeUndefined() expect(h.tracker.state?.tasks).toHaveLength(1) h.dispose() }) diff --git a/src/main/codex/codex-subagent-executions.ts b/src/main/codex/codex-subagent-executions.ts index 8d7874b97da..2bdcb3a35bf 100644 --- a/src/main/codex/codex-subagent-executions.ts +++ b/src/main/codex/codex-subagent-executions.ts @@ -1,5 +1,8 @@ import type { NativeChatSubagentState } from '../../shared/native-chat-types' -import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary' +import { + MAX_SUBAGENT_FIELD_CHARS, + canReplaceSubagentState +} from '../../shared/native-chat-subagent-summary' const MAX_CHILDREN = 128 const MAX_SETTLED_TURNS = 256 @@ -87,7 +90,11 @@ export class CodexSubagentExecutions { ) { return null } - const execution = { turnId, state: settled ?? state } + // A turn's first outcome latches, except lost contact: a later verdict still says how it ended. + const execution = { + turnId, + state: settled !== undefined && !canReplaceSubagentState(settled, state) ? settled : state + } if (state !== 'working') { this.settledTurns.set(key, execution.state) while (this.settledTurns.size > MAX_SETTLED_TURNS) { diff --git a/src/main/codex/codex-subagent-roster-characterization.test.ts b/src/main/codex/codex-subagent-roster-characterization.test.ts new file mode 100644 index 00000000000..b0a86bace27 --- /dev/null +++ b/src/main/codex/codex-subagent-roster-characterization.test.ts @@ -0,0 +1,189 @@ +// Golden row writes for the Codex subagent roster: every append and publish it makes, and every +// admission it hands back, for scripted event sequences, so moving the roster onto shared code +// cannot change a saved byte. + +import { describe, expect, it } from 'vitest' +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' +import { recordingSubagentRowSink } from '../native-chat/subagent-row-sink-recorder-test-support' +import type { CodexThreadItem } from './codex-structured-item-translation' +import { CodexSubagentRoster } from './codex-subagent-roster' + +const PARENT = 'thread-parent' + +/** The clock moves once per delivered event, never per read, so a row's times do not depend on how + * often the roster happens to read it. */ +function harness() { + const recorder = recordingSubagentRowSink() + let clock = 1_000 + let parentTurn: string | null = 'turn-1' + const roster = new CodexSubagentRoster({ + sink: recorder.sink, + primaryThreadId: () => PARENT, + activeTurn: (threadId) => (threadId === PARENT ? parentTurn : null), + turnScopeFor: (threadId, turnId): AgentJournalTurnScope => + turnId === null ? { kind: 'thread' } : { kind: 'turn', turnItemId: `${threadId}/${turnId}` }, + now: () => clock + }) + const admissions: boolean[] = [] + const note = (admission: { accepted: boolean } | null) => { + admissions.push(admission?.accepted ?? true) + } + const tick = (deliver: () => T): T => { + clock += 10 + return deliver() + } + const activity = (kind: string, child: string, path: string | null): CodexThreadItem => ({ + type: 'subAgentActivity', + id: `item-${child}-${kind}`, + kind, + agentThreadId: child, + agentPath: path + }) + return { + ...recorder, + roster, + admissions, + parentTurn: (next: string | null) => { + parentTurn = next + }, + tick, + announce: (kind: string, child: string, path: string | null) => { + clock += 10 + note( + roster.handleItem({ + threadId: PARENT, + turnId: parentTurn, + item: activity(kind, child, path) + }) + ) + }, + turnStarted: (child: string, turnId: string) => { + clock += 10 + note(roster.handleTurnEvent({ method: 'turn/started', threadId: child, params: { turnId } })) + }, + turnCompleted: (child: string, turnId: string, status: string) => { + clock += 10 + note( + roster.handleTurnEvent({ + method: 'turn/completed', + threadId: child, + params: { turn: { id: turnId, status } } + }) + ) + }, + tokens: (child: string, totalTokens: number) => { + clock += 10 + note(roster.handleTokenUsage({ threadId: child, tokenUsage: { total: { totalTokens } } })) + } + } +} + +describe('Codex subagent roster rows (characterization)', () => { + it('one child: announced, runs a turn, reports tokens, completes', () => { + const h = harness() + h.announce('started', 'c1', '/root/reader') + h.turnStarted('c1', 'ct1') + h.announce('started', 'c1', '/root/reader') + h.tokens('c1', 120) + h.tokens('c1', 120) + h.tokens('c1', 340) + h.turnCompleted('c1', 'ct1', 'completed') + h.tokens('c1', 400) + expect({ log: h.log, admissions: h.admissions }).toMatchSnapshot() + }) + + it('two children sharing a path label, one failing and one interrupted', () => { + const h = harness() + h.turnStarted('c1', 'ct1') + h.announce('started', 'c1', '/root/worker') + h.turnStarted('c2', 'ct2') + h.announce('started', 'c2', '/root/team/worker') + h.turnStarted('c3', 'ct3') + h.announce('started', 'c3', '/root/worker 2') + h.turnCompleted('c1', 'ct1', 'failed') + h.turnCompleted('c2', 'ct2', 'interrupted') + h.turnCompleted('c3', 'ct3', 'mystery') + expect(h.log).toMatchSnapshot() + }) + + it('a child given new work in a later parent turn, and a late end of its earlier run', () => { + const h = harness() + h.turnStarted('c1', 'ct1') + h.announce('started', 'c1', '/root/reader') + h.turnCompleted('c1', 'ct1', 'completed') + h.parentTurn('turn-2') + h.announce('interacted', 'c1', '/root/reader') + h.turnStarted('c1', 'ct2') + h.turnCompleted('c1', 'ct1', 'failed') + h.turnStarted('c1', 'ct1') + h.turnCompleted('c1', 'ct2', 'completed') + expect(h.log).toMatchSnapshot() + }) + + it('a child running when the session ends, then a late verdict', () => { + const h = harness() + h.turnStarted('c1', 'ct1') + h.announce('started', 'c1', '/root/a') + h.turnStarted('c2', 'ct2') + h.announce('started', 'c2', '/root/b') + h.turnCompleted('c2', 'ct2', 'completed') + note(h.tick(() => h.roster.settleSession())) + h.turnCompleted('c1', 'ct1', 'completed') + expect(h.log).toMatchSnapshot() + + function note(admission: { accepted: boolean }) { + h.admissions.push(admission.accepted) + } + }) + + it('a child thread closing mid-turn, then its turn completing', () => { + const h = harness() + h.turnStarted('c1', 'ct1') + h.announce('started', 'c1', '/root/a') + h.tick(() => h.roster.executions.closeThread('c1')) + h.turnCompleted('c1', 'ct1', 'completed') + expect(h.log).toMatchSnapshot() + }) + + it('a child announced outside any parent turn', () => { + const h = harness() + h.parentTurn(null) + h.turnStarted('c1', 'ct1') + h.announce('started', 'c1', '/root/loose') + h.parentTurn('turn-5') + h.turnCompleted('c1', 'ct1', 'completed') + expect(h.log).toMatchSnapshot() + }) + + it('more than 32 settled groups, then a late frame for an evicted run', () => { + const h = harness() + for (let index = 0; index < 34; index++) { + h.parentTurn(`turn-${index}`) + h.turnStarted(`c${index}`, `ct${index}`) + h.announce('started', `c${index}`, `/root/job${index}`) + h.turnCompleted(`c${index}`, `ct${index}`, 'completed') + } + h.parentTurn('turn-live') + h.turnStarted('live', 'ctl') + h.announce('started', 'live', '/root/live') + h.turnCompleted('c0', 'ct0', 'failed') + h.turnStarted('c0', 'ct0') + h.announce('started', 'c0', '/root/job0') + expect(h.log.length).toMatchSnapshot() + expect(h.log.slice(-6)).toMatchSnapshot() + expect(h.roster.retentionSizes()).toMatchSnapshot() + }) + + it('a refused append or publish is handed back, and the redelivered frame writes it', () => { + const h = harness() + h.turnStarted('c1', 'ct1') + h.refuseNextAppend() + h.announce('started', 'c1', '/root/a') + h.announce('started', 'c1', '/root/a') + h.refuseNextPublish() + h.turnCompleted('c1', 'ct1', 'completed') + h.turnCompleted('c1', 'ct1', 'completed') + h.tokens('c1', 50) + expect({ log: h.log, admissions: h.admissions }).toMatchSnapshot() + }) +}) diff --git a/src/main/codex/codex-subagent-roster-restart.test.ts b/src/main/codex/codex-subagent-roster-restart.test.ts new file mode 100644 index 00000000000..6808820713f --- /dev/null +++ b/src/main/codex/codex-subagent-roster-restart.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { isSubagentGroupBlock } from '../../shared/native-chat-types' +import { subagentGroupJournalBody } from '../native-chat/agent-session-journal/journal-subagent-group-body' +import { recordingSubagentRowSink } from '../native-chat/subagent-row-sink-recorder-test-support' +import { + CodexSubagentRoster, + codexSubagentGroupId, + codexSubagentGroupIdentity +} from './codex-subagent-roster' + +const PARENT = 'thread-parent' +const OUTSIDE = codexSubagentGroupId(PARENT, null) + +describe('Codex subagent roster after a provider restart', () => { + it('continues the row no turn owns instead of rewriting it from one child', () => { + const recorder = recordingSubagentRowSink() + const earlier = subagentGroupJournalBody(OUTSIDE, [ + { id: 'c1', label: 'one', state: 'completed', startedAt: 1, settledAt: 2 }, + { id: 'c2', label: 'two', state: 'unverifiable', startedAt: 1 } + ]) + const roster = new CodexSubagentRoster({ + sink: { + ...recorder.sink, + journalLinkage: () => ({ + epoch: 'e1', + visitItemsWithLinkage: (visit) => + visit(agentJournalItemKey(codexSubagentGroupIdentity(OUTSIDE)), 1, earlier, { + turnScope: { kind: 'thread' } + }) + }) + }, + primaryThreadId: () => PARENT, + activeTurn: () => null, + turnScopeFor: () => ({ kind: 'thread' }) + }) + roster.handleTurn({ threadId: 'c3', turnId: 't3', state: 'working' }) + roster.handleItem({ + threadId: PARENT, + turnId: null, + item: { + type: 'subAgentActivity', + id: 'i3', + kind: 'started', + agentThreadId: 'c3', + agentPath: '/root/three' + } + }) + + const last = recorder.log.findLast((write) => write.op === 'append') + const agents = + last?.op === 'append' && last.body.kind === 'message' + ? last.body.blocks.find(isSubagentGroupBlock)?.agents + : undefined + expect(last?.op === 'append' && last.identity).toEqual(codexSubagentGroupIdentity(OUTSIDE)) + expect(agents?.map((agent) => [agent.id, agent.label, agent.state])).toEqual([ + ['c1', 'one', 'completed'], + ['c2', 'two', 'unverifiable'], + ['c3', 'three', 'working'] + ]) + }) +}) diff --git a/src/main/codex/codex-subagent-roster-state.ts b/src/main/codex/codex-subagent-roster-state.ts index ccbef83ade6..531a5e38195 100644 --- a/src/main/codex/codex-subagent-roster-state.ts +++ b/src/main/codex/codex-subagent-roster-state.ts @@ -1,73 +1,19 @@ -// One spawn group's roster state, and the ids its journal row is keyed on. +// The ids a Codex spawn group's journal row is keyed on. -import type { - AgentJournalItemIdentity, - AgentJournalTurnScope -} from '../../shared/agent-session-journal-types' -import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' -import { isTerminalSubagentState } from '../../shared/native-chat-subagent-summary' -import { SubagentRosterRetention } from '../native-chat/subagent-roster-retention' -import { - MAX_CODEX_SUBAGENT_GROUPS, - MAX_CODEX_SUBAGENTS_PER_GROUP -} from './codex-structured-journal-limits' +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' -/** The turn a group belongs to when Codex reports activity outside any turn. - * Mirrors the generic-frame bucket name so the two read alike in the journal. */ -export type RosterGroup = { - groupId: string - identity: AgentJournalItemIdentity - /** The spawning turn's scope: the row reports its children beside that turn's work. */ - turnScope: AgentJournalTurnScope - /** Insertion order is the display order; the map holds the state. */ - entries: Map - executionTurns: Map - /** Times each label has been claimed, so a repeat gets an ordinal suffix. */ - labelCounts: Map - /** Last body written, so an idempotent replay writes no new revision. */ - lastSerialized: string | null -} - -/** A reused child thread starts a new execution only when its turn id changes. */ -export function codexSubagentExecutionIdentity(threadId: string, turnId: string): string { - return JSON.stringify([threadId, turnId]) -} - -export function retainSupersededCodexExecution( - group: RosterGroup, - threadId: string, - nextTurnId: string | null, - retention: SubagentRosterRetention -): void { - const previousTurnId = group.executionTurns.get(threadId) - const previous = group.entries.get(threadId) - if ( - typeof previousTurnId === 'string' && - previousTurnId !== nextTurnId && - previous && - isTerminalSubagentState(previous.state) - ) { - retention.rememberSettled(codexSubagentExecutionIdentity(threadId, previousTurnId)) - } -} - -export function createCodexSubagentRosterRetention(groups: Map) { - return new SubagentRosterRetention(groups, { - maxGroups: MAX_CODEX_SUBAGENT_GROUPS, - maxSettledIdentities: MAX_CODEX_SUBAGENT_GROUPS * MAX_CODEX_SUBAGENTS_PER_GROUP, - entries: (group) => group.entries.values(), - identities: (group) => - [...group.executionTurns].flatMap(([threadId, turnId]) => - turnId === null ? [] : [codexSubagentExecutionIdentity(threadId, turnId)] - ) - }) -} +const OUTSIDE_TURN = 'outside-turn' /** Group identity: the parent turn that spawned the children. `agentPath` is a * tree rooted at the parent thread, so every child of one turn shares a row * no matter which thread's stream carried its activity item. */ export function codexSubagentGroupId(threadId: string, turnId: string | null): string { - return `${threadId}:${turnId ?? 'outside-turn'}` + return `${threadId}:${turnId ?? OUTSIDE_TURN}` +} + +/** The group of children reported outside any of the parent's turns. */ +export function isCodexOutsideTurnGroup(groupId: string): boolean { + return groupId.endsWith(`:${OUTSIDE_TURN}`) } /** Durable journal identity for the group's row — stable across revisions and diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts index 0b55d231fbe..ac00a0cafbc 100644 --- a/src/main/codex/codex-subagent-roster.ts +++ b/src/main/codex/codex-subagent-roster.ts @@ -2,22 +2,22 @@ // // An announcement supplies membership — a `subAgentActivity` item, or in // Codex's default multi-agent mode any collab call naming the helper — and -// child turn events supply execution state. -// -// KNOWN LIMITATION: `groups` is process-local and is never seeded from the -// journal, while the row's identity is keyed on the group id alone. After a -// restart loses membership for `threadId:outside-turn`, the next activity -// rebuilds that row from one child — rewriting N down to one. This group id -// outlives the process: `thread/resume` returns the same thread, while real turn -// ids are assumed freshly minted per turn. Seeding from the journal is the fix. +// child turn events supply execution state: each child turn is one run of the +// child. Everything the row says follows the shared subagent tracker's rules; +// this module only reads Codex's events. Rows earlier provider runs journaled +// are inherited, so a restart neither splits a child nor rewrites the row no +// turn owns from one child. import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' -import { isTerminalSubagentState } from '../../shared/native-chat-subagent-summary' import type { NativeChatSubagentState } from '../../shared/native-chat-types' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { JournaledSubagentGroups } from '../native-chat/subagent-tracker/journaled-subagent-groups' +import { subagentRowSinkPort } from '../native-chat/subagent-tracker/subagent-row-sink-port' +import { SubagentTracker } from '../native-chat/subagent-tracker/subagent-tracker' +import type { SubagentReport } from '../native-chat/subagent-tracker/subagent-tracker-types' import { readCodexSubagentActivity, readCodexSubagentAnnouncements, @@ -32,21 +32,16 @@ import { } from './codex-subagent-executions' import { readRecord } from './codex-item-field-readers' import { readCodexTurnId } from './codex-structured-thread-facts' -import { subagentGroupJournalBody } from '../native-chat/agent-session-journal/journal-subagent-group-body' import { CodexSubagentLinkage } from './codex-subagent-linkage' -export { subagentGroupJournalBody as codexSubagentGroupBody } from '../native-chat/agent-session-journal/journal-subagent-group-body' -export { codexSubagentGroupId, codexSubagentGroupIdentity } from './codex-subagent-roster-state' import { codexSubagentGroupId, codexSubagentGroupIdentity, - codexSubagentExecutionIdentity as executionIdentity, - createCodexSubagentRosterRetention, - retainSupersededCodexExecution, - type RosterGroup + isCodexOutsideTurnGroup } from './codex-subagent-roster-state' import type { CodexThreadItem } from './codex-structured-item-translation' -import { MAX_CODEX_SUBAGENTS_PER_GROUP } from './codex-structured-journal-limits' import { CodexThreadTokenTotals } from './codex-thread-token-totals' +export { subagentGroupJournalBody as codexSubagentGroupBody } from '../native-chat/agent-session-journal/journal-subagent-group-body' +export { codexSubagentGroupId, codexSubagentGroupIdentity } from './codex-subagent-roster-state' const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } @@ -61,11 +56,9 @@ export type CodexSubagentRosterDeps = { } export class CodexSubagentRoster { - private readonly groups = new Map() - private readonly retention = createCodexSubagentRosterRetention(this.groups) - /** Every thread's total, members or not; children are selected at write time. */ + private readonly tracker: SubagentTracker + /** Every thread's total, members or not; a child shows its total once it is listed. */ private readonly tokensByThread = new CodexThreadTokenTotals() - private readonly now: () => number /** The one owner of child membership and turn state; the rows of calls on a helper read it too. */ readonly executions: CodexSubagentExecutions private readonly unfollow: () => void @@ -73,11 +66,18 @@ export class CodexSubagentRoster { readonly linkage: CodexSubagentLinkage constructor(private readonly deps: CodexSubagentRosterDeps) { - this.now = deps.now ?? (() => Date.now()) + this.tracker = new SubagentTracker({ + port: subagentRowSinkPort(deps.sink, codexSubagentGroupIdentity), + outsideTurn: isCodexOutsideTurnGroup, + journaled: new JournaledSubagentGroups( + () => deps.sink.journalLinkage?.() ?? null, + codexSubagentGroupIdentity + ), + ...(deps.now ? { now: deps.now } : {}) + }) this.executions = deps.executions ?? new CodexSubagentExecutions() // The row follows the executions, so every frame that ends a child's turn — its own // `turn/completed` (a failed one included) or its thread closing — settles it. - // A refused write clears `lastSerialized`, so the next write of the group retries it. this.unfollow = this.executions.onExecutionChanged( (child) => child.execution && this.follow(child, child.execution) ) @@ -112,19 +112,9 @@ export class CodexSubagentRoster { // Only a spawn names the spawner: other announcements ride whichever agent acted. announcement.spawned ? input.threadId : undefined ) - if ( - !child?.execution || - this.retention.hasSettled(executionIdentity(child.agentThreadId, child.execution.turnId)) - ) { - return ADMITTED - } - const group = - this.executionGroup(child.agentThreadId, child.execution.turnId) ?? - this.groupFor(input.threadId, input.turnId) - if (!group.entries.has(child.agentThreadId)) { - this.recordExecution(group, child, child.execution) - } - return this.write(group) + return child?.execution + ? this.report(child, child.execution, true, this.groupFor(input.threadId, input.turnId)) + : ADMITTED } handleTurnEvent(event: { @@ -152,7 +142,7 @@ export class CodexSubagentRoster { }): StructuredAgentSessionSinkAdmission { if ( input.threadId === this.deps.primaryThreadId() || - this.retention.hasSettled(executionIdentity(input.threadId, input.turnId)) + this.tracker.hasSettled(input.threadId, input.turnId) ) { return ADMITTED } @@ -161,35 +151,17 @@ export class CodexSubagentRoster { return observed ? this.follow(observed.child, observed.execution) : ADMITTED } + /** A child turn starting is a run starting; one ending only revises the row listing it. */ private follow( child: Readonly, execution: CodexChildExecution ): StructuredAgentSessionSinkAdmission { - if ( - !child.registered || - this.retention.hasSettled(executionIdentity(child.agentThreadId, execution.turnId)) - ) { + if (!child.registered) { return ADMITTED } - if (execution.state === 'working') { - const parent = this.deps.primaryThreadId() ?? child.agentThreadId - const group = - this.executionGroup(child.agentThreadId, execution.turnId) ?? - this.groupFor(parent, this.deps.activeTurn(parent) ?? child.parentTurnId) - this.recordExecution(group, child, execution) - return this.write(group) - } - for (const group of this.groups.values()) { - if (group.executionTurns.get(child.agentThreadId) !== execution.turnId) { - continue - } - this.recordExecution(group, child, execution) - const admission = this.write(group) - if (!admission.accepted) { - return admission - } - } - return ADMITTED + const parent = this.deps.primaryThreadId() ?? child.agentThreadId + const group = this.groupFor(parent, this.deps.activeTurn(parent) ?? child.parentTurnId) + return this.report(child, execution, execution.state === 'working', group) } /** Consume `thread/tokenUsage/updated`. Returns null when the params are not one. */ @@ -199,16 +171,15 @@ export class CodexSubagentRoster { return null } this.tokensByThread.record(usage.threadId, usage.totalTokens) - for (const group of this.groups.values()) { - if (!group.entries.has(usage.threadId)) { - continue - } - const admission = this.write(group) - if (!admission.accepted) { - return admission - } - } - return ADMITTED + const located = this.tracker.locate(usage.threadId) + return located + ? this.tracker.report({ + id: usage.threadId, + group: { id: located.group.groupId, placement: () => located.group.placement }, + announces: false, + tokens: usage.totalTokens + }) + : ADMITTED } /** @@ -221,146 +192,50 @@ export class CodexSubagentRoster { */ settleSession(): StructuredAgentSessionSinkAdmission { this.executions.settleSession() - for (const group of this.groups.values()) { - const admission = this.sweep(group) - if (!admission.accepted) { - return admission - } - } - return ADMITTED + return this.tracker.settleSession() } dispose(): void { this.unfollow() - this.groups.clear() - this.retention.clear() + this.tracker.dispose() this.tokensByThread.clear() } retentionSizes(): { groups: number; settledIdentities: number } { - return this.retention.sizes() + return this.tracker.sizes() } - private sweep(group: RosterGroup): StructuredAgentSessionSinkAdmission { - let changed = false - for (const [id, entry] of group.entries) { - if (isTerminalSubagentState(entry.state)) { - continue - } - group.entries.set(id, { ...entry, state: 'unverifiable', settledAt: this.now() }) - changed = true - } - // A null `lastSerialized` means the previous write was refused part-way, so - // the settled roster's last revision is queued but never published. Nothing - // is guaranteed to write this group again, so retry here even when the sweep - // itself changed nothing. - return changed || group.lastSerialized === null ? this.write(group) : ADMITTED + private report( + child: Readonly, + execution: CodexChildExecution, + announces: boolean, + group: SubagentReport['group'] + ): StructuredAgentSessionSinkAdmission { + const tokens = this.tokensByThread.get(child.agentThreadId) + return this.tracker.report({ + id: child.agentThreadId, + run: execution.turnId, + group, + announces, + label: child.label, + state: execution.state, + ...(typeof tokens === 'number' ? { tokens } : {}), + // A spawned child may outlive the turn that spawned it. + backgrounded: true + }) } - private groupFor(threadId: string, turnId: string | null): RosterGroup { + /** The group a child spawned now belongs to: the session's own agent's current turn. */ + private groupFor( + threadId: string, + turnId: string | null + ): SubagentReport['group'] { const ownerThreadId = this.deps.primaryThreadId() ?? threadId const ownerTurnId = ownerThreadId === threadId ? turnId : (this.deps.activeTurn(ownerThreadId) ?? turnId) - const groupId = codexSubagentGroupId(ownerThreadId, ownerTurnId) - const existing = this.groups.get(groupId) - if (existing) { - return existing + return { + id: codexSubagentGroupId(ownerThreadId, ownerTurnId), + placement: () => this.deps.turnScopeFor(ownerThreadId, ownerTurnId) } - const group: RosterGroup = { - groupId, - identity: codexSubagentGroupIdentity(groupId), - turnScope: this.deps.turnScopeFor(ownerThreadId, ownerTurnId), - entries: new Map(), - executionTurns: new Map(), - labelCounts: new Map(), - lastSerialized: null - } - this.groups.set(groupId, group) - return group - } - - private executionGroup(threadId: string, turnId: string): RosterGroup | undefined { - return [...this.groups.values()].find((group) => group.executionTurns.get(threadId) === turnId) - } - - /** Two children can share a trailing path segment; the ordinal keeps their - * rows apart without inventing a name the provider never sent. */ - private claimLabel(group: RosterGroup, label: string | null): string { - const base = label ?? 'subagent' - const seen = group.labelCounts.get(base) ?? 0 - group.labelCounts.set(base, seen + 1) - return seen === 0 ? base : `${base} ${seen + 1}` - } - - private recordExecution( - group: RosterGroup, - child: CodexExecutionChild, - execution: CodexChildExecution | null - ): void { - const existing = group.entries.get(child.agentThreadId) - if (!existing && group.entries.size >= MAX_CODEX_SUBAGENTS_PER_GROUP) { - return - } - const turnId = execution?.turnId ?? null - const state = execution?.state ?? 'unverifiable' - const sameTurn = existing && group.executionTurns.get(child.agentThreadId) === turnId - if (sameTurn && existing.state === state) { - return - } - const now = this.now() - retainSupersededCodexExecution(group, child.agentThreadId, turnId, this.retention) - group.executionTurns.set(child.agentThreadId, turnId) - group.entries.set(child.agentThreadId, { - id: child.agentThreadId, - label: existing?.label ?? this.claimLabel(group, child.label), - state, - startedAt: sameTurn ? existing.startedAt : now, - ...(isTerminalSubagentState(state) ? { settledAt: now } : {}), - ...(existing?.tokens !== undefined ? { tokens: existing.tokens } : {}) - }) - } - - private write(group: RosterGroup): StructuredAgentSessionSinkAdmission { - const agents = [...group.entries].map(([id, entry]) => { - const tokens = this.tokensByThread.get(id) - if (typeof tokens !== 'number' || tokens === entry.tokens) { - return entry - } - // Persisted, not merely read: the thread map is LRU-capped, and reading it - // afresh each write would retract a count this row has already shown. - const merged = { ...entry, tokens } - group.entries.set(id, merged) - return merged - }) - const body = subagentGroupJournalBody(group.groupId, agents) - const serialized = JSON.stringify(body) - if (serialized === group.lastSerialized) { - // Nothing changed — a duplicate delivery must not burn a revision. - return ADMITTED - } - group.lastSerialized = serialized - // Deliberately unstamped: a child's frame can trigger this write, but the - // row is the PARENT's roster of its children. - const options = { turnScope: group.turnScope } - const admission = this.deps.sink.tryAppendItem - ? this.deps.sink.tryAppendItem(group.identity, body, options) - : (this.deps.sink.appendItem(group.identity, body, options), ADMITTED) - if (!admission.accepted) { - group.lastSerialized = null - this.retention.trim([group]) - return admission - } - const published = this.deps.sink.tryPublish - ? this.deps.sink.tryPublish() - : (this.deps.sink.publish(), ADMITTED) - if (!published.accepted) { - // Symmetric with the append refusal above: the suppression state may only - // advance once the revision is both queued AND published. Left set, an - // identical replay short-circuits and the last revision of a settled - // roster stays queued but never reaches the renderer. - group.lastSerialized = null - } - this.retention.trim([group]) - return published } } diff --git a/src/main/claude/claude-subagent-group-row.test.ts b/src/main/native-chat/agent-session-journal/journal-subagent-group-body-fallback.test.ts similarity index 88% rename from src/main/claude/claude-subagent-group-row.test.ts rename to src/main/native-chat/agent-session-journal/journal-subagent-group-body-fallback.test.ts index 2d5ac56e22c..e59e882e915 100644 --- a/src/main/claude/claude-subagent-group-row.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-group-body-fallback.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' -import { claudeSubagentGroupBody } from './claude-subagent-group-row' +import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types' +import { subagentGroupJournalBody } from './journal-subagent-group-body' function entry(id: string, state: NativeChatSubagentEntry['state']): NativeChatSubagentEntry { return { id, label: id, state, startedAt: 1 } @@ -9,12 +9,12 @@ function entry(id: string, state: NativeChatSubagentEntry['state']): NativeChatS /** The fallback sentence is the WHOLE row on mobile and paired web, which have * no roster renderer, so these assertions are the entire contract there. */ function sentence(agents: readonly NativeChatSubagentEntry[]): string { - const body = claudeSubagentGroupBody('turn-1', agents) + const body = subagentGroupJournalBody('turn-1', agents) const block = body.kind === 'message' ? body.blocks[0] : undefined return block && block.type === 'text' ? block.text : '' } -describe('claudeSubagentGroupBody fallback sentence', () => { +describe('subagentGroupJournalBody fallback sentence', () => { it('reads as a plain completion when every child completed', () => { expect(sentence([entry('a', 'completed'), entry('b', 'completed')])).toBe('Ran 2 subagents') }) diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts index ea4cebab150..9a99ecf8d65 100644 --- a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts @@ -68,12 +68,16 @@ export function staleSubagentRosterRevisions( } /** The row once the host lost the session running its live work: every working child and - * in-flight background task `unverifiable`, plain-text twins restated. Null when none is live. */ -export function lostLiveWorkJournalBody(body: AgentJournalItemBody): AgentJournalItemBody | null { + * in-flight background task `unverifiable`, plain-text twins restated. Null when none is live. + * `lostAt` is when a live host saw the session end; a reopened journal cannot know it. */ +export function lostLiveWorkJournalBody( + body: AgentJournalItemBody, + lostAt?: number +): AgentJournalItemBody | null { if (body.kind !== 'message' || !body.blocks.some(hasStaleLiveWork)) { return null } - return { ...body, blocks: settleBlocks(body.blocks) } + return { ...body, blocks: settleBlocks(body.blocks, lostAt) } } function hasStaleLiveWork(block: NativeChatBlock): boolean { @@ -94,15 +98,15 @@ function hasLiveBackgroundTask(block: NativeChatBlock): block is NativeChatBackg ) } -/** No `settledAt`: the child stopped being observable at an unknown moment, and - * stamping the reopen would report the time the app was down as how long it - * ran. Readers already draw an unverifiable child with no stamp as having no - * known run length. */ -function settleBlocks(blocks: readonly NativeChatBlock[]): NativeChatBlock[] { +/** Without `lostAt`, no `settledAt`: the child stopped being observable at an + * unknown moment, and stamping the reopen would report the time the app was + * down as how long it ran. Readers already draw an unverifiable child with no + * stamp as having no known run length. */ +function settleBlocks(blocks: readonly NativeChatBlock[], lostAt?: number): NativeChatBlock[] { const backgroundTaskTwinText = new Map() const settled = blocks.map((block) => { if (hasWorkingChild(block)) { - return settleGroup(block) + return settleGroup(block, lostAt) } if (hasLiveBackgroundTask(block)) { const next = settleBackgroundTask(block) @@ -134,12 +138,19 @@ function settleBlocks(blocks: readonly NativeChatBlock[]): NativeChatBlock[] { ) } -function settleGroup(block: NativeChatSubagentGroupBlock): NativeChatSubagentGroupBlock { +function settleGroup( + block: NativeChatSubagentGroupBlock, + lostAt?: number +): NativeChatSubagentGroupBlock { return { ...block, agents: block.agents.map((agent) => normalizeSubagentState(agent.state) === 'working' - ? { ...agent, state: 'unverifiable' as const } + ? { + ...agent, + state: 'unverifiable' as const, + ...(lostAt !== undefined ? { settledAt: lostAt } : {}) + } : agent ) } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts index 4cd8b8650ff..ee603bbd048 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -126,4 +126,9 @@ export type ProviderTimelineEvent = /** Provider traffic no typed event covers; it becomes the shared bounded fallback row. */ | ({ type: 'provider.frame'; frameKind: string; payload: unknown } & Joined) /** The provider child is gone. The verdict is what the host can prove about its end. */ - | { type: 'session.ended'; verdict: StructuredAgentSessionTurnVerdict } + | { + type: 'session.ended' + verdict: StructuredAgentSessionTurnVerdict + /** When the host saw the end: the moment it lost contact with any child still running. */ + endedAt?: number + } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts index d81d8745414..ae6e2ef32c8 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -73,6 +73,8 @@ export type ProviderTimelineSettlementScope = { turnItemId: string } | 'session' export type ProviderTimelineSettlementEnding = { turns: readonly { identity: AgentJournalItemIdentity; itemId: string }[] end: ProviderTimelineTurnEnd + /** When the session's end was seen, which is when its live subagents lost contact. */ + lostAt?: number } /** The settled rows of `scope`, then each ended turn's row while the journal holds it running. */ @@ -95,7 +97,7 @@ export function providerTimelineSettlement( const settled = !covered ? null : (terminalAgentJournalBody(body, end) ?? - (scope === 'session' ? lostLiveWorkJournalBody(body) : null)) + (scope === 'session' ? lostLiveWorkJournalBody(body, ending?.lostAt) : null)) const identity = settled ? parseAgentJournalItemKey(itemId) : null if (settled && identity) { // No linkage: a revision keeps the row's own producer. diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts index 973de114862..612eaec8594 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts @@ -264,7 +264,8 @@ export function decideSessionEnd( resolve: (journal) => providerTimelineSettlement(journal, 'session', { turns: runningProviderTimelineTurns(journal), - end: event.verdict + end: event.verdict, + ...(event.endedAt !== undefined ? { lostAt: event.endedAt } : {}) }) }, commit: (next) => next.endSession() diff --git a/src/main/native-chat/subagent-roster-retention.test.ts b/src/main/native-chat/subagent-roster-retention.test.ts index 5eaacca3568..c89831e2263 100644 --- a/src/main/native-chat/subagent-roster-retention.test.ts +++ b/src/main/native-chat/subagent-roster-retention.test.ts @@ -133,7 +133,7 @@ it('keeps only the accessed inherited row during a trim and re-derives it on the for (let index = 0; index < 70; index++) { const inherited = group(`inherited-${index}`, 'completed', 1) groups.set(inherited.groupId, inherited) - retention.trim([inherited], inherited.groupId) + retention.trim([inherited], (groupId) => groupId === inherited.groupId) expect(groups.size).toBe(33) expect(groups.has(inherited.groupId)).toBe(true) } @@ -142,7 +142,7 @@ it('keeps only the accessed inherited row during a trim and re-derives it on the expect(retention.hasSettled('inherited-69:0')).toBe(true) const resumed = group('resumed', 'completed', 1) groups.set(resumed.groupId, resumed) - retention.trim([resumed], resumed.groupId) + retention.trim([resumed], (groupId) => groupId === resumed.groupId) resumed.entries.set('resumed:0', { id: 'resumed:0', label: 'resumed', state: 'working' }) retention.trim([resumed]) expect(groups.has(resumed.groupId)).toBe(true) diff --git a/src/main/native-chat/subagent-roster-retention.ts b/src/main/native-chat/subagent-roster-retention.ts index 6d4f5d865f5..3e3584a4efb 100644 --- a/src/main/native-chat/subagent-roster-retention.ts +++ b/src/main/native-chat/subagent-roster-retention.ts @@ -34,7 +34,8 @@ export class SubagentRosterRetention { this.settledIdentities.set(identity, true) } - trim(changed: Iterable, retainedGroupId?: string): void { + /** `kept` names groups that are never released: rows a later report can name again. */ + trim(changed: Iterable, kept?: (groupId: string) => boolean): void { for (const group of changed) { if ( group.lastSerialized !== null && @@ -49,7 +50,7 @@ export class SubagentRosterRetention { if (this.groups.size <= this.options.maxGroups) { break } - if (groupId === retainedGroupId) { + if (kept?.(groupId)) { continue } const group = this.groups.get(groupId) diff --git a/src/main/native-chat/subagent-row-sink-recorder-test-support.ts b/src/main/native-chat/subagent-row-sink-recorder-test-support.ts new file mode 100644 index 00000000000..b1d8c07b7d0 --- /dev/null +++ b/src/main/native-chat/subagent-row-sink-recorder-test-support.ts @@ -0,0 +1,82 @@ +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionSinkAdmission +} from './agent-session-wire/structured-agent-session-event-sink' + +export type RecordedSubagentRowWrite = + | { + op: 'append' + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + turnScope: AgentJournalTurnScope | undefined + accepted: boolean + } + | { op: 'tombstone'; identity: AgentJournalItemIdentity; accepted: boolean } + | { op: 'publish'; accepted: boolean } + +const ACCEPTED: StructuredAgentSessionSinkAdmission = { accepted: true } +const REFUSED: StructuredAgentSessionSinkAdmission = { accepted: false, reason: 'backpressure' } + +/** A sink that records every row write in order, with refusals the test schedules. */ +export function recordingSubagentRowSink(): { + sink: StructuredAgentSessionEventSink + log: RecordedSubagentRowWrite[] + refuseNextAppend: () => void + refuseNextPublish: () => void +} { + const log: RecordedSubagentRowWrite[] = [] + let refuseAppend = false + let refusePublish = false + const admit = (refuse: boolean): StructuredAgentSessionSinkAdmission => + refuse ? REFUSED : ACCEPTED + const sink: StructuredAgentSessionEventSink = { + appendItem: () => { + throw new Error('rows must use tryAppendItem') + }, + appendTombstone: () => { + throw new Error('rows must use tryAppendTombstone') + }, + publish: () => { + throw new Error('rows must use tryPublish') + }, + tryAppendItem: (identity, body, options) => { + const admission = admit(refuseAppend) + refuseAppend = false + log.push({ + op: 'append', + identity, + body: structuredClone(body), + turnScope: options.turnScope, + accepted: admission.accepted + }) + return admission + }, + tryAppendTombstone: (identity) => { + const admission = admit(refuseAppend) + refuseAppend = false + log.push({ op: 'tombstone', identity, accepted: admission.accepted }) + return admission + }, + tryPublish: () => { + const admission = admit(refusePublish) + refusePublish = false + log.push({ op: 'publish', accepted: admission.accepted }) + return admission + } + } + return { + sink, + log, + refuseNextAppend: () => { + refuseAppend = true + }, + refuseNextPublish: () => { + refusePublish = true + } + } +} diff --git a/src/main/native-chat/subagent-tracker/journaled-subagent-groups.ts b/src/main/native-chat/subagent-tracker/journaled-subagent-groups.ts new file mode 100644 index 00000000000..32ea45200b9 --- /dev/null +++ b/src/main/native-chat/subagent-tracker/journaled-subagent-groups.ts @@ -0,0 +1,112 @@ +// What earlier provider runs of a session left of its subagent rows, re-derived from the rows they +// journaled. +// +// The tracker lives in one provider process, but every row it writes outlives that process under a +// restart-stable identity. A run that started from nothing would rewrite a row no turn owns from +// empty and list a resumed child twice, so a run reads what earlier runs left instead: derived, +// never stored, so it cannot disagree with the rows it came from. + +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalProducerLinkage, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { + isSubagentGroupBlock, + type NativeChatSubagentEntry +} from '../../../shared/native-chat-types' +import type { StructuredAgentSessionLinkageJournal } from '../agent-session-wire/structured-agent-session-event-sink' +import type { JournaledSubagentSource } from './subagent-tracker-types' + +type JournaledGroup = { + entries: readonly NativeChatSubagentEntry[] + placement: AgentJournalTurnScope +} + +/** More an agent reads from the same pass, from who produced each row. */ +export type JournaledSubagentExtension = { + create: () => Extra + visit: (extra: Extra, linkage: AgentJournalProducerLinkage) => void +} + +type Reading = { + rowsByGroup: Map + groupByEntry: Map + extra: Extra | null +} + +/** Read once per bound journal epoch. Before the journal is bound nothing is read and nothing is + * kept, so the first read after bind still sees every row. */ +export class JournaledSubagentGroups< + Extra = never +> implements JournaledSubagentSource { + private journal: StructuredAgentSessionLinkageJournal | null = null + private epoch: string | null = null + private reading: Reading | null = null + + constructor( + private readonly bound: () => StructuredAgentSessionLinkageJournal | null, + private readonly identityFor: (groupId: string) => AgentJournalItemIdentity, + private readonly extension?: JournaledSubagentExtension + ) {} + + groupOf = (id: string): string | null => this.current()?.groupByEntry.get(id) ?? null + + claimGroup = (groupId: string): JournaledGroup | null => { + const reading = this.current() + const row = reading?.rowsByGroup.get(groupId) ?? null + reading?.rowsByGroup.delete(groupId) + return row + } + + /** The extension's reading of the bound journal; null before bind. */ + extra(): Extra | null { + return this.current()?.extra ?? null + } + + private current(): Reading | null { + const journal = this.bound() + if (!journal) { + return null + } + if (!this.reading || journal !== this.journal || journal.epoch !== this.epoch) { + this.journal = journal + this.epoch = journal.epoch + this.reading = this.read(journal) + } + return this.reading + } + + private read(journal: StructuredAgentSessionLinkageJournal): Reading { + const reading: Reading = { + rowsByGroup: new Map(), + groupByEntry: new Map(), + extra: this.extension?.create() ?? null + } + // A child two rows list (only an older build wrote that) is the later-created row's: a turn's + // row is created with its turn, so that is where it last ran. + const listedAt = new Map() + journal.visitItemsWithLinkage((itemId, sequence, body, attribution) => { + if (this.extension && reading.extra !== null) { + this.extension.visit(reading.extra, attribution) + } + const group = body.kind === 'message' ? body.blocks.find(isSubagentGroupBlock) : undefined + if (!group || itemId !== agentJournalItemKey(this.identityFor(group.groupId))) { + return + } + reading.rowsByGroup.set(group.groupId, { + entries: group.agents, + placement: attribution.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + }) + for (const entry of group.agents) { + if ((listedAt.get(entry.id) ?? -1) < sequence) { + listedAt.set(entry.id, sequence) + reading.groupByEntry.set(entry.id, group.groupId) + } + } + }) + return reading + } +} diff --git a/src/main/native-chat/subagent-tracker/subagent-row-sink-port.ts b/src/main/native-chat/subagent-tracker/subagent-row-sink-port.ts new file mode 100644 index 00000000000..c596e367d5d --- /dev/null +++ b/src/main/native-chat/subagent-tracker/subagent-row-sink-port.ts @@ -0,0 +1,37 @@ +// A subagent group's row written straight to the session's journal sink. + +import type { + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionSinkAdmission +} from '../agent-session-wire/structured-agent-session-event-sink' +import type { SubagentRowPort } from './subagent-tracker-types' + +const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +/** + * Deliberately ROOT: the row is written from a child's frame but describes the PARENT's children, + * so it is the session's own agent reporting what it spawned. Stamping it as a child's would hide + * the roster from the very row that owns it. + */ +export function subagentRowSinkPort( + sink: StructuredAgentSessionEventSink, + identityFor: (groupId: string) => AgentJournalItemIdentity +): SubagentRowPort { + return { + write: (group, { body }) => { + const identity = identityFor(group.groupId) + const admission = body + ? (sink.tryAppendItem?.(identity, body, { turnScope: group.placement }) ?? + (sink.appendItem(identity, body, { turnScope: group.placement }), ADMITTED)) + : (sink.tryAppendTombstone?.(identity) ?? (sink.appendTombstone(identity), ADMITTED)) + if (!admission.accepted) { + return admission + } + return sink.tryPublish?.() ?? (sink.publish(), ADMITTED) + } + } +} diff --git a/src/main/native-chat/subagent-tracker/subagent-row-writes.ts b/src/main/native-chat/subagent-tracker/subagent-row-writes.ts new file mode 100644 index 00000000000..e752b23c4d7 --- /dev/null +++ b/src/main/native-chat/subagent-tracker/subagent-row-writes.ts @@ -0,0 +1,29 @@ +// Writing a group's row: one dedupe and one refusal rule for every agent. + +import { subagentGroupJournalBody } from '../agent-session-journal/journal-subagent-group-body' +import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' +import type { SubagentGroup, SubagentRowPort } from './subagent-tracker-types' + +/** Writes each group whose row changed. The dedupe advances only once a revision is both queued and + * published, so a refused one is written again by the next write of that group; the first refusal + * is handed back after every group was tried. */ +export function writeSubagentRows( + groups: Iterable>, + port: SubagentRowPort +): StructuredAgentSessionSinkAdmission | null { + let refused: StructuredAgentSessionSinkAdmission | null = null + for (const group of groups) { + const agents = [...group.entries.values()].map((tracked) => tracked.entry) + const body = subagentGroupJournalBody(group.groupId, agents) + const serialized = JSON.stringify(body) + if (serialized === group.lastSerialized) { + // A duplicate delivery must not burn a revision. + continue + } + // A group that lost its last child removes its row. + const admission = port.write(group, { body: agents.length > 0 ? body : null, serialized }) + group.lastSerialized = admission.accepted ? serialized : null + refused ??= admission.accepted ? null : admission + } + return refused +} diff --git a/src/main/native-chat/subagent-tracker/subagent-tracker-entries.ts b/src/main/native-chat/subagent-tracker/subagent-tracker-entries.ts new file mode 100644 index 00000000000..3efe65091d1 --- /dev/null +++ b/src/main/native-chat/subagent-tracker/subagent-tracker-entries.ts @@ -0,0 +1,69 @@ +// How one listed child changes: its name, how it starts a run, and which states may replace which. + +import { + MAX_SUBAGENT_FIELD_CHARS, + canReplaceSubagentState, + isTerminalSubagentState +} from '../../../shared/native-chat-subagent-summary' +import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types' +import { ownRetainedString } from '../../../shared/own-retained-string' +import type { SubagentGroup, SubagentReport, TrackedSubagent } from './subagent-tracker-types' + +export const UNLABELLED_SUBAGENT = 'subagent' + +/** Provider strings are kept to one unit past the row's bound, so the row still marks the clip. */ +export function boundedSubagentLabel(label: string | null | undefined): string | null { + const trimmed = label?.trim() + return trimmed ? ownRetainedString(trimmed.slice(0, MAX_SUBAGENT_FIELD_CHARS + 1)) : null +} + +/** Two children can share a name; the ordinal keeps their rows apart without inventing a name the + * provider never sent. The probe is over the labels actually claimed, so a generated `Audit 2` + * never collides with a child the provider itself named `Audit 2`. */ +export function claimSubagentLabel(group: SubagentGroup, base: string): string { + let candidate = base + for (let ordinal = 2; group.claimedLabels.has(candidate); ordinal++) { + candidate = `${base} ${ordinal}` + } + group.claimedLabels.add(candidate) + return candidate +} + +/** A run starting now. A new run keeps the token count its child already showed. */ +export function startedSubagentEntry( + report: SubagentReport, + label: string, + now: number, + previous?: NativeChatSubagentEntry +): NativeChatSubagentEntry { + const state = report.state ?? 'working' + const tokens = report.tokens ?? previous?.tokens + return { + id: report.id, + label, + state, + startedAt: now, + ...(isTerminalSubagentState(state) ? { settledAt: now } : {}), + ...(tokens !== undefined ? { tokens } : {}) + } +} + +/** Proven outcomes latch; lost contact can still receive a later verdict. Null when `state` may not + * replace the current one. */ +export function withSubagentState( + tracked: TrackedSubagent, + state: NativeChatSubagentEntry['state'], + now: number +): TrackedSubagent | null { + if (tracked.entry.state === state || !canReplaceSubagentState(tracked.entry.state, state)) { + return null + } + // Only a terminal state replaces another. A child an earlier provider process left ended with + // that process: a verdict says how, not when. + const settledAt = tracked.inherited ? tracked.entry.settledAt : now + const { settledAt: _previous, ...rest } = tracked.entry + return { + ...tracked, + entry: { ...rest, state, ...(settledAt !== undefined ? { settledAt } : {}) } + } +} diff --git a/src/main/native-chat/subagent-tracker/subagent-tracker-groups.ts b/src/main/native-chat/subagent-tracker/subagent-tracker-groups.ts new file mode 100644 index 00000000000..f7ea43906a0 --- /dev/null +++ b/src/main/native-chat/subagent-tracker/subagent-tracker-groups.ts @@ -0,0 +1,167 @@ +// Which group lists each subagent: the groups this provider run wrote, and the ones earlier runs of +// the session journaled, inherited one at a time as this run's own reports reach them. Every lookup +// is read from the rows themselves, so no second index can disagree with what a row lists. + +import { SubagentRosterRetention } from '../subagent-roster-retention' +import { subagentGroupJournalBody } from '../agent-session-journal/journal-subagent-group-body' +import { UNLABELLED_SUBAGENT } from './subagent-tracker-entries' +import type { + JournaledSubagentSource, + SubagentGroup, + SubagentReport, + TrackedSubagent +} from './subagent-tracker-types' + +/** Settled history target; groups with unfinished children remain owned. */ +export const MAX_SUBAGENT_GROUPS = 32 +const MAX_SETTLED_IDENTITIES = 2048 + +export type LocatedSubagent = { + group: SubagentGroup + tracked: TrackedSubagent +} + +export class SubagentTrackerGroups { + private readonly groups = new Map>() + private readonly retention = new SubagentRosterRetention(this.groups, { + maxGroups: MAX_SUBAGENT_GROUPS, + maxSettledIdentities: MAX_SETTLED_IDENTITIES, + entries: (group) => [...group.entries.values()].map((tracked) => tracked.entry), + // A released child's every run stays settled, so no replay of any of them lists it again. + identities: (group) => + [...group.entries.values()].flatMap(({ entry, runs }) => [ + settledIdentity(entry.id, null), + ...runs.map((run) => settledIdentity(entry.id, run)) + ]), + onEvict: (group) => this.deps.onEvict?.(group) + }) + + constructor( + private readonly deps: { + journaled?: JournaledSubagentSource + /** A group no turn owns: later reports name it again, so its row is never released, or a + * later child would rewrite it from empty. */ + outsideTurn: (groupId: string) => boolean + onEvict?: (group: SubagentGroup) => void + } + ) {} + + private get journaled(): JournaledSubagentSource | undefined { + return this.deps.journaled + } + + values(): Iterable> { + return this.groups.values() + } + + get(groupId: string): SubagentGroup | undefined { + return this.groups.get(groupId) + } + + /** The child's entry, inheriting the group an earlier provider run last listed it in. */ + locate(id: string): LocatedSubagent | null { + const listed = this.listed(id) + if (listed) { + return listed + } + const groupId = this.journaled?.groupOf(id) ?? null + return groupId !== null && !this.groups.has(groupId) && this.inherit(groupId) + ? this.listed(id) + : null + } + + /** This run's group for a key, else the earlier run's row it continues, else a new one. */ + groupFor(group: SubagentReport['group']): SubagentGroup { + const existing = this.groups.get(group.id) ?? this.inherit(group.id) + if (existing) { + return existing + } + const created: SubagentGroup = { + groupId: group.id, + placement: group.placement(), + entries: new Map(), + admittedEntries: 0, + claimedLabels: new Set(), + lastSerialized: null + } + this.groups.set(group.id, created) + return created + } + + hasSettled(id: string, run: string | null): boolean { + return this.retention.hasSettled(settledIdentity(id, run)) + } + + /** A run an entry no longer remembers is settled for good. */ + rememberSettledRun(id: string, run: string): void { + this.retention.rememberSettled(settledIdentity(id, run)) + } + + trim(changed: Iterable>, retainedGroupId?: string): void { + this.retention.trim( + changed, + (groupId) => groupId === retainedGroupId || this.deps.outsideTurn(groupId) + ) + } + + sizes(): { groups: number; settledIdentities: number } { + return this.retention.sizes() + } + + clear(): void { + this.retention.clear() + this.groups.clear() + } + + /** An older build could list one child in two rows: the inherited copy that counts is the one + * the journal reading says it last ran in; the other is history. */ + private listed(id: string): LocatedSubagent | null { + const chosen = this.journaled?.groupOf(id) ?? null + for (const group of this.groups.values()) { + const tracked = group.entries.get(id) + if (tracked && !(tracked.inherited && chosen !== null && chosen !== group.groupId)) { + return { group, tracked } + } + } + return null + } + + /** Once per group: after that this run's copy is the newer one. */ + private inherit(groupId: string): SubagentGroup | null { + const row = this.journaled?.claimGroup(groupId) ?? null + if (!row) { + return null + } + const group: SubagentGroup = { + groupId, + // The row keeps the turn it was created beside; a later run's writes never move it. + placement: row.placement, + entries: new Map(), + admittedEntries: row.entries.length, + claimedLabels: new Set(row.entries.map((entry) => entry.label)), + lastSerialized: JSON.stringify(subagentGroupJournalBody(groupId, row.entries)) + } + for (const entry of row.entries) { + group.entries.set(entry.id, { + entry: { ...entry }, + run: null, + runs: [], + attempt: this.journaled?.attempt?.(entry.id) ?? 1, + turn: null, + // Each child outlived the run that spawned it, so none is tied to this run's turns. + backgrounded: true, + labelBase: entry.label, + provisional: entry.label === UNLABELLED_SUBAGENT, + inherited: true + }) + } + this.groups.set(groupId, group) + // Counted as settled history now, even if only a lookup reached it; released by a later trim. + this.retention.trim([group], () => true) + return group + } +} + +function settledIdentity(id: string, run: string | null): string { + return JSON.stringify([id, run]) +} diff --git a/src/main/native-chat/subagent-tracker/subagent-tracker-types.ts b/src/main/native-chat/subagent-tracker/subagent-tracker-types.ts new file mode 100644 index 00000000000..e53fd11a9b0 --- /dev/null +++ b/src/main/native-chat/subagent-tracker/subagent-tracker-types.ts @@ -0,0 +1,88 @@ +// The one shape every agent reports its subagents in, and the state the shared tracker keeps. + +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + NativeChatSubagentEntry, + NativeChatSubagentState +} from '../../../shared/native-chat-types' +import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' + +/** What an agent observed about one of its subagents. Started, updated and finished are the state it + * carries; the tracker decides what the row says. */ +export type SubagentReport = { + /** The child's canonical id: the entry key, stable across its runs. */ + id: string + /** One execution of the child: Claude's spawn or resume call, Codex's child turn. Absent when the + * agent cannot tell, which means whichever run is current. */ + run?: string + /** The group of the turn this report happens in: where a new child is listed. A known child + * keeps its one entry, run after run. */ + group: { id: string; placement: () => Placement } + /** Only an announcement creates a child or starts a run; anything else revises a known one. */ + announces: boolean + label?: string | null + state?: NativeChatSubagentState | null + /** Latest total the provider counted for this child. */ + tokens?: number + /** Told to outlive the turn that spawned it, so that turn's end is no evidence about it. */ + backgrounded?: boolean +} + +export type TrackedSubagent = { + entry: NativeChatSubagentEntry + /** The run this entry shows; null when unknown (a provisional child, or one an earlier provider + * run journaled). */ + run: string | null + /** Runs this entry has shown, oldest first and bounded; a report for one of them is stale. */ + runs: string[] + /** Which run of the child this is: 1, then one more per run started. */ + attempt: number + /** The group of the turn that started the current run, wherever the entry is listed: that turn's + * end is what a foreground run cannot outlive. Null for a run an earlier provider process left. */ + turn: string | null + backgrounded: boolean + /** The name before its ordinal suffix; a placeholder until an announcement names the child. */ + labelBase: string + provisional: boolean + /** Listed by an earlier provider run and not run again here: a verdict says how it ended, not when. */ + inherited: boolean +} + +export type SubagentGroup = { + groupId: string + placement: Placement + /** Insertion order is the display order. */ + entries: Map + /** Lifetime admissions: bounds the labels a row retains even after removals. */ + admittedEntries: number + /** Labels stay reserved after removal or renaming, so an ordinal is never reused. */ + claimedLabels: Set + /** Last body written and accepted, so an identical revision is not written twice. */ + lastSerialized: string | null +} + +/** Where a group's row goes: the journal sink, or a provider timeline the host admits later. */ +export type SubagentRowPort = { + write: ( + group: SubagentGroup, + body: SubagentRowBody + ) => StructuredAgentSessionSinkAdmission +} + +export type SubagentRowBody = { + /** Null when the group lost its last child and its row is removed. */ + body: AgentJournalMessageItem | null + serialized: string +} + +/** What earlier provider runs of the session journaled, inherited one group at a time. */ +export type JournaledSubagentSource = { + /** The group row an earlier run last listed this child in. */ + groupOf: (id: string) => string | null + /** That group's row, handed over once: after that the tracker's copy is the newer one. */ + claimGroup: ( + groupId: string + ) => { entries: readonly NativeChatSubagentEntry[]; placement: Placement } | null + /** The latest run number any row records for this child; 1 when none says more. */ + attempt?: (id: string) => number +} diff --git a/src/main/native-chat/subagent-tracker/subagent-tracker.test.ts b/src/main/native-chat/subagent-tracker/subagent-tracker.test.ts new file mode 100644 index 00000000000..bdccd49ad10 --- /dev/null +++ b/src/main/native-chat/subagent-tracker/subagent-tracker.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types' +import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' +import { SubagentTracker } from './subagent-tracker' +import type { JournaledSubagentSource, SubagentReport } from './subagent-tracker-types' + +type Write = { groupId: string; agents: NativeChatSubagentEntry[] | null } + +function harness(journaled?: JournaledSubagentSource) { + const writes: Write[] = [] + let refuse = false + let clock = 100 + const tracker = new SubagentTracker({ + port: { + write: (group, { body }): StructuredAgentSessionSinkAdmission => { + if (refuse) { + return { accepted: false, reason: 'backpressure' } + } + const block = body?.blocks.find((candidate) => candidate.type === 'subagent-group') + writes.push({ + groupId: group.groupId, + agents: block?.type === 'subagent-group' ? block.agents : null + }) + return { accepted: true } + } + }, + ...(journaled ? { journaled } : {}), + outsideTurn: (groupId) => groupId.startsWith('outside'), + now: () => clock + }) + const report = ( + group: string, + fields: Omit, 'group' | 'announces'> & { announces?: boolean } + ) => { + clock += 10 + return tracker.report({ + announces: true, + ...fields, + group: { id: group, placement: () => group } + }) + } + const row = (groupId: string) => writes.findLast((write) => write.groupId === groupId)?.agents + return { + tracker, + writes, + report, + row, + refuse: (value: boolean) => { + refuse = value + }, + tick: () => (clock += 10) + } +} + +describe('SubagentTracker', () => { + it('lists only an announced child, in the group its report names', () => { + const h = harness() + h.report('turn-1', { id: 'a', announces: false, state: 'completed' }) + expect(h.writes).toEqual([]) + h.report('turn-1', { id: 'a', label: 'Audit' }) + h.report('turn-2', { id: 'a', state: 'completed', announces: false }) + expect(h.row('turn-1')).toEqual([ + { id: 'a', label: 'Audit', state: 'completed', startedAt: 120, settledAt: 130 } + ]) + expect(h.row('turn-2')).toBeUndefined() + }) + + it('numbers shared names over the labels already claimed, provider-named ones included', () => { + const h = harness() + for (const [id, label] of [ + ['a', 'Audit'], + ['b', 'Audit 2'], + ['c', 'Audit'], + ['d', null] + ] as const) { + h.report('turn', { id, label }) + } + expect(h.row('turn')?.map((entry) => entry.label)).toEqual([ + 'Audit', + 'Audit 2', + 'Audit 3', + 'subagent' + ]) + h.report('turn', { id: 'd', label: 'Named later', announces: false }) + expect(h.row('turn')?.at(-1)?.label).toBe('Named later') + }) + + it('latches proven outcomes but lets a verdict correct lost contact', () => { + const h = harness() + h.report('turn', { id: 'a' }) + h.report('turn', { id: 'b' }) + h.tracker.settleSession() + expect(h.row('turn')?.map((entry) => entry.state)).toEqual(['unverifiable', 'unverifiable']) + h.report('turn', { id: 'a', state: 'completed', announces: false }) + h.report('turn', { id: 'a', state: 'failed', announces: false }) + h.report('turn', { id: 'b', state: 'working', announces: false }) + expect(h.row('turn')).toEqual([ + expect.objectContaining({ id: 'a', state: 'completed', settledAt: 130 }), + expect.objectContaining({ id: 'b', state: 'unverifiable' }) + ]) + }) + + it('reopens a resumed child in the one row that lists it, and ignores its earlier runs', () => { + const h = harness() + h.report('turn-1', { id: 'a', run: 'r1', label: 'Audit', tokens: 5 }) + h.report('turn-1', { id: 'a', run: 'r1', state: 'completed', announces: false }) + h.report('turn-2', { id: 'a', run: 'r2', label: 'Audit' }) + expect(h.row('turn-2')).toBeUndefined() + expect(h.row('turn-1')).toEqual([ + { id: 'a', label: 'Audit', state: 'working', startedAt: 130, tokens: 5 } + ]) + // A late verdict or a replayed announcement of the earlier run changes nothing. + h.report('turn-2', { id: 'a', run: 'r1', state: 'failed', announces: false }) + h.report('turn-2', { id: 'a', run: 'r1' }) + expect(h.row('turn-1')).toEqual([expect.objectContaining({ state: 'working' })]) + expect(h.tracker.attempt('a')).toBe(2) + }) + + it('names a placeholder child when a later run is announced with a name', () => { + const h = harness() + h.report('turn', { id: 'a', run: 'r1' }) + h.report('turn', { id: 'a', run: 'r2', label: 'Audit' }) + expect(h.row('turn')).toEqual([expect.objectContaining({ label: 'Audit', state: 'working' })]) + }) + + it('never releases the row no turn owns, so a later child cannot rewrite it from empty', () => { + const h = harness() + h.report('outside', { id: 'loose', state: 'completed' }) + for (let turn = 0; turn < 40; turn++) { + h.report(`turn-${turn}`, { id: `c${turn}`, state: 'completed' }) + } + h.report('outside', { id: 'later' }) + expect(h.row('outside')?.map((entry) => entry.id)).toEqual(['loose', 'later']) + }) + + it('reopens a run started again in the same turn, and never applies an old run to it', () => { + const h = harness() + h.report('turn', { id: 'a', run: 'r1' }) + h.report('turn', { id: 'a', run: 'r1', state: 'completed', announces: false }) + h.report('turn', { id: 'a', run: 'r2' }) + h.report('turn', { id: 'a', run: 'r1', state: 'failed', announces: false }) + expect(h.row('turn')).toEqual([ + { id: 'a', label: 'subagent', state: 'working', startedAt: 130 } + ]) + }) + + it('remembers runs aged out of a busy child, so none is revived', () => { + const h = harness() + for (let run = 0; run < 20; run++) { + h.report('turn', { id: 'a', run: `r${run}` }) + h.report('turn', { id: 'a', run: `r${run}`, state: 'completed', announces: false }) + } + h.report('turn', { id: 'a', run: 'r0' }) + expect(h.row('turn')).toEqual([expect.objectContaining({ state: 'completed' })]) + expect(h.tracker.attempt('a')).toBe(20) + }) + + it('ends a foreground run with the turn that started it, wherever its child is listed', () => { + const h = harness() + h.report('turn-1', { id: 'x', run: 'r1' }) + h.report('turn-1', { id: 'x', run: 'r1', state: 'completed', announces: false }) + h.report('turn-5', { id: 'x', run: 'r2' }) + h.tracker.settleTurn('turn-1') + expect(h.row('turn-1')).toEqual([expect.objectContaining({ state: 'working' })]) + h.tracker.settleTurn('turn-5') + expect(h.row('turn-1')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + }) + + it('keeps an inherited no-turn row first reached through one of its children', () => { + let claimed = false + const h = harness({ + groupOf: (id) => (id === 'a' ? 'outside-turn' : null), + claimGroup: (groupId) => { + if (groupId !== 'outside-turn' || claimed) { + return null + } + claimed = true + return { + entries: [{ id: 'a', label: 'One', state: 'completed', startedAt: 1, settledAt: 2 }], + placement: 'outside-turn' + } + } + }) + h.report('outside-turn', { id: 'a', tokens: 3, announces: false }) + for (let turn = 0; turn < 40; turn++) { + h.report(`turn-${turn}`, { id: `c${turn}`, state: 'completed' }) + } + h.report('outside-turn', { id: 'b' }) + expect(h.row('outside-turn')?.map((entry) => entry.id)).toEqual(['a', 'b']) + }) + + it('releases settled rows an earlier run journaled even when only a lookup reached them', () => { + const h = harness({ + groupOf: (id) => (id.startsWith('old-') ? `old-turn-${id.slice(4)}` : null), + claimGroup: (groupId) => ({ + entries: [ + { id: `old-${groupId.slice(9)}`, label: 'Old', state: 'completed', startedAt: 1, settledAt: 2 } + ], + placement: groupId + }) + }) + for (let index = 0; index < 100; index++) { + h.tracker.locate(`old-${index}`) + } + h.report('turn-new', { id: 'fresh', state: 'completed' }) + expect(h.tracker.locate('fresh')).not.toBeNull() + expect(h.tracker.sizes().groups).toBeLessThanOrEqual(32) + }) + + it('ends only foreground children with their turn, and every child with the session', () => { + const h = harness() + h.report('turn', { id: 'fg' }) + h.report('turn', { id: 'bg', backgrounded: true }) + h.tracker.settleTurn('turn') + expect(h.row('turn')?.map((entry) => entry.state)).toEqual(['unverifiable', 'working']) + h.tracker.dispose() + expect(h.row('turn')?.map((entry) => entry.state)).toEqual(['unverifiable', 'unverifiable']) + }) + + it('writes a refused revision again on the next write, and hands the refusal back', () => { + const h = harness() + h.refuse(true) + expect(h.report('turn', { id: 'a' }).accepted).toBe(false) + expect(h.report('turn', { id: 'a' }).accepted).toBe(false) + h.refuse(false) + h.report('turn', { id: 'a' }) + expect(h.writes).toHaveLength(1) + h.report('turn', { id: 'a' }) + expect(h.writes).toHaveLength(1) + }) + + it('tries every group at session end before handing back the first refusal', () => { + const h = harness() + h.report('one', { id: 'a' }) + h.report('two', { id: 'b' }) + h.refuse(true) + expect(h.tracker.settleSession().accepted).toBe(false) + h.refuse(false) + expect(h.tracker.settleSession().accepted).toBe(true) + expect([h.row('one'), h.row('two')]).toEqual([ + [expect.objectContaining({ state: 'unverifiable' })], + [expect.objectContaining({ state: 'unverifiable' })] + ]) + }) + + it('writes each group a batch changed once', () => { + const h = harness() + h.tracker.batch(() => { + h.report('turn', { id: 'a' }) + h.report('turn', { id: 'b' }) + h.report('turn', { id: 'a', tokens: 0, announces: false }) + }) + expect(h.writes).toEqual([ + { + groupId: 'turn', + agents: [ + expect.objectContaining({ id: 'a', tokens: 0 }), + expect.objectContaining({ id: 'b' }) + ] + } + ]) + }) + + it('removes a child that turned out not to be one, and the row with its last child', () => { + const h = harness() + h.report('turn', { id: 'a', label: 'Audit' }) + h.tracker.remove('a') + expect(h.writes.at(-1)).toEqual({ groupId: 'turn', agents: null }) + h.report('turn', { id: 'b', label: 'Audit' }) + // A removed child's name stays claimed. + expect(h.row('turn')).toEqual([expect.objectContaining({ label: 'Audit 2' })]) + }) + + it('continues the row an earlier provider run journaled instead of rewriting it from empty', () => { + let claimed = false + const earlier: NativeChatSubagentEntry[] = [ + { id: 'a', label: 'One', state: 'completed', startedAt: 1, settledAt: 2 }, + { id: 'b', label: 'Two', state: 'unverifiable', startedAt: 1 } + ] + const h = harness({ + groupOf: (id) => (id === 'a' || id === 'b' ? 'outside-turn' : null), + claimGroup: (groupId) => { + if (groupId !== 'outside-turn' || claimed) { + return null + } + claimed = true + return { entries: earlier, placement: 'outside-turn' } + }, + attempt: (id) => (id === 'b' ? 3 : 1) + }) + h.report('outside-turn', { id: 'c', label: 'Three' }) + expect(h.row('outside-turn')?.map((entry) => entry.id)).toEqual(['a', 'b', 'c']) + // A verdict on a run the earlier process left says how it ended, not when. + h.report('outside-turn', { id: 'b', run: 'old-call', state: 'stopped', announces: false }) + expect(h.row('outside-turn')?.[1]).toEqual({ + id: 'b', + label: 'Two', + state: 'stopped', + startedAt: 1 + }) + h.report('turn-9', { id: 'b', run: 'resume' }) + expect(h.tracker.attempt('b')).toBe(4) + expect(h.row('turn-9')).toBeUndefined() + expect(h.row('outside-turn')?.[1]).toEqual( + expect.objectContaining({ id: 'b', label: 'Two', state: 'working' }) + ) + }) +}) diff --git a/src/main/native-chat/subagent-tracker/subagent-tracker.ts b/src/main/native-chat/subagent-tracker/subagent-tracker.ts new file mode 100644 index 00000000000..cedbda67266 --- /dev/null +++ b/src/main/native-chat/subagent-tracker/subagent-tracker.ts @@ -0,0 +1,308 @@ +// The one subagent tracker every native-chat agent reports into. +// +// An agent's own code only reads its provider's events and reports what it saw about a child in +// one shape. This tracker decides everything the user sees from that: which turn's row lists a +// child, its name, how its state may change, what a turn's or the session's end means for it, and +// how the row is written. A child keeps one entry, in the row of the turn that first spawned it; +// a later run reopens that entry. A child is released only when it settles (bounded settled +// history) or the chat ends — never by count while it may still run. + +import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types' +import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' +import { writeSubagentRows } from './subagent-row-writes' +import { + UNLABELLED_SUBAGENT, + boundedSubagentLabel, + claimSubagentLabel, + startedSubagentEntry, + withSubagentState +} from './subagent-tracker-entries' +import { SubagentTrackerGroups, type LocatedSubagent } from './subagent-tracker-groups' +import type { + JournaledSubagentSource, + SubagentGroup, + SubagentReport, + SubagentRowPort, + TrackedSubagent +} from './subagent-tracker-types' + +/** Children one row lists. Bounds the row, which is rewritten on every change. */ +export const MAX_SUBAGENTS_PER_GROUP = 64 +/** Runs one entry remembers, so a late report for one cannot pass for a new run. */ +const MAX_RUNS_PER_SUBAGENT = 16 +const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +export class SubagentTracker { + private readonly groups: SubagentTrackerGroups + /** Groups changed inside `batch`, written once when it ends. */ + private pending: Set> | null = null + private readonly now: () => number + + constructor( + private readonly deps: { + port: SubagentRowPort + journaled?: JournaledSubagentSource + /** Whether a group is the one no turn owns. */ + outsideTurn: (groupId: string) => boolean + onEvict?: (group: SubagentGroup) => void + now?: () => number + } + ) { + this.now = deps.now ?? (() => Date.now()) + this.groups = new SubagentTrackerGroups(deps) + } + + report(report: SubagentReport): StructuredAgentSessionSinkAdmission { + const run = report.run ?? null + const current = this.groups.locate(report.id) + if (this.groups.hasSettled(report.id, run) && (run !== null || !current)) { + return ADMITTED + } + if (!current) { + return report.announces ? this.create(report, run) : ADMITTED + } + const { tracked } = current + if (run === null || run === tracked.run) { + return this.revise(current, report) + } + if (tracked.runs.includes(run)) { + // A run since superseded: nothing it says changes the run shown now. + return ADMITTED + } + if (tracked.inherited && !report.announces) { + // A verdict on the run an earlier provider process left, under one of its calls. + return this.revise(current, report) + } + if (tracked.run === null && !tracked.inherited) { + // A child listed before its run was known learns it. + const learned = { ...tracked, run, runs: [run] } + current.group.entries.set(report.id, learned) + return this.revise({ group: current.group, tracked: learned }, report) + } + return report.announces ? this.startRun(current, report, run) : ADMITTED + } + + /** Re-keys a provisional child onto the canonical id the provider finally named. */ + rekey(from: string, to: string): void { + const located = this.groups.locate(from) + if (!located || from === to) { + return + } + const entries = [...located.group.entries].map(([id, tracked]): [string, TrackedSubagent] => + id === from ? [to, { ...tracked, entry: { ...tracked.entry, id: to } }] : [id, tracked] + ) + located.group.entries = new Map(entries) + } + + /** The child turned out not to be a subagent at all; its row forgets it. */ + remove(id: string): StructuredAgentSessionSinkAdmission { + const located = this.groups.locate(id) + if (!located) { + return ADMITTED + } + located.group.entries.delete(id) + return this.write(located.group) + } + + locate(id: string): LocatedSubagent | null { + return this.groups.locate(id) + } + + /** Known now, or settled and released: either way not some other kind of work. */ + has(id: string): boolean { + return this.groups.locate(id) !== null || this.groups.hasSettled(id, null) + } + + hasSettled(id: string, run: string | null): boolean { + return this.groups.hasSettled(id, run) + } + + /** Which run of the child its current one is. */ + attempt(id: string): number { + return this.groups.locate(id)?.tracked.attempt ?? 1 + } + + /** Keeps a group a child's own traffic just reached, whatever retention would release. */ + retain(group: SubagentGroup): void { + this.groups.trim([group], group.groupId) + } + + /** The turn ended: a run it started in the foreground can no longer report, wherever its child is + * listed, and contact with it is lost — not evidence it exited. A backgrounded run was told to + * outlive the turn. */ + settleTurn(groupId: string): StructuredAgentSessionSinkAdmission { + const swept: SubagentGroup[] = [] + for (const group of this.groups.values()) { + let changed = false + for (const [id, tracked] of group.entries) { + if (tracked.turn === groupId && !tracked.backgrounded) { + changed = this.setState(group, id, tracked, 'unverifiable') || changed + } + } + if (changed || group.groupId === groupId) { + swept.push(group) + } + } + return this.write(...swept) + } + + /** The provider is gone: nothing more arrives for any child. Every group is swept; the first + * refusal is handed back after all were tried. */ + settleSession(): StructuredAgentSessionSinkAdmission { + let refused: StructuredAgentSessionSinkAdmission | null = null + for (const group of this.groups.values()) { + const admission = this.sweep(group) + refused ??= admission.accepted ? null : admission + } + return refused ?? ADMITTED + } + + /** Writes each group the reports inside `apply` changed once, after all of them. */ + batch(apply: () => void): StructuredAgentSessionSinkAdmission { + if (this.pending) { + apply() + return ADMITTED + } + const changed = new Set>() + this.pending = changed + try { + apply() + } finally { + this.pending = null + } + return this.flush(changed) + } + + sizes(): { groups: number; settledIdentities: number } { + return this.groups.sizes() + } + + dispose(): void { + this.settleSession() + this.groups.clear() + } + + private create( + report: SubagentReport, + run: string | null + ): StructuredAgentSessionSinkAdmission { + const group = this.groups.groupFor(report.group) + if (group.admittedEntries >= MAX_SUBAGENTS_PER_GROUP) { + return ADMITTED + } + group.admittedEntries += 1 + const label = boundedSubagentLabel(report.label) + group.entries.set(report.id, { + entry: startedSubagentEntry( + report, + claimSubagentLabel(group, label ?? UNLABELLED_SUBAGENT), + this.now() + ), + run, + runs: run === null ? [] : [run], + attempt: 1, + turn: report.group.id, + backgrounded: report.backgrounded ?? false, + labelBase: label ?? UNLABELLED_SUBAGENT, + provisional: label === null, + inherited: false + }) + return this.write(group) + } + + /** A new run of a known child reopens its one entry, wherever the row listing it is: its clock + * starts again, and it is the next attempt. */ + private startRun( + current: LocatedSubagent, + report: SubagentReport, + run: string + ): StructuredAgentSessionSinkAdmission { + const { group, tracked } = current + const runs = [...tracked.runs, run] + for (const forgotten of runs.splice(0, runs.length - MAX_RUNS_PER_SUBAGENT)) { + this.groups.rememberSettledRun(report.id, forgotten) + } + const label = boundedSubagentLabel(report.label) + const named = tracked.provisional && label !== null && label !== UNLABELLED_SUBAGENT + group.entries.set(report.id, { + entry: startedSubagentEntry( + report, + named ? claimSubagentLabel(group, label) : tracked.entry.label, + this.now(), + tracked.entry + ), + run, + runs, + attempt: tracked.attempt + 1, + turn: report.group.id, + backgrounded: report.backgrounded ?? false, + labelBase: named ? label : tracked.labelBase, + provisional: tracked.provisional && !named, + inherited: false + }) + return this.write(group) + } + + private revise( + located: LocatedSubagent, + report: SubagentReport + ): StructuredAgentSessionSinkAdmission { + const { group, tracked } = located + const id = tracked.entry.id + const next: TrackedSubagent = { + ...tracked, + backgrounded: report.backgrounded ?? tracked.backgrounded, + entry: { ...tracked.entry } + } + const label = boundedSubagentLabel(report.label) + if (label !== null && tracked.provisional && label !== UNLABELLED_SUBAGENT) { + next.provisional = false + next.labelBase = label + next.entry.label = claimSubagentLabel(group, label) + } + if (typeof report.tokens === 'number') { + next.entry.tokens = report.tokens + } + group.entries.set(id, next) + if (report.state) { + this.setState(group, id, next, report.state) + } + return this.write(group) + } + + private setState( + group: SubagentGroup, + id: string, + tracked: TrackedSubagent, + state: NativeChatSubagentEntry['state'] + ): boolean { + const next = withSubagentState(tracked, state, this.now()) + if (next) { + group.entries.set(id, next) + } + return next !== null + } + + private sweep(group: SubagentGroup): StructuredAgentSessionSinkAdmission { + for (const [id, tracked] of group.entries) { + this.setState(group, id, tracked, 'unverifiable') + } + // A null `lastSerialized` is a refused earlier write that nothing else may retry. + return this.write(group) + } + + private write(...groups: SubagentGroup[]): StructuredAgentSessionSinkAdmission { + if (this.pending) { + groups.forEach((group) => this.pending?.add(group)) + return ADMITTED + } + return this.flush(groups) + } + + private flush(groups: Iterable>): StructuredAgentSessionSinkAdmission { + const written = [...groups] + const refused = writeSubagentRows(written, this.deps.port) + this.groups.trim(written) + return refused ?? ADMITTED + } +}