From 5b228ea71df028a4593b09cece795955d3cb3513 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:04:04 -0700 Subject: [PATCH] One shared subagent tracker for Claude, Codex and Grok chat rows (#27228) * test(native-chat): pin each agent's subagent row writes before unifying the rosters * refactor(native-chat): one shared subagent tracker owns every agent's subagent rows Claude, Codex and ACP (Grok) each kept their own subagent roster with drifted rules. They now only report what they observe; one tracker decides grouping, runs, names, state, sweeps, retention, row writes and restart inheritance. The golden row-write tests from the previous commit show each rule change. * fix(native-chat): one entry per subagent, derived lookups, ACP end stamped in its session-end write Review round 1: a resumed child keeps its one entry and reopens it where it is listed (the chat draws one section per child, anchored at the first row naming it), every lookup is read from the rows, the no-turn row is never released, and ACP's session end stamps its live children inside the session-end write instead of writing rows ahead of it. * fix(native-chat): derive the no-turn group, end a foreground run with the turn that started it Review round 2: the row no turn owns is recognised from its id (a stored flag was lost when an earlier run's row was first reached through one of its children), and a turn's end settles the foreground runs it started wherever their child is listed. * fix(native-chat): count an inherited subagent row as settled history when a lookup loads it A read-only lookup could load an earlier run's settled row that retention never saw, so it was never released and a fresh settled row was released in its place. --- ...ent-timeline-characterization.test.ts.snap | 538 +++++ .../acp-structured-host-subagent-stop.test.ts | 3 +- src/main/acp/acp-structured-session.ts | 8 +- ...subagent-timeline-characterization.test.ts | 115 ++ src/main/acp/acp-subagent-timeline.test.ts | 68 +- src/main/acp/acp-subagent-timeline.ts | 243 +-- ...agent-roster-characterization.test.ts.snap | 1734 +++++++++++++++++ src/main/claude/claude-subagent-group-row.ts | 144 -- .../claude-subagent-journaled-roster.test.ts | 11 +- .../claude-subagent-journaled-roster.ts | 120 +- ...aude-subagent-resume-after-restart.test.ts | 5 +- ...e-subagent-roster-characterization.test.ts | 196 ++ .../claude-subagent-roster-groups.test.ts | 152 -- .../claude/claude-subagent-roster-groups.ts | 150 -- .../claude-subagent-roster-retention.test.ts | 2 +- .../claude/claude-subagent-roster-state.ts | 117 -- .../claude/claude-subagent-roster.test.ts | 13 +- src/main/claude/claude-subagent-roster.ts | 305 +-- ...agent-roster-characterization.test.ts.snap | 1425 ++++++++++++++ .../codex/codex-child-work-evidence.test.ts | 6 +- .../codex-default-mode-subagents.test.ts | 5 +- .../codex/codex-structured-journal-limits.ts | 7 +- ...odex-subagent-execution-projection.test.ts | 15 +- src/main/codex/codex-subagent-executions.ts | 11 +- ...x-subagent-roster-characterization.test.ts | 189 ++ .../codex-subagent-roster-restart.test.ts | 62 + src/main/codex/codex-subagent-roster-state.ts | 72 +- src/main/codex/codex-subagent-roster.ts | 265 +-- ...rnal-subagent-group-body-fallback.test.ts} | 8 +- .../journal-subagent-liveness.ts | 33 +- .../provider-timeline-event.ts | 7 +- .../provider-timeline-settlement.ts | 4 +- .../provider-timeline-turn-decisions.ts | 3 +- .../subagent-roster-retention.test.ts | 4 +- .../native-chat/subagent-roster-retention.ts | 5 +- ...subagent-row-sink-recorder-test-support.ts | 82 + .../journaled-subagent-groups.ts | 112 ++ .../subagent-row-sink-port.ts | 37 + .../subagent-tracker/subagent-row-writes.ts | 29 + .../subagent-tracker-entries.ts | 69 + .../subagent-tracker-groups.ts | 167 ++ .../subagent-tracker-types.ts | 88 + .../subagent-tracker/subagent-tracker.test.ts | 308 +++ .../subagent-tracker/subagent-tracker.ts | 308 +++ 44 files changed, 5841 insertions(+), 1404 deletions(-) create mode 100644 src/main/acp/__snapshots__/acp-subagent-timeline-characterization.test.ts.snap create mode 100644 src/main/acp/acp-subagent-timeline-characterization.test.ts create mode 100644 src/main/claude/__snapshots__/claude-subagent-roster-characterization.test.ts.snap delete mode 100644 src/main/claude/claude-subagent-group-row.ts create mode 100644 src/main/claude/claude-subagent-roster-characterization.test.ts delete mode 100644 src/main/claude/claude-subagent-roster-groups.test.ts delete mode 100644 src/main/claude/claude-subagent-roster-groups.ts delete mode 100644 src/main/claude/claude-subagent-roster-state.ts create mode 100644 src/main/codex/__snapshots__/codex-subagent-roster-characterization.test.ts.snap create mode 100644 src/main/codex/codex-subagent-roster-characterization.test.ts create mode 100644 src/main/codex/codex-subagent-roster-restart.test.ts rename src/main/{claude/claude-subagent-group-row.test.ts => native-chat/agent-session-journal/journal-subagent-group-body-fallback.test.ts} (88%) create mode 100644 src/main/native-chat/subagent-row-sink-recorder-test-support.ts create mode 100644 src/main/native-chat/subagent-tracker/journaled-subagent-groups.ts create mode 100644 src/main/native-chat/subagent-tracker/subagent-row-sink-port.ts create mode 100644 src/main/native-chat/subagent-tracker/subagent-row-writes.ts create mode 100644 src/main/native-chat/subagent-tracker/subagent-tracker-entries.ts create mode 100644 src/main/native-chat/subagent-tracker/subagent-tracker-groups.ts create mode 100644 src/main/native-chat/subagent-tracker/subagent-tracker-types.ts create mode 100644 src/main/native-chat/subagent-tracker/subagent-tracker.test.ts create mode 100644 src/main/native-chat/subagent-tracker/subagent-tracker.ts 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 + } +}