diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 3113f414809..40a668f58eb 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -45,7 +45,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/claude/claude-subagent-permission-request.test.ts', 'src/main/claude/claude-subagent-resume-after-restart.test.ts', 'src/main/codex/codex-provider-retry-idle-sweep.test.ts', - 'src/main/codex/codex-subagent-roster-retention.test.ts', 'src/main/codex/codex-structured-journal-exit-convergence.test.ts', 'src/main/codex/codex-structured-journal-goal-revision.test.ts', 'src/main/codex/codex-structured-journal-repeated-questions.test.ts', @@ -81,6 +80,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts', @@ -180,6 +180,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-subagent-rows.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-background-tasks.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts', diff --git a/mobile/src/session/MobileNativeChatBackgroundTasks.tsx b/mobile/src/session/MobileNativeChatBackgroundTasks.tsx index 0b1e53e5262..26c2adad013 100644 --- a/mobile/src/session/MobileNativeChatBackgroundTasks.tsx +++ b/mobile/src/session/MobileNativeChatBackgroundTasks.tsx @@ -236,7 +236,10 @@ function MobileNativeChatBackgroundTasksImpl({ ) : ( {say('detailsUnavailable')} )} - {!view.supportsStop && view.supportsStopAll ? ( + {view.incomplete && groups.length > 0 ? ( + {say('detailsUnavailable')} + ) : null} + {view.offersStopAll ? ( 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-session-update.ts b/src/main/acp/acp-session-update.ts index b9d45a7245b..1a5e92bcfe5 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -6,7 +6,7 @@ import { import { acpWindowUsage } from './acp-context-usage' import type { AcpDialect } from './acp-dialects/acp-dialect' import type { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' -import type { AcpSubagentTimeline } from './acp-subagent-timeline' +import type { AcpSubagents } from './acp-subagents' import type { AcpToolTimeline } from './acp-tool-timeline' import type { SessionNotification } from './generated/acp-protocol.generated' import { acpNamedTextKey } from './acp-turn-messages' @@ -29,7 +29,7 @@ export function acpSessionUpdate( tools: AcpToolTimeline dialect: AcpDialect backgroundTasks: AcpBackgroundTaskTimeline - subagents: AcpSubagentTimeline + subagents: AcpSubagents messageKey?: string } ): ProviderTimelineEvent[] { diff --git a/src/main/acp/acp-structured-child-session-end.test.ts b/src/main/acp/acp-structured-child-session-end.test.ts index 475b6a2430d..7acf01695bb 100644 --- a/src/main/acp/acp-structured-child-session-end.test.ts +++ b/src/main/acp/acp-structured-child-session-end.test.ts @@ -1,16 +1,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' -import { isSubagentGroupBlock } from '../../shared/native-chat-types' +import { isSubagentBlock } from '../../shared/native-chat-types' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' import { GENERIC_ACP_DIALECT, type AcpDialect } from './acp-dialects/acp-dialect' -import { acpChildWorkStatusSink } from './acp-structured-child-work.test-support' +import { + acpChildWorkStatusSink, + acpSubagentRecords +} from './acp-structured-child-work.test-support' import { PROVIDER_SESSION, replyChunk } from './acp-structured-adapter.test-support' import { openAttachedHostRig, promptIdOf, send, stop } from './acp-structured-host.test-support' import { readAcpFixture } from './acp-timeline-fixture.test-support' import { AcpTimelineTranslator } from './acp-timeline-translator' -import type { AcpTimelineEvent } from './acp-timeline-event' const cleanup: (() => Promise)[] = [] afterEach(async () => { @@ -21,14 +23,6 @@ afterEach(async () => { await closeProviderTimelineRigs() }) -function agents(events: AcpTimelineEvent[]) { - return events.flatMap((event) => - event.type === 'item.update' && event.body.kind === 'message' - ? event.body.blocks.filter(isSubagentGroupBlock).flatMap((group) => group.agents) - : [] - ) -} - function translator(dialect: AcpDialect = GROK_ACP_DIALECT) { const lane = new AcpTimelineTranslator({ sessionId: PROVIDER_SESSION, dialect }) const { promptId } = lane.openPrompt('parent', 100) @@ -46,6 +40,9 @@ function translator(dialect: AcpDialect = GROK_ACP_DIALECT) { }, 102 ) + const records = acpSubagentRecords() + const host = () => records(lane.subagents.drainEvidence()) + host() const end = ( reason: string, sessionId = 'child', @@ -67,7 +64,7 @@ function translator(dialect: AcpDialect = GROK_ACP_DIALECT) { }, 120 ) - return { lane, end, promptId } + return { lane, end, host, promptId } } describe('owned Grok child-session completion', () => { @@ -78,19 +75,17 @@ describe('owned Grok child-session completion', () => { '_x.ai/session/update' ])('settles a known child through %s without ending or charging the parent turn', (method) => { const f = translator() - const events = f.end('end_turn', 'child', {}, method) - expect(agents(events)).toEqual([ - { id: 'child', label: 'Original label', state: 'completed', startedAt: 102, settledAt: 120 } - ]) - expect(events).toMatchObject([ + expect(f.end('end_turn', 'child', {}, method)).toEqual([]) + expect(f.host()).toMatchObject([ { - type: 'item.update', - item: 'subagents:prompt:parent', - join: { turn: f.promptId }, - subagentIdentities: ['child'] + providerId: 'child', + description: 'Original label', + membership: 'settled', + outcome: 'succeeded' } ]) expect(f.end('cancelled')).toEqual([]) + expect(f.host()).toMatchObject([{ providerId: 'child', outcome: 'succeeded' }]) const parent = f.lane.notification( 'session/update', replyChunk(f.promptId, 'Parent continues'), @@ -106,9 +101,8 @@ describe('owned Grok child-session completion', () => { it('latches cancellation against conflicting child ends, parent reports and later working progress', () => { const f = translator() - expect(agents(f.end('cancelled'))).toMatchObject([ - { id: 'child', state: 'stopped', settledAt: 120 } - ]) + expect(f.end('cancelled')).toEqual([]) + expect(f.host()).toMatchObject([{ providerId: 'child', outcome: 'cancelled' }]) expect(f.end('end_turn')).toEqual([]) expect( f.lane.notification( @@ -128,8 +122,9 @@ describe('owned Grok child-session completion', () => { }, 122 ) - expect(agents(progress)).toMatchObject([ - { id: 'child', state: 'stopped', settledAt: 120, tokens: 7 } + expect(progress).toEqual([]) + expect(f.host()).toMatchObject([ + { providerId: 'child', membership: 'settled', outcome: 'cancelled' } ]) }) @@ -138,6 +133,7 @@ describe('owned Grok child-session completion', () => { expect(f.end('future_stop_reason')).toEqual([]) expect(f.end('cancelled', 'unowned-child')).toEqual([]) expect(f.end('cancelled', 'child', {}, '_x.ai/other_notification')).toEqual([]) + expect(f.host()).toMatchObject([{ providerId: 'child', membership: 'live' }]) expect(f.lane.promptResult('parent', { stopReason: 'end_turn' }, 122)).toMatchObject([ { type: 'turn.end', turn: f.promptId, outcome: 'success' } ]) @@ -154,7 +150,9 @@ describe('owned Grok child-session completion', () => { f.lane.notification('_x.ai/session_notification', { sessionId: 'child', update }, 120) ).toEqual([]) } - expect(agents(f.end('end_turn'))).toMatchObject([{ id: 'child', state: 'completed' }]) + expect(f.host()).toMatchObject([{ providerId: 'child', membership: 'live' }]) + expect(f.end('end_turn')).toEqual([]) + expect(f.host()).toMatchObject([{ providerId: 'child', outcome: 'succeeded' }]) }) it('ignores malformed completions and child-scoped roster reports without learning a new child', () => { @@ -170,7 +168,9 @@ describe('owned Grok child-session completion', () => { ).toEqual([]) } expect(f.end('cancelled', 'nested-child')).toEqual([]) - expect(agents(f.end('end_turn'))).toMatchObject([{ id: 'child', state: 'completed' }]) + expect(f.host()).toMatchObject([{ providerId: 'child', membership: 'live' }]) + expect(f.end('end_turn')).toEqual([]) + expect(f.host()).toMatchObject([{ providerId: 'child', outcome: 'succeeded' }]) }) it('ignores replay and loading even when a loading notice explicitly says it is live', () => { @@ -181,7 +181,9 @@ describe('owned Grok child-session completion', () => { expect(f.end('cancelled')).toEqual([]) expect(f.end('cancelled', 'child', { isReplay: false })).toEqual([]) f.lane.finishLoad() - expect(agents(f.end('end_turn'))).toMatchObject([{ id: 'child', state: 'completed' }]) + expect(f.host()).toMatchObject([{ providerId: 'child', membership: 'live' }]) + expect(f.end('end_turn')).toEqual([]) + expect(f.host()).toMatchObject([{ providerId: 'child', outcome: 'succeeded' }]) f.lane.dispose() expect(f.end('cancelled')).toEqual([]) }) @@ -192,6 +194,7 @@ describe('owned Grok child-session completion', () => { notification: GROK_ACP_DIALECT.notification }) expect(f.end('cancelled')).toEqual([]) + expect(f.host()).toMatchObject([{ providerId: 'child', membership: 'live' }]) }) }) @@ -241,14 +244,15 @@ describe('recorded child-session outcome during a full parent Stop', () => { await hosted.rows() vi.setSystemTime(1791496678726) agent.notify(selectedFinished.method, selectedFinished.params) - const rosters = async () => + // One saved row per subagent, which the host writes from its records. + const subagents = async () => (await hosted.rows()).flatMap((row) => row.body.kind === 'message' - ? row.body.blocks.filter(isSubagentGroupBlock).map((group) => ({ row, group })) + ? row.body.blocks.filter(isSubagentBlock).map((block) => ({ row, block })) : [] ) - const [original] = await rosters() - expect(original.group.agents).toMatchObject([ + const original = await subagents() + expect(original.map(({ block }) => block)).toMatchObject([ { id: 'subagent-1', state: 'stopped' }, { id: 'subagent-2', state: 'working' } ]) @@ -272,16 +276,18 @@ describe('recorded child-session outcome during a full parent Stop', () => { }) expect(await stop(hosted.host)).toMatchObject({ ok: true, value: { cancelled: true } }) await vi.advanceTimersByTimeAsync(17) - const [beforeParentResult] = await rosters() - expect.soft(beforeParentResult.row.itemId).toBe(original.row.itemId) - expect.soft(beforeParentResult.row.turnScope).toEqual(original.row.turnScope) + const beforeParentResult = await subagents() expect - .soft(beforeParentResult.group.agents) - .toEqual([ - original.group.agents[0], - reason === 'cancelled' - ? { ...original.group.agents[1], state: 'stopped', settledAt: 1791496686302 } - : original.group.agents[1] + .soft(beforeParentResult.map(({ row }) => row.itemId)) + .toEqual(original.map(({ row }) => row.itemId)) + expect + .soft(beforeParentResult.map(({ row }) => row.turnScope)) + .toEqual(original.map(({ row }) => row.turnScope)) + expect + .soft(beforeParentResult.map(({ block }) => block)) + .toMatchObject([ + original[0].block, + reason === 'cancelled' ? { id: 'subagent-2', state: 'stopped' } : original[1].block ]) expect.soft((await hosted.turns()).at(-1)).toMatchObject({ state: 'running' }) expect.soft(child.closes).toBe(0) @@ -297,8 +303,8 @@ describe('recorded child-session outcome during a full parent Stop', () => { state: 'interrupted', outcome: 'cancellation' }) - const [settled] = await rosters() - expect(settled.group.agents).toMatchObject([ + const settled = await subagents() + expect(settled.map(({ block }) => block)).toMatchObject([ { id: 'subagent-1', state: 'stopped' }, { id: 'subagent-2', state } ]) diff --git a/src/main/acp/acp-structured-child-stop.ts b/src/main/acp/acp-structured-child-stop.ts index acabe6ba6b6..2ed2f7c40a8 100644 --- a/src/main/acp/acp-structured-child-stop.ts +++ b/src/main/acp/acp-structured-child-stop.ts @@ -2,10 +2,18 @@ import type { AcpStructuredSession } from './acp-structured-session' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpChildStops, AcpDialect } from './acp-dialects/acp-dialect' import { AcpAgentError } from './acp-errors' -import type { AgentSessionBackgroundTaskStops } from '../../shared/agent-child-work-stop-targets' +import type { + AgentChildWorkStopAcknowledgement, + AgentSessionBackgroundTaskStops +} from '../../shared/agent-child-work-stop-targets' import { acpBackgroundTaskStopId } from './acp-background-task-child-work' +import { acpSubagentOutcome } from './acp-subagents' -type ChildStopResult = { cancelled: boolean; stillRunning?: true } +type ChildStopResult = { + cancelled: boolean + stillRunning?: true + acknowledged?: AgentChildWorkStopAcknowledgement +} type SubagentStop = NonNullable type BackgroundTaskStop = NonNullable @@ -72,8 +80,7 @@ export async function probeAcpChildStops( async function stopAcpSubagent( session: AcpStructuredSession, control: SubagentStop, - id: string, - at: () => number + id: string ): Promise { const request = control.request(session.lane.translator.providerSessionId, id) let value: unknown @@ -87,10 +94,17 @@ async function stopAcpSubagent( throw error } const result = control.response(value, id) - if (result.state && result.state !== 'working') { - session.lane.apply(session.lane.translator.reconcileSubagent(id, result.state, at())) + if (result.state === 'working') { + return { cancelled: result.cancelled } } - return { cancelled: result.cancelled } + // Not found is no acceptance; an answer that names how the subagent finished is its own ending. + const outcome = result.state === undefined ? undefined : acpSubagentOutcome(result.state) + return result.cancelled || outcome !== undefined + ? { + cancelled: result.cancelled, + acknowledged: { taskId: id, ...(outcome !== undefined ? { outcome } : {}) } + } + : { cancelled: false } } async function stopAcpBackgroundTask( @@ -126,8 +140,8 @@ export async function stopAcpChildren( session: AcpStructuredSession, fence: number, taskIds: readonly string[], - at: () => number -): Promise { + acknowledge: (acknowledgement: AgentChildWorkStopAcknowledgement) => void = () => {} +): Promise<{ cancelled: boolean; stillRunning?: true }> { const subagentStop = session.childStops?.subagents ? session.spec.dialect.subagentStop : undefined const taskStop = session.childStops?.backgroundTasks ? session.spec.dialect.backgroundTaskStop @@ -141,14 +155,17 @@ export async function stopAcpChildren( let stillRunning = false for (const id of taskIds) { const taskId = acpBackgroundTaskStopId(id) - const result = + const result: ChildStopResult = taskId !== undefined && taskStop ? await stopAcpBackgroundTask(session, taskStop, taskId) : subagentStop - ? await stopAcpSubagent(session, subagentStop, id, at) + ? await stopAcpSubagent(session, subagentStop, id) : { cancelled: false } cancelled ||= result.cancelled stillRunning ||= result.stillRunning === true + if (result.acknowledged) { + acknowledge(result.acknowledged) + } } return { cancelled, ...(stillRunning ? { stillRunning: true as const } : {}) } } diff --git a/src/main/acp/acp-structured-child-work-admission.test.ts b/src/main/acp/acp-structured-child-work-admission.test.ts index c7d54a72c8c..aa8ebe163dc 100644 --- a/src/main/acp/acp-structured-child-work-admission.test.ts +++ b/src/main/acp/acp-structured-child-work-admission.test.ts @@ -1,14 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' -import { subagentGroupJournalBody } from '../native-chat/agent-session-journal/journal-subagent-group-body' import { closeProviderTimelineRigs, openProviderTimelineRig, - providerTurnItemId, refusingSink } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { AcpStructuredLane } from './acp-structured-lane' +import type { AcpTimelineEvent } from './acp-timeline-event' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' @@ -16,10 +14,6 @@ import { recordingStructuredAgentSessionLogger, testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' -import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' -import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' -import { AgentHookServer } from '../agent-hooks/server' const lanes: AcpStructuredLane[] = [] afterEach(async () => { @@ -27,14 +21,17 @@ afterEach(async () => { await closeProviderTimelineRigs() }) -function roster(state: 'working' | 'completed'): ProviderTimelineEvent { - return { - type: 'item.update', - item: 'subagents:turn', - body: subagentGroupJournalBody('turn', [ - { id: 'child', label: 'Exact label', state, startedAt: 1 } - ]) - } +/** What Grok says about one subagent, translated as the adapter would. */ +function subagent(lane: AcpStructuredLane, state: 'working' | 'completed'): AcpTimelineEvent[] { + const update = + state === 'working' + ? { sessionUpdate: 'subagent_spawned', subagent_id: 'child', description: 'Exact label' } + : { sessionUpdate: 'subagent_finished', subagent_id: 'child', status: 'completed' } + return lane.translator.notification( + '_x.ai/session/update', + { sessionId: 'provider-session-1', update }, + 1 + ) } async function fixture(reason: 'backpressure' | 'failed' = 'backpressure') { @@ -60,101 +57,7 @@ async function fixture(reason: 'backpressure' | 'failed' = 'backpressure') { return { rig, lane, refused, delivery, failed } } -describe('ACP roster evidence at the ordered journal boundary', () => { - it('does not settle host child work from a preliminary parent settlement while its roster write is refused', async () => { - const rig = await openProviderTimelineRig() - const server = new AgentHookServer() - const subject = makeStructuredAgentStatusSubject( - { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - 'session-timeline' - ) - server.ingestStructuredStatus( - { - sessionId: subject.sessionId, - workspaceId: subject.workspaceId, - agent: 'grok', - status: 'working', - hostExecutionOwned: true, - latestPrompt: 'Delegate', - updatedAt: 1 - }, - subject - ) - let refuseRoster = false - const internalSettlements = vi.fn() - const delivery = vi.fn((evidence: AgentChildWorkEvidence[]) => - server.ingestStructuredChildWork(subject, evidence, 'grok') - ) - const lane = new AcpStructuredLane({ - sink: { - ...rig.sink, - tryAppendTransition: (transition) => { - if (refuseRoster && !transition.lifecycle) { - return { accepted: false, reason: 'backpressure' } - } - if (refuseRoster && transition.lifecycle) { - internalSettlements() - } - return rig.sink.tryAppendTransition(transition) - } - }, - sessionId: 'session-timeline', - providerSessionId: 'provider-session-1', - agent: 'grok', - agentName: 'Grok', - generation: 'gen-1', - dialect: GROK_ACP_DIALECT, - logger: recordingStructuredAgentSessionLogger().logger, - onInputAccepted: () => {}, - onFailed: () => {}, - onChildWorkEvidence: delivery, - now: () => 10 - }) - lanes.push(lane) - lane.apply([{ type: 'turn.open', turn: 'turn', at: 1 }, roster('working')]) - await rig.rows() - const running = await rig.turn('turn') - const identity = parseAgentJournalItemKey(providerTurnItemId('turn')) - if (!running || !identity) { - throw new Error('Missing running parent turn') - } - await rig.journal.appendItem( - identity, - agentJournalTurnBody({ ...running, state: 'interrupted', completedAt: 2 }), - { fence: 1, turnScope: { kind: 'thread' } } - ) - expect(lane.openTurnId).not.toBeNull() - refuseRoster = true - lane.apply([roster('completed')]) - const heldRows = await rig.rows() - expect(internalSettlements).toHaveBeenCalledTimes(1) - expect(lane.openTurnId).toBeNull() - expect( - heldRows.some( - (row) => - row.body.kind === 'message' && - row.body.blocks.some( - (block) => block.type === 'subagent-group' && block.agents[0]?.state === 'working' - ) - ) - ).toBe(true) - expect(server.getStructuredChildWorkViews(subject)).toMatchObject([ - { membership: 'live', state: 'working' } - ]) - expect(delivery).toHaveBeenCalledTimes(1) - refuseRoster = false - lane.retry() - await rig.rows() - expect(server.getStructuredChildWorkViews(subject)).toMatchObject([ - { membership: 'settled', outcome: 'succeeded' } - ]) - expect(delivery).toHaveBeenCalledTimes(2) - }) +describe('ACP subagent evidence at the ordered journal boundary', () => { it('delivers acquisition-time snapshots only after the journal binds and publishes the parent', async () => { const rig = await openProviderTimelineRig() const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) @@ -217,7 +120,8 @@ describe('ACP roster evidence at the ordered journal boundary', () => { it('holds working and finished snapshots in order across backpressure and publishes each once', async () => { const { rig, lane, refused, delivery } = await fixture() refused.value = true - lane.apply([roster('working'), roster('completed')]) + lane.apply(subagent(lane, 'working')) + lane.apply(subagent(lane, 'completed')) await rig.rows() expect(delivery).toEqual([]) refused.value = false @@ -231,16 +135,16 @@ describe('ACP roster evidence at the ordered journal boundary', () => { expect(delivery).toHaveLength(2) }) - it('publishes no roster facts for fatal refusal, disposed held events or events dropped after session end', async () => { + it('publishes no subagent facts for fatal refusal, disposed held events or events dropped after session end', async () => { const fatal = await fixture('failed') fatal.refused.value = true - fatal.lane.apply([roster('working')]) + fatal.lane.apply(subagent(fatal.lane, 'working')) await fatal.rig.rows() expect(fatal.failed).toHaveBeenCalledWith('failed') expect(fatal.delivery).toEqual([]) const held = await fixture() held.refused.value = true - held.lane.apply([roster('working')]) + held.lane.apply(subagent(held.lane, 'working')) held.lane.dispose() held.refused.value = false held.lane.retry() @@ -251,7 +155,7 @@ describe('ACP roster evidence at the ordered journal boundary', () => { { type: 'session.ended', verdict: { state: 'interrupted', completedAt: 10 } } ]) await ended.rig.rows() - ended.lane.apply([roster('working')]) + ended.lane.apply(subagent(ended.lane, 'working')) await ended.rig.rows() expect(ended.delivery.flat().map((edge) => edge.type)).toEqual(['session-ended']) }) @@ -277,21 +181,23 @@ describe('ACP roster evidence at the ordered journal boundary', () => { }) lane.dispose() lanes.push(throwing) - throwing.apply([roster('working'), roster('completed')]) + throwing.apply(subagent(throwing, 'working')) + throwing.apply(subagent(throwing, 'completed')) + throwing.apply([{ type: 'turn.open', turn: 'turn', at: 2 }]) expect(await rig.rows()).toHaveLength(1) expect(failure).toHaveBeenCalledTimes(2) }) it('releases live ownership when session-end admission is held and disposal ends observation', async () => { const { rig, lane, refused, delivery } = await fixture() - lane.apply([roster('working')]) + lane.apply(subagent(lane, 'working')) await rig.rows() refused.value = true lane.apply([{ type: 'session.ended', verdict: { state: 'interrupted', completedAt: 20 } }]) lane.dispose() await rig.rows() expect(delivery.flat().map((edge) => edge.type)).toEqual(['live', 'session-ended']) - lane.apply([roster('working')]) + lane.apply(subagent(lane, 'working')) lane.retry() await rig.rows() expect(delivery.flat().map((edge) => edge.type)).toEqual(['live', 'session-ended']) diff --git a/src/main/acp/acp-structured-child-work.test-support.ts b/src/main/acp/acp-structured-child-work.test-support.ts index 821a11cfe70..60961989736 100644 --- a/src/main/acp/acp-structured-child-work.test-support.ts +++ b/src/main/acp/acp-structured-child-work.test-support.ts @@ -1,5 +1,9 @@ import { AgentHookServer } from '../agent-hooks/server' -import type { AgentStatusStructuredSessionSubject } from '../../shared/agent-status-subject' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' +import { + makeStructuredAgentStatusSubject, + type AgentStatusStructuredSessionSubject +} from '../../shared/agent-status-subject' import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-ownership' export function acpChildWorkStatusSink() { @@ -13,7 +17,39 @@ export function acpChildWorkStatusSink() { forget: (subject) => server.dropStructuredStatus(subject), publishChildWork: (subject, evidence, provider) => server.ingestStructuredChildWork(subject, evidence, provider), - readChildWork: (subject) => server.getStructuredChildWorkViews(subject) + readChildWork: (subject) => server.getStructuredChildWorkViews(subject), + readChildWorkRecords: (subject) => server.getStructuredChildWork(subject) } return { sink, views: () => (parent ? server.getStructuredChildWorkViews(parent) : []) } } + +/** A host holding one working Grok parent: what a translator reported becomes its child records, + * the one authority on each subagent. */ +export function acpSubagentRecords() { + const { sink, views } = acpChildWorkStatusSink() + const subject = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + 'session-timeline' + ) + sink.publish( + { + sessionId: subject.sessionId, + workspaceId: subject.workspaceId, + agent: 'grok', + status: 'working', + hostExecutionOwned: true, + latestPrompt: 'Delegate', + updatedAt: 1 + }, + subject + ) + return (evidence: AgentChildWorkEvidence[]) => { + sink.publishChildWork?.(subject, evidence, 'grok') + return views() + } +} diff --git a/src/main/acp/acp-structured-child-work.test.ts b/src/main/acp/acp-structured-child-work.test.ts index 98a234e2d39..f7a05016964 100644 --- a/src/main/acp/acp-structured-child-work.test.ts +++ b/src/main/acp/acp-structured-child-work.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { _internals } from '../agent-hooks/server' -import { isSubagentGroupBlock } from '../../shared/native-chat-types' +import { isSubagentBlock } from '../../shared/native-chat-types' import type { AgentChildWorkView } from '../../shared/agent-status-child-work-view' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { @@ -89,9 +89,7 @@ async function fixture() { (await hosted.host.history({ sessionId: SESSION, direction: 'tail' })).page.backgroundTasks const roster = async () => (await hosted.rows()).flatMap((row) => - row.body.kind === 'message' - ? row.body.blocks.filter(isSubagentGroupBlock).flatMap((group) => group.agents) - : [] + row.body.kind === 'message' ? row.body.blocks.filter(isSubagentBlock) : [] ) const targetedStop = (taskId: string) => { const fields = { turnId: 'background-tasks', scope: 'background-tasks' as const, taskId } @@ -154,14 +152,15 @@ describe('Grok roster evidence through the existing host child store', () => { ).toBeNull() }) - it('shares exact ordinal labels between roster, agent strip and sidebar after parent completion', async () => { + it('shares one label and one id between saved rows, agent strip and sidebar after parent completion', async () => { const f = await fixture() await f.spawn('first') await f.spawn('sibling') - expect(await f.roster()).toMatchObject([{ label: 'Exact label' }, { label: 'Exact label 2' }]) + // Each saved row keeps the raw label; the shared presentation derives the ordinal, as the strip does. + expect(await f.roster()).toMatchObject([{ label: 'Exact label' }, { label: 'Exact label' }]) expect((await f.strip())?.tasks).toMatchObject([ { id: 'first', kind: 'agent', description: 'Exact label' }, - { id: 'sibling', kind: 'agent', description: 'Exact label 2' } + { id: 'sibling', kind: 'agent', description: 'Exact label' } ]) const stripIds = (await f.strip())?.children?.map((view) => view.id) expect(f.sidebar.at(-1)?.map((view) => view.id)).toEqual(stripIds) @@ -189,7 +188,11 @@ describe('Grok roster evidence through the existing host child store', () => { }) }) expect(await f.targetedStop('first')).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(f.childWork.views().every((view) => view.membership === 'live')).toBe(true) + // The accepted Stop settles that run until Grok's own ending says how it ended. + expect(f.childWork.views()).toMatchObject([ + { providerId: 'first', membership: 'settled', outcome: 'cancelled' }, + { providerId: 'sibling', membership: 'live' } + ]) expect(f.agent.frames.filter((frame) => frame.method === 'session/cancel')).toEqual([]) await f.finish('first', 'cancelled') expect((await f.roster())[0].state).toBe('stopped') @@ -277,7 +280,7 @@ describe('Grok roster evidence through the existing host child store', () => { await f.spawn(first, 'First', 'turn-A') await f.spawn(second, 'Second', 'turn-B') const entries = await f.roster() - expect(entries[0].id).toBe(entries[1].id) + expect(entries[0].id).not.toBe(entries[1].id) const bothStrip = (await f.strip())?.tasks const bothSidebar = f.sidebar.at(-1) await f.finish(first, 'completed') @@ -301,7 +304,7 @@ describe('Grok roster evidence through the existing host child store', () => { { description: entry.label, kind: 'agent', stoppable: false } ]) expect((await f.strip())?.tasks?.[0]?.id).toMatch(/^acp-child:[0-9a-f]{64}$/) - expect((await f.strip())?.tasks?.[0]?.id).not.toBe(entry.id) + expect((await f.strip())?.tasks?.[0]?.id).toBe(entry.id) }) it('keeps 34 live groups in both host surfaces and never recreates the first settled eviction', async () => { @@ -403,10 +406,10 @@ describe('Grok roster evidence through the existing host child store', () => { } ) - it('records a terminal first snapshot without leaving either running surface occupied', async () => { + it('records nothing for a subagent first reported finished, and occupies no running surface', async () => { const f = await fixture() await f.finish('finished-before-spawn', 'completed') - expect(await f.roster()).toMatchObject([{ id: 'finished-before-spawn', state: 'completed' }]) + expect(await f.roster()).toEqual([]) expect(f.childWork.views()).toEqual([]) expect(await f.strip()).toBeNull() }) diff --git a/src/main/acp/acp-structured-grok-subagent-recordings.test.ts b/src/main/acp/acp-structured-grok-subagent-recordings.test.ts index 21f02f0e302..1cb51a1ba30 100644 --- a/src/main/acp/acp-structured-grok-subagent-recordings.test.ts +++ b/src/main/acp/acp-structured-grok-subagent-recordings.test.ts @@ -1,10 +1,11 @@ // The adapter against Grok's recorded subagents (Grok 1.0.46): each recording plays back as the -// agent, and the assertions read the roster and reply rows a client would see. +// agent, and the assertions read what it reports to the host about each subagent and the reply +// rows a client would see. import { afterEach, describe, expect, it } from 'vitest' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' -import { isBackgroundTaskBlock, isSubagentGroupBlock } from '../../shared/native-chat-types' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' import { closeProviderTimelineRigs, SESSION @@ -43,12 +44,32 @@ function send(rig: AcpAdapterRig, clientMessageId: string) { return rig.adapter.dispatch({ sessionId: SESSION, clientMessageId, body: ask, fence: 1 }) } -async function subagents(rig: AcpAdapterRig) { - return (await rig.rig.rows()).flatMap((row) => - row.body.kind === 'message' - ? row.body.blocks.filter(isSubagentGroupBlock).flatMap((group) => group.agents) - : [] - ) +type Reported = { id: string; label?: string; outcome?: string; tokens?: number } + +/** Each subagent as the adapter last reported it to the host. */ +function subagents(evidence: readonly AgentChildWorkEvidence[]): Reported[] { + const reported = new Map() + for (const edge of evidence) { + const handle = + edge.type === 'live' ? edge.child.handle : edge.type === 'ended' ? edge.handle : null + if (!handle) { + continue + } + const prior = reported.get(handle.id) ?? { id: handle.id } + const tokens = + edge.type === 'live' + ? edge.child.totalTokens + : edge.type === 'ended' + ? edge.totalTokens + : undefined + reported.set(handle.id, { + ...prior, + ...(edge.type === 'live' && edge.child.description ? { label: edge.child.description } : {}), + ...(edge.type === 'ended' ? { outcome: edge.outcome } : {}), + ...(tokens !== undefined ? { tokens } : {}) + }) + } + return [...reported.values()] } async function replies(rig: AcpAdapterRig) { @@ -77,15 +98,15 @@ async function stop(rig: AcpAdapterRig, replay: GrokFixtureReplay) { } describe('Grok subagent recordings through the adapter', () => { - it('a foreground subagent completes in the roster and its reply opens under it', async () => { + it('a foreground subagent completes and its reply opens under it', async () => { const { rig, replay, evidence } = await replaying('s7-subagent-foreground') await send(rig, 'send-1') await waitFor(() => expect(replay.awaiting).toBeNull()) await waitFor(async () => expect(await turns(rig)).toMatchObject([{ state: 'completed', outcome: 'success' }]) ) - expect(await subagents(rig)).toMatchObject([ - { id: 'subagent-1', label: 'Count note lines', state: 'completed', tokens: 13778 } + expect(subagents(evidence)).toMatchObject([ + { id: 'subagent-1', label: 'Count note lines', outcome: 'succeeded', tokens: 13778 } ]) expect(await replies(rig)).toEqual([ { agentId: 'subagent-1', blocks: [{ type: 'text', text: '4' }] } @@ -101,13 +122,13 @@ describe('Grok subagent recordings through the adapter', () => { ) }) - it('a background subagent completes in the roster and stays out of the background tasks', async () => { + it('a background subagent completes and stays out of the background tasks', async () => { const { rig, replay, evidence } = await replaying('s7-subagent-background') await send(rig, 'send-1') await waitFor(() => expect(replay.awaiting).toBeNull()) - await waitFor(async () => - expect(await subagents(rig)).toMatchObject([ - { id: 'subagent-1', label: 'List folder files', state: 'completed' } + await waitFor(() => + expect(subagents(evidence)).toMatchObject([ + { id: 'subagent-1', label: 'List folder files', outcome: 'succeeded' } ]) ) expect(await replies(rig)).toEqual([ @@ -126,12 +147,12 @@ describe('Grok subagent recordings through the adapter', () => { }) it('Stop while a foreground and then a background subagent runs ends each stopped', async () => { - const { rig, replay } = await replaying('s7-subagent-stop') + const { rig, replay, evidence } = await replaying('s7-subagent-stop') await send(rig, 'send-1') await stop(rig, replay) await waitFor(() => expect(replay.awaiting).toBe('session/prompt')) - await waitFor(async () => - expect(await subagents(rig)).toMatchObject([{ label: 'Slow sleeper', state: 'stopped' }]) + await waitFor(() => + expect(subagents(evidence)).toMatchObject([{ label: 'Slow sleeper', outcome: 'cancelled' }]) ) await send(rig, 'send-2') await stop(rig, replay) @@ -145,9 +166,9 @@ describe('Grok subagent recordings through the adapter', () => { 'success' ]) ) - expect(await subagents(rig)).toMatchObject([ - { id: 'subagent-1', label: 'Slow sleeper', state: 'stopped' }, - { id: 'subagent-2', label: 'Background sleeper', state: 'stopped' } + expect(subagents(evidence)).toMatchObject([ + { id: 'subagent-1', label: 'Slow sleeper', outcome: 'cancelled' }, + { id: 'subagent-2', label: 'Background sleeper', outcome: 'cancelled' } ]) expect(await replies(rig)).toEqual([]) expect(await backgroundTasks(rig)).toEqual([]) 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 e851df7f931..a0a5a4b8c39 100644 --- a/src/main/acp/acp-structured-host-subagent-stop.test.ts +++ b/src/main/acp/acp-structured-host-subagent-stop.test.ts @@ -3,7 +3,7 @@ import { PassThrough } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' import type { PipedChildProcess, PipedProcessSpawner } from '@orca/process-host/process-spec' -import { isSubagentGroupBlock } from '../../shared/native-chat-types' +import { isSubagentBlock } from '../../shared/native-chat-types' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { terminateProviderProcessTree } from '../provider-process/provider-process-teardown' @@ -100,7 +100,7 @@ async function stoppingChild(reportsOutcome: boolean) { const rosters = async () => (await hosted.rows()).flatMap((row) => row.body.kind === 'message' - ? row.body.blocks.filter(isSubagentGroupBlock).map((group) => ({ row, group })) + ? row.body.blocks.filter(isSubagentBlock).map((block) => ({ row, block })) : [] ) const original = (await rosters())[0] @@ -125,7 +125,7 @@ describe('Grok child outcome during a full host Stop', () => { await vi.advanceTimersByTimeAsync(TURN_ANSWER_AFTER_STOP_MS) expect(fixture.closeAt).toEqual([STOP_AT + TURN_ANSWER_AFTER_STOP_MS]) expect((await fixture.turns()).at(-1)).toMatchObject({ state: 'interrupted' }) - expect((await fixture.rosters())[0].group.agents[0].state).toBe('working') + expect((await fixture.rosters())[0].block.state).toBe('working') expect(fixture.childWork.views()).toMatchObject([ { membership: 'live', providerId: 'subagent-1' } ]) @@ -133,18 +133,16 @@ describe('Grok child outcome during a full host Stop', () => { const [cancelled] = await fixture.rosters() expect(cancelled.row.itemId).toBe(fixture.original.row.itemId) expect(cancelled.row.turnScope).toEqual(fixture.original.row.turnScope) - expect(cancelled.group.agents).toEqual([ - { - ...fixture.original.group.agents[0], - state: 'stopped', - settledAt: STOP_AT + 233 - } - ]) + expect(cancelled.block).toEqual({ + ...fixture.original.block, + state: 'stopped', + settledAt: STOP_AT + 233 + }) await vi.advanceTimersByTimeAsync(EXIT_AFTER_STOP_MS - 233) expect(fixture.childWork.views()).toMatchObject([ { membership: 'settled', outcome: 'cancelled' } ]) - expect((await fixture.rosters())[0].group).toEqual(cancelled.group) + expect((await fixture.rosters())[0].block).toEqual(cancelled.block) const settled = await fixture.rows() fixture.agent.notify(fixture.finished.method, fixture.finished.params) expect(await fixture.rows()).toEqual(settled) @@ -158,11 +156,13 @@ describe('Grok child outcome during a full host Stop', () => { 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', settledAt: expect.any(Number) } - ]) + expect(unavailable.block).toEqual({ + ...fixture.original.block, + state: 'unverifiable', + settledAt: expect.any(Number) + }) fixture.agent.notify(fixture.finished.method, fixture.finished.params) - expect((await fixture.rosters())[0].group).toEqual(unavailable.group) + expect((await fixture.rosters())[0].block).toEqual(unavailable.block) await fixture.host.close(SESSION, 'user-close') }) }) diff --git a/src/main/acp/acp-structured-lane.ts b/src/main/acp/acp-structured-lane.ts index 045e1b544d5..b21c0405cd5 100644 --- a/src/main/acp/acp-structured-lane.ts +++ b/src/main/acp/acp-structured-lane.ts @@ -13,7 +13,6 @@ import type { ProviderTimelineSink } from '../native-chat/agent-session-timeline import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { AcpChildStops, AcpDialect } from './acp-dialects/acp-dialect' import { AcpTimelineTranslator } from './acp-timeline-translator' -import { acpSubagentChildWork, isAcpSubagentChildWorkEvent } from './acp-subagent-child-work' import { acpBackgroundTaskChildWork, isAcpBackgroundTaskChildWorkEvent @@ -29,6 +28,10 @@ import type { AcpTextDrop } from './acp-turn-messages' /** How long a held event waits for the sink to say it drained before trying again on its own. */ const BACKPRESSURE_RETRY_MS = 250 +/** Subagent evidence holds its place among the events: it reaches the host only after the journal + * wrote what came before it. */ +type SubagentWork = { type: 'subagent.work'; evidence: AgentChildWorkEvidence[] } + export type AcpStructuredLaneDeps = { sink: ProviderTimelineSink sessionId: string @@ -52,11 +55,12 @@ export type AcpStructuredLaneDeps = { export class AcpStructuredLane { readonly translator: AcpTimelineTranslator private readonly assembler: ProviderTimelineAssembler - private readonly backlog: AcpTimelineEvent[] = [] + private readonly backlog: (AcpTimelineEvent | SubagentWork)[] = [] private readonly turnWatchers = new Set<() => void>() private readonly requestIdentity: ReturnType private retryTimer: ReturnType | null = null private failed = false + private ended = false private disposed = false private readonly logger: StructuredAgentSessionLogger @@ -102,6 +106,10 @@ export class AcpStructuredLane { return } this.backlog.push(...events) + const evidence = this.translator.subagents.drainEvidence() + if (evidence.length > 0) { + this.backlog.push({ type: 'subagent.work', evidence }) + } this.drain() } @@ -165,11 +173,20 @@ export class AcpStructuredLane { this.clearRetry() while (this.backlog.length > 0 && !this.failed && !this.disposed) { const event = this.backlog[0] + if (event.type === 'subagent.work') { + if (!this.queueSubagentWork(event.evidence)) { + return + } + this.backlog.shift() + continue + } const { admission, dropped } = this.assembler.apply( event, - isAcpSubagentChildWorkEvent(event) || isAcpBackgroundTaskChildWorkEvent(event) - ? () => this.publishChildWork(event) - : undefined + event.type === 'session.ended' + ? () => this.publishSessionEnd() + : isAcpBackgroundTaskChildWorkEvent(event) + ? () => this.publishChildWork(event) + : undefined ) if (dropped === 'stream-mismatch' && event.type === 'text.delta') { this.reportTextDrop({ @@ -182,17 +199,12 @@ export class AcpStructuredLane { }) } if (!admission.accepted) { - if (admission.reason === 'backpressure') { - this.retryTimer = setTimeout(() => this.drain(), BACKPRESSURE_RETRY_MS) - return - } - this.failed = true - this.backlog.length = 0 - this.notifyTurnWatchers() - this.deps.onFailed(admission.reason) + this.refused(admission.reason) return } this.backlog.shift() + // A subagent the provider reports after its session ended is no live child of it. + this.ended ||= event.type === 'session.ended' && dropped === undefined if (event.type === 'input.accepted') { this.deps.onInputAccepted(event.clientMessageId) } @@ -203,14 +215,50 @@ export class AcpStructuredLane { private publishChildWork(event: AcpTimelineEvent): void { const observedAt = this.deps.now?.() ?? Date.now() const stops = this.deps.childStops?.() - const evidence = [ - ...acpSubagentChildWork(event, observedAt, stops?.subagents === true), - ...acpBackgroundTaskChildWork(event, observedAt, stops?.backgroundTasks === true) - ] - if (!evidence.length) { + const evidence = acpBackgroundTaskChildWork(event, observedAt, stops?.backgroundTasks === true) + if (evidence.length > 0) { + this.publishEvidence(evidence) + } + } + + private refused(reason: string): void { + if (reason === 'backpressure') { + this.retryTimer = setTimeout(() => this.drain(), BACKPRESSURE_RETRY_MS) return } - this.publishEvidence(evidence) + this.failed = true + this.backlog.length = 0 + this.notifyTurnWatchers() + this.deps.onFailed(reason) + } + + /** False when the sink held it back; it stays first in the backlog. */ + private queueSubagentWork(evidence: AgentChildWorkEvidence[]): boolean { + if (this.ended) { + return true + } + const admission = this.deps.sink.tryAppendTransition({ + steps: [], + lifecycle: false, + publish: false, + onPublished: () => this.publishSubagentWork(evidence) + }) + if (!admission.accepted) { + this.refused(admission.reason) + } + return admission.accepted + } + + /** A subagent's stop is offered only once the agent proved it honours one. */ + private publishSubagentWork(evidence: AgentChildWorkEvidence[]): void { + const stoppable = this.deps.childStops?.().subagents === true + this.publishEvidence( + evidence.map((edge) => + edge.type === 'live' + ? { ...edge, child: { ...edge.child, stoppable: stoppable && edge.child.stoppable } } + : edge + ) + ) } private publishEvidence(evidence: AgentChildWorkEvidence[]): void { diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 766d4b9ed73..d1911c3c481 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -235,7 +235,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte stopBackgroundTasks: NonNullable = ( input - ) => stopAcpChildren(this.live(input.sessionId), input.fence, input.taskIds, () => this.now()) + ) => stopAcpChildren(this.live(input.sessionId), input.fence, input.taskIds, input.acknowledge) backgroundTaskStops: NonNullable = ( sessionId diff --git a/src/main/acp/acp-subagent-child-work.ts b/src/main/acp/acp-subagent-child-work.ts deleted file mode 100644 index 2e5a33b53b8..00000000000 --- a/src/main/acp/acp-subagent-child-work.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' -import type { AgentChildWorkOutcome } from '../../shared/agent-status-child-work' -import { isSubagentGroupBlock } from '../../shared/native-chat-types' -import type { AcpTimelineEvent } from './acp-timeline-event' -import { createHash } from 'node:crypto' -import { AGENT_CHILD_WORK_ID_MAX_LENGTH } from '../../shared/agent-status-child-work-value-guards' - -export function isAcpSubagentChildWorkEvent(event: AcpTimelineEvent): boolean { - return ( - event.type === 'session.ended' || - (['item.open', 'item.update', 'item.close'].includes(event.type) && - 'body' in event && - event.body.kind === 'message' && - event.body.blocks.some(isSubagentGroupBlock)) - ) -} - -/** Project the admitted snapshot, never the translator's potentially newer roster. */ -export function acpSubagentChildWork( - event: AcpTimelineEvent, - observedAt: number, - stoppable: boolean -): AgentChildWorkEvidence[] { - if (event.type === 'session.ended') { - return [{ type: 'session-ended', observedAt }] - } - if ( - !['item.open', 'item.update', 'item.close'].includes(event.type) || - !('body' in event) || - event.body.kind !== 'message' - ) { - return [] - } - const evidence: AgentChildWorkEvidence[] = [] - for (const block of event.body.blocks) { - if (!isSubagentGroupBlock(block)) { - continue - } - for (const [index, child] of block.agents.entries()) { - const identity = event.subagentIdentities?.[index] ?? child.id - const handle = { - idKind: 'task_id' as const, - id: identity, - // Invocation fences are smaller than aliases; keep the roster's full stable handle. - ...(identity.length > AGENT_CHILD_WORK_ID_MAX_LENGTH - ? { runId: `acp-child:${createHash('sha256').update(identity).digest('hex')}` } - : {}) - } - if (child.state === 'working') { - evidence.push({ - type: 'live', - observedAt, - child: { - handle, - kind: 'agent', - residency: 'background', - state: 'working', - description: child.label, - totalTokens: child.tokens, - // Display-clipped handles cannot safely address the provider's cancellation API. - stoppable: stoppable && identity === child.id && !/…~\d+$/.test(child.id) - } - }) - } else { - const outcome: AgentChildWorkOutcome = - child.state === 'failed' - ? 'failed' - : child.state === 'stopped' - ? 'cancelled' - : child.state === 'unverifiable' - ? 'unknown' - : 'succeeded' - evidence.push({ type: 'ended', observedAt, handle, outcome, totalTokens: child.tokens }) - } - } - } - return evidence -} diff --git a/src/main/acp/acp-subagent-timeline-characterization.test.ts b/src/main/acp/acp-subagent-timeline-characterization.test.ts deleted file mode 100644 index f28c8346b4c..00000000000 --- a/src/main/acp/acp-subagent-timeline-characterization.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -// 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 deleted file mode 100644 index 86e9864b795..00000000000 --- a/src/main/acp/acp-subagent-timeline.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, expect, it } from 'vitest' -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' - -function spawnGroups( - timeline: AcpSubagentTimeline, - count: number, - state: NativeChatSubagentState = 'working' -) { - for (let index = 0; index < count; index++) { - timeline.translate( - [{ id: `child-${index}`, turn: `turn-${index}`, state }], - { thread: 'parent', turn: `turn-${index}` }, - index - ) - } -} - -describe('ACP subagent retention', () => { - it('keeps every live owner beyond both former cache limits and releases newly settled groups', () => { - const timeline = new AcpSubagentTimeline() - for (let group = 0; group < 33; group++) { - timeline.translate( - Array.from({ length: 64 }, (_, child): AcpSubagentUpdate => ({ - id: `${group}:${child}`, - turn: `turn-${group}`, - state: 'working' - })), - { thread: 'parent' }, - group - ) - } - expect(timeline.has('0:0')).toBe(true) - expect(timeline.has('32:63')).toBe(true) - expect(timeline.retentionSizes().groups).toBe(33) - timeline.translate( - Array.from({ length: 63 }, (_, child): AcpSubagentUpdate => ({ - id: `0:${child}`, - state: 'completed', - result: `Answer ${child}` - })), - { thread: 'parent' }, - 100 - ) - expect(timeline.retentionSizes()).toMatchObject({ groups: 33, replies: 63 }) - const events = timeline.translate( - [{ id: '0:63', state: 'completed', result: 'Last sibling' }], - { thread: 'parent', turn: 'later' }, - 101 - ) - expect(events.map((event) => event.type === 'item.update' && event.join?.turn)).toEqual([ - 'turn-0', - 'turn-0' - ]) - // 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([]) - }) - - it('bounds settled history and evicted child ids while keeping live groups', () => { - const timeline = new AcpSubagentTimeline() - timeline.translate([{ id: 'live', turn: 'original' }], {}, 0) - for (let group = 0; group < 65; group++) { - timeline.translate( - Array.from({ length: 64 }, (_, child): AcpSubagentUpdate => ({ - id: `${group}:${child}`, - turn: `turn-${group}`, - state: 'completed', - result: 'Answer' - })), - {}, - group + 1 - ) - } - 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, 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) => { - const update = grokSubagentNotification({ - sessionUpdate: 'subagent_spawned', - subagent_id: id, - description - }) - return update ? [update] : [] - }) - const events = timeline.translate( - [...updates, { id: 'normal-1', label: 'Review' }, { id: 'normal-2', label: 'Review' }], - { turn: 'turn' }, - 0 - ) - const labels = events - .flatMap((event) => - event.type === 'item.update' && event.body.kind === 'message' - ? event.body.blocks.filter(isSubagentGroupBlock).flatMap((group) => group.agents) - : [] - ) - .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']) - }) - - it('drops ownership and cached replies on disposal even if the producer continues', () => { - const timeline = new AcpSubagentTimeline() - spawnGroups(timeline, 33) - timeline.translate([{ id: 'child-0', state: 'completed', result: 'Answer' }], {}, 100) - timeline.dispose() - 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(timeline.retentionSizes()).toEqual(empty) - }) -}) diff --git a/src/main/acp/acp-subagent-timeline.ts b/src/main/acp/acp-subagent-timeline.ts deleted file mode 100644 index 33b651f2b42..00000000000 --- a/src/main/acp/acp-subagent-timeline.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { - boundInlineText, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' -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' - -/** 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. 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 { - /** 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.results.delete(id) - } - } - }) - private disposed = false - - /** Known children include recently evicted outcomes, so they cannot become background tasks. */ - has(id: string): boolean { - 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.tracker.dispose() - this.results.clear() - this.written = [] - } - - translate( - updates: AcpSubagentUpdate[], - join: ProviderTimelineJoin, - at: number - ): AcpTimelineEvent[] { - if (this.disposed) { - return [] - } - 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 [] - } - 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: group.placement - } - ] - } - - 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/acp/acp-subagents.ts b/src/main/acp/acp-subagents.ts new file mode 100644 index 00000000000..a8ce14c9a55 --- /dev/null +++ b/src/main/acp/acp-subagents.ts @@ -0,0 +1,173 @@ +// An ACP agent's subagents, read as evidence for the host's child records, plus each completed +// subagent's reply as its own row. +// +// The host decides what every subagent is doing and writes its saved row; this only reads the +// dialect's updates. It lives as long as the provider child: a subagent an earlier run spawned is +// not known here. + +import { createHash } from 'node:crypto' +import { BoundedMap } from '../../shared/bounded-map' +import type { AgentChildWorkOutcome } from '../../shared/agent-status-child-work' +import type { + AgentChildWorkEvidence, + AgentChildWorkEvidenceHandle +} from '../../shared/agent-status-child-work-evidence' +import { AGENT_CHILD_WORK_ID_MAX_LENGTH } from '../../shared/agent-status-child-work-value-guards' +import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary' +import type { NativeChatSubagentState } from '../../shared/native-chat-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { boundSubagentLabel } from '../native-chat/agent-session-journal/journal-subagent-item' +import type { ProviderTimelineJoin } from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpSubagentUpdate } from './acp-dialects/acp-dialect' +import type { AcpTimelineEvent } from './acp-timeline-event' + +/** Subagents remembered, so a later report about one is still known as a subagent's. */ +const MAX_KNOWN_SUBAGENTS = 2048 + +type KnownSubagent = { + /** Where its reply row goes: the turn that spawned it. */ + placement: ProviderTimelineJoin + /** The reply already written, so a repeated result writes nothing. */ + reply?: string + /** How it first ended: a reply belongs only to a run that completed. */ + ended?: NativeChatSubagentState +} + +const OUTCOMES: Record< + Exclude, + AgentChildWorkOutcome +> = { + completed: 'succeeded', + failed: 'failed', + stopped: 'cancelled', + unverifiable: 'unknown' +} + +/** How a subagent's run ended, by the state the dialect read; undefined while it runs. */ +export function acpSubagentOutcome( + state: NativeChatSubagentState +): AgentChildWorkOutcome | undefined { + return state === 'working' || state === 'idle' ? undefined : OUTCOMES[state] +} + +export class AcpSubagents { + private readonly known = new BoundedMap({ + maxEntries: MAX_KNOWN_SUBAGENTS + }) + private readonly pending: AgentChildWorkEvidence[] = [] + + /** Known children include settled ones, so they cannot become background tasks. */ + has(id: string): boolean { + return this.known.has(id) + } + + dispose(): void { + this.known.clear() + this.pending.length = 0 + } + + /** What the updates translated since the last drain said, for the host's records. */ + drainEvidence(): AgentChildWorkEvidence[] { + return this.pending.splice(0) + } + + translate( + updates: readonly AcpSubagentUpdate[], + join: ProviderTimelineJoin, + at: number + ): AcpTimelineEvent[] { + const replies: AcpTimelineEvent[] = [] + for (const update of updates) { + const known = this.known.get(update.id) + if (!known && update.knownOnly === true) { + continue + } + const turn = update.turn ?? join.turn + const subagent = known ?? { + placement: { + ...(join.thread === undefined ? {} : { thread: join.thread }), + ...(turn === undefined ? {} : { turn }) + } + } + this.known.set(update.id, subagent) + const ending = update.state !== undefined && acpSubagentOutcome(update.state) !== undefined + // A late report about an ended run is not a new run: the host drops the run's record at the + // next send, so a live edge then would bring it back as working. + if (ending || subagent.ended === undefined) { + this.pending.push(subagentEvidence(update, at)) + } + if (ending) { + subagent.ended ??= update.state + } + if (update.result && subagent.ended === 'completed') { + replies.push(...this.reply(update.id, subagent, update.result)) + } + } + return replies + } + + private reply(id: string, subagent: KnownSubagent, text: string): AcpTimelineEvent[] { + const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + if (subagent.reply === bounded) { + return [] + } + subagent.reply = 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: subagent.placement + } + ] + } +} + +/** A subagent runs on past the prompt turn that spawned it, and the provider names no run. */ +function subagentEvidence(update: AcpSubagentUpdate, observedAt: number): AgentChildWorkEvidence { + const handle = subagentHandle(update.id) + const outcome = update.state === undefined ? undefined : acpSubagentOutcome(update.state) + if (outcome === undefined) { + return { + type: 'live', + observedAt, + child: { + handle, + kind: 'agent', + residency: 'background', + state: update.state === 'idle' ? 'idle' : 'working', + // The strip and the saved row show the same label. + ...(update.label !== undefined ? { description: boundSubagentLabel(update.label) } : {}), + ...(update.tokens !== undefined ? { totalTokens: update.tokens } : {}), + // An id the handle had to digest cannot address the provider's cancellation. + stoppable: handle.id === update.id + } + } + } + return { + type: 'ended', + observedAt, + handle, + outcome, + ...(update.tokens !== undefined ? { totalTokens: update.tokens } : {}) + } +} + +function subagentHandle(id: string): AgentChildWorkEvidenceHandle { + const bounded = + id.length <= MAX_SUBAGENT_FIELD_CHARS + ? id + : `acp-child:${createHash('sha256').update(id).digest('hex')}` + return { + idKind: 'task_id', + id: bounded, + // Invocation fences are smaller than handles; keep the full stable handle. + ...(bounded.length > AGENT_CHILD_WORK_ID_MAX_LENGTH + ? { runId: `acp-child:${createHash('sha256').update(bounded).digest('hex')}` } + : {}) + } +} diff --git a/src/main/acp/acp-timeline-event.ts b/src/main/acp/acp-timeline-event.ts index 605a4e53184..9b2c8eea878 100644 --- a/src/main/acp/acp-timeline-event.ts +++ b/src/main/acp/acp-timeline-event.ts @@ -1,14 +1,3 @@ -import { createHash } from 'node:crypto' -import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' -export type AcpTimelineEvent = ProviderTimelineEvent & { - /** Event-local identities in roster order, before display clipping; never journaled. */ - subagentIdentities?: readonly string[] -} - -export function acpSubagentIdentity(providerId: string): string { - return providerId.length <= MAX_SUBAGENT_FIELD_CHARS - ? providerId - : `acp-child:${createHash('sha256').update(providerId).digest('hex')}` -} +export type AcpTimelineEvent = ProviderTimelineEvent diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts index c611cfabbb9..3f16ad4139c 100644 --- a/src/main/acp/acp-timeline-fixture.test-support.ts +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -11,6 +11,9 @@ import { openProviderTimelineRig } from '../native-chat/agent-session-timeline/p import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' import type { AcpRequestPresentation } from './acp-dialects/acp-dialect' import { AcpTimelineTranslator } from './acp-timeline-translator' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' +import type { AgentChildWorkView } from '../../shared/agent-status-child-work-view' +import { acpSubagentRecords } from './acp-structured-child-work.test-support' import { PromptResponseSchema } from './generated/acp-protocol.generated' const frameSchema = z.object({ @@ -73,6 +76,15 @@ export async function openAcpFixtureRig() { const translator = () => new AcpTimelineTranslator({ sessionId: providerSessionId, dialect: GROK_ACP_DIALECT }) let lane = translator() + const records = acpSubagentRecords() + // What each subagent's saved row last said: its host record, kept past the record's retirement. + const latest = new Map() + const report = (evidence: AgentChildWorkEvidence[]) => { + for (const view of records(evidence)) { + latest.set(view.id, view) + } + } + const drain = () => report(lane.subagents.drainEvidence()) const requests = new Map< string | number, { request: string; presentation: AcpRequestPresentation } @@ -80,6 +92,16 @@ export async function openAcpFixtureRig() { const apply = (events: ProviderTimelineEvent[]) => { for (const event of events) { expect(rig.assembler.apply(event).admission.accepted).toBe(true) + if (event.type === 'turn.open') { + // As the host retires finished records when the user's next send is accepted. + drain() + report([{ type: 'turn-started', observedAt: event.at }]) + } + if (event.type === 'session.ended') { + // As the lane reports a session end once the journal admitted it. + drain() + report([{ type: 'session-ended', observedAt: 9_000 }]) + } } } const feed = async (frames: AcpFixtureFrame[]) => { @@ -92,6 +114,7 @@ export async function openAcpFixtureRig() { const session = z.object({ sessionId: z.string() }).parse(message.params) if (providerSessionId !== session.sessionId) { providerSessionId = session.sessionId + drain() lane = translator() } apply(lane.openPrompt(`${frame.process}:${message.id}`, at).events) @@ -162,8 +185,14 @@ export async function openAcpFixtureRig() { /** A new provider child: the sweep, a new generation's assembler and a new translator. */ restart: async () => { await rig.restart() + drain() lane = translator() }, + /** Every subagent reported so far, as its host record last read. */ + subagents: () => { + drain() + return [...latest.values()] + }, finishLoad: () => lane.finishLoad() } } diff --git a/src/main/acp/acp-timeline-grok-subagent-retention.test.ts b/src/main/acp/acp-timeline-grok-subagent-retention.test.ts index edacc93ab73..e6a9d442cac 100644 --- a/src/main/acp/acp-timeline-grok-subagent-retention.test.ts +++ b/src/main/acp/acp-timeline-grok-subagent-retention.test.ts @@ -1,9 +1,8 @@ import { afterEach, describe, expect, it } from 'vitest' -import { isBackgroundTaskBlock, isSubagentGroupBlock } from '../../shared/native-chat-types' -import { - closeProviderTimelineRigs, - providerItemId -} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { AcpStructuredLane } from './acp-structured-lane' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' @@ -67,16 +66,14 @@ function laterGroups(fixture: Fixture, settled: boolean) { } } -async function roster(fixture: Fixture) { - return (await fixture.rig.rows()).flatMap((row) => - row.body.kind === 'message' - ? row.body.blocks.filter(isSubagentGroupBlock).map((group) => ({ row, group })) - : [] - ) +function record(fixture: Fixture, id: string) { + return fixture.subagents().filter((view) => view.providerId === id) } -async function original(fixture: Fixture) { - return (await roster(fixture)).find(({ group }) => group.groupId === 'prompt:original') +/** The scope of the rows the latest turn writes. */ +async function openTurnScope(fixture: Fixture) { + const turn = (await fixture.rig.rows()).findLast((row) => readAgentJournalTurn(row.body)) + return turn ? { kind: 'turn', turnItemId: turn.itemId } : undefined } function toolResult(fixture: Fixture, kind: 'output' | 'kill', at: number) { @@ -117,12 +114,13 @@ function toolResult(fixture: Fixture, kind: 'output' | 'kill', at: number) { ) } -describe('Grok roster ownership after more than 32 spawning groups', () => { +describe('a Grok subagent reported on long after the turn that spawned it', () => { it.each([false, true])( - 'finishes the original durable row with siblings and reply when later groups settled=%s', + 'finishes the original child and files its reply under its spawn turn when 33 later turns settled=%s', async (settled) => { const fixture = await openAcpFixtureRig() openTurn(fixture, 'original', 1) + const spawnScope = await openTurnScope(fixture) spawn(fixture, 'old', 'original', 2) spawn(fixture, 'sibling', 'original', 3) notify( @@ -131,8 +129,6 @@ describe('Grok roster ownership after more than 32 spawning groups', () => { 4 ) fixture.apply(fixture.lane().promptResult('original', { stopReason: 'end_turn' }, 5)) - const before = await original(fixture) - expect(before).toBeDefined() laterGroups(fixture, settled) openTurn(fixture, 'current', 300) notify( @@ -146,27 +142,17 @@ describe('Grok roster ownership after more than 32 spawning groups', () => { }, 301 ) - const after = await original(fixture) - expect(after?.row.itemId).toBe(before?.row.itemId) - expect(after?.row.turnScope).toEqual(before?.row.turnScope) - expect(after?.group.agents).toEqual([ + expect(record(fixture, 'old')).toMatchObject([ { - id: 'old', - label: 'Original label', - startedAt: 2, - settledAt: 301, - state: 'completed', - tokens: 12 - }, - { id: 'sibling', label: 'Original label 2', startedAt: 3, settledAt: 4, state: 'completed' } + description: 'Original label', + membership: 'settled', + outcome: 'succeeded', + totalTokens: 12 + } ]) - expect( - (await roster(fixture)).flatMap(({ group }) => - group.agents.filter((entry) => entry.id === 'old') - ) - ).toHaveLength(1) + expect(record(fixture, 'sibling')).toMatchObject([{ outcome: 'succeeded' }]) const reply = (await fixture.rig.rows()).find((row) => row.agentId === 'old') - expect(reply?.turnScope).toEqual(before?.row.turnScope) + expect(reply?.turnScope).toEqual(spawnScope) expect(reply?.body).toMatchObject({ kind: 'message', role: 'assistant', @@ -182,20 +168,11 @@ describe('Grok roster ownership after more than 32 spawning groups', () => { openTurn(fixture, 'original', 1) spawn(fixture, 'old', 'original', 2) fixture.apply(fixture.lane().promptResult('original', { stopReason: 'end_turn' }, 3)) - const before = await original(fixture) laterGroups(fixture, false) openTurn(fixture, 'current', 300) toolResult(fixture, kind, 301) - const after = await original(fixture) - expect(after?.row.itemId).toBe(before?.row.itemId) - expect(after?.group.agents).toEqual([ - { - id: 'old', - label: 'Original label', - startedAt: 2, - settledAt: 301, - state: kind === 'output' ? 'completed' : 'stopped' - } + expect(record(fixture, 'old')).toMatchObject([ + { membership: 'settled', outcome: kind === 'output' ? 'succeeded' : 'cancelled' } ]) const rows = await fixture.rig.rows() expect( @@ -207,13 +184,12 @@ describe('Grok roster ownership after more than 32 spawning groups', () => { } ) - it('drops late progress, repeated spawn and finish for a stopped child after its settled group is evicted', async () => { + it('keeps a stopped child stopped through late progress, a repeated spawn and a finish', async () => { const fixture = await openAcpFixtureRig() openTurn(fixture, 'original', 1) spawn(fixture, 'old', 'original', 2) toolResult(fixture, 'kill', 3) fixture.apply(fixture.lane().promptResult('original', { stopReason: 'end_turn' }, 4)) - const before = await original(fixture) laterGroups(fixture, true) openTurn(fixture, 'current', 300) notify(fixture, { sessionUpdate: 'subagent_progress', subagent_id: 'old', tokens_used: 9 }, 301) @@ -228,17 +204,13 @@ describe('Grok roster ownership after more than 32 spawning groups', () => { }, 303 ) - expect(await original(fixture)).toEqual(before) - expect( - (await roster(fixture)).flatMap(({ group }) => - group.agents.filter((entry) => entry.id === 'old') - ) - ).toHaveLength(1) + expect(record(fixture, 'old')).toMatchObject([{ membership: 'settled', outcome: 'cancelled' }]) expect((await fixture.rig.rows()).filter((row) => row.agentId === 'old')).toEqual([]) }) - it('lane disposal ends in-memory ownership while the journal settles a disconnected producer as unverifiable', async () => { + it('lane disposal ends observation: the host hears the session end, and nothing after it', async () => { const fixture = await openAcpFixtureRig() + const evidence: AgentChildWorkEvidence[] = [] const lane = new AcpStructuredLane({ sink: fixture.rig.sink, sessionId: 'session-timeline', @@ -251,7 +223,8 @@ describe('Grok roster ownership after more than 32 spawning groups', () => { onInputAccepted: () => {}, onFailed: (reason) => { throw new Error(reason) - } + }, + onChildWorkEvidence: (batch) => evidence.push(...batch) }) lane.apply( lane.translator.notification( @@ -279,12 +252,9 @@ describe('Grok roster ownership after more than 32 spawning groups', () => { 2 ) expect(events).toEqual([]) - const row = await fixture.rig.row( - providerItemId('item', 'subagents:thread', { namespace: 'session-1', thread: 'session-1' }) - ) - expect( - row?.body.kind === 'message' && - row.body.blocks.filter(isSubagentGroupBlock).flatMap((group) => group.agents) - ).toMatchObject([{ id: 'old', state: 'unverifiable' }]) + await fixture.rig.rows() + // Disposal says the session ended once more; nothing reports the child live again. + expect(evidence.map((edge) => edge.type).slice(0, 2)).toEqual(['live', 'session-ended']) + expect(evidence.filter((edge) => edge.type === 'live')).toHaveLength(1) }) }) diff --git a/src/main/acp/acp-timeline-grok-subagents.test.ts b/src/main/acp/acp-timeline-grok-subagents.test.ts index 2acd725cf28..18129c702f9 100644 --- a/src/main/acp/acp-timeline-grok-subagents.test.ts +++ b/src/main/acp/acp-timeline-grok-subagents.test.ts @@ -1,13 +1,10 @@ // Grok's recorded subagents (Grok 1.0.46, s7 fixtures) through translation, assembly and the -// journal: each subagent is an entry in the shared roster row, and its reply is its own row. +// journal: each subagent is one host record, which the host saves as its row, and its reply is its +// own row. import { afterEach, describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' -import { - isBackgroundTaskBlock, - isSubagentGroupBlock, - type NativeChatSubagentEntry -} from '../../shared/native-chat-types' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' @@ -15,15 +12,14 @@ afterEach(closeProviderTimelineRigs) type Fixture = Awaited> -async function rosters(fixture: Fixture) { - return (await fixture.rig.rows()).flatMap((row) => { - const group = row.body.kind === 'message' ? row.body.blocks.find(isSubagentGroupBlock) : null - return group ? [{ row, group }] : [] - }) -} - -async function entries(fixture: Fixture): Promise { - return (await rosters(fixture)).flatMap(({ group }) => group.agents) +function subagents(fixture: Fixture) { + return fixture.subagents().map((view) => ({ + id: view.providerId, + label: view.description, + membership: view.membership, + outcome: view.outcome, + tokens: view.totalTokens + })) } function replies(rows: AgentJournalRenderItem[]) { @@ -54,25 +50,18 @@ async function untilSpawned() { } describe('Grok subagents through the shared timeline', () => { - it('a foreground subagent is one roster entry that completes, with its reply filed under it', async () => { + it('a foreground subagent is one record that completes, with its reply filed under it', async () => { const fixture = await openAcpFixtureRig() const rows = await fixture.feed(await readAcpFixture('s7-subagent-foreground')) - const [roster] = await rosters(fixture) - expect(roster?.group.agents).toEqual([ + expect(subagents(fixture)).toEqual([ { id: 'subagent-1', label: 'Count note lines', - state: 'completed', - tokens: 13778, - startedAt: expect.any(Number), - settledAt: expect.any(Number) + membership: 'settled', + outcome: 'succeeded', + tokens: 13778 } ]) - expect(roster?.row.body).toMatchObject({ - role: 'system', - blocks: [{ type: 'text', text: 'Ran 1 subagent' }, { type: 'subagent-group' }] - }) - expect(roster?.row.turnScope?.kind).toBe('turn') expect(replies(rows)).toEqual([ { agentId: 'subagent-1', role: 'assistant', blocks: [{ type: 'text', text: '4' }] } ]) @@ -89,8 +78,8 @@ describe('Grok subagents through the shared timeline', () => { it('a background subagent completes from Grok, and reading its output adds no background task', async () => { const fixture = await openAcpFixtureRig() const rows = await fixture.feed(await readAcpFixture('s7-subagent-background')) - expect(await entries(fixture)).toMatchObject([ - { id: 'subagent-1', label: 'List folder files', state: 'completed', tokens: 13829 } + expect(subagents(fixture)).toMatchObject([ + { id: 'subagent-1', label: 'List folder files', outcome: 'succeeded', tokens: 13829 } ]) expect(replies(rows)).toEqual([ { @@ -105,13 +94,9 @@ describe('Grok subagents through the shared timeline', () => { it('Stop ends a foreground and a background subagent as stopped, and a later read keeps them so', async () => { const fixture = await openAcpFixtureRig() const rows = await fixture.feed(await readAcpFixture('s7-subagent-stop')) - expect(await entries(fixture)).toMatchObject([ - { id: 'subagent-1', label: 'Slow sleeper', state: 'stopped', tokens: 4094 }, - { id: 'subagent-2', label: 'Background sleeper', state: 'stopped', tokens: 4094 } - ]) - expect((await rosters(fixture)).map(({ group }) => group.groupId)).toEqual([ - 'prompt:p1:5', - 'prompt:p1:6' + expect(subagents(fixture)).toMatchObject([ + { id: 'subagent-1', label: 'Slow sleeper', outcome: 'cancelled', tokens: 4094 }, + { id: 'subagent-2', label: 'Background sleeper', outcome: 'cancelled', tokens: 4094 } ]) expect(replies(rows)).toEqual([]) expect(backgroundTasks(rows)).toEqual([]) @@ -125,7 +110,7 @@ describe('Grok subagents through the shared timeline', () => { it('a kill stops a running subagent without a background-task row, and nothing relights it', async () => { const fixture = await openAcpFixtureRig() await fixture.feed(await untilSpawned()) - expect((await entries(fixture)).map((entry) => entry.state)).toEqual(['working']) + expect(subagents(fixture).map((entry) => entry.membership)).toEqual(['live']) const lane = fixture.lane() fixture.apply( lane.notification( @@ -162,8 +147,8 @@ describe('Grok subagents through the shared timeline', () => { 2002 ) ) - expect(await entries(fixture)).toMatchObject([ - { id: 'subagent-1', state: 'stopped', settledAt: 2000, tokens: 7 } + expect(subagents(fixture)).toMatchObject([ + { id: 'subagent-1', membership: 'settled', outcome: 'cancelled' } ]) expect(backgroundTasks(await fixture.rig.rows())).toEqual([]) }) @@ -194,14 +179,14 @@ describe('Grok subagents through the shared timeline', () => { 2000 ) ) - expect((await entries(fixture)).map((entry) => entry.id)).toEqual(['subagent-1']) + expect(subagents(fixture).map((entry) => entry.id)).toEqual(['subagent-1']) }) it('a subagent still running when the session ends reads unverifiable', async () => { const fixture = await openAcpFixtureRig() await fixture.feed(await untilSpawned()) - expect((await entries(fixture)).map((entry) => entry.state)).toEqual(['working']) + expect(subagents(fixture).map((entry) => entry.membership)).toEqual(['live']) fixture.apply([{ type: 'session.ended', verdict: { state: 'unverifiable' } }]) - expect((await entries(fixture)).map((entry) => entry.state)).toEqual(['unverifiable']) + expect(subagents(fixture).map((entry) => entry.outcome)).toEqual(['unknown']) }) }) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index a5bd8746b3b..cb462f806a3 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -9,7 +9,7 @@ import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' import { translateAcpRequest } from './acp-timeline-requests' import { ACP_SUBSTANTIVE_UPDATES, acpSessionUpdate } from './acp-session-update' -import { AcpSubagentTimeline } from './acp-subagent-timeline' +import { AcpSubagents } from './acp-subagents' import { AcpToolTimeline } from './acp-tool-timeline' import { AcpTurnFailures, @@ -39,7 +39,8 @@ export class AcpTimelineTranslator { private readonly prompts: AcpPromptTurns private readonly tools = new AcpToolTimeline() private readonly backgroundTasks: AcpBackgroundTaskTimeline - private readonly subagents = new AcpSubagentTimeline() + /** Its evidence is drained by the lane, in the journal's order. */ + readonly subagents = new AcpSubagents() private readonly messages = new AcpTurnMessages() private readonly started = new BoundedMap({ maxEntries: 128 }) private readonly failures: AcpTurnFailures diff --git a/src/main/agent-hooks/agent-hook-runtime-deps.ts b/src/main/agent-hooks/agent-hook-runtime-deps.ts index 774344dfe7c..340f4fe8ee7 100644 --- a/src/main/agent-hooks/agent-hook-runtime-deps.ts +++ b/src/main/agent-hooks/agent-hook-runtime-deps.ts @@ -33,7 +33,8 @@ export function agentHookRuntimeDeps( forget: (subject) => server.dropStructuredStatus(subject), publishChildWork: (subject, evidence, provider) => server.ingestStructuredChildWork(subject, evidence, provider), - readChildWork: (subject) => server.getStructuredChildWorkViews(subject) + readChildWork: (subject) => server.getStructuredChildWorkViews(subject), + readChildWorkRecords: (subject) => server.getStructuredChildWork(subject) }, checkHookAgentPresence: (paneKey) => server.checkAgentPresence(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => 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 deleted file mode 100644 index e894e027798..00000000000 --- a/src/main/claude/__snapshots__/claude-subagent-roster-characterization.test.ts.snap +++ /dev/null @@ -1,1734 +0,0 @@ -// 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-background-stop-records.test.ts b/src/main/claude/claude-background-stop-records.test.ts index 0092d9e2f29..935e5bc5021 100644 --- a/src/main/claude/claude-background-stop-records.test.ts +++ b/src/main/claude/claude-background-stop-records.test.ts @@ -3,7 +3,11 @@ // order through the real adapter and a real hook server. import { afterEach, describe, expect, it, vi } from 'vitest' -import { agentChildWorkStopTargets } from '../../shared/agent-child-work-stop-targets' +import { + agentChildWorkStopEvidence, + agentChildWorkStopTargets, + type AgentChildWorkStopAcknowledgement +} from '../../shared/agent-child-work-stop-targets' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { conversationCommandBlocked } from '../native-chat/agent-session-wire/structured-conversation-command-admission' import type { CapturedFrame } from './claude-captured-frame-builders.test-fixture' @@ -46,43 +50,55 @@ async function droppedFromRoster() { ]) expect(run.adapter.backgroundTaskState('session-1')).toBeNull() expect(blocked()?.message).toBe('Stop background tasks before using this command.') - return { run, records, blocked, stopCalls } + /** A Stop as the host sends it: targets captured first, what the CLI accepted settled after. */ + const stop = async (taskId?: string) => { + const targets = agentChildWorkStopTargets(records(), taskId) + const acknowledged: AgentChildWorkStopAcknowledgement[] = [] + const outcome = await run.adapter.stopBackgroundTasks({ + sessionId: 'session-1', + fence: 7, + taskIds: targets.map((target) => target.providerId), + acknowledge: (acknowledgement) => void acknowledged.push(acknowledgement) + }) + host.ingestStructuredChildWork( + parent, + agentChildWorkStopEvidence(targets, acknowledged, 6_500), + 'claude' + ) + return outcome + } + const basis = () => host.getStructuredChildWork(parent).map((record) => record.outcomeBasis) + return { run, records, blocked, stopCalls, stop, basis } } describe('a background Stop acts on the host child records', () => { it("stops the row's own task, which the records keep after the roster dropped it", async () => { - const { run, records, blocked, stopCalls } = await droppedFromRoster() + const { records, blocked, stopCalls, stop, basis } = await droppedFromRoster() - const outcome = await run.adapter.stopBackgroundTasks({ - sessionId: 'session-1', - fence: 7, - taskIds: agentChildWorkStopTargets(records(), 'agent-1') - }) + const outcome = await stop('agent-1') - expect(outcome).toEqual({ cancelled: true }) + expect(outcome).toMatchObject({ cancelled: true }) expect(stopCalls()).toEqual([{ subtype: 'stop_task', params: { taskId: 'agent-1' } }]) // The CLI acknowledges a task it no longer runs with no frame: the acknowledgement ends it. expect(records()).toMatchObject([{ membership: 'settled', outcome: 'cancelled' }]) + expect(basis()).toEqual(['stop-acknowledged']) expect(blocked()).toBeNull() }) it('stops every task the strip offers a stop when none is named', async () => { - const { run, records, blocked, stopCalls } = await droppedFromRoster() + const { records, blocked, stopCalls, stop, basis } = await droppedFromRoster() - const outcome = await run.adapter.stopBackgroundTasks({ - sessionId: 'session-1', - fence: 7, - taskIds: agentChildWorkStopTargets(records()) - }) + const outcome = await stop() - expect(outcome).toEqual({ cancelled: true }) + expect(outcome).toMatchObject({ cancelled: true }) expect(stopCalls()).toEqual([{ subtype: 'stop_task', params: { taskId: 'agent-1' } }]) expect(records()).toMatchObject([{ membership: 'settled', outcome: 'cancelled' }]) + expect(basis()).toEqual(['stop-acknowledged']) expect(blocked()).toBeNull() }) it('leaves a task that ended on its own frame as it ended: its row offers no stop', async () => { - const { run, records, stopCalls } = await droppedFromRoster() + const { run, records, stopCalls, stop } = await droppedFromRoster() run.replay([ { at: 7_000, @@ -95,25 +111,17 @@ describe('a background Stop acts on the host child records', () => { } ]) - const outcome = await run.adapter.stopBackgroundTasks({ - sessionId: 'session-1', - fence: 7, - taskIds: agentChildWorkStopTargets(records(), 'agent-1') - }) + const outcome = await stop('agent-1') // The finished row offers no stop, so nothing reaches the CLI and the outcome stands. - expect(outcome).toEqual({ cancelled: false }) + expect(outcome).toMatchObject({ cancelled: false }) expect(stopCalls()).toEqual([]) expect(records()).toMatchObject([{ membership: 'settled', outcome: 'succeeded' }]) }) it('lets the task’s own completion, written before the acknowledgement, replace the Stop', async () => { - const { run, records } = await droppedFromRoster() - await run.adapter.stopBackgroundTasks({ - sessionId: 'session-1', - fence: 7, - taskIds: agentChildWorkStopTargets(records(), 'agent-1') - }) + const { run, records, stop } = await droppedFromRoster() + await stop('agent-1') expect(records()).toMatchObject([{ membership: 'settled', outcome: 'cancelled' }]) // The completion the CLI wrote before its answer, which the SDK delivered after it. diff --git a/src/main/claude/claude-captured-wake-frames.test-fixture.ts b/src/main/claude/claude-captured-wake-frames.test-fixture.ts new file mode 100644 index 00000000000..62a19ca45b0 --- /dev/null +++ b/src/main/claude/claude-captured-wake-frames.test-fixture.ts @@ -0,0 +1,125 @@ +// Claude CLI 2.1.280 stream-json capture, cut to the frames and fields the child-work path reads. +// Ids and prompts are replaced; the frame order and the relative clock (`at`, ms after the spawn) +// are the captured ones. + +import { + result, + says, + spawn, + system, + toolResult, + type CapturedFrame +} from './claude-captured-frame-builders.test-fixture' + +/** A background agent backgrounds its own 40 s shell and ends its turn; the CLI reports it + * completed, then starts it again under the same spawn call when the shell ends, and it ends again + * with its answer. CLI 2.1.280. */ +export const WAKES_AFTER_ITS_SHELL: CapturedFrame[] = [ + { + at: 0, + frame: spawn( + 'toolu_agent', + 'Agent', + { description: 'Sleep 40 seconds then reply', run_in_background: true }, + null + ) + }, + { + at: 15, + frame: system('task_started', { + task_id: 'agent-1', + tool_use_id: 'toolu_agent', + description: 'Sleep 40 seconds then reply', + subagent_type: 'general-purpose', + is_backgrounded: true, + task_type: 'local_agent' + }) + }, + { at: 30, frame: toolResult('toolu_agent', 'Async agent launched successfully.', null) }, + { at: 1_944, frame: result('success') }, + { + at: 4_343, + frame: spawn( + 'toolu_shell', + 'Bash', + { + command: 'sleep 40', + description: 'Wait 40 seconds in background', + run_in_background: true + }, + 'toolu_agent' + ) + }, + { + at: 4_431, + frame: system('task_started', { + task_id: 'shell-1', + owned_by_subagent: true, + tool_use_id: 'toolu_shell', + description: 'Wait 40 seconds in background', + is_backgrounded: true, + task_type: 'local_bash' + }) + }, + { + at: 4_441, + frame: toolResult( + 'toolu_shell', + 'Command running in background with ID: shell-1.', + 'toolu_agent' + ) + }, + { + at: 11_913, + frame: system('task_updated', { task_id: 'agent-1', patch: { status: 'completed' } }) + }, + { + at: 11_913, + frame: system('task_notification', { + task_id: 'agent-1', + tool_use_id: 'toolu_agent', + status: 'completed', + summary: '`sleep 40` is still running in the background.', + usage: { total_tokens: 17_928, tool_uses: 2, duration_ms: 11_901 } + }) + }, + { + at: 44_454, + frame: system('task_updated', { task_id: 'shell-1', patch: { status: 'completed' } }) + }, + { + at: 44_454, + frame: system('task_notification', { + task_id: 'shell-1', + tool_use_id: 'toolu_shell', + status: 'completed', + summary: 'Background command "Wait 40 seconds in background" completed (exit code 0)' + }) + }, + { + at: 44_460, + frame: system('task_started', { + task_id: 'agent-1', + tool_use_id: 'toolu_agent', + description: 'Sleep 40 seconds then reply', + subagent_type: 'general-purpose', + is_backgrounded: true, + task_type: 'local_agent' + }) + }, + { at: 46_198, frame: says('agent-1-final', 'DONE', 'toolu_agent') }, + { + at: 46_198, + frame: system('task_updated', { task_id: 'agent-1', patch: { status: 'completed' } }) + }, + { + at: 46_199, + frame: system('task_notification', { + task_id: 'agent-1', + tool_use_id: 'toolu_agent', + status: 'completed', + summary: 'DONE', + usage: { total_tokens: 18_181, tool_uses: 2, duration_ms: 46_184 } + }) + } +] diff --git a/src/main/claude/claude-child-work-decoder.test.ts b/src/main/claude/claude-child-work-decoder.test.ts index ae4ca6a9c38..084fd6eac0e 100644 --- a/src/main/claude/claude-child-work-decoder.test.ts +++ b/src/main/claude/claude-child-work-decoder.test.ts @@ -145,7 +145,7 @@ describe('Claude child-work decoder', () => { ]) }) - it('reports a start for an ended child as a restart, and a late start of its run as nothing', () => { + it('reports the run a completion ended waking under the same spawn call as that run again', () => { const decoder = decoderWith( backgroundAgent, system('task_notification', { @@ -155,6 +155,29 @@ describe('Claude child-work decoder', () => { }) ) decoder.observe(backgroundAgent) + decoder.observe(system('task_progress', { task_id: 'agent-bg', last_tool_name: 'Read' })) + expect(decoder.drain(500)).toEqual([ + expect.objectContaining({ + type: 'live', + restart: true, + child: expect.objectContaining({ + handle: { idKind: 'task_id', id: 'agent-bg', runId: 'toolu_bg' } + }) + }), + expect.objectContaining({ type: 'live' }) + ]) + }) + + it('reports a start for an ended child as a restart, and a late start of a stopped run as nothing', () => { + const decoder = decoderWith( + backgroundAgent, + system('task_notification', { + task_id: 'agent-bg', + tool_use_id: 'toolu_bg', + status: 'stopped' + }) + ) + decoder.observe(backgroundAgent) decoder.observe(system('task_updated', { task_id: 'agent-bg', patch: { description: 'Late' } })) decoder.observe(system('task_progress', { task_id: 'agent-bg', last_tool_name: 'Read' })) expect(decoder.drain(500)).toEqual([]) @@ -185,22 +208,12 @@ describe('Claude child-work decoder', () => { expect(decoder.drain(500)).toEqual([]) }) - it('bounds the live children it tracks', () => { + it('admits every live child: only the host store bounds them', () => { const decoder = new ClaudeChildWorkDecoder() - for (let index = 0; index < 257; index += 1) { + for (let index = 0; index < 300; index += 1) { decoder.observe({ ...backgroundAgent, task_id: `agent-${index}` }) } - expect(decoder.drain(500)).toHaveLength(256) - }) - - it('ends what still runs as stopped when Orca ends the session, and only then', () => { - const decoder = decoderWith(backgroundAgent) - decoder.stopLive() - decoder.clear() - expect(decoder.drain(500)).toEqual([ - expect.objectContaining({ type: 'ended', outcome: 'cancelled' }), - { type: 'session-ended', observedAt: 500 } - ]) + expect(decoder.drain(500)).toHaveLength(300) }) it('marks the end of the provider session', () => { diff --git a/src/main/claude/claude-child-work-decoder.ts b/src/main/claude/claude-child-work-decoder.ts index ce975e8d39f..6cdab01d8f3 100644 --- a/src/main/claude/claude-child-work-decoder.ts +++ b/src/main/claude/claude-child-work-decoder.ts @@ -3,16 +3,15 @@ // A child is live from its `task_started` until its own terminal `task_updated` or // `task_notification`; nothing else ends it. A roster (`background_tasks_changed`), a turn ending // or a spawn call returning is the parent's view of the child, not the child's, and the CLI sends -// every child its own terminal frame, so none of them settles one. When Orca ends the session and -// proves the close, what is still live is stopped (`stopLive`); any other end leaves it for the -// host to settle as unknown. A live child blocked on a permission request reads waiting; no task -// frame says so, so the caller hands over which children a request blocks. Edges wait here until -// the frame is journaled, then take the host clock. +// every child its own terminal frame, so none of them settles one. When the session ends, however +// it ends, the host settles what is still live as unknown. A live child blocked on a permission +// request reads waiting; no task frame says so, so the caller hands over which children a request +// blocks. Edges wait here until the frame is journaled, then take the host clock. import type { AgentChildWorkKind, AgentChildWorkOperation, - AgentChildWorkOutcomeBasis + AgentChildWorkOutcome } from '../../shared/agent-status-child-work' import type { AgentChildWorkEvidence, @@ -30,14 +29,15 @@ import { taskUsageTotalTokens, terminalClaudeTaskRunState } from './claude-background-task-frames' +import { isClaudeSubagentTask } from './claude-subagent-task-frames' import { claudeChildWorkOutcome, claudeTaskProgressFacts, type ClaudeTaskFacts } from './claude-child-work-evidence' -/** Live children one session tracks, and the ended ids it remembers to recognise a restart. */ -const MAX_LIVE_TASKS = 256 +/** Ended ids remembered to recognise a restart. Live children take no bound here: refusing one + * would hide a running child; the host's global limits bound them. */ const MAX_ENDED_TASKS = 256 type DecodedClaudeTask = { @@ -85,8 +85,11 @@ function observation( export class ClaudeChildWorkDecoder { private readonly live = new Map() - /** Ended task ids, with the spawn call each ended under. */ - private readonly ended = new Map() + /** Ended task ids, with the spawn call each ended under and how. */ + private readonly ended = new Map< + string, + { run: string | undefined; outcome: AgentChildWorkOutcome } + >() /** The children a pending permission request blocks, as the caller last derived them. */ private waiting: ReadonlySet = new Set() private pending: PendingEdge[] = [] @@ -119,16 +122,6 @@ export class ClaudeChildWorkDecoder { } } - /** The CLI acknowledged a stop of this task, and one it no longer knows is acknowledged with no - * frame at all, so the acknowledgement ends a task still live here. Provisionally: the SDK hands - * the acknowledgement over as soon as it reads it, ahead of frames the CLI wrote before it, so - * the task's own ending may still arrive and replace this one. */ - stopAcknowledged(id: string): void { - if (this.live.has(id)) { - this.end(id, 'stopped', { basis: 'stop-acknowledged' }) - } - } - /** Which children a pending request blocks, re-derived by the caller before every drain. A * live child that starts or stops waiting is a live edge of its own. */ observeWaiting(waiting: ReadonlySet): void { @@ -142,14 +135,6 @@ export class ClaudeChildWorkDecoder { } } - /** Orca ended the session and proved the close: what still ran is stopped. The - * ending is Orca's, not the child's, so a frame of the child's own still replaces it. */ - stopLive(): void { - for (const id of this.live.keys()) { - this.end(id, 'stopped', { basis: 'stop-acknowledged' }) - } - } - /** The provider session is gone: the host settles what it still holds live. */ clear(): void { this.live.clear() @@ -171,12 +156,15 @@ export class ClaudeChildWorkDecoder { } const toolUseId = taskAliasId(message.tool_use_id) const existing = this.live.get(id) - const restart = !existing && this.ended.has(id) - // A start under the spawn call a run ended with is that run's late start, not a new run. - if (restart && toolUseId !== undefined && this.ended.get(id) === toolUseId) { + const ended = existing ? undefined : this.ended.get(id) + const restart = ended !== undefined + // Under the spawn call a run ended with, a start after it completed is that run waking (the + // CLI ends a helper's turn while work it started runs, then resumes it when that work ends); + // after any other ending it is the ended run's late start. + if (ended?.run !== undefined && ended.run === toolUseId && ended.outcome !== 'succeeded') { return } - const kind = classifyClaudeBackgroundTaskKind(message.task_type) + const kind = startedTaskKind(message) this.upsert( id, { @@ -251,9 +239,6 @@ export class ClaudeChildWorkDecoder { facts: ClaudeTaskFacts, restart = false ): void { - if (!this.live.has(id) && this.live.size >= MAX_LIVE_TASKS) { - return - } this.ended.delete(id) this.live.set(id, task) this.report(id, task, facts, restart) @@ -276,20 +261,19 @@ export class ClaudeChildWorkDecoder { runId?: string lastMessage?: string totalTokens?: number - basis?: AgentChildWorkOutcomeBasis } ): void { const task = this.live.get(id) this.live.delete(id) + const outcome = claudeChildWorkOutcome(status) this.ended.delete(id) - this.ended.set(id, reported.runId ?? task?.toolUseId) + this.ended.set(id, { run: reported.runId ?? task?.toolUseId, outcome }) if (this.ended.size > MAX_ENDED_TASKS) { const [oldest] = this.ended.keys() if (oldest !== undefined) { this.ended.delete(oldest) } } - const outcome = claudeChildWorkOutcome(status) this.pending.push((observedAt) => ({ type: 'ended', observedAt, @@ -299,9 +283,15 @@ export class ClaudeChildWorkDecoder { ...(reported.runId !== undefined ? { runId: reported.runId } : {}) }, outcome, - ...(reported.basis ? { basis: reported.basis } : {}), ...(reported.lastMessage ? { lastMessage: reported.lastMessage } : {}), ...(reported.totalTokens !== undefined ? { totalTokens: reported.totalTokens } : {}) })) } } + +/** Releases predating `task_type` still name an agent in `subagent_type`. */ +function startedTaskKind(message: Record): AgentChildWorkKind { + return isClaudeSubagentTask(message) + ? 'agent' + : classifyClaudeBackgroundTaskKind(message.task_type) +} diff --git a/src/main/claude/claude-command-turn.test.ts b/src/main/claude/claude-command-turn.test.ts index d777e8a423d..d8dfb63aaad 100644 --- a/src/main/claude/claude-command-turn.test.ts +++ b/src/main/claude/claude-command-turn.test.ts @@ -246,32 +246,6 @@ describe('the command turn at each point the ordinary result path threads throug expect(finished.translator.currentTurnId).not.toBeNull() }) - it("settles a child still working in the command's turn when the command ends", () => { - const { begin, frame, rows } = harness() - begin('compact-input') - frame({ - type: 'system', - subtype: 'task_started', - task_id: 'task-1', - task_type: 'local_agent', - description: 'Map the lane' - }) - frame({ type: 'result', subtype: 'success', is_error: false }) - const group = rows.get( - agentJournalItemKey({ - provider: 'orca', - clientMessageId: `claude-subagents:${CAPTURED_COMPACT_SESSION_ID}:${RUNNING.turnId}` - }) - ) - const agents = - group?.body.kind === 'message' - ? group.body.blocks.flatMap((block) => - block.type === 'subagent-group' ? block.agents : [] - ) - : [] - expect(agents).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - }) - it("records the context window the command's result reports on the command's turn", () => { const { begin, frame, commandTurn } = harness() begin('compact-input') diff --git a/src/main/claude/claude-message-journaling.ts b/src/main/claude/claude-message-journaling.ts index bc7cb347ca0..7dbade47848 100644 --- a/src/main/claude/claude-message-journaling.ts +++ b/src/main/claude/claude-message-journaling.ts @@ -30,7 +30,7 @@ import type { createClaudeStreamedBlockRegistry } from './claude-streamed-block- import type { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints' import type { ClaudeStreamedThinking } from './claude-streamed-thinking' import type { ClaudeProvisionalRowCorrections } from './claude-provisional-row-corrections' -import type { ClaudeSubagentRoster } from './claude-subagent-roster' +import type { ClaudeSubagentIdentities } from './claude-subagent-identities' import { claudeTurnOpenedBySendEcho, type ClaudeTurnSource } from './claude-turn-opening' import type { ClaudeOpenTurn } from './claude-open-turn' import type { ClaudeAuthenticationFailures } from './claude-authentication-failures' @@ -41,7 +41,7 @@ export type ClaudeMessageJournalContext = { streamedBlocks: ReturnType streamedText: ReturnType streamedThinking: ClaudeStreamedThinking - subagents: ClaudeSubagentRoster + subagents: ClaudeSubagentIdentities toolOrigins: ClaudeToolOriginRegistry backgroundTasks: ClaudeBackgroundTaskRows providerFallback: ClaudeProviderFrameFallback @@ -67,9 +67,6 @@ export function journalClaudeMessage( return false } let changed = false - if (envelope.parentToolUseId) { - ctx.subagents.observeChildActivity(envelope.parentToolUseId) - } const results = claudeToolResults(envelope) // Everything this envelope journals belongs to whoever produced the envelope. // A child's rows live in the parent's journal, so without this the parent's @@ -139,7 +136,6 @@ export function journalClaudeMessage( const resultIdentity = claudeToolIdentity(envelope.sessionId, result.toolUseId) const resultBody = claudeToolBody({ tool, result }) ctx.sink.appendItem(resultIdentity, resultBody, stamp(resultIdentity, resultBody)) - ctx.subagents.observeToolResult(result.toolUseId, result.failed) if ( results.length === 1 && envelope.parentToolUseId === null && diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index 84b4ed44c68..9732ca2ffb3 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -24,8 +24,8 @@ import { createClaudeTurnOpener, type ClaudeTurnSource } from './claude-turn-ope export type ClaudeOpenTurnDeps = { sink: StructuredAgentSessionEventSink - /** Settles the superseded turn's children; they get no later event of their own. */ - settleChildren: (groupKey: string | null) => void + /** The superseded turn's spawn calls get no announcement now. */ + turnSuperseded: () => void /** Ends what the ending turn left open, at the instant it ended; no later frame will. */ endOpenWork: (completedAt: number) => void /** A turn opening moves the conversation on. */ @@ -80,10 +80,6 @@ export class ClaudeOpenTurn { : AGENT_JOURNAL_THREAD_SCOPE } - get groupKey(): string | null { - return this.current ? `${this.current.sessionId}:${this.current.turnId}` : null - } - get isOpen(): boolean { return this.current !== null } @@ -112,7 +108,7 @@ export class ClaudeOpenTurn { open(turn: ClaudeCurrentTurn, observedAt: number): void { this.deps.onOpen?.() if (this.current) { - this.deps.settleChildren(this.groupKey) + this.deps.turnSuperseded() this.deps.endOpenWork(observedAt) this.publish(this.current, { state: 'interrupted', @@ -130,7 +126,7 @@ export class ClaudeOpenTurn { beginCommand(turn: ClaudeCurrentTurn): void { this.deps.onOpen?.() if (this.current) { - this.deps.settleChildren(this.groupKey) + this.deps.turnSuperseded() this.deps.endOpenWork(turn.startedAt) this.publish(this.current, { state: 'interrupted', diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index c971b2333c6..a62b09a7ccf 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -21,7 +21,6 @@ export type ClaudeResultJournalContext = Pick< | 'sink' | 'streamedBlocks' | 'streamedText' - | 'subagents' | 'providerFallback' | 'corrections' | 'turn' @@ -36,7 +35,6 @@ export function journalClaudeResult( sink, streamedBlocks, streamedText, - subagents, providerFallback, corrections, turn, @@ -65,9 +63,7 @@ export function journalClaudeResult( if (settlesTurn) { prompts.retryPendingCancellations() turn.suppressReopenOnFailure(message.is_error === true) - // The turn is over however it ended, so a foreground child still - // reported as working will never be settled by an event. - subagents.settleTurn(turn.groupKey) + corrections.abandon() context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt, leftToStop)) // The turn is over. A block still awaiting its final keeps the text the // flush above journaled, but its live state goes: an interrupted turn diff --git a/src/main/claude/claude-structured-child-work-captures.test.ts b/src/main/claude/claude-structured-child-work-captures.test.ts index cdad53f8823..c7541bb901c 100644 --- a/src/main/claude/claude-structured-child-work-captures.test.ts +++ b/src/main/claude/claude-structured-child-work-captures.test.ts @@ -87,7 +87,8 @@ describe('Claude child work from captured frame orders', () => { }) }) - it('stops what still runs when Orca ends the session, and keeps every record', async () => { + // A close Orca proves is still loss of contact: no Stop was accepted, so no outcome is claimed. + it('leaves how they ended unknown when Orca ends the session, and keeps every record', async () => { const run = await producer(hostWithParent()) const [running] = until(MOVED_TO_BACKGROUND, 51_275) run.replay(running) @@ -99,8 +100,8 @@ describe('Claude child work from captured frame orders', () => { outcome })) ).toEqual([ - { description: 'Run 45s sleep command', membership: 'settled', outcome: 'cancelled' }, - { description: 'Sleep 45 seconds then print 1', membership: 'settled', outcome: 'cancelled' } + { description: 'Run 45s sleep command', membership: 'settled', outcome: 'unknown' }, + { description: 'Sleep 45 seconds then print 1', membership: 'settled', outcome: 'unknown' } ]) }) diff --git a/src/main/claude/claude-structured-child-work-producer.test.ts b/src/main/claude/claude-structured-child-work-producer.test.ts index 86d1d9a7ac4..7210a654037 100644 --- a/src/main/claude/claude-structured-child-work-producer.test.ts +++ b/src/main/claude/claude-structured-child-work-producer.test.ts @@ -18,8 +18,8 @@ afterEach(() => vi.restoreAllMocks()) describe('Claude structured child-work producer', () => { it('delivers evidence only after the journal wrote and published the frame', async () => { const { send, records } = await producer() - send(toolUse('toolu_bg', 'Agent', { description: 'Audit the build' })) - const deliveries = send( + const spawnCall = send(toolUse('toolu_bg', 'Agent', { description: 'Audit the build' })) + const announced = send( system('task_started', { task_id: 'agent-bg', tool_use_id: 'toolu_bg', @@ -29,8 +29,8 @@ describe('Claude structured child-work producer', () => { is_backgrounded: true }) ) - const kinds = deliveries.map((delivery) => delivery.kind) - // The frame's own rows, then the parent's republished row, and only then its children. + const kinds = [...spawnCall, ...announced].map((delivery) => delivery.kind) + // The spawn call's own row, then the parent's republished row, and only then its child. expect(kinds.filter((kind) => kind === 'journal').length).toBeGreaterThan(0) expect(kinds.lastIndexOf('publish')).toBeGreaterThan(kinds.lastIndexOf('journal')) expect(kinds.at(-1)).toBe('evidence') @@ -195,7 +195,8 @@ describe('Claude structured child-work producer', () => { step.check?.() } await adapter.closeSession('session-1') - // Orca ended the session: what still ran is stopped with it, and every record stays. + // Orca ended the session: what still ran ends unknown, as any lost contact does, and every + // record stays. const summary = records().map((record) => ({ description: record.description, membership: record.membership, @@ -210,7 +211,7 @@ describe('Claude structured child-work producer', () => { generation: 1 }, { description: 'npm test', membership: 'settled', outcome: 'cancelled', generation: 1 }, - { description: 'Audit the build', membership: 'settled', outcome: 'cancelled', generation: 2 } + { description: 'Audit the build', membership: 'settled', outcome: 'unknown', generation: 2 } ]) expect(adapter.backgroundTaskState('session-1')).toBeUndefined() }) diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index 115c22f31d7..ff34648136e 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -308,34 +308,28 @@ describe('stopClaudeBackgroundTasks', () => { return { childWork, connection: { stopTask } } as unknown as ClaudeSession } - it('stops exactly the ids the host named, and ends each acknowledged one', async () => { + it('stops exactly the ids the host named, and reports each acknowledged one', async () => { const childWork = liveChildWork(['task-agent', 'task-bash']) const stopTask = vi.fn(async (_taskId: string, _options?: { timeoutMs?: number }) => {}) const session = stoppingSession(childWork, stopTask) + const acknowledge = vi.fn() await expect( - stopClaudeBackgroundTasks(session, 5_000, () => true, ['task-agent', 'task-bash']) + stopClaudeBackgroundTasks( + session, + 5_000, + () => true, + ['task-agent', 'task-bash'], + acknowledge + ) ).resolves.toEqual({ cancelled: true }) + expect(acknowledge.mock.calls).toEqual([[{ taskId: 'task-agent' }], [{ taskId: 'task-bash' }]]) expect(stopTask.mock.calls).toEqual([ ['task-agent', { timeoutMs: 5_000 }], ['task-bash', { timeoutMs: 5_000 }] ]) - expect(childWork.drain(2)).toEqual([ - { - type: 'ended', - observedAt: 2, - handle: { idKind: 'task_id', id: 'task-agent' }, - outcome: 'cancelled', - basis: 'stop-acknowledged' - }, - { - type: 'ended', - observedAt: 2, - handle: { idKind: 'task_id', id: 'task-bash' }, - outcome: 'cancelled', - basis: 'stop-acknowledged' - } - ]) + // The host settles what was acknowledged; the decoder records nothing of its own. + expect(childWork.drain(2)).toEqual([]) }) it('stops issuing requests when ownership changes between tasks', async () => { @@ -356,15 +350,16 @@ describe('stopClaudeBackgroundTasks', () => { throw new ClaudeControlRequestError('stop_task', 'Task task-live is owned by another agent') }) const session = stoppingSession(childWork, stopTask) + const acknowledge = vi.fn() await expect( - stopClaudeBackgroundTasks(session, undefined, () => true, ['task-live']) + stopClaudeBackgroundTasks(session, undefined, () => true, ['task-live'], acknowledge) + ).resolves.toEqual({ cancelled: false }) + await expect( + stopClaudeBackgroundTasks(session, undefined, () => true, [], acknowledge) ).resolves.toEqual({ cancelled: false }) - expect(childWork.drain(2)).toEqual([]) - await expect(stopClaudeBackgroundTasks(session, undefined, () => true, [])).resolves.toEqual({ - cancelled: false - }) expect(stopTask).toHaveBeenCalledTimes(1) + expect(acknowledge).not.toHaveBeenCalled() }) it('stops the remaining tasks after one request fails, then reports the failure', async () => { @@ -376,34 +371,39 @@ describe('stopClaudeBackgroundTasks', () => { } }) const session = stoppingSession(childWork, stopTask) + const acknowledge = vi.fn() await expect( - stopClaudeBackgroundTasks(session, undefined, () => true, ['task-1', 'task-2']) + stopClaudeBackgroundTasks(session, undefined, () => true, ['task-1', 'task-2'], acknowledge) ).rejects.toBe(lost) expect(stopTask.mock.calls.map(([taskId]) => taskId)).toEqual(['task-1', 'task-2']) - expect(childWork.drain(2)).toMatchObject([ - { type: 'ended', handle: { id: 'task-2' }, outcome: 'cancelled' } - ]) + // The failure is reported, and the stop it did not touch is still named. + expect(acknowledge.mock.calls).toEqual([[{ taskId: 'task-2' }]]) }) it('stops asking after a request times out: a CLI not answering costs one deadline, not one per task', async () => { const childWork = liveChildWork(['task-0', 'task-1', 'task-2', 'task-3', 'task-4']) // Never answered, behind the real deadline wrapper, with a short deadline. - const stopTask = vi.fn((_taskId: string, options?: { timeoutMs?: number }) => - runClaudeControl('stop_task', () => new Promise(() => {}), options?.timeoutMs) + const stopTask = vi.fn((taskId: string, options?: { timeoutMs?: number }) => + taskId === 'task-0' + ? Promise.resolve() + : runClaudeControl('stop_task', () => new Promise(() => {}), options?.timeoutMs) ) const session = stoppingSession(childWork, stopTask) + const acknowledge = vi.fn() await expect( - stopClaudeBackgroundTasks(session, 50, () => true, [ - 'task-0', - 'task-1', - 'task-2', - 'task-3', - 'task-4' - ]) + stopClaudeBackgroundTasks( + session, + 50, + () => true, + ['task-0', 'task-1', 'task-2', 'task-3', 'task-4'], + acknowledge + ) ).rejects.toBeInstanceOf(ClaudeControlRequestTimeoutError) - expect(stopTask).toHaveBeenCalledTimes(1) + expect(stopTask).toHaveBeenCalledTimes(2) + // What the CLI accepted before it went quiet is still named. + expect(acknowledge.mock.calls).toEqual([[{ taskId: 'task-0' }]]) expect(childWork.drain(2)).toEqual([]) }) }) diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 03648b4bbb6..3342b2351ac 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -1,4 +1,5 @@ import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' +import type { AgentChildWorkStopAcknowledgement } from '../../shared/agent-child-work-stop-targets' import type { ClaudePromptClaim } from './claude-structured-prompt-replies' import { ClaudeControlRequestError } from './claude-stream-json-connection' import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-requests' @@ -61,13 +62,15 @@ export async function cancelClaudeTurn( } } -/** Stops each task the host named. An acknowledged stop ends the task's record: the CLI answers - * success for a task it no longer knows without sending that task any frame. */ +/** Stops each task the host named, and names each the CLI accepted the stop of as it does: the host + * ends that run provisionally, since the CLI answers success for a task it no longer knows without + * sending that task any frame. */ export async function stopClaudeBackgroundTasks( session: ClaudeSession, timeoutMs: number | undefined, isCurrent: ClaudeTurnCancellationGuard, - taskIds: readonly string[] + taskIds: readonly string[], + acknowledge: (acknowledgement: AgentChildWorkStopAcknowledgement) => void = () => {} ): Promise<{ cancelled: boolean }> { let cancelled = false // A failed request for one task still leaves the others to stop; it is reported after them. A @@ -80,8 +83,8 @@ export async function stopClaudeBackgroundTasks( } try { await session.connection.stopTask(taskId, { timeoutMs }) - session.childWork.stopAcknowledged(taskId) cancelled = true + acknowledge({ taskId }) } catch (error) { if (error instanceof ClaudeControlRequestTimeoutError) { throw error @@ -97,8 +100,7 @@ export async function stopClaudeBackgroundTasks( return { cancelled } } -/** Stops the tasks while `session` is still the one the host asked about, then publishes its child - * work so the host's records follow every acknowledged stop. */ +/** Stops the tasks while `session` is still the one the host asked about. */ export async function stopCurrentClaudeBackgroundTasks(input: { sessions: ReadonlyMap session: ClaudeSession @@ -106,7 +108,7 @@ export async function stopCurrentClaudeBackgroundTasks(input: { fence: number taskIds: readonly string[] timeoutMs: number | undefined - publishChildWork: (session: ClaudeSession) => void + acknowledge?: (acknowledgement: AgentChildWorkStopAcknowledgement) => void }): Promise<{ cancelled: boolean }> { const { sessions, session, sessionId, fence } = input const acquisitionGeneration = session.acquisitionGeneration @@ -114,13 +116,13 @@ export async function stopCurrentClaudeBackgroundTasks(input: { sessions.get(sessionId) === session && session.fence === fence && session.acquisitionGeneration === acquisitionGeneration - try { - return await stopClaudeBackgroundTasks(session, input.timeoutMs, isCurrent, input.taskIds) - } finally { - if (isCurrent()) { - input.publishChildWork(session) - } - } + return stopClaudeBackgroundTasks( + session, + input.timeoutMs, + isCurrent, + input.taskIds, + input.acknowledge + ) } export async function answerClaudePrompt( diff --git a/src/main/claude/claude-structured-journal-translation-context-usage.test.ts b/src/main/claude/claude-structured-journal-translation-context-usage.test.ts index 534600c71ea..6d969eb3a0b 100644 --- a/src/main/claude/claude-structured-journal-translation-context-usage.test.ts +++ b/src/main/claude/claude-structured-journal-translation-context-usage.test.ts @@ -339,7 +339,7 @@ describe('context usage on journal rows', () => { const onOpen = vi.fn() const turn = new ClaudeOpenTurn({ sink: journal().sink, - settleChildren: () => {}, + turnSuperseded: () => {}, endOpenWork: () => {}, onOpen }) diff --git a/src/main/claude/claude-structured-journal-translation-subagents.test.ts b/src/main/claude/claude-structured-journal-translation-subagents.test.ts index e1da571eb34..697da0d2a9d 100644 --- a/src/main/claude/claude-structured-journal-translation-subagents.test.ts +++ b/src/main/claude/claude-structured-journal-translation-subagents.test.ts @@ -3,18 +3,12 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' -import type { - NativeChatSubagentEntry, - NativeChatSubagentGroupBlock -} from '../../shared/native-chat-types' import type { StructuredAgentSessionAppendOptions, StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' -const GROUP_ITEM_ID = 'claude-subagents:claude-session:user-1' - /** The union's other arms carry no client message id, so reading one narrows. */ function orcaClientMessageId(identity: AgentJournalItemIdentity): string | null { return identity.provider === 'orca' ? identity.clientMessageId : null @@ -51,27 +45,6 @@ function harness() { const run = scheduled as (() => void) | null run?.() } - const groupRows = () => - items.filter((item) => orcaClientMessageId(item.identity) === GROUP_ITEM_ID) - const agentsOf = (body: AgentJournalItemBody | undefined): NativeChatSubagentEntry[] => { - if (!body || body.kind !== 'message') { - return [] - } - const block = body.blocks.find( - (candidate): candidate is NativeChatSubagentGroupBlock => candidate.type === 'subagent-group' - ) - return block ? block.agents : [] - } - /** The last roster row written for one group, so a test can read a group that - * is no longer the live one. */ - const rosterIn = (groupId: string): NativeChatSubagentEntry[] => - agentsOf( - items.findLast((item) => orcaClientMessageId(item.identity) === `claude-subagents:${groupId}`) - ?.body - ) - const rosterOf = (turnUuid: string): NativeChatSubagentEntry[] => - rosterIn(`claude-session:${turnUuid}`) - const roster = (): NativeChatSubagentEntry[] => agentsOf(groupRows().at(-1)?.body) const fallbackRows = (): AgentJournalItemBody[] => items .filter((item) => (orcaClientMessageId(item.identity) ?? '').startsWith('provider-frame:')) @@ -92,10 +65,6 @@ function harness() { return { translator, items, - groupRows, - roster, - rosterIn, - rosterOf, fallbackRows, linkageOfProse, writesOfProse, @@ -158,8 +127,8 @@ function resultFrame() { } describe('claude journal translation — subagents', () => { - it('rosters a spawned subagent and settles it on the spawn call result', () => { - const { translator, roster, fallbackRows } = harness() + it('writes no subagent rows of its own: the host saves each one from its records', () => { + const { translator, items, fallbackRows } = harness() translator.handle(userTurn('user-1')) translator.handle( systemFrame('task_started', { @@ -170,141 +139,20 @@ describe('claude journal translation — subagents', () => { description: 'Map the lane' }) ) - expect(roster()).toEqual([ - expect.objectContaining({ id: 'task-1', label: 'Map the lane', state: 'working' }) - ]) - // The task frames stay status-chrome, so none of them prints an opcode row. + translator.handle(spawnResult('user-2', 'toolu_1')) + translator.handle(resultFrame()) + translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) + expect( + items.filter( + (item) => + item.body.kind === 'message' && + item.body.blocks.some( + (block) => block.type === 'subagent' || block.type === 'subagent-group' + ) + ) + ).toEqual([]) + // The task frames stay status chrome, so none of them prints an opcode row. expect(fallbackRows()).toEqual([]) - translator.handle(spawnResult('user-2', 'toolu_1')) - expect(roster()).toEqual([expect.objectContaining({ state: 'completed' })]) - }) - - it('marks a child still working at turn end unverifiable', () => { - const { translator, roster } = harness() - translator.handle(userTurn('user-1')) - translator.handle( - systemFrame('task_started', { - task_id: 'task-1', - task_type: 'local_agent', - description: 'Map the lane' - }) - ) - translator.handle(resultFrame()) - expect(roster()).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - }) - - it('leaves a backgrounded child running past the end of its turn', () => { - const { translator, roster } = harness() - translator.handle(userTurn('user-1')) - translator.handle( - systemFrame('task_started', { - task_id: 'task-1', - tool_use_id: 'toolu_1', - task_type: 'local_agent', - description: 'Watch the build', - is_backgrounded: true - }) - ) - // A backgrounded spawn returns its tool result immediately; the child runs on. - translator.handle(spawnResult('user-2', 'toolu_1')) - translator.handle(resultFrame()) - expect(roster()).toEqual([expect.objectContaining({ state: 'working' })]) - translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) - expect(roster()).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - }) - - it('keeps a backgrounded shell task out of the roster entirely', () => { - const { translator, groupRows } = harness() - translator.handle(userTurn('user-1')) - translator.handle( - systemFrame('task_started', { - task_id: 'task-bash', - tool_use_id: 'toolu_bash', - task_type: 'local_bash', - description: 'sleep 20', - is_backgrounded: true - }) - ) - translator.handle(resultFrame()) - expect(groupRows()).toEqual([]) - }) - - it('shows a subagent whose release announces no task frames, from its child traffic', () => { - const { translator, roster } = harness() - translator.handle(userTurn('user-1')) - translator.handle({ - type: 'message' as const, - sessionId: 'orca-session', - message: { - type: 'assistant', - uuid: 'child-1', - session_id: 'claude-session', - parent_tool_use_id: 'toolu_1', - message: { role: 'assistant', content: [{ type: 'text', text: 'looking' }] } - } - }) - expect(roster()).toEqual([ - expect.objectContaining({ id: 'toolu_1', label: 'subagent', state: 'working' }) - ]) - }) - - it('settles the turn a new turn superseded, and leaves the new one running', () => { - const { translator, rosterOf } = harness() - translator.handle(userTurn('user-1')) - translator.handle( - systemFrame('task_started', { - task_id: 'task-1', - task_type: 'local_agent', - description: 'First turn' - }) - ) - // A second turn starts with no result frame for the first: the first turn - // ends here, and nothing else will ever name its group again. - translator.handle(userTurn('user-2')) - translator.handle( - systemFrame('task_started', { - task_id: 'task-2', - task_type: 'local_agent', - description: 'Second turn' - }) - ) - expect(rosterOf('user-1')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - expect(rosterOf('user-2')).toEqual([expect.objectContaining({ state: 'working' })]) - }) - - it('does not let an unrelated turn end settle a child announced outside a turn', () => { - const { translator, rosterIn } = harness() - // No turn is live yet, so this child has no turn key to belong to. - translator.handle( - systemFrame('task_started', { - task_id: 'task-early', - task_type: 'local_agent', - description: 'Before the turn' - }) - ) - translator.handle(userTurn('user-1')) - translator.handle(resultFrame()) - expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'working' })]) - // The outcome still lands, which a latched `unverifiable` would have lost. - translator.handle( - systemFrame('task_updated', { task_id: 'task-early', patch: { status: 'completed' } }) - ) - expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'completed' })]) - }) - - it('settles a child left outside every turn when the session ends', () => { - const { translator, rosterIn } = harness() - translator.handle( - systemFrame('task_started', { - task_id: 'task-early', - task_type: 'local_agent', - description: 'Before the turn' - }) - ) - translator.handle(userTurn('user-1')) - translator.handle(resultFrame()) - translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) - expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) }) }) @@ -829,19 +677,4 @@ describe('claude journal translation — which agent produced a row', () => { expect(linkageOfProse('shell output')).toMatchObject({ producerKind: 'background' }) }) - - it("leaves the spawn-group row the parent's, though a child frame triggered it", () => { - // Hazard: the group row is written from a child's frame but describes the - // PARENT's children. Stamping it as a child's would hide the roster from - // the very row that owns it. Asserted on the written row, not on a field. - const { translator, groupRows } = harness() - translator.handle(userTurn('user-1')) - translator.handle(spawnCall('assistant-1', 'toolu_1')) - translator.handle(announce('task-1', 'toolu_1')) - translator.handle(childProse('child-1', 'toolu_1', 'looking')) - - const groupRow = groupRows().at(-1) - expect(groupRow).toBeDefined() - expect(groupRow?.options?.agentId).toBeUndefined() - }) }) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 37029917137..779f1581460 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -16,8 +16,8 @@ import { ClaudeBackgroundTaskRows } from './claude-background-task-rows' import { ClaudeToolOriginRegistry } from './claude-tool-origin-registry' import { ClaudeProvisionalRowCorrections } from './claude-provisional-row-corrections' import { createClaudeStreamedThinking } from './claude-streamed-thinking' -import { ClaudeSubagentRoster } from './claude-subagent-roster' -import { ClaudeJournaledRoster } from './claude-subagent-journaled-roster' +import { ClaudeSubagentIdentities } from './claude-subagent-identities' +import { ClaudeJournaledSubagents } from './claude-subagent-journaled-identities' import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity' import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints' import { @@ -63,7 +63,9 @@ export function createClaudeJournalTranslator( const streamedBlocks = createClaudeStreamedBlockRegistry() const turn = new ClaudeOpenTurn({ sink: deps.sink, - settleChildren: (groupKey) => subagents.settleTurn(groupKey), + // No announcement can come after its turn, so a correction still owed is never coming; the + // rows keep the stamp they already have. + turnSuperseded: () => corrections.abandon(), endOpenWork: (completedAt) => streamedThinking.finishOpen(completedAt), onOpen: () => context.markActivity() }) @@ -71,18 +73,12 @@ export function createClaudeJournalTranslator( const fallbackId = deps.fallbackIdPrefix ?? 'acquisition' const providerFallback = createClaudeProviderFrameFallback(deps.sink, fallbackId, turnScope) const toolOrigins = new ClaudeToolOriginRegistry() - const journaled = new ClaudeJournaledRoster(() => deps.sink.journalLinkage?.() ?? null) - const subagents = new ClaudeSubagentRoster({ - sink: deps.sink, - currentGroupKey: () => turn.groupKey, - currentTurnScope: turnScope, + const journaled = new ClaudeJournaledSubagents(() => deps.sink.journalLinkage?.() ?? null) + const subagents = new ClaudeSubagentIdentities({ isForwardedParentTool: (toolUseId) => toolOrigins.has(toolUseId), childOwnerRefOf: (toolUseId) => toolOrigins.childOwnerRef(toolUseId), - // A restarted provider continues the roster earlier runs journaled. - journaled, - // A settled group can receive no further announcement, so a correction - // still owed is never coming; the rows keep the stamp they already have. - onIdentitiesFinal: () => corrections.abandon() + // A restarted provider continues what earlier runs journaled. + journaled }) const childQueries = claudeChildToolQueries({ tools, toolOrigins, linkage: subagents.linkage }) const corrections = new ClaudeProvisionalRowCorrections({ @@ -104,7 +100,7 @@ export function createClaudeJournalTranslator( const backgroundTasks = new ClaudeBackgroundTaskRows({ sink: deps.sink, isForwardedParentTool: (toolUseId) => toolOrigins.has(toolUseId), - rosteredByEarlierRun: (taskId) => journaled.groupOf(taskId) !== null, + rosteredByEarlierRun: journaled.isSubagent, // A typed task row is provider output: journaling one must open a resumed // turn, or the session shows the row while reading idle. openOutputTurn: (frame, observedAt) => @@ -188,7 +184,7 @@ export function createClaudeJournalTranslator( if (event.type === 'ended') { prompts.retryPendingCancellations() flush() - subagents.settleSession() + corrections.abandon() backgroundTasks.settleSession() // The host saw the child end, so the turn's end is observed, not lost. Whether it was a // person's Stop is the journal's Stop event to say (`turnEndAfterStop`), else it is news. @@ -272,7 +268,7 @@ export function createClaudeJournalTranslator( // after an idle belongs to a turn, and suppressing it would read as // idle while the agent works. if (claudeSessionStateEndsTurn(event.message)) { - subagents.settleTurn(turn.groupKey) + corrections.abandon() // No verdict: the CLI said the turn is over, not how it ended. turn.settle({ state: 'completed', completedAt: event.observedAt ?? Date.now() }) } @@ -317,6 +313,7 @@ export function createClaudeJournalTranslator( tools.clear() prompts.clear() streamedBlocks.clear() + corrections.abandon() subagents.dispose() backgroundTasks.dispose() toolOrigins.clear() diff --git a/src/main/claude/claude-structured-real-cli-stop-task.test.ts b/src/main/claude/claude-structured-real-cli-stop-task.test.ts new file mode 100644 index 00000000000..719e41c5027 --- /dev/null +++ b/src/main/claude/claude-structured-real-cli-stop-task.test.ts @@ -0,0 +1,183 @@ +// A targeted Stop against the real CLI: `stop_task` on one live background agent. The CLI's +// acknowledgement is what the host settles that run on until the agent's own ending arrives. +// Runs only where a signed-in Claude CLI exists, like the rest of the real-CLI suite. + +import { randomUUID } from 'node:crypto' +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' +import { + realClaudeAuthenticated, + realClaudeAvailable, + realClaudeCommand, + realClaudeLaunchHome +} from './claude-real-cli-availability-test-support' +import { claudeStartupSettled } from './claude-structured-session-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' + +const SESSION_ID = 'real-cli-stop-task' +const SETTINGS = JSON.stringify({ permissions: { allow: ['Bash(sleep:*)'] } }) + +function identity(providerSessionId: string): AgentSessionJournalIdentity { + return { + sessionId: SESSION_ID, + workspaceId: 'real-cli-stop-task-workspace', + hostId: 'local', + agent: 'claude', + providerHandle: claudeProviderHandle(providerSessionId, null) + } +} + +async function until(check: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (check()) { + return true + } + await new Promise((resolve) => setTimeout(resolve, 200)) + } + return check() +} + +/** The task frames the CLI sent about one task, in order. */ +function taskFrames(events: readonly ClaudeStructuredSessionEvent[], taskId: string) { + return events.flatMap((event) => + event.type === 'message' && + event.message.type === 'system' && + typeof event.message.subtype === 'string' && + event.message.subtype.startsWith('task_') && + event.message.task_id === taskId + ? [event.message] + : [] + ) +} + +describe.skipIf(!realClaudeAvailable)('Claude structured real CLI targeted stop', () => { + it.skipIf(!realClaudeAuthenticated)( + 'acknowledges stop_task on one live background agent', + async () => { + const providerSessionId = randomUUID() + const { claudeConfigDir, env } = realClaudeLaunchHome() + const cwd = await mkdtemp(join(tmpdir(), 'orca-real-stop-task-')) + const events: ClaudeStructuredSessionEvent[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + } + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: realClaudeCommand, + options: { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, settings: SETTINGS }, + sessionId: providerSessionId + }, + cwd, + env, + claudeConfigDir, + providerSessionId, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + onEvent: (event) => events.push(event), + readProcessStartTime: async () => 1 + }) + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-stop-task', + events: sink + }) + await claudeStartupSettled(adapter, SESSION_ID) + await expect( + adapter.dispatch({ + sessionId: SESSION_ID, + clientMessageId: 'client-A', + body: { + kind: 'message', + role: 'user', + blocks: [ + { + type: 'text', + text: 'Use the Agent tool once, with run_in_background set to true and description "Sleeper", asking the subagent to run `sleep 120` with the Bash tool. Do not wait for it. Then reply exactly: LAUNCHED' + } + ] + }, + requestedAt: Date.now(), + fence: 1 + }) + ).resolves.toEqual({ state: 'admitted' }) + + const started = () => + events.flatMap((event) => + event.type === 'message' && + event.message.type === 'system' && + event.message.subtype === 'task_started' && + event.message.task_type === 'local_agent' && + typeof event.message.task_id === 'string' + ? [event.message.task_id] + : [] + ) + expect(await until(() => started().length > 0, 90_000)).toBe(true) + const [taskId] = started() + // Give the subagent time to reach its sleep, so the stop lands on a live run. + await new Promise((resolve) => setTimeout(resolve, 5_000)) + + const acknowledged: unknown[] = [] + const outcome = await adapter.stopBackgroundTasks({ + sessionId: SESSION_ID, + fence: 1, + taskIds: [taskId], + acknowledge: (acknowledgement) => void acknowledged.push(acknowledgement) + }) + expect(outcome).toMatchObject({ cancelled: true }) + expect(acknowledged).toEqual([{ taskId }]) + + // The CLI sends the run its own ending, and it says the run stopped. + const ended = await until( + () => + taskFrames(events, taskId).some( + (frame) => + frame.subtype === 'task_notification' || + (frame.subtype === 'task_updated' && + typeof frame.patch === 'object' && + frame.patch !== null && + 'status' in frame.patch) + ), + 30_000 + ) + expect(ended).toBe(true) + const frames = taskFrames(events, taskId).map((frame) => ({ + subtype: frame.subtype, + status: + frame.status ?? + (typeof frame.patch === 'object' && frame.patch !== null && 'status' in frame.patch + ? frame.patch.status + : undefined) + })) + console.info('[real-cli stop_task] task frames', JSON.stringify(frames)) + const endings = frames.filter((frame) => frame.status !== undefined) + expect(endings.length).toBeGreaterThan(0) + expect( + endings.every((frame) => + ['stopped', 'killed', 'cancelled'].includes(String(frame.status)) + ) + ).toBe(true) + } finally { + await adapter.closeAll() + } + }, + 180_000 + ) +}) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 6f8bc901989..bbc1d6fea64 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -197,8 +197,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda ...input, sessions: this.sessions, session: this.session(input.sessionId), - timeoutMs: this.deps.requestTimeoutMs, - publishChildWork: (session) => this.publishChildWork(input.sessionId, session) + timeoutMs: this.deps.requestTimeoutMs }) /** The tracker's own roster, for the tests that compare it with the host's child records. No * production code reads it: what runs, what a Stop reaches and what blocks a command are all diff --git a/src/main/claude/claude-structured-session-close.test.ts b/src/main/claude/claude-structured-session-close.test.ts index 5c36899ade6..8619569ca84 100644 --- a/src/main/claude/claude-structured-session-close.test.ts +++ b/src/main/claude/claude-structured-session-close.test.ts @@ -144,8 +144,8 @@ describe('Claude published session close lifecycle', () => { expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) expect(events.filter((event) => event.type === 'handle')).toHaveLength(0) expect(disposeTranslator).toHaveBeenCalledOnce() - // A close Orca asked for stops the child still running, then the host hears the session end. - expect(childWork).toEqual(['live', 'ended', 'session-ended']) + // A close Orca asked for claims no outcome for the child still running: only the session ends. + expect(childWork).toEqual(['live', 'session-ended']) // Nothing of the dead child stays indexed for a retry. await expect(adapter.closeSession('session-1')).resolves.toBe(true) diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 93a607c13f3..f6b4b0a8c9e 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -119,12 +119,8 @@ async function finalizeClaudePublishedSession( rootExitVerdict = cleanupError } // Queues the session's ending for the host's child records; the adapter delivers it after close. - // A proven close stopped what still ran: on POSIX the whole tree was seen gone; on Windows Claude - // left after its stdin ended, or taskkill reported its tree terminated. Any other end, like an - // exit of the session's own, leaves how it ended unknown. - if (connectionClosed === true) { - session.childWork.stopLive() - } + // Even a proven close is Orca losing contact, not a Stop the CLI accepted: what still ran ends + // with no outcome anyone reported. session.childWork.clear() session.backgroundTasks.clear() // The exit is proven, so the session ends now. Saving its resume point is bookkeeping that diff --git a/src/main/claude/claude-subagent-identities.ts b/src/main/claude/claude-subagent-identities.ts new file mode 100644 index 00000000000..a3eb74619ff --- /dev/null +++ b/src/main/claude/claude-subagent-identities.ts @@ -0,0 +1,101 @@ +// Which Claude subagent a frame belongs to, read from Claude's task frames. +// +// The host's child records decide what every subagent is doing and what its saved row says; the +// task-frame decoder is what feeds them. This only names children, so each row a child writes is +// filed under it: `task_id` is the key, and each `tool_use_id` is one run of it, since Claude +// re-announces a resumed task under a NEW tool id. An announcement that a task is not a subagent +// is remembered too, so later frames for it are never read as a subagent's. + +import { BoundedMap } from '../../shared/bounded-map' +import { ClaudeSubagentIds } from './claude-subagent-id-aliases' +import type { ClaudeJournaledSubagents } from './claude-subagent-journaled-identities' +import { + ClaudeSubagentLinkage, + type ClaudeAgentLinkageSource, + type ClaudeSubagentLinkageSource +} from './claude-subagent-linkage' +import { readClaudeSubagentTaskFrame } from './claude-subagent-task-frames' + +/** Children whose current run attribution remembers; an older one's rows read as its first run. */ +const MAX_KNOWN_SUBAGENTS = 1024 + +type KnownSubagent = { run: string | null; attempt: number } + +export type ClaudeSubagentIdentitiesDeps = { + /** Whether a tool id was forwarded at the TOP level. A child parented to one + * was spawned by a call the transcript shows, so its announcement is still + * expected; a child parented to anything else names an id that only ever + * existed inside a sidechain, which this CLI will never announce. */ + isForwardedParentTool?: (toolUseId: string) => boolean + /** The reference naming the child that journaled a tool call, when a child + * did. It is how a grandchild's row reaches the agent that spawned it. */ + childOwnerRefOf?: (toolUseId: string) => string | null + /** What earlier provider runs of this session journaled. */ + journaled?: ClaudeJournaledSubagents +} + +export class ClaudeSubagentIdentities { + private readonly ids: ClaudeSubagentIds + private readonly known = new BoundedMap({ + maxEntries: MAX_KNOWN_SUBAGENTS + }) + /** Who produced a row, for every write site journaling this session. */ + readonly linkage: ClaudeSubagentLinkageSource & ClaudeAgentLinkageSource + + constructor(private readonly deps: ClaudeSubagentIdentitiesDeps) { + this.ids = new ClaudeSubagentIds(deps.journaled?.canonical) + this.linkage = new ClaudeSubagentLinkage({ + ids: this.ids, + trackedFor: (canonicalId) => { + const attempt = + this.known.get(canonicalId)?.attempt ?? deps.journaled?.attempt(canonicalId) ?? 0 + return attempt > 0 ? { attempt } : null + }, + isForwardedParentTool: deps.isForwardedParentTool, + childOwnerRefOf: deps.childOwnerRefOf, + spawnRefOf: (canonicalId) => this.known.get(canonicalId)?.run ?? null + }) + } + + /** Consume a `message:system:task_*` frame. Returns false when it is not one. */ + observeSystemFrame(message: Record): boolean { + const frame = readClaudeSubagentTaskFrame(message) + if (!frame) { + return false + } + if (frame.excluded) { + for (const id of [frame.taskId, frame.toolUseId]) { + if (id !== null) { + this.ids.exclude(id) + } + } + return true + } + if (this.ids.isExcluded(frame.taskId, frame.toolUseId)) { + return true + } + if (frame.toolUseId) { + this.ids.alias(frame.toolUseId, frame.taskId) + } + if (frame.announcesSubagent) { + this.announce(frame.taskId, frame.toolUseId) + } + return true + } + + dispose(): void { + this.ids.clear() + this.known.clear() + } + + /** A new spawn call is the child's next run; one an earlier provider process ran it under was + * that process's, so this run follows the last one its rows record. */ + private announce(taskId: string, run: string | null): void { + const known = this.known.get(taskId) + if (known && (run === null || run === known.run)) { + return + } + const previous = known?.attempt ?? this.deps.journaled?.attempt(taskId) ?? 0 + this.known.set(taskId, { run, attempt: previous + 1 }) + } +} diff --git a/src/main/claude/claude-subagent-journaled-identities.ts b/src/main/claude/claude-subagent-journaled-identities.ts new file mode 100644 index 00000000000..126beaf43e4 --- /dev/null +++ b/src/main/claude/claude-subagent-journaled-identities.ts @@ -0,0 +1,86 @@ +// What earlier runs of this session recorded about its Claude subagents, re-derived from the rows +// they journaled: the task id each spawn call resolved to (a resumed child's frames still carry the +// old call), the latest run of each child, and which task ids were subagents at all. + +import type { AgentJournalProducerLinkage } from '../../shared/agent-session-journal-types' +import { namedSubagents } from '../../shared/native-chat-subagent-items' +import type { StructuredAgentSessionLinkageJournal } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { isBoundedClaudeTaskId } from './claude-background-task-tracker' + +type Reading = { + canonicalByToolUse: Map + attemptByAgent: Map + subagents: Set +} + +/** 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 ClaudeJournaledSubagents { + private journal: StructuredAgentSessionLinkageJournal | null = null + private epoch: string | null = null + private reading: Reading | null = null + + constructor(private readonly bound: () => StructuredAgentSessionLinkageJournal | null) {} + + /** The task id an earlier run resolved this spawn call to. */ + canonical = (toolUseId: string): string | null => + this.current()?.canonicalByToolUse.get(toolUseId) ?? null + + /** The latest run any row records for this child; 0 when no row names it. */ + attempt = (agentId: string): number => this.current()?.attemptByAgent.get(agentId) ?? 0 + + /** An earlier run saved this task as a subagent, so it is not a background task. */ + isSubagent = (taskId: string): boolean => this.current()?.subagents.has(taskId) === true + + 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 = read(journal) + } + return this.reading + } +} + +function read(journal: StructuredAgentSessionLinkageJournal): Reading { + const reading: Reading = { + canonicalByToolUse: new Map(), + attemptByAgent: new Map(), + subagents: new Set() + } + journal.visitItemsWithLinkage((_itemId, _sequence, body, linkage) => { + readAgentRow(reading, linkage) + if (body.kind === 'message' && linkage.agentId === undefined) { + for (const entry of body.blocks.flatMap(namedSubagents)) { + reading.subagents.add(entry.id) + } + } + }) + return reading +} + +function readAgentRow( + reading: Reading, + { agentId, providerParentRef, producerKind, attempt }: AgentJournalProducerLinkage +): void { + if (producerKind !== 'agent' || agentId === undefined) { + return + } + reading.attemptByAgent.set( + agentId, + Math.max(attempt ?? 1, reading.attemptByAgent.get(agentId) ?? 1) + ) + // A row stamped with its own reference was never resolved, so it names no alias. + if ( + providerParentRef !== undefined && + agentId !== providerParentRef && + isBoundedClaudeTaskId(agentId) && + isBoundedClaudeTaskId(providerParentRef) + ) { + reading.canonicalByToolUse.set(providerParentRef, agentId) + } +} diff --git a/src/main/claude/claude-subagent-journaled-roster.test.ts b/src/main/claude/claude-subagent-journaled-roster.test.ts deleted file mode 100644 index b6f806fbe89..00000000000 --- a/src/main/claude/claude-subagent-journaled-roster.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { - AgentJournalItemBody, - AgentJournalProducerLinkage, - AgentJournalTurnScope -} 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 { subagentGroupJournalBody } from '../native-chat/agent-session-journal/journal-subagent-group-body' -import { - ClaudeJournaledRoster, - claudeSubagentGroupIdentity -} from './claude-subagent-journaled-roster' - -type Row = { - itemId: string - sequence: number - body: AgentJournalItemBody - linkage: AgentJournalProducerLinkage & { turnScope?: AgentJournalTurnScope } -} - -const prose: AgentJournalItemBody = { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'working on it' }] -} - -const agentRow = (sequence: number, linkage: AgentJournalProducerLinkage): Row => ({ - itemId: `claude:claude-session:row-${sequence}`, - sequence, - body: prose, - linkage -}) - -const groupRow = ( - sequence: number, - groupId: string, - agents: NativeChatSubagentEntry[], - itemId = agentJournalItemKey(claudeSubagentGroupIdentity(groupId)), - turnScope?: AgentJournalTurnScope -): Row => ({ - itemId, - sequence, - body: subagentGroupJournalBody(groupId, agents), - linkage: turnScope ? { turnScope } : {} -}) - -const entry = (id: string, state: NativeChatSubagentEntry['state']): NativeChatSubagentEntry => ({ - id, - label: `Child ${id}`, - state, - startedAt: 1 -}) - -function linkageJournal( - epoch: string, - rows: () => Row[] -): { - epoch: string - visitItemsWithLinkage: StructuredAgentSessionLinkageJournal['visitItemsWithLinkage'] -} { - return { - epoch, - visitItemsWithLinkage: (visit) => { - for (const row of rows()) { - visit(row.itemId, row.sequence, row.body, row.linkage) - } - } - } -} - -describe('ClaudeJournaledRoster', () => { - it('recalls only an agent row that resolved its reference to another id', () => { - const rows = [ - agentRow(1, { agentId: 'task-a', providerParentRef: 'toolu_a', producerKind: 'agent' }), - // Stamped with its own reference: never resolved, so it names no alias. - agentRow(2, { agentId: 'toolu_raw', providerParentRef: 'toolu_raw', producerKind: 'agent' }), - // A backgrounded shell is not a subagent and never outlives its process. - agentRow(3, { - agentId: 'shell-1', - providerParentRef: 'toolu_shell', - producerKind: 'background' - }) - ] - const journal = linkageJournal('epoch-1', () => rows) - const journaled = new ClaudeJournaledRoster(() => journal) - expect(journaled.canonical('toolu_a')).toBe('task-a') - expect(journaled.canonical('toolu_raw')).toBeNull() - expect(journaled.canonical('toolu_shell')).toBeNull() - }) - - it('reads the latest run any row of a child records', () => { - const rows = [ - agentRow(1, { agentId: 'task-a', providerParentRef: 'toolu_a', producerKind: 'agent' }), - agentRow(2, { - agentId: 'task-a', - providerParentRef: 'toolu_a', - producerKind: 'agent', - attempt: 3 - }), - agentRow(3, { - agentId: 'task-a', - providerParentRef: 'toolu_a', - producerKind: 'agent', - attempt: 2 - }) - ] - const journal = linkageJournal('epoch-1', () => rows) - const journaled = new ClaudeJournaledRoster(() => journal) - expect(journaled.attempt('task-a')).toBe(3) - expect(journaled.attempt('task-unknown')).toBe(1) - }) - - it('hands an earlier run’s group over once, and places a child in the newest row listing it', () => { - const turnA: AgentJournalTurnScope = { - kind: 'turn', - turnItemId: 'claude:claude-session:turn-a' - } - const rows = [ - groupRow( - 10, - 'turn-a', - [entry('task-a', 'failed'), entry('task-b', 'failed')], - undefined, - turnA - ), - groupRow(20, 'turn-b', [entry('task-a', 'working')]), - // Another lane's group block under a key this roster never writes. - groupRow(30, 'turn-c', [entry('task-c', 'working')], 'orca:elsewhere') - ] - const journal = linkageJournal('epoch-1', () => rows) - const journaled = new ClaudeJournaledRoster(() => journal) - expect(journaled.groupOf('task-a')).toBe('turn-b') - expect(journaled.groupOf('task-b')).toBe('turn-a') - expect(journaled.groupOf('task-c')).toBeNull() - 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?.placement).toEqual(turnA) - // After that the roster's own copy is the newer one. - expect(journaled.claimGroup('turn-a')).toBeNull() - }) - - it('reads nothing before the journal is bound, and does not keep that answer', () => { - let bound: StructuredAgentSessionLinkageJournal | null = null - const journaled = new ClaudeJournaledRoster(() => bound) - expect(journaled.canonical('toolu_a')).toBeNull() - expect(journaled.claimGroup('turn-a')).toBeNull() - bound = linkageJournal('epoch-1', () => [ - agentRow(1, { agentId: 'task-a', providerParentRef: 'toolu_a', producerKind: 'agent' }), - groupRow(2, 'turn-a', [entry('task-a', 'failed')]) - ]) - expect(journaled.canonical('toolu_a')).toBe('task-a') - expect(journaled.claimGroup('turn-a')?.entries).toHaveLength(1) - }) - - it('re-reads the same journal once its epoch is replaced', () => { - let rows = [ - agentRow(1, { agentId: 'task-a', providerParentRef: 'toolu_a', producerKind: 'agent' }) - ] - const journal = linkageJournal('epoch-1', () => rows) - const journaled = new ClaudeJournaledRoster(() => journal) - expect(journaled.canonical('toolu_a')).toBe('task-a') - rows = [agentRow(1, { agentId: 'task-b', providerParentRef: 'toolu_b', producerKind: 'agent' })] - journal.epoch = 'epoch-2' - expect(journaled.canonical('toolu_a')).toBeNull() - expect(journaled.canonical('toolu_b')).toBe('task-b') - }) -}) diff --git a/src/main/claude/claude-subagent-journaled-roster.ts b/src/main/claude/claude-subagent-journaled-roster.ts deleted file mode 100644 index 3d5d5df6b4f..00000000000 --- a/src/main/claude/claude-subagent-journaled-roster.ts +++ /dev/null @@ -1,75 +0,0 @@ -// What earlier runs of this session left of the Claude subagent roster, re-derived -// from the rows they journaled. -// -// 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 type { - AgentJournalItemIdentity, - AgentJournalProducerLinkage, - AgentJournalTurnScope -} from '../../shared/agent-session-journal-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' - -/** 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 = JournaledSubagentSource & { - /** The task id an earlier run resolved this spawn call to. */ - canonical: (toolUseId: string) => string | null -} - -type ClaudeAgentRows = { - canonicalByToolUse: Map - attemptByAgent: Map -} - -export class ClaudeJournaledRoster implements ClaudeJournaledRosterSource { - private readonly rows: JournaledSubagentGroups - - 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.rows.extra()?.canonicalByToolUse.get(toolUseId) ?? null - - groupOf = (entryId: string): string | null => this.rows.groupOf(entryId) - - claimGroup = (groupId: string) => this.rows.claimGroup(groupId) - - attempt = (agentId: string): number => this.rows.extra()?.attemptByAgent.get(agentId) ?? 1 -} - -function readAgentRow( - reading: ClaudeAgentRows, - { agentId, providerParentRef, producerKind, attempt }: AgentJournalProducerLinkage -): void { - if (producerKind !== 'agent' || agentId === undefined) { - return - } - if (attempt !== undefined && attempt > (reading.attemptByAgent.get(agentId) ?? 1)) { - reading.attemptByAgent.set(agentId, attempt) - } - // A row stamped with its own reference was never resolved, so it names no alias. - if ( - providerParentRef !== undefined && - agentId !== providerParentRef && - isBoundedClaudeTaskId(agentId) && - isBoundedClaudeTaskId(providerParentRef) - ) { - reading.canonicalByToolUse.set(providerParentRef, agentId) - } -} 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 1a02e576984..e8bd07bc2a3 100644 --- a/src/main/claude/claude-subagent-resume-after-restart.test.ts +++ b/src/main/claude/claude-subagent-resume-after-restart.test.ts @@ -2,21 +2,11 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalRenderItem -} from '../../shared/agent-session-journal-types' -import { - isBackgroundTaskBlock, - isSubagentGroupBlock, - type NativeChatSubagentEntry -} from '../../shared/native-chat-types' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' 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 { 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' @@ -89,30 +79,6 @@ const taskFailed = (taskId: string, toolUseId: string) => status: 'failed' }) -const taskCompleted = (taskId: string, toolUseId: string) => - frame({ - type: 'system', - subtype: 'task_notification', - uuid: `completed-${taskId}`, - task_id: taskId, - tool_use_id: toolUseId, - status: 'completed' - }) - -/** A call a CHILD makes: its frame names the child's own spawn call as parent. */ -const childToolCall = ( - uuid: string, - parentToolUseId: string, - id: string, - input: Record -) => - frame({ - type: 'assistant', - uuid, - parent_tool_use_id: parentToolUseId, - message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Agent', input }] } - }) - const childSays = (uuid: string, parentToolUseId: string, text: string) => frame({ type: 'assistant', @@ -164,70 +130,6 @@ const proseRow = (journal: AgentSessionJournal, text: string): AgentJournalRende item.body.blocks.some((block) => block.type === 'text' && block.text === text) ) -type GroupRow = { groupId: string; revision: number; agents: NativeChatSubagentEntry[] } - -/** Every spawn-group row, as a reader sees it now. */ -const groupRows = (journal: AgentSessionJournal): GroupRow[] => - journal.snapshot().items.flatMap((item) => { - const block = item.body.kind === 'message' ? item.body.blocks.find(isSubagentGroupBlock) : null - return block ? [{ groupId: block.groupId, revision: item.revision, agents: block.agents }] : [] - }) - -const rowsListing = (journal: AgentSessionJournal, agentId: string): GroupRow[] => - groupRows(journal).filter((row) => row.agents.some((agent) => agent.id === agentId)) - -/** Every task a background-task row names. */ -const backgroundTaskIds = (journal: AgentSessionJournal): string[] => - journal - .snapshot() - .items.flatMap((item) => - item.body.kind === 'message' - ? item.body.blocks.flatMap((block) => (isBackgroundTaskBlock(block) ? [block.taskId] : [])) - : [] - ) - -/** Every id any spawn-group row names: what a transcript can put a name on. */ -const rosterIds = (journal: AgentSessionJournal): Set => - new Set(groupRows(journal).flatMap((row) => row.agents.map((agent) => agent.id))) - -/** An older build re-rostered a resumed child in the later turn's row, so two rows list it. */ -async function journalAnOlderBuildListedTwice(): Promise { - const journal = await openJournal() - const older = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - older.bind({ journal, fence: 1, publish: () => {} }) - const listed = (id: string, state: NativeChatSubagentEntry['state']) => ({ - id, - label: `Child ${id}`, - state, - startedAt: 1, - settledAt: 2 - }) - for (const [groupId, agents] of [ - [ - 'claude-session:turn-a', - [listed('agent-a', 'unverifiable'), listed('agent-b', 'unverifiable')] - ], - ['claude-session:turn-b', [listed('agent-a', 'completed')]] - ] as const) { - older.sink.appendItem( - claudeSubagentGroupIdentity(groupId), - subagentGroupJournalBody(groupId, agents), - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - } - await expect(older.drained()).resolves.toEqual({ ok: true }) - older.close() - return journal -} - -const agentAStateByRow = (journal: AgentSessionJournal): Record => - Object.fromEntries( - rowsListing(journal, 'agent-a').map((row) => [ - row.groupId, - row.agents.find((agent) => agent.id === 'agent-a')?.state - ]) - ) - describe('a Claude subagent resumed after its provider restarted', () => { it('keeps the canonical id its first run was given, so one child stays one child', async () => { const journal = await openJournal() @@ -284,234 +186,13 @@ describe('a Claude subagent resumed after its provider restarted', () => { agentId: 'agent-b', providerParentRef: 'toolu_spawn_b' }) - // The resume revised the child's own entry, as it would have without a restart: one row names - // it, and its rows say which run of it they are. - expect(rowsListing(journal, 'agent-a')).toEqual([ - expect.objectContaining({ - groupId: 'claude-session:turn-a', - agents: [ - expect.objectContaining({ id: 'agent-a', state: 'working' }), - expect.objectContaining({ id: 'agent-b', state: 'working' }) - ] - }) - ]) + // Its rows say which run of it they are. expect(proseRow(journal, 'A reads the rig recipes')?.attempt).toBeUndefined() expect(proseRow(journal, 'A restarts the receiver')?.attempt).toBe(2) expect(proseRow(journal, 'B checks the rig')?.attempt).toBe(2) - // What a reader sees: every child row names an agent a roster can name. - const named = rosterIds(journal) - const childRows = journal.snapshot().items.filter((item) => item.agentId !== undefined) - expect(childRows.length).toBeGreaterThan(0) - expect(childRows.filter((item) => !named.has(item.agentId ?? ''))).toEqual([]) await second.exit() }) - it('keeps the earlier run’s children in the roster row no turn owns', async () => { - // A backgrounded child spawns its own child after the parent's turn has ended, so the - // grandchild is rostered outside any turn; the next provider run does the same again. - const journal = await openJournal() - const first = acquire(journal) - first.translator.handle(userTurn('turn-a')) - first.translator.handle( - toolCall('spawn-c', 'toolu_spawn_c', 'Agent', { - description: 'Readiness', - run_in_background: true - }) - ) - first.translator.handle(taskStarted('agent-c', 'toolu_spawn_c', 'Readiness')) - first.translator.handle(toolResult('spawned-c', 'toolu_spawn_c', 'Async agent launched.')) - first.translator.handle(result('result-a')) - first.translator.handle( - childToolCall('c-spawn', 'toolu_spawn_c', 'toolu_spawn_x', { description: 'Sweep leases' }) - ) - first.translator.handle(taskStarted('agent-x', 'toolu_spawn_x', 'Sweep leases')) - first.translator.handle(childSays('x-1', 'toolu_spawn_x', 'X reads the hydration')) - first.translator.handle(taskCompleted('agent-x', 'toolu_spawn_x')) - first.translator.handle(taskCompleted('agent-c', 'toolu_spawn_c')) - await first.exit() - expect(rowsListing(journal, 'agent-x')).toEqual([ - expect.objectContaining({ groupId: 'outside-turn' }) - ]) - - const second = acquire(journal) - second.translator.handle(userTurn('turn-b')) - second.translator.handle( - toolCall('spawn-d', 'toolu_spawn_d', 'Agent', { - description: 'Checklist', - run_in_background: true - }) - ) - second.translator.handle(taskStarted('agent-d', 'toolu_spawn_d', 'Checklist')) - second.translator.handle(toolResult('spawned-d', 'toolu_spawn_d', 'Async agent launched.')) - second.translator.handle(result('result-b')) - second.translator.handle( - childToolCall('d-spawn', 'toolu_spawn_d', 'toolu_spawn_y', { description: 'Run checklist' }) - ) - second.translator.handle(taskStarted('agent-y', 'toolu_spawn_y', 'Run checklist')) - await second.settle() - - const outside = groupRows(journal).filter((row) => row.groupId === 'outside-turn') - expect(outside).toEqual([ - expect.objectContaining({ - agents: [ - expect.objectContaining({ id: 'agent-x', label: 'Sweep leases', state: 'completed' }), - expect.objectContaining({ id: 'agent-y', label: 'Run checklist', state: 'working' }) - ] - }) - ]) - expect(proseRow(journal, 'X reads the hydration')?.agentId).toBe('agent-x') - expect(rosterIds(journal).has('agent-x')).toBe(true) - await second.exit() - }) - - it('takes Claude’s verdict on a child the earlier run lost contact with, and reopens it on an announcement', async () => { - // The provider exited while its backgrounded child still ran, so the roster said contact was - // lost. The next run is told the child did not finish, works on its own, then messages it. - vi.useFakeTimers({ toFake: ['Date'] }) - vi.setSystemTime(1_000_000) - const journal = await openJournal() - const first = acquire(journal) - first.translator.handle(userTurn('turn-a')) - first.translator.handle( - toolCall('spawn-a', 'toolu_spawn_a', 'Agent', { - description: 'Grok PR', - run_in_background: true - }) - ) - first.translator.handle(taskStarted('agent-a', 'toolu_spawn_a', 'Grok PR')) - first.translator.handle(toolResult('spawned-a', 'toolu_spawn_a', 'Async agent launched.')) - first.translator.handle(childSays('a-1', 'toolu_spawn_a', 'A reads the docs')) - first.translator.handle(result('result-a')) - await first.exit() - const [lost] = rowsListing(journal, 'agent-a') - expect(lost?.agents).toEqual([ - expect.objectContaining({ id: 'agent-a', state: 'unverifiable' }) - ]) - const lostAt = lost?.agents[0]?.settledAt - expect(lostAt).toBe(1_000_000) - - // The restart comes much later: that is not when the child stopped. - vi.setSystemTime(5_000_000) - const second = acquire(journal) - second.translator.handle(userTurn('turn-b')) - second.translator.handle( - frame({ - type: 'system', - subtype: 'task_notification', - uuid: 'unfinished-a', - task_id: 'agent-a', - status: 'stopped', - summary: 'Background agent "Grok PR" didn\'t finish before the previous session ended' - }) - ) - second.translator.handle(toolCall('bash-1', 'toolu_bash_1', 'Bash', { command: 'git status' })) - second.translator.handle(toolResult('bashed-1', 'toolu_bash_1', 'clean')) - await second.settle() - // The child's own row takes the verdict, keeping when that run lost it; no second row names it. - expect(rowsListing(journal, 'agent-a')).toEqual([ - expect.objectContaining({ - groupId: 'claude-session:turn-a', - agents: [expect.objectContaining({ id: 'agent-a', state: 'stopped', settledAt: lostAt })] - }) - ]) - expect(backgroundTaskIds(journal)).toEqual([]) - - // The resume is a new run: its clock starts now, not at the first run's start. - vi.setSystemTime(5_100_000) - second.translator.handle( - toolCall('resume-a', 'toolu_message_a', 'SendMessage', { to: 'agent-a', message: 'resume' }) - ) - second.translator.handle(taskStarted('agent-a', 'toolu_message_a', 'Grok PR')) - second.translator.handle(childSays('a-2', 'toolu_spawn_a', 'A resumes the capture')) - await second.settle() - expect(rowsListing(journal, 'agent-a')).toEqual([ - expect.objectContaining({ - groupId: 'claude-session:turn-a', - agents: [expect.objectContaining({ id: 'agent-a', state: 'working', startedAt: 5_100_000 })] - }) - ]) - expect(rowsListing(journal, 'agent-a')[0]?.agents[0]?.settledAt).toBeUndefined() - expect(proseRow(journal, 'A resumes the capture')).toMatchObject({ - agentId: 'agent-a', - attempt: 2 - }) - await second.exit() - }) - - it('invents no stop time for a child whose host died without sweeping it', async () => { - vi.useFakeTimers({ toFake: ['Date'] }) - vi.setSystemTime(1_000_000) - const crashed = await openJournal() - const first = acquire(crashed) - first.translator.handle(userTurn('turn-a')) - first.translator.handle( - toolCall('spawn-a', 'toolu_spawn_a', 'Agent', { - description: 'Grok PR', - run_in_background: true - }) - ) - first.translator.handle(taskStarted('agent-a', 'toolu_spawn_a', 'Grok PR')) - await first.settle() - // The host dies: nothing sweeps the child, and reopening the journal can say only that - // contact was lost, not when. - await crashed.close() - const journal = await openJournal() - const [lost] = rowsListing(journal, 'agent-a')[0]?.agents ?? [] - expect(lost).toMatchObject({ id: 'agent-a', state: 'unverifiable' }) - expect(lost?.settledAt).toBeUndefined() - - vi.setSystemTime(5_000_000) - const second = acquire(journal) - second.translator.handle(userTurn('turn-b')) - second.translator.handle( - frame({ - type: 'system', - subtype: 'task_notification', - uuid: 'unfinished-a', - task_id: 'agent-a', - status: 'stopped' - }) - ) - await second.settle() - const [stopped] = rowsListing(journal, 'agent-a')[0]?.agents ?? [] - expect(stopped).toMatchObject({ id: 'agent-a', state: 'stopped' }) - expect(stopped?.settledAt).toBeUndefined() - await second.exit() - }) - - it('keeps one live entry for a child an older build listed in two rows', async () => { - const journal = await journalAnOlderBuildListedTwice() - const run = acquire(journal) - run.translator.handle(userTurn('turn-c')) - run.translator.handle(taskStarted('agent-a', 'toolu_message_a', 'Child agent-a')) - // A frame for a sibling only the other row lists brings that row in too. - run.translator.handle(taskCompleted('agent-b', 'toolu_spawn_b')) - run.translator.handle(taskCompleted('agent-a', 'toolu_message_a')) - await run.settle() - - // The copy the resume reopened is the one its outcome lands on; nothing is left running. - expect(agentAStateByRow(journal)).toEqual({ - 'claude-session:turn-a': 'unverifiable', - 'claude-session:turn-b': 'completed' - }) - await run.exit() - }) - - it('resumes a twice-listed child in its later row even when a sibling reaches the older row first', async () => { - const journal = await journalAnOlderBuildListedTwice() - const run = acquire(journal) - run.translator.handle(userTurn('turn-c')) - run.translator.handle(taskCompleted('agent-b', 'toolu_spawn_b')) - run.translator.handle(taskStarted('agent-a', 'toolu_message_a', 'Child agent-a')) - await run.settle() - - expect(agentAStateByRow(journal)).toEqual({ - 'claude-session:turn-a': 'unverifiable', - 'claude-session:turn-b': 'working' - }) - await run.exit() - }) - it('still files a child no journaled row resolved under its own reference, never the parent', async () => { const journal = await openJournal() const first = acquire(journal) diff --git a/src/main/claude/claude-subagent-roster-characterization.test.ts b/src/main/claude/claude-subagent-roster-characterization.test.ts deleted file mode 100644 index d48ca7f1ae4..00000000000 --- a/src/main/claude/claude-subagent-roster-characterization.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -// 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-retention.test.ts b/src/main/claude/claude-subagent-roster-retention.test.ts deleted file mode 100644 index 1549b2778b2..00000000000 --- a/src/main/claude/claude-subagent-roster-retention.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemBody, - type AgentJournalItemIdentity -} from '../../shared/agent-session-journal-types' -import { isSubagentGroupBlock } from '../../shared/native-chat-types' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { ClaudeSubagentRoster } from './claude-subagent-roster' -import type { ClaudeJournaledRosterSource } from './claude-subagent-journaled-roster' - -function harness(journaled?: ClaudeJournaledRosterSource) { - const rows = new Map() - let group = 'oldest' - let writes = 0 - let refuseAppend = false - let refusePublish = false - const append = (identity: AgentJournalItemIdentity, body: AgentJournalItemBody): void => { - if (identity.provider !== 'orca') { - throw new Error('unexpected roster identity') - } - rows.set(identity.clientMessageId, body) - writes++ - } - const sink: StructuredAgentSessionEventSink = { - appendItem: append, - appendTombstone: () => {}, - publish: () => {}, - tryAppendItem: (identity, body) => { - if (refuseAppend) { - return { accepted: false, reason: 'backpressure' } - } - append(identity, body) - return { accepted: true } - }, - tryPublish: () => - refusePublish ? { accepted: false, reason: 'backpressure' } : { accepted: true } - } - const roster = new ClaudeSubagentRoster({ - sink, - journaled, - currentGroupKey: () => group, - currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE - }) - const start = (id: string, tool = id, backgrounded = false): void => { - roster.observeSystemFrame({ - type: 'system', - subtype: 'task_started', - task_type: 'local_agent', - task_id: id, - tool_use_id: tool, - description: id, - is_backgrounded: backgrounded - }) - } - const finish = (id: string, tool = id): void => { - roster.observeSystemFrame({ - type: 'system', - subtype: 'task_notification', - task_id: id, - tool_use_id: tool, - status: 'completed' - }) - } - const agents = (key: string) => { - const body = rows.get(`claude-subagents:${key}`) - return body?.kind === 'message' ? (body.blocks.find(isSubagentGroupBlock)?.agents ?? []) : [] - } - const churn = (count: number, settled = true): void => { - for (let index = 0; index < count; index++) { - group = `later-${index}` - start(`later-child-${index}`) - if (settled) { - finish(`later-child-${index}`) - } - } - } - return { - roster, - start, - finish, - agents, - churn, - writes: () => writes, - setGroup: (key: string) => { - group = key - }, - refuse: (append: boolean, publish: boolean) => { - refuseAppend = append - refusePublish = publish - } - } -} - -describe('Claude shared settled-only roster retention', () => { - it('keeps every child of the oldest live group past 33 later spawn groups', () => { - const h = harness() - h.start('a', 'tool-a', true) - h.start('b', 'tool-b', true) - h.roster.settleTurn('oldest') - h.churn(34) - expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['working', 'working']) - h.finish('a', 'tool-a') - expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['completed', 'working']) - h.finish('b', 'tool-b') - expect(h.agents('oldest').map((entry) => entry.id)).toEqual(['a', 'b']) - expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['completed', 'completed']) - }) - - it('reaches every still-running group in the session-end sweep', () => { - const h = harness() - h.start('a', 'tool-a', true) - h.start('b') - h.churn(34, false) - h.roster.settleSession() - expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['unverifiable', 'unverifiable']) - expect(h.agents('later-33')[0].state).toBe('unverifiable') - }) - - it('ignores old announcements after settled eviction and alias-cache churn, but admits a new invocation', () => { - const h = harness() - h.start('a', 'first') - h.finish('a', 'first') - h.start('a', 'second') - h.finish('a', 'second') - h.churn(600) - const before = h.writes() - h.start('a', 'first') - h.start('a', 'second') - h.roster.observeSystemFrame({ - type: 'system', - subtype: 'task_started', - task_type: 'local_agent', - task_id: 'a' - }) - h.roster.observeChildActivity('first') - expect(h.writes()).toBe(before) - expect(h.roster.linkage.settledLinkageFor('first').linkage.agentId).toBe('a') - h.setGroup('resumed') - h.start('a', 'third') - expect(h.agents('resumed')).toEqual([expect.objectContaining({ id: 'a', state: 'working' })]) - }) - - it('keeps an inherited row reachable when child traffic precedes its resume announcement', () => { - let claimed = false - const h = harness({ - canonical: (tool) => (tool === 'original-spawn' ? 'a' : null), - groupOf: (id) => (['a', 'b'].includes(id) ? 'inherited' : null), - claimGroup: (group) => { - if (group !== 'inherited' || claimed) { - return null - } - claimed = true - return { - entries: ['a', 'b'].map((id) => ({ - id, - label: id, - state: 'completed', - startedAt: 1, - settledAt: 2 - })), - placement: AGENT_JOURNAL_THREAD_SCOPE - } - }, - attempt: () => 4 - }) - h.churn(32, false) - h.roster.observeChildActivity('original-spawn') - h.setGroup('resumed') - h.start('a', 'resume-tool', true) - expect(h.agents('inherited').map((entry) => entry.state)).toEqual(['working', 'completed']) - expect(h.agents('resumed')).toHaveLength(0) - expect(h.roster.linkage.settledLinkageFor('original-spawn').linkage).toMatchObject({ - agentId: 'a', - attempt: 5 - }) - }) - - for (const failure of ['append', 'publish']) { - it(`retains a terminal group whose final ${failure} was refused and retries it`, () => { - const h = harness() - h.start('a') - h.start('b') - h.refuse(failure === 'append', failure === 'publish') - h.finish('a') - h.finish('b') - h.refuse(false, false) - h.churn(34) - h.finish('b') - expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['completed', 'completed']) - const before = h.writes() - h.start('a') - expect(h.writes()).toBe(before) - }) - } -}) diff --git a/src/main/claude/claude-subagent-roster.test.ts b/src/main/claude/claude-subagent-roster.test.ts deleted file mode 100644 index 54b1db9750d..00000000000 --- a/src/main/claude/claude-subagent-roster.test.ts +++ /dev/null @@ -1,624 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' -import { describe, expect, it, vi } from 'vitest' -import type { - AgentJournalItemBody, - AgentJournalItemIdentity -} from '../../shared/agent-session-journal-types' -import type { - NativeChatSubagentEntry, - NativeChatSubagentGroupBlock -} from '../../shared/native-chat-types' -import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' -import { - createDeferredStructuredAgentSessionEventSink, - type StructuredAgentSessionEventSink -} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { ClaudeSubagentRoster } from './claude-subagent-roster' -import { withJournalQueueMembers } from '../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support' -import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' - -const TURN_1 = 'claude-session:turn-1' - -function agentsOf(body: AgentJournalItemBody | undefined): NativeChatSubagentEntry[] { - if (!body || body.kind !== 'message') { - return [] - } - const block = body.blocks.find( - (candidate): candidate is NativeChatSubagentGroupBlock => candidate.type === 'subagent-group' - ) - return block ? block.agents : [] -} - -function isGroupRow(identity: AgentJournalItemIdentity, groupId: string): boolean { - return identity.provider === 'orca' && identity.clientMessageId === `claude-subagents:${groupId}` -} - -function harness(groupKey: string | null = TURN_1) { - const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] - const tombstones: AgentJournalItemIdentity[] = [] - const sink: StructuredAgentSessionEventSink = { - appendItem: (identity, body) => items.push({ identity, body }), - appendTombstone: (identity) => tombstones.push(identity), - publish: vi.fn() - } - let clock = 1_000 - let pinnedClock: number | null = null - let key = groupKey - const roster = new ClaudeSubagentRoster({ - sink, - currentGroupKey: () => key, - currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE, - now: () => pinnedClock ?? (clock += 1) - }) - const roles = (): NativeChatSubagentEntry[] => agentsOf(items.at(-1)?.body) - /** The last row written for one group, so a test can read a row that is no - * longer the newest one. */ - const rolesIn = (groupId: string): NativeChatSubagentEntry[] => - agentsOf(items.findLast((item) => isGroupRow(item.identity, groupId))?.body) - return { - roster, - items, - tombstones, - roles, - rolesIn, - setGroupKey: (next: string | null) => { - key = next - }, - pinClock: (at: number) => { - pinnedClock = at - } - } -} - -function system(subtype: string, fields: Record): Record { - return { type: 'system', subtype, session_id: 'claude-session', ...fields } -} - -function started(fields: Record): Record { - return system('task_started', { task_type: 'local_agent', ...fields }) -} - -describe('ClaudeSubagentRoster', () => { - it('builds the row from task_started, with the fallback sentence beside the block', () => { - const { roster, items, roles } = harness() - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Review the diff' }) - ) - expect(items).toHaveLength(1) - expect(items[0]?.identity).toEqual({ - provider: 'orca', - clientMessageId: 'claude-subagents:claude-session:turn-1' - }) - const body = items[0]?.body - expect(body?.kind === 'message' && body.blocks[0]).toEqual({ - type: 'text', - text: 'Kicked off 1 subagent' - }) - expect(roles()).toEqual([ - expect.objectContaining({ id: 'task-1', label: 'Review the diff', state: 'working' }) - ]) - }) - - it('keeps a backgrounded shell task out of the roster', () => { - const { roster, items } = harness() - roster.observeSystemFrame( - system('task_started', { - task_id: 'task-bash', - tool_use_id: 'toolu_bash', - task_type: 'local_bash', - description: 'sleep 20', - is_backgrounded: true - }) - ) - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-bash', patch: { status: 'running' } }) - ) - // Its own frames carry a tool_use_id, so only the excluded-id memory stops it. - roster.observeChildActivity('toolu_bash') - expect(items).toHaveLength(0) - }) - - it('never renders a task marked skip_transcript', () => { - const { roster, items } = harness() - roster.observeSystemFrame( - started({ task_id: 'task-a', tool_use_id: 'toolu_a', skip_transcript: true }) - ) - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-a', patch: { status: 'completed' } }) - ) - roster.observeChildActivity('toolu_a') - expect(items).toHaveLength(0) - }) - - it('drops a provisional row once an announcement says the task is not a subagent', () => { - const { roster, items, tombstones, roles } = harness() - roster.observeChildActivity('toolu_bash') - expect(roles()).toHaveLength(1) - roster.observeSystemFrame( - system('task_started', { - task_id: 'task-bash', - tool_use_id: 'toolu_bash', - task_type: 'local_bash' - }) - ) - expect(tombstones).toEqual([ - { provider: 'orca', clientMessageId: 'claude-subagents:claude-session:turn-1' } - ]) - expect(items).toHaveLength(1) - }) - - it('does not duplicate a resumed task re-announced under a new tool_use_id', () => { - const { roster, roles } = harness() - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'toolu_first', description: 'Audit' }) - ) - roster.observeChildActivity('toolu_first') - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'toolu_second', description: 'Audit' }) - ) - roster.observeChildActivity('toolu_second') - expect(roles()).toEqual([ - expect.objectContaining({ id: 'task-1', label: 'Audit', state: 'working' }) - ]) - }) - - it('adopts a row built from child traffic when the announcement finally names it', () => { - const { roster, roles } = harness() - roster.observeChildActivity('toolu_1') - expect(roles()).toEqual([expect.objectContaining({ id: 'toolu_1', label: 'subagent' })]) - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' }) - ) - expect(roles()).toEqual([ - expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' }) - ]) - }) - - it('is idempotent: a repeated frame writes no new revision', () => { - const { roster, items } = harness() - const frame = started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Audit' }) - roster.observeSystemFrame(frame) - roster.observeSystemFrame(frame) - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-1', patch: { status: 'running' } }) - ) - expect(items).toHaveLength(1) - }) - - it('latches a terminal state against a later live report', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-1', patch: { status: 'failed' } }) - ) - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-1', patch: { status: 'running' } }) - ) - expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })]) - }) - - it('ignores an update for a task it never rostered', () => { - const { roster, items } = harness() - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-unknown', patch: { status: 'running' } }) - ) - expect(items).toHaveLength(0) - }) - - it('disambiguates children that share a description', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Explore' })) - roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Explore' })) - expect(roles().map((agent) => agent.label)).toEqual(['Explore', 'Explore 2']) - }) - - describe('turn end', () => { - it('leaves a backgrounded child working and marks a foreground one unverifiable', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-fg', description: 'Foreground' })) - roster.observeSystemFrame( - started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true }) - ) - roster.settleTurn(TURN_1) - expect(roles()).toEqual([ - expect.objectContaining({ label: 'Foreground', state: 'unverifiable' }), - expect.objectContaining({ label: 'Background', state: 'working' }) - ]) - }) - - it('never re-settles a child that already reported an outcome', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-1', patch: { status: 'completed' } }) - ) - roster.settleTurn(TURN_1) - expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })]) - }) - - it('sweeps backgrounded children only when the provider itself is gone', () => { - const { roster, roles } = harness() - roster.observeSystemFrame( - started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true }) - ) - roster.settleTurn(TURN_1) - roster.settleSession() - expect(roles()).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - }) - }) - - describe('spawn tool result', () => { - it('settles a foreground child', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'toolu_1' })) - roster.observeToolResult('toolu_1', false) - expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })]) - }) - - it('reports a failed spawn as failed', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'toolu_1' })) - roster.observeToolResult('toolu_1', true) - expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })]) - }) - - it('ignores the immediate result a backgrounded spawn returns', () => { - const { roster, roles } = harness() - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'toolu_1', is_backgrounded: true }) - ) - roster.observeToolResult('toolu_1', false) - expect(roles()).toEqual([expect.objectContaining({ state: 'working' })]) - }) - - it('ignores results for tools that are not spawn calls', () => { - const { roster, items } = harness() - roster.observeToolResult('toolu_read', false) - expect(items).toHaveLength(0) - }) - }) - - describe('label ordinals', () => { - it('never re-issues an ordinal a removed row gave up', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) - roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Audit' })) - // task-1 is re-announced as a shell task, so its row goes; reclaiming the - // ordinal it held would print a second 'Audit 2' beside the one still shown. - roster.observeSystemFrame( - system('task_started', { task_id: 'task-1', task_type: 'local_bash' }) - ) - roster.observeSystemFrame(started({ task_id: 'task-3', description: 'Audit' })) - expect(roles().map((agent) => agent.label)).toEqual(['Audit 2', 'Audit 3']) - }) - - it('never generates a label a provider-supplied one already took', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) - roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Audit' })) - // The provider's own name for the third child is the label the ordinal just - // generated for the second; a per-base counter would print it twice. - roster.observeSystemFrame(started({ task_id: 'task-3', description: 'Audit 2' })) - const labels = roles().map((agent) => agent.label) - expect(labels).toEqual(['Audit', 'Audit 2', 'Audit 2 2']) - expect(new Set(labels).size).toBe(labels.length) - }) - }) - - describe('child traffic for an id the CLI never declared', () => { - it('creates nothing once the CLI has announced any task at all', () => { - const { roster, items } = harness() - // A rejected announcement still proves this CLI declares what it spawns. - roster.observeSystemFrame( - system('task_started', { task_id: 'task-bash', task_type: 'local_bash' }) - ) - roster.observeChildActivity('toolu_never_announced') - expect(items).toHaveLength(0) - }) - - it('rejects an over-long provisional id instead of storing it as an entry id', () => { - const { roster, items } = harness() - // The announced path drops an id past `claudeTaskId`'s bound; the - // provisional one writes the same durable entry id, so it must too. - roster.observeChildActivity(`toolu_${'x'.repeat(512)}`) - expect(items).toHaveLength(0) - roster.observeChildActivity(`toolu_${'x'.repeat(500)}`) - expect(items).toHaveLength(1) - }) - - it('still rosters a subagent announced after a task the filter rejected', () => { - const { roster, roles } = harness() - roster.observeSystemFrame( - system('task_started', { task_id: 'task-bash', task_type: 'local_bash' }) - ) - // The gate closes the child-traffic fallback, never the announcement path. - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' }) - ) - roster.observeChildActivity('toolu_1') - expect(roles()).toEqual([ - expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' }) - ]) - }) - - it('leaves a grandchild parented inside the sidechain out of the roster', () => { - const { roster, roles } = harness() - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' }) - ) - roster.observeChildActivity('toolu_1') - // A tool the subagent itself ran: never announced, so never excluded either. - roster.observeChildActivity('toolu_inner') - expect(roles()).toEqual([ - expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' }) - ]) - }) - - it('still mints the provisional row for a release that announces no task', () => { - const { roster, roles } = harness() - roster.observeChildActivity('toolu_1') - // Not an announcement: the fallback path stays open for this release. - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-x', patch: { status: 'running' } }) - ) - roster.observeChildActivity('toolu_2') - expect(roles().map((agent) => agent.label)).toEqual(['subagent', 'subagent 2']) - }) - }) - - describe('groups that no later event can reach', () => { - it('retains live groups past the settled-history bound', () => { - const { roster, rolesIn, setGroupKey } = harness('turn-0') - for (let index = 0; index < 33; index += 1) { - setGroupKey(`turn-${index}`) - roster.observeSystemFrame(started({ task_id: `task-${index}`, description: 'Audit' })) - } - expect(rolesIn('turn-0')).toEqual([expect.objectContaining({ state: 'working' })]) - expect(rolesIn('turn-32')).toEqual([expect.objectContaining({ state: 'working' })]) - }) - - it('loses contact with a live child when the translator is disposed without an end', () => { - const { roster, roles } = harness() - roster.observeSystemFrame( - started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true }) - ) - roster.dispose() - expect(roles()).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - }) - - it('writes nothing on dispose when the session already settled', () => { - const { roster, items } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) - roster.settleSession() - const written = items.length - roster.dispose() - expect(items).toHaveLength(written) - }) - }) - - it('groups children outside any turn under their own row', () => { - const { roster, items } = harness(null) - roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) - expect(items[0]?.identity).toEqual({ - provider: 'orca', - clientMessageId: 'claude-subagents:outside-turn' - }) - }) -}) - -describe('ClaudeSubagentRoster — the turn that is ending', () => { - it('leaves a child announced outside any turn alone when an unrelated turn ends', () => { - const { roster, rolesIn, setGroupKey } = harness(null) - roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' })) - setGroupKey(TURN_1) - roster.observeSystemFrame(started({ task_id: 'task-turn', description: 'In turn' })) - roster.settleTurn(TURN_1) - expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'working' })]) - expect(rolesIn(TURN_1)).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - // `unverifiable` latches, so sweeping it above would have swallowed this. - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-early', patch: { status: 'completed' } }) - ) - expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'completed' })]) - }) - - it('sweeps the outside-turn group when a turn with no key of its own ends', () => { - const { roster, rolesIn } = harness(null) - roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' })) - roster.settleTurn(null) - expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - }) - - it('still settles an outside-turn child once the session itself ends', () => { - const { roster, rolesIn, setGroupKey } = harness(null) - roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' })) - setGroupKey(TURN_1) - roster.observeSystemFrame(started({ task_id: 'task-turn', description: 'In turn' })) - roster.settleTurn(TURN_1) - roster.settleSession() - expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - }) - - it('sweeps the turn that ended, not whichever turn is live now', () => { - const { roster, rolesIn, setGroupKey } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', description: 'First turn' })) - setGroupKey('claude-session:turn-2') - roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Second turn' })) - // Turn 1's result lands after turn 2 has already begun. - roster.settleTurn(TURN_1) - expect(rolesIn(TURN_1)).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - expect(rolesIn('claude-session:turn-2')).toEqual([ - expect.objectContaining({ state: 'working' }) - ]) - }) -}) - -describe('ClaudeSubagentRoster — through the real sink queue', () => { - it('lands every revision, not just the one that was already in flight', async () => { - const appended: AgentJournalItemBody[] = [] - let published = 0 - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a double for the journal members this path calls; the helper adds the in-order ones. - const journal = withJournalQueueMembers({ - appendItem: async (_identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { - appended.push(body) - return { cursor: { epoch: 'e', sequence: appended.length } } - }, - appendTombstone: async () => ({ epoch: 'e', sequence: 0 }) - }) as unknown as AgentSessionJournal - const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - deferred.bind({ - journal, - fence: 1, - publish: () => { - published += 1 - } - }) - const roster = new ClaudeSubagentRoster({ - sink: deferred.sink, - currentGroupKey: () => TURN_1, - currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE - }) - - // The first append is in flight while the rest are submitted; each must still land. - roster.observeSystemFrame(started({ task_id: 'task-1', description: 'One' })) - roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Two' })) - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-1', patch: { status: 'completed' } }) - ) - const drained = await deferred.drained() - - expect(drained).toEqual({ ok: true }) - expect(agentsOf(appended.at(-1))).toEqual([ - expect.objectContaining({ id: 'task-1', label: 'One', state: 'completed' }), - expect.objectContaining({ id: 'task-2', label: 'Two', state: 'working' }) - ]) - expect(published).toBeGreaterThan(0) - }) -}) - -describe('ClaudeSubagentRoster — authoritative outcomes and retained budgets', () => { - it('accepts a notification after the foreground turn lost contact', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1' })) - roster.settleTurn(TURN_1) - roster.observeSystemFrame( - system('task_notification', { task_id: 'task-1', status: 'completed' }) - ) - expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })]) - }) - - it('settles a background child from its notification without a task_updated', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', is_backgrounded: true })) - roster.settleTurn(TURN_1) - roster.observeSystemFrame(system('task_notification', { task_id: 'task-1', status: 'failed' })) - expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })]) - }) - - it('bounds lifetime admissions when reclassification repeatedly removes entries', () => { - const { roster, items } = harness() - for (let i = 0; i < 100; i++) { - roster.observeSystemFrame(started({ task_id: `task-${i}`, description: `Agent ${i}` })) - roster.observeSystemFrame( - system('task_started', { task_id: `task-${i}`, task_type: 'local_bash' }) - ) - } - expect(items).toHaveLength(64) - }) -}) - -describe('ClaudeSubagentRoster — resumed invocation', () => { - it('reopens one canonical child on a new announcement without replaying old results', () => { - const { roster, roles, pinClock } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) - roster.observeToolResult('first', false) - // Resumed long after its first run: the new run starts its own clock. - pinClock(900_000) - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'resumed', is_backgrounded: true }) - ) - expect(roles()).toEqual([ - expect.objectContaining({ id: 'task-1', state: 'working', startedAt: 900_000 }) - ]) - expect(roles()[0].settledAt).toBeUndefined() - expect(roster.linkage.settledLinkageFor('resumed')).toMatchObject({ - linkage: { agentId: 'task-1', attempt: 2 } - }) - roster.observeSystemFrame( - system('task_notification', { task_id: 'task-1', tool_use_id: 'first', status: 'completed' }) - ) - roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) - expect(roles()[0].state).toBe('working') - roster.observeSystemFrame( - system('task_notification', { - task_id: 'task-1', - tool_use_id: 'resumed', - status: 'completed' - }) - ) - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'resumed', is_backgrounded: true }) - ) - expect(roles()[0].state).toBe('completed') - }) -}) - -describe('ClaudeSubagentRoster — invocation fences', () => { - it('ignores a previous invocation tool result even without a background flag', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) - roster.observeToolResult('first', false) - roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'next' })) - roster.observeToolResult('first', true) - expect(roles()[0].state).toBe('working') - roster.observeToolResult('next', false) - expect(roles()[0].state).toBe('completed') - }) - - it('does not treat an evicted alias as a new invocation', () => { - const { roster, rolesIn, setGroupKey } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) - roster.observeToolResult('first', false) - setGroupKey('churn') - for (let i = 0; i < 513; i++) { - roster.observeSystemFrame( - system('task_updated', { task_id: `other-${i}`, tool_use_id: `tool-${i}` }) - ) - } - roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) - expect(rolesIn(TURN_1)[0].state).toBe('completed') - }) - - 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}` })) - 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' })) - 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 } - }) - }) -}) - -it('merges an explicit foreground patch without clearing on absent metadata', () => { - const { roster, roles } = harness() - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'tool', is_backgrounded: true }) - ) - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-1', patch: { description: 'Audit' } }) - ) - roster.observeToolResult('tool', false) - expect(roles()[0].state).toBe('working') - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: false } }) - ) - roster.observeToolResult('tool', false) - expect(roles()[0].state).toBe('completed') -}) diff --git a/src/main/claude/claude-subagent-roster.ts b/src/main/claude/claude-subagent-roster.ts deleted file mode 100644 index f59347e835a..00000000000 --- a/src/main/claude/claude-subagent-roster.ts +++ /dev/null @@ -1,223 +0,0 @@ -// The Claude subagent roster: one journal row per turn that spawned children. -// -// Entries are built from `task_started`, never from child traffic: a -// BACKGROUNDED subagent emits no child frames at all, so a roster fed by -// `parent_tool_use_id` alone would leave every one of them an unlabelled row -// forever. Child traffic only creates an entry for CLI releases that announce -// no task frames. -// -// Claude re-announces a resumed task under a NEW `tool_use_id`, so `task_id` is -// 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 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 { ClaudeSubagentIds } from './claude-subagent-id-aliases' -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' - -/** The turn a group belongs to when Claude reports a task outside any turn. */ -const OUTSIDE_TURN = 'outside-turn' - -export type ClaudeSubagentRosterDeps = { - sink: StructuredAgentSessionEventSink - /** The turn that owns children spawned right now; null outside any turn. */ - currentGroupKey: () => string | null - /** That turn's scope, which the roster row it spawns belongs to. */ - currentTurnScope: () => AgentJournalTurnScope - /** Whether a tool id was forwarded at the TOP level. A child parented to one - * was spawned by a call the transcript shows, so its announcement is still - * expected; a child parented to anything else names an id that only ever - * existed inside a sidechain, which this CLI will never announce. */ - isForwardedParentTool?: (toolUseId: string) => boolean - /** The reference naming the child that journaled a tool call, when a child - * did. It is how a grandchild's row reaches the agent that spawned it. */ - childOwnerRefOf?: (toolUseId: string) => string | null - /** What earlier provider runs of this session journaled of this roster. */ - journaled?: ClaudeJournaledRosterSource - /** A settled group can receive no further announcement, so an identity still - * provisional will stay that way. Fires on EVERY settle path, so a caller - * holding rows against a pending identity cannot miss one. */ - onIdentitiesFinal?: () => void - now?: () => number -} - -export class ClaudeSubagentRoster { - private readonly tracker: SubagentTracker - private readonly ids: ClaudeSubagentIds - /** Who produced a row, for every write site journaling this session. */ - readonly linkage: ClaudeSubagentLinkageSource & ClaudeAgentLinkageSource - /** Set by ANY `task_started`, including one the subagent filter rejects. Once - * 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 - - constructor(private readonly deps: ClaudeSubagentRosterDeps) { - 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.tracker.locate(canonicalId) ? { attempt: this.tracker.attempt(canonicalId) } : null, - isForwardedParentTool: deps.isForwardedParentTool, - childOwnerRefOf: deps.childOwnerRefOf, - spawnRefOf: (canonicalId) => this.tracker.locate(canonicalId)?.tracked.run ?? null - }) - } - - /** Consume a `message:system:task_*` frame. Returns false when it is not one. */ - observeSystemFrame(message: Record): boolean { - const frame = readClaudeSubagentTaskFrame(message) - if (!frame) { - return false - } - this.announcesTasks ||= frame.announcement - if (frame.excluded) { - // Child traffic may already have built a provisional row under the tool id; - // the announcement is the first frame that says it is not a subagent. - for (const id of [frame.taskId, frame.toolUseId]) { - if (id !== null) { - this.ids.exclude(id) - this.tracker.remove(id) - } - } - return true - } - if (this.ids.isExcluded(frame.taskId, frame.toolUseId)) { - return true - } - if (frame.toolUseId) { - this.ids.alias(frame.toolUseId, frame.taskId) - 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) - } - } - this.tracker.report({ - id: frame.taskId, - ...(frame.toolUseId ? { run: frame.toolUseId } : {}), - group: this.currentGroup(), - announces: frame.announcesSubagent, - label: frame.label, - state: frame.state, - ...(frame.backgrounded !== null ? { backgrounded: frame.backgrounded } : {}) - }) - return true - } - - /** - * A frame carrying `parent_tool_use_id` — the child's own traffic. It refreshes - * nothing on an announced child; it exists so a CLI release that sends no task - * frames still shows the subagent it is running. - */ - observeChildActivity(parentToolUseId: string): void { - const canonical = this.ids.canonical(parentToolUseId) - if ( - this.ids.isExcluded(parentToolUseId, canonical) || - this.tracker.hasSettled(canonical, parentToolUseId) - ) { - return - } - const located = this.tracker.locate(canonical) - if (located) { - // Child traffic can precede the announcement that reopens an inherited row. - if (located.tracked.inherited) { - this.tracker.retain(located.group) - } - return - } - 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 - } - this.tracker.report({ - id: canonical, - run: parentToolUseId, - group: this.currentGroup(), - announces: true, - state: 'working' - }) - } - - /** - * The parent turn's tool result for a spawn call. It settles a foreground - * child, whose result IS the turn's evidence the child finished. A backgrounded - * child's spawn call returns immediately while the child keeps running, so its - * result proves nothing and is ignored. - */ - observeToolResult(toolUseId: string, failed: boolean): void { - const canonical = this.ids.canonical(toolUseId) - const located = this.tracker.locate(canonical) - if ( - !located || - located.tracked.backgrounded || - (located.tracked.run !== null && located.tracked.run !== toolUseId) - ) { - return - } - this.tracker.report({ - id: canonical, - run: toolUseId, - group: this.currentGroup(), - announces: false, - state: failed ? 'failed' : 'completed' - }) - } - - /** 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 { - 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 { - this.tracker.settleSession() - this.deps.onIdentitiesFinal?.() - } - - dispose(): void { - // 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.tracker.dispose() - this.ids.clear() - this.announcesTasks = false - } - - private currentGroup(): SubagentReport['group'] { - return { - id: this.deps.currentGroupKey() ?? OUTSIDE_TURN, - placement: this.deps.currentTurnScope - } - } -} diff --git a/src/main/claude/claude-subagent-task-frames.test.ts b/src/main/claude/claude-subagent-task-frames.test.ts index 58950307ba5..480c59dab4d 100644 --- a/src/main/claude/claude-subagent-task-frames.test.ts +++ b/src/main/claude/claude-subagent-task-frames.test.ts @@ -27,7 +27,6 @@ describe('readClaudeSubagentTaskFrame', () => { expect(frame).toMatchObject({ taskId: 'task-1', toolUseId: 'toolu_1', - label: 'Review the diff', announcesSubagent: true, excluded: false }) @@ -75,20 +74,12 @@ describe('readClaudeSubagentTaskFrame', () => { } }) - it('caps a subagent_type label the way a description is capped', () => { - const frame = readClaudeSubagentTaskFrame( - system('task_started', { task_id: 'task-1', subagent_type: 'a'.repeat(900) }) - ) - // The roster stores this label verbatim, so nothing downstream bounds it. - expect(frame?.label).toHaveLength(512) - }) - it('falls back to subagent_type only when the release sends no task_type', () => { expect( readClaudeSubagentTaskFrame( system('task_started', { task_id: 'task-old', subagent_type: 'explorer' }) ) - ).toMatchObject({ announcesSubagent: true, label: 'explorer' }) + ).toMatchObject({ announcesSubagent: true }) expect( readClaudeSubagentTaskFrame(system('task_started', { task_id: 'task-bare' })) ).toMatchObject({ announcesSubagent: false, excluded: true }) @@ -120,96 +111,11 @@ describe('readClaudeSubagentTaskFrame', () => { }) }) - describe('status', () => { - it('collapses every in-flight status to working', () => { - for (const status of ['pending', 'running', 'paused']) { - expect( - readClaudeSubagentTaskFrame( - system('task_updated', { task_id: 'task-1', patch: { status } }) - ) - ).toMatchObject({ state: 'working' }) - } - }) - - it('maps the settled statuses onto the carrier vocabulary', () => { - const mapped: [string, string][] = [ - ['completed', 'completed'], - ['failed', 'failed'], - ['killed', 'stopped'], - ['stopped', 'stopped'] - ] - for (const [status, state] of mapped) { - expect( - readClaudeSubagentTaskFrame( - system('task_updated', { task_id: 'task-1', patch: { status } }) - ) - ).toMatchObject({ state }) - } - }) - - it('reports no state for a status it cannot map', () => { - for (const status of ['__proto__', 'toString', 'invented', 7, null]) { - expect( - readClaudeSubagentTaskFrame( - system('task_updated', { task_id: 'task-1', patch: { status } }) - ) - ).toMatchObject({ state: null }) - } - }) - - it('treats progress as no lifecycle verdict', () => { - for (const subtype of ['task_progress']) { - expect( - readClaudeSubagentTaskFrame( - system(subtype, { task_id: 'task-1', status: 'completed', patch: { status: 'failed' } }) - ) - ).toMatchObject({ state: null }) - } - }) - }) - - it('reads the notification verdict from its top-level status', () => { - for (const state of ['completed', 'failed', 'stopped']) { - expect( - readClaudeSubagentTaskFrame( - system('task_notification', { - task_id: 'task-1', - status: state, - patch: { status: 'running' } - }) - ) - ).toMatchObject({ state }) - } - }) - - it('reads the backgrounded flag from the frame or its patch', () => { + it('reads the spawn call id from an update patch', () => { expect( readClaudeSubagentTaskFrame( - system('task_started', { - task_id: 'task-1', - task_type: 'local_agent', - is_backgrounded: true - }) + system('task_updated', { task_id: 'task-1', patch: { tool_use_id: 'toolu_1' } }) ) - ).toMatchObject({ backgrounded: true }) - expect( - readClaudeSubagentTaskFrame( - system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: true } }) - ) - ).toMatchObject({ backgrounded: true }) - expect( - readClaudeSubagentTaskFrame(system('task_updated', { task_id: 'task-1', patch: {} })) - ).toMatchObject({ backgrounded: null }) - }) - - it('collapses a multi-line description into one bounded label', () => { - expect( - readClaudeSubagentTaskFrame( - system('task_updated', { - task_id: 'task-1', - patch: { description: ' audit\n the lockfile ' } - }) - ) - ).toMatchObject({ label: 'audit the lockfile' }) + ).toMatchObject({ taskId: 'task-1', toolUseId: 'toolu_1', excluded: false }) }) }) diff --git a/src/main/claude/claude-subagent-task-frames.ts b/src/main/claude/claude-subagent-task-frames.ts index 99984a21069..0182f02cf7f 100644 --- a/src/main/claude/claude-subagent-task-frames.ts +++ b/src/main/claude/claude-subagent-task-frames.ts @@ -1,4 +1,4 @@ -// Claude's declarative task protocol, read as subagent roster events. +// Claude's declarative task protocol, read for which tasks are subagents and which ids name them. // // `local_agent`, `local_workflow` and `local_bash` tasks all arrive on the same // `message:system:task_*` channel and ALL carry a `tool_use_id`, so id presence @@ -6,10 +6,8 @@ // the subagent roster. `task_type` is the discriminator, with `subagent_type` // covering CLI releases that predate it. -import type { NativeChatSubagentState } from '../../shared/native-chat-types' import { classifyClaudeBackgroundTaskKind, - claudeTaskDescription, claudeTaskId, isBoundedClaudeTaskId } from './claude-background-task-tracker' @@ -23,37 +21,16 @@ const TASK_SUBTYPES: ReadonlySet = new Set([ 'task_notification' ]) -/** Provider status → the carrier's vocabulary. `killed` and `stopped` both mean - * the task was deliberately ended, which the carrier calls `stopped`; every - * in-flight status collapses to `working`. A Map, not an object, so a payload - * carrying `__proto__` as its status cannot resolve to an inherited value. */ -const TASK_STATES: ReadonlyMap = new Map([ - ['pending', 'working'], - ['running', 'working'], - ['paused', 'working'], - ['completed', 'completed'], - ['failed', 'failed'], - ['killed', 'stopped'], - ['stopped', 'stopped'] -] satisfies [string, NativeChatSubagentState][]) - export type ClaudeSubagentTaskFrame = { - /** Canonical, resume-stable id — the roster key. */ + /** Canonical, resume-stable id. */ taskId: string /** Re-minted when Claude re-announces a resumed task, so it is only an alias. */ toolUseId: string | null - label: string | null - /** null when the frame reported no lifecycle status. */ - state: NativeChatSubagentState | null - backgrounded: boolean | null - /** Any `task_started`, subagent or not. Proof this CLI declares its tasks. */ - announcement: boolean - /** `task_started` for a task the roster should show. Only an announcement - * creates an entry: an update carries no `task_type`, so honouring one for an - * unknown id would roster whatever else shares this channel. */ + /** `task_started` for a subagent. Only an announcement says what a task is: an update carries + * no `task_type`. */ announcesSubagent: boolean - /** Ambient housekeeping, or a task that is not a subagent at all. Its ids must - * never reach the roster, by this frame or by later child traffic. */ + /** Ambient housekeeping, or a task that is not a subagent at all. Its ids must never be read as + * a subagent's, by this frame or by later child traffic. */ excluded: boolean } @@ -72,10 +49,6 @@ export function isClaudeSubagentTask(message: Record): boolean ) } -function taskState(value: unknown): NativeChatSubagentState | null { - return typeof value === 'string' ? (TASK_STATES.get(value) ?? null) : null -} - export function readClaudeSubagentTaskFrame( message: Record ): ClaudeSubagentTaskFrame | null { @@ -90,8 +63,8 @@ export function readClaudeSubagentTaskFrame( if (!taskId) { return null } - const patch = claudeRecord(message.patch) - const toolUseId = claudeText(message.tool_use_id) ?? claudeText(patch?.tool_use_id) + const toolUseId = + claudeText(message.tool_use_id) ?? claudeText(claudeRecord(message.patch)?.tool_use_id) const announcement = subtype === 'task_started' // Housekeeping Claude runs for itself; the user never asked for it. const suppressed = message.ambient === true || message.skip_transcript === true @@ -99,25 +72,6 @@ export function readClaudeSubagentTaskFrame( return { taskId, toolUseId: toolUseId && isBoundedClaudeTaskId(toolUseId) ? toolUseId : null, - label: - claudeTaskDescription(message.description) ?? - claudeTaskDescription(patch?.description) ?? - // Bounded like a description: the roster stores whatever this returns. - (announcement ? (claudeTaskDescription(message.subagent_type) ?? null) : null), - // Notifications carry terminal evidence; progress carries usage only. - state: - subtype === 'task_notification' - ? taskState(message.status) - : announcement || subtype === 'task_updated' - ? taskState(patch?.status ?? message.status) - : null, - backgrounded: - typeof patch?.is_backgrounded === 'boolean' - ? patch.is_backgrounded - : typeof message.is_backgrounded === 'boolean' - ? message.is_backgrounded - : null, - announcement, announcesSubagent: subagent, excluded: announcement && !subagent } 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 deleted file mode 100644 index 1f759ef11af..00000000000 --- a/src/main/codex/__snapshots__/codex-subagent-roster-characterization.test.ts.snap +++ /dev/null @@ -1,1425 +0,0 @@ -// 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-background-task-frames.ts b/src/main/codex/codex-background-task-frames.ts index 06536a857b8..c7b66988447 100644 --- a/src/main/codex/codex-background-task-frames.ts +++ b/src/main/codex/codex-background-task-frames.ts @@ -1,6 +1,6 @@ import type { NativeChatSubagentState } from '../../shared/native-chat-types' import { readCodexSubagentAnnouncements } from './codex-subagent-activity' -import { codexChildTurnState } from './codex-subagent-executions' +import { codexChildTurnState, type CodexSubagentExecutions } from './codex-subagent-executions' import { readRecord } from './codex-item-field-readers' import { readCodexThreadItem } from './codex-structured-item-translation' import { readCodexTurnId } from './codex-structured-thread-facts' @@ -8,7 +8,6 @@ import { readCodexTurnId } from './codex-structured-thread-facts' export type CodexAnnouncedChild = { agentThreadId: string label: string | null - parentTurnId: string | null | undefined /** The reporting thread, for a spawn: the agent that spawned the child. */ spawnerThreadId: string | undefined } @@ -83,10 +82,27 @@ export function readCodexBackgroundTaskFrame( (announcement) => ({ agentThreadId: announcement.agentThreadId, label: announcement.label, - parentTurnId: announcement.namesParentTurn ? readCodexTurnId(event.params) : undefined, // Only a spawn names the spawner: other announcements ride whichever agent acted. spawnerThreadId: announcement.spawned ? event.threadId : undefined }) ) return children.length > 0 ? { kind: 'subagents', children } : null } + +/** What one frame says about the child executions. Repeating it changes nothing, so the journal + * reads it before it writes the frame's rows and the background tasks read it again after. */ +export function observeCodexExecutionFrame( + executions: CodexSubagentExecutions, + frame: CodexBackgroundTaskFrame | null, + primaryThreadId: string +): void { + if (frame?.kind === 'subagents') { + for (const child of frame.children) { + executions.register(child.agentThreadId, child.label, child.spawnerThreadId) + } + } else if (frame?.kind === 'thread-closed') { + executions.closeThread(frame.threadId) + } else if (frame && frame.threadId !== primaryThreadId) { + executions.observeTurn(frame.threadId, frame.turnId, frame.state) + } +} diff --git a/src/main/codex/codex-background-task-tracker.test.ts b/src/main/codex/codex-background-task-tracker.test.ts index 7926131ae5b..5072b5cae3d 100644 --- a/src/main/codex/codex-background-task-tracker.test.ts +++ b/src/main/codex/codex-background-task-tracker.test.ts @@ -77,7 +77,7 @@ describe('readCodexBackgroundTaskFrame', () => { it('reads activity as child metadata without inferring execution state', () => { expect(readCodexBackgroundTaskFrame(activity('interacted'), PRIMARY)).toEqual({ kind: 'subagents', - children: [{ agentThreadId: CHILD, label: 'count_a', parentTurnId: PARENT_TURN }] + children: [{ agentThreadId: CHILD, label: 'count_a' }] }) }) diff --git a/src/main/codex/codex-background-task-tracker.ts b/src/main/codex/codex-background-task-tracker.ts index 3456fec089b..2ce5cf5a4de 100644 --- a/src/main/codex/codex-background-task-tracker.ts +++ b/src/main/codex/codex-background-task-tracker.ts @@ -4,6 +4,7 @@ import type { } from '../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import { + observeCodexExecutionFrame, readCodexBackgroundTaskFrame, type CodexBackgroundTaskEvent } from './codex-background-task-frames' @@ -12,7 +13,7 @@ import { CodexBackgroundCommandTracker } from './codex-background-command-tracke import { CodexChildWorkEvidence } from './codex-child-work-evidence' import type { CodexAbandonedCommand } from './codex-prompt-registry' import type { CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' -import { boundSubagentField } from '../native-chat/agent-session-journal/journal-subagent-group-body' +import { boundSubagentLabel } from '../native-chat/agent-session-journal/journal-subagent-item' /** Where a session's child-work evidence goes, and the host clock that stamps it. */ export type CodexChildWorkSink = { @@ -153,20 +154,7 @@ export class CodexBackgroundTaskTracker { ? [command] : []) ] - if (frame?.kind === 'subagents') { - for (const child of frame.children) { - this.executions.register( - child.agentThreadId, - child.label, - child.parentTurnId, - child.spawnerThreadId - ) - } - } else if (frame?.kind === 'thread-closed') { - this.executions.closeThread(frame.threadId) - } else if (frame && frame.threadId !== this.primaryThreadId) { - this.executions.observeTurn(frame.threadId, frame.turnId, frame.state) - } + observeCodexExecutionFrame(this.executions, frame, this.primaryThreadId) this.childWork.observe(event, frame, commands) if (!frame) { return itemEvent || commands.length > 0 ? this.refresh() : false @@ -200,10 +188,10 @@ export class CodexBackgroundTaskTracker { private tasks(): AgentSessionBackgroundTask[] { const children = this.executions.workingChildren() - const agents: AgentSessionBackgroundTask[] = children.map((child, index) => ({ + const agents: AgentSessionBackgroundTask[] = children.map((child) => ({ id: `codex-agent:${child.agentThreadId}`, kind: 'agent', - ...(child.label ? { description: boundSubagentField(child.label, index) } : {}) + ...(child.label ? { description: boundSubagentLabel(child.label) } : {}) })) return [ ...agents, diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts index 9ac082784ac..00023b11bac 100644 --- a/src/main/codex/codex-child-work-evidence.test.ts +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -136,6 +136,19 @@ describe('Codex child-work evidence', () => { expect(log.flat().filter((edge) => edge.type === 'live')).toHaveLength(1) }) + it('keeps usage that arrives before the child is announced, and never credits the parent its own', () => { + const { send, byKind } = harness() + const usage = (threadId: string, totalTokens: number): CodexBackgroundTaskEvent => ({ + method: 'thread/tokenUsage/updated', + threadId, + params: { threadId, tokenUsage: { total: { totalTokens } } } + }) + send(turn('turn/started', PRIMARY, PARENT_TURN), usage(PRIMARY, 9_000), usage(CHILD, 1_500)) + expect(byKind('agent')).toEqual([]) + send(spawned(), turn('turn/started', CHILD, 'c1')) + expect(byKind('agent')).toEqual([expect.objectContaining({ totalTokens: 1_500 })]) + }) + it('makes no record for a child whose turn began before its announcement, until it lands', () => { const { send, records } = harness() send(turn('turn/started', PRIMARY, PARENT_TURN), turn('turn/started', CHILD, 'c1')) diff --git a/src/main/codex/codex-child-work-evidence.ts b/src/main/codex/codex-child-work-evidence.ts index 2eb57d7d6f9..9569acac65c 100644 --- a/src/main/codex/codex-child-work-evidence.ts +++ b/src/main/codex/codex-child-work-evidence.ts @@ -34,7 +34,7 @@ import { codexThreadWaitsOnUser, readCodexTurnId } from './codex-structured-thre import { CODEX_TOKEN_USAGE_METHOD, readCodexThreadTokenTotal } from './codex-subagent-activity' import type { CodexExecutionChild, CodexSubagentExecutions } from './codex-subagent-executions' -/** The executions' own child bound. */ +/** Facts kept for settled children; a working child always has its own. */ const MAX_CHILD_FACTS = 128 const MAX_OPEN_CALLS_PER_CHILD = 16 const CHILD_FRAME_METHODS: ReadonlySet = new Set([ @@ -113,7 +113,7 @@ export class CodexChildWorkEvidence { this.queueCommands(commands) for (const threadId of this.childThreads(event, frame)) { const facts = this.factsFor(threadId) - if (facts && event.threadId === threadId) { + if (event.threadId === threadId) { this.record(facts, event) } this.queueChild(threadId) @@ -299,19 +299,19 @@ export class CodexChildWorkEvidence { } } - private factsFor(threadId: string): ChildFacts | undefined { + private factsFor(threadId: string): ChildFacts { const existing = this.facts.get(threadId) if (existing) { return existing } if (this.facts.size >= MAX_CHILD_FACTS) { + // Refusing a working child its facts would drop its every edge; the host's limits bound them. const idle = [...this.facts.keys()].find( (id) => this.executions.find(id)?.execution?.state !== 'working' ) - if (idle === undefined) { - return undefined + if (idle !== undefined) { + this.facts.delete(idle) } - this.facts.delete(idle) } const facts: ChildFacts = { openCalls: new Map(), waiting: false } this.facts.set(threadId, facts) diff --git a/src/main/codex/codex-default-mode-subagents.test.ts b/src/main/codex/codex-default-mode-subagents.test.ts index 61c99be2f19..0b58dd84630 100644 --- a/src/main/codex/codex-default-mode-subagents.test.ts +++ b/src/main/codex/codex-default-mode-subagents.test.ts @@ -1,7 +1,7 @@ // Codex's default multi-agent mode announces a helper only by the `collabAgentToolCall` items that // spawn, message, wait on or close it. These frames, through the real adapter, must register that -// helper the same way a `subAgentActivity` does: one child in the strip, the host's records and -// the roster row. +// helper the same way a `subAgentActivity` does: one child in the strip and the host's records, +// which the host saves as the helper's row. import { describe, expect, it } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' @@ -179,21 +179,15 @@ async function session() { const strip = () => adapter.backgroundTaskState('session-1')?.tasks ?? [] const toolRow = (name: string) => [...rows.values()].find((body) => body.kind === 'tool-call' && body.name === name) - const rosterRows = () => - [...rows.values()].flatMap((body) => - body.kind === 'message' - ? body.blocks.flatMap((block) => (block.type === 'subagent-group' ? [block] : [])) - : [] - ) const rawCollabRows = () => [...rows.values()].filter( (body) => body.kind === 'status' && body.providerFrame?.kind === 'item:collabAgentToolCall' ) - return { send, agents, commands, strip, toolRow, rosterRows, rawCollabRows } + return { send, agents, commands, strip, toolRow, rawCollabRows } } describe('Codex default-mode helpers', () => { - it('registers a helper announced only by its spawn call, in the strip, the records and the roster row', async () => { + it('registers a helper announced only by its spawn call, in the strip and the records', async () => { const run = await session() run.send(turn('turn/started', THREAD_ID, PARENT_TURN), spawnStarted) // The call has not said which thread the helper is yet. @@ -212,11 +206,6 @@ describe('Codex default-mode helpers', () => { expect(run.strip()).toEqual([ { id: `codex-agent:${HELPER}`, kind: 'agent', description: LABEL } ]) - expect(run.rosterRows()).toEqual([ - expect.objectContaining({ - agents: [expect.objectContaining({ id: HELPER, label: LABEL, state: 'working' })] - }) - ]) // While the helper runs, its command is the helper's work, not the session's own. run.send(helperShell('item/started')) @@ -245,9 +234,6 @@ describe('Codex default-mode helpers', () => { }) ]) expect(run.strip()).toEqual([]) - expect(run.rosterRows().at(-1)?.agents).toEqual([ - expect.objectContaining({ id: HELPER, state: 'completed' }) - ]) }) it('labels a command the helper leaves running with the helper, once its turn is over', async () => { @@ -316,12 +302,7 @@ describe('Codex default-mode helpers', () => { expect.objectContaining({ membership: 'live', state: 'working', description: LABEL }) ]) expect(run.strip()).toHaveLength(1) - expect(run.rosterRows()).toEqual([ - expect.objectContaining({ - agents: [expect.objectContaining({ id: HELPER, label: LABEL, state: 'working' })] - }) - ]) - // The roster row does not stand in for the call's own row. + // The call keeps its own row. expect(run.toolRow('spawn_agent')).toMatchObject({ state: 'completed', input: { description: LABEL } @@ -355,11 +336,6 @@ describe('Codex default-mode helpers', () => { expect(run.strip()).toEqual([ { id: `codex-agent:${HELPER}`, kind: 'agent', description: LABEL } ]) - expect(run.rosterRows()).toEqual([ - expect.objectContaining({ - agents: [expect.objectContaining({ id: HELPER, state: 'working' })] - }) - ]) }) it('registers nothing for a spawn that created no thread', async () => { @@ -376,7 +352,6 @@ describe('Codex default-mode helpers', () => { ) expect(run.agents()).toEqual([]) expect(run.strip()).toEqual([]) - expect(run.rosterRows()).toEqual([]) }) it('leaves a helper running when its caller failed to close it', async () => { @@ -415,9 +390,6 @@ describe('Codex default-mode helpers', () => { ) expect(run.agents()).toEqual([expect.objectContaining({ membership: 'live' })]) expect(run.strip()).toHaveLength(1) - expect(run.rosterRows().at(-1)?.agents).toEqual([ - expect.objectContaining({ id: HELPER, state: 'working' }) - ]) // A close that works aborts the helper's turn, which Codex reports on the helper's thread. run.send(closeCompleted, turn('turn/completed', HELPER, HELPER_TURN, 'interrupted')) @@ -425,9 +397,6 @@ describe('Codex default-mode helpers', () => { expect.objectContaining({ membership: 'settled', outcome: 'cancelled' }) ]) expect(run.strip()).toEqual([]) - expect(run.rosterRows().at(-1)?.agents).toEqual([ - expect.objectContaining({ id: HELPER, state: 'stopped' }) - ]) expect(run.toolRow('close_agent')).toMatchObject({ state: 'completed', input: { description: LABEL } @@ -480,9 +449,6 @@ describe('Codex default-mode helpers', () => { expect(run.strip()).toEqual([ { id: `codex-agent:${HELPER}`, kind: 'agent', description: 'sleeper' } ]) - expect(run.rosterRows().at(-1)?.agents).toEqual([ - expect.objectContaining({ id: HELPER, label: 'sleeper' }) - ]) }) }) @@ -520,9 +486,6 @@ describe('a helper whose spawn was never seen', () => { parentChildWorkId: run.agents()[0]?.childWorkId }) ]) - expect(run.rosterRows().at(-1)?.agents).toEqual([ - expect.objectContaining({ id: HELPER, label: 'subagent', state: 'working' }) - ]) expect(run.toolRow('send_input')).toMatchObject({ input: { description: HELPER } }) }) @@ -548,10 +511,6 @@ describe('a helper whose spawn was never seen', () => { expect.objectContaining({ membership: 'live' }), expect.objectContaining({ membership: 'live' }) ]) - expect(run.rosterRows().at(-1)?.agents).toEqual([ - expect.objectContaining({ id: HELPER, label: 'subagent' }), - expect.objectContaining({ id: second, label: 'subagent 2' }) - ]) }) it("reopens a helper's one entry when a later turn sends it work, even after that turn ended", async () => { @@ -567,11 +526,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(1) - expect(run.rosterRows().at(-1)?.agents).toEqual([ - expect.objectContaining({ id: HELPER, state: 'working' }) + // One record per helper, back at work. + expect(run.agents()).toEqual([ + expect.objectContaining({ membership: 'live', state: 'working' }) ]) }) @@ -590,14 +547,13 @@ describe('a helper whose spawn was never seen', () => { } } expect(readCodexSubagentAnnouncements(waited, THREAD_ID)).toEqual([ - { agentThreadId: HELPER, label: null, namesParentTurn: false, spawned: false }, - { agentThreadId: second, label: null, namesParentTurn: false, spawned: false } + { agentThreadId: HELPER, label: null, spawned: false }, + { agentThreadId: second, label: null, spawned: false } ]) }) }) -describe('the roster row follows a helper whose turn ends', () => { - const lastRow = (run: Awaited>) => run.rosterRows().at(-1)?.agents +describe('the record follows a helper whose turn ends', () => { const running = async () => { const run = await session() run.send( @@ -605,11 +561,10 @@ describe('the roster row follows a helper whose turn ends', () => { activityStarted, turn('turn/started', HELPER, HELPER_TURN) ) - expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'working' })]) return run } - it('settles the row with the strip and the record on the failed completion that follows a fatal error', async () => { + it('settles the strip and the record on the failed completion that follows a fatal error', async () => { const run = await running() // Codex sends the fatal error, then the helper's failed `turn/completed` 0-32 ms later. run.send({ @@ -623,24 +578,21 @@ describe('the roster row follows a helper whose turn ends', () => { }) expect(run.strip()).toEqual([expect.objectContaining({ id: `codex-agent:${HELPER}` })]) expect(run.agents()).toEqual([expect.objectContaining({ membership: 'live' })]) - expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'working' })]) run.send(turn('turn/completed', HELPER, HELPER_TURN, 'failed')) expect(run.strip()).toEqual([]) expect(run.agents()).toEqual([ expect.objectContaining({ membership: 'settled', outcome: 'failed' }) ]) - expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'failed' })]) }) - it('settles the row with the strip and the record when its thread closes with no turn/completed', async () => { + it('settles the strip and the record when its thread closes with no turn/completed', async () => { const run = await running() run.send({ method: 'thread/closed', params: { threadId: HELPER } }) expect(run.strip()).toEqual([]) expect(run.agents()).toEqual([ expect.objectContaining({ membership: 'settled', outcome: 'unknown' }) ]) - expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'unverifiable' })]) }) }) diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index 4317b5669d7..2c762121327 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -203,11 +203,13 @@ const shellFrame = ( describe('Codex structured child-work producer', () => { it('delivers evidence only after the journal wrote and published the frame', async () => { const { send, records } = await producer() - send(turn('turn/started', THREAD_ID, 'p1')) - send(turn('turn/started', REVIEWER, 'r1')) - const deliveries = send(spawned(REVIEWER, 'review', 'p1')) + const deliveries = [ + ...send(turn('turn/started', THREAD_ID, 'p1')), + ...send(turn('turn/started', REVIEWER, 'r1')), + ...send(spawned(REVIEWER, 'review', 'p1')) + ] const kinds = deliveries.map((delivery) => delivery.kind) - // The frame's own rows, then the parent's republished row, and only then its children. + // The turn's own rows, then the parent's republished row, and only then its child. expect(kinds.filter((kind) => kind === 'journal').length).toBeGreaterThan(0) expect(kinds.lastIndexOf('publish')).toBeGreaterThan(kinds.lastIndexOf('journal')) expect(kinds.at(-1)).toBe('evidence') @@ -474,7 +476,8 @@ describe('Codex structured child-work producer', () => { before = step.parent step.check?.() } - expect(journalMoments).toBeGreaterThan(steps.length) + // The host writes the children's rows, so not every step writes one here. + expect(journalMoments).toBeGreaterThan(0) const settled = records() await adapter.closeSession('session-1') // Every child had already ended; closing the session changes none of what they said. diff --git a/src/main/codex/codex-structured-journal-limits.ts b/src/main/codex/codex-structured-journal-limits.ts index 5d5d4c1c7f8..c68d9684ed7 100644 --- a/src/main/codex/codex-structured-journal-limits.ts +++ b/src/main/codex/codex-structured-journal-limits.ts @@ -11,9 +11,3 @@ 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 -/** 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-structured-journal-thread-item-routing.ts b/src/main/codex/codex-structured-journal-thread-item-routing.ts index 85769d0ac82..bccadba6e9e 100644 --- a/src/main/codex/codex-structured-journal-thread-item-routing.ts +++ b/src/main/codex/codex-structured-journal-thread-item-routing.ts @@ -1,6 +1,7 @@ -// Where one `item/started` or `item/completed` notification goes: the subagent -// roster, the typed item translator, or the generic fallback — and, when the item -// is Codex echoing a send back, the turn attribution that echo unlocks. +// Where one `item/started` or `item/completed` notification goes: nowhere for a subagent's +// activity item, whose child draws its own row from the host's records; else the typed item +// translator or the generic fallback — and, when the item is Codex echoing a send back, the turn +// attribution that echo unlocks. import type { CodexJournalGenericFrames } from './codex-structured-journal-generic-frames' import type { CodexJournalItems } from './codex-structured-journal-items' @@ -8,12 +9,13 @@ import type { CodexJournalTranslationAdmission, CodexJournalTranslatorDeps } from './codex-structured-journal-contracts' -import type { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state' import type { CodexJournalTurnBoundaries } from './codex-structured-journal-translation-turn-boundaries' import { readCodexThreadItem } from './codex-structured-item-translation' -import { readCodexNotificationThreadItem } from './codex-subagent-activity' -import type { CodexSubagentRoster } from './codex-subagent-roster' -import { readCodexTurnId } from './codex-structured-thread-facts' +import { + readCodexNotificationThreadItem, + readCodexSubagentActivity +} from './codex-subagent-activity' +import { CODEX_JOURNAL_ADMITTED } from './codex-structured-journal-contracts' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' type ThreadItemEvent = Extract @@ -23,25 +25,14 @@ export type CodexThreadItemRouting = CodexJournalTranslationAdmission | null export function createCodexThreadItemRouter(input: { deps: Pick - subagents: Pick items: Pick - activeTurns: Pick turnBoundaries: Pick genericFrames: Pick }): (event: ThreadItemEvent) => CodexThreadItemRouting { return (event) => { const subagentItem = readCodexNotificationThreadItem(event.params, readCodexThreadItem) - // Null means the roster did not claim it; fall through to normal item - // handling. Returning here unconditionally swallows every other item. - const subagentAdmission = subagentItem - ? input.subagents.handleItem({ - threadId: event.threadId, - turnId: readCodexTurnId(event.params) ?? input.activeTurns.current(event.threadId), - item: subagentItem - }) - : null - if (subagentAdmission) { - return subagentAdmission + if (subagentItem && readCodexSubagentActivity(subagentItem) !== null) { + return CODEX_JOURNAL_ADMITTED } const translated = input.items.handle(event) if (translated.handled && translated.dispatchEcho) { diff --git a/src/main/codex/codex-structured-journal-translation-restore.ts b/src/main/codex/codex-structured-journal-translation-restore.ts index 9f7efc15ae4..09bf20b8403 100644 --- a/src/main/codex/codex-structured-journal-translation-restore.ts +++ b/src/main/codex/codex-structured-journal-translation-restore.ts @@ -36,7 +36,7 @@ export function restoreCodexHistoryItem( const item = readCodexNotificationThreadItem(event.params, readCodexThreadItem) const announcements = item ? readCodexSubagentAnnouncements(item, input.primaryThreadId) : [] for (const announcement of announcements) { - input.executions.register(announcement.agentThreadId, announcement.label, undefined) + input.executions.register(announcement.agentThreadId, announcement.label) } const compaction = input.compactions.handle(event) if (compaction) { diff --git a/src/main/codex/codex-structured-journal-translation-subagent-linkage.test.ts b/src/main/codex/codex-structured-journal-translation-subagent-linkage.test.ts index 16474874d5d..8202eb8754b 100644 --- a/src/main/codex/codex-structured-journal-translation-subagent-linkage.test.ts +++ b/src/main/codex/codex-structured-journal-translation-subagent-linkage.test.ts @@ -109,14 +109,8 @@ describe('codex journal translation — subagent producer linkage', () => { expect(commandRows.map((write) => write.linkage)).toEqual(commandRows.map(() => CHILD_LINKAGE)) expect(linkageOf('child prose')).toEqual(CHILD_LINKAGE) expect(linkageOf('own prose')).toEqual({}) - // The spawn-group row is the parent's list of its children, though a child's - // frames also write it. - expect( - writes.filter((write) => write.key.includes('codex-subagents')).map((w) => w.linkage) - ).toEqual(expect.arrayContaining([{}])) - expect( - writes.filter((write) => write.key.includes('codex-subagents') && write.linkage.agentId) - ).toEqual([]) + // The host writes each child's own row; the translator writes none. + expect(writes.filter((write) => write.key.includes('subagent'))).toEqual([]) }) it('stamps a child row that arrives before the spawn announcement names it', () => { diff --git a/src/main/codex/codex-structured-journal-translation-subagents.test.ts b/src/main/codex/codex-structured-journal-translation-subagents.test.ts index 8db30d31b21..ff1b458b4c7 100644 --- a/src/main/codex/codex-structured-journal-translation-subagents.test.ts +++ b/src/main/codex/codex-structured-journal-translation-subagents.test.ts @@ -5,7 +5,6 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' -import { isSubagentGroupBlock } from '../../shared/native-chat-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' @@ -79,38 +78,27 @@ function deliverActivity( translator.handle(notification('item/completed', params)) } -function rosterAgents(rows: Row[]): { id: string; state: string; tokens?: number }[] { - const body = rows.findLast((row) => row.key.startsWith('orca:codex-subagents'))?.body - if (!body || body.kind !== 'message') { - return [] - } - return body.blocks.find(isSubagentGroupBlock)?.agents ?? [] -} - describe('codex journal translation — subagents', () => { - it('renders a spawn group as one roster row and no opcode-shaped duplicate', () => { + it('writes neither a subagent row nor an opcode-shaped one: the host saves each child', () => { const { translator, rows } = harness() translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) deliverActivity(translator, subagentItem('started', 'child-1', '/root/list_directory')) deliverActivity(translator, subagentItem('interacted', 'child-1', '/root/list_directory')) - expect(rosterAgents(rows)).toMatchObject([ - { id: 'child-1', label: 'list_directory', state: 'working' } - ]) - // Four wire deliveries (two items, each sent twice) collapse to ONE roster - // row, and none of the gray `codex · item:subAgentActivity` rows survive. + // Four wire deliveries (two items, each sent twice), and none of the gray + // `codex · item:subAgentActivity` rows survive. const providerFrameKinds = rows.flatMap((row) => row.body.kind === 'status' && row.body.providerFrame ? [row.body.providerFrame.kind] : [] ) expect(providerFrameKinds).toEqual([]) - expect(rows.filter((row) => row.key.startsWith('orca:codex-subagents'))).toHaveLength(1) + expect(rows.filter((row) => row.key.includes('subagent'))).toEqual([]) }) - // The roster claims the item, but claiming it must not take the turn tail with + // The translator claims the item, but claiming it must not take the turn tail with // it: the activity table is reached only through the publish arm, so a bare // return leaves the tail stuck on whatever the previous frame said. - it('still publishes the turn tail for an item the roster claims', () => { + it('still publishes the turn tail for an item it claims', () => { const { translator, activities } = harness() translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) @@ -122,63 +110,4 @@ describe('codex journal translation — subagents', () => { text: 'Coordinating with another agent' }) }) - - it('consumes thread/tokenUsage/updated instead of swallowing it as chrome', () => { - const { translator, rows } = harness() - - translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) - deliverActivity(translator, subagentItem('started', 'child-1', '/root/read')) - translator.handle( - notification('thread/tokenUsage/updated', { - threadId: 'child-1', - tokenUsage: { total: { totalTokens: 40661 } } - }) - ) - - expect(rosterAgents(rows)).toMatchObject([{ id: 'child-1', tokens: 40661 }]) - }) - - // The QA scenario this row got wrong: three `spawn_agent` children were still - // running when a mid-turn correction ended their turn and opened a new one. - // They reported `completed` 57-87s later, so a turn boundary is a fact about - // the turn and never evidence that contact with a child was lost. - it('leaves children working when their turn ends and a newer turn opens', () => { - const { translator, rows } = harness() - - translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) - deliverActivity(translator, subagentItem('started', 'child-1', '/root/read_readme')) - deliverActivity(translator, subagentItem('started', 'child-2', '/root/read_package')) - translator.handle(notification('turn/completed', { turn: { id: TURN_ID } })) - translator.handle(notification('turn/started', { turn: { id: 'turn-2' } })) - - expect(rosterAgents(rows)).toMatchObject([ - { id: 'child-1', state: 'working' }, - { id: 'child-2', state: 'working' } - ]) - - // And the verdict a child reports after its turn ended still lands on the row. - deliverActivity(translator, subagentItem('completed', 'child-1', '/root/read_readme')) - - expect(rosterAgents(rows)).toMatchObject([ - { id: 'child-1', state: 'completed' }, - { id: 'child-2', state: 'working' } - ]) - }) - - it('sweeps every group when the provider ends', () => { - const { translator, rows } = harness() - - translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) - deliverActivity(translator, subagentItem('started', 'child-1', '/root/read')) - translator.handle({ - type: 'ended', - sessionId: SESSION_ID, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: 1, - acquisitionGeneration: 'gen-1' - } as CodexStructuredSessionEvent) - - expect(rosterAgents(rows)).toMatchObject([{ id: 'child-1', state: 'unverifiable' }]) - }) }) diff --git a/src/main/codex/codex-structured-journal-translation-writers.ts b/src/main/codex/codex-structured-journal-translation-writers.ts index 9e577074c5e..589d3c6a7d9 100644 --- a/src/main/codex/codex-structured-journal-translation-writers.ts +++ b/src/main/codex/codex-structured-journal-translation-writers.ts @@ -1,6 +1,6 @@ // The translator's writers, built once. Split out so the translator reads as // routing, and so one place shows that every writer is handed the same -// row attribution: the roster's producer, which knows each child thread, and the turn scope. +// row attribution: the producer each child thread is, and the turn scope. import { CodexJournalCompactions } from './codex-structured-journal-compactions' import type { CodexJournalTranslatorDeps } from './codex-structured-journal-contracts' @@ -11,8 +11,8 @@ import { CodexJournalPrompts } from './codex-structured-journal-prompts' import { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state' import { CodexJournalTurnScopes } from './codex-journal-turn-scopes' import { CodexJournalCommandTurn } from './codex-journal-command-turn' -import { CodexSubagentRoster } from './codex-subagent-roster' -import type { CodexRowAttribution } from './codex-subagent-linkage' +import { CodexSubagentExecutions } from './codex-subagent-executions' +import { CodexSubagentLinkage, type CodexRowAttribution } from './codex-subagent-linkage' export function createCodexJournalTranslatorWriters(deps: CodexJournalTranslatorDeps) { const activeTurns = new CodexJournalActiveTurns() @@ -25,14 +25,10 @@ export function createCodexJournalTranslatorWriters(deps: CodexJournalTranslator activeTurn, commandScope: (turnId) => commands.scopeFor(turnId) }) - const subagents = new CodexSubagentRoster({ - sink: deps.sink, - primaryThreadId, - activeTurn, - turnScopeFor: (threadId, turnId) => turnScopes.scopeFor(threadId, turnId), - ...(deps.subagentExecutions ? { executions: deps.subagentExecutions } : {}) - }) - const { linkageFor } = subagents.linkage + // The translator and the background-task tracker both feed these from the same frames, which + // repeat harmlessly; the rows only read them. + const executions = deps.subagentExecutions ?? new CodexSubagentExecutions() + const { linkageFor } = new CodexSubagentLinkage({ primaryThreadId, executions }) const attributionFor: CodexRowAttribution = (threadId, turnId) => ({ ...linkageFor(threadId, turnId), turnScope: turnScopes.scopeFor(threadId, turnId) @@ -43,13 +39,13 @@ export function createCodexJournalTranslatorWriters(deps: CodexJournalTranslator producerDeps, activeTurn, (threadId, turnId) => genericFrames.suppress(threadId, turnId), - // A collab call's row names its helpers the way the roster does. - (threadId) => subagents.executions.label(threadId) + // A collab call's row names its helpers the way their own rows do. + (threadId) => executions.label(threadId) ) return { activeTurns, commands, - subagents, + executions, attributionFor, genericFrames, items, diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index 19fee37a536..31f92ec2c30 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -1,5 +1,4 @@ import { createCodexProviderActivityReader } from '../native-chat/agent-session-wire/provider-frame-activity' -import { CODEX_TOKEN_USAGE_METHOD } from './codex-subagent-activity' import { CODEX_JOURNAL_ADMITTED, type CodexJournalTranslationAdmission, @@ -17,6 +16,10 @@ import { publishCodexTurnLifecycle } from './codex-structured-journal-translatio import { codexProviderRetryRowBody, isCodexProviderRetryFrame } from './codex-provider-retry-row' import { createCodexThreadItemRouter } from './codex-structured-journal-thread-item-routing' import { codexThreadStoppedRunning, readCodexTurnId } from './codex-structured-thread-facts' +import { + observeCodexExecutionFrame, + readCodexBackgroundTaskFrame +} from './codex-background-task-frames' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' export type { @@ -42,7 +45,7 @@ export function createCodexJournalTranslator( const { activeTurns, commands, - subagents, + executions, attributionFor, genericFrames, items, @@ -83,9 +86,7 @@ export function createCodexJournalTranslator( } const routeThreadItem = createCodexThreadItemRouter({ deps, - subagents, items, - activeTurns, turnBoundaries, genericFrames }) @@ -122,7 +123,7 @@ export function createCodexJournalTranslator( primaryThreadId: deps.primaryThreadId?.() ?? null, compactions, items, - executions: subagents.executions + executions }), ...(deps.sessionId !== undefined ? { @@ -140,6 +141,15 @@ export function createCodexJournalTranslator( }) }, handle: (event) => { + const primaryThreadId = deps.primaryThreadId?.() ?? null + if (event.type === 'notification' && primaryThreadId !== null) { + // Before any row: the rows this frame writes name the child run it starts. + observeCodexExecutionFrame( + executions, + readCodexBackgroundTaskFrame(event, primaryThreadId), + primaryThreadId + ) + } if (event.type === 'ended') { const streamAdmission = flushStreams() if (!streamAdmission.accepted) { @@ -173,11 +183,6 @@ export function createCodexJournalTranslator( if (!admission.accepted) { return admission } - // No event will ever settle a child once the provider is gone. - const sweep = subagents.settleSession() - if (!sweep.accepted) { - return sweep - } readActivity = createCodexProviderActivityReader() deps.sink.setActivity?.(null) items.activeItems.clear() @@ -212,10 +217,6 @@ export function createCodexJournalTranslator( return genericFrames.appendUnhandled(event.kind, event.payload, event.threadId) } if (event.method === 'turn/started' || event.method === 'turn/completed') { - const childAdmission = subagents.handleTurnEvent(event) - if (!childAdmission.accepted) { - return childAdmission - } const admission = event.method === 'turn/started' ? turnBoundaries.start(event) @@ -233,14 +234,6 @@ export function createCodexJournalTranslator( if (goal) { return publishActivity(event, goal) } - if (event.method === CODEX_TOKEN_USAGE_METHOD) { - // Classified `status-chrome`, so the generic-frame path swallows it - // before the journal. The roster consumes it as a typed notification. - const admission = subagents.handleTokenUsage(event.params) - if (admission) { - return admission - } - } if (event.method === 'item/started' || event.method === 'item/completed') { const routed = routeThreadItem(event) // Not a bare return: a claimed item must not skip the turn-tail arm, @@ -298,7 +291,6 @@ export function createCodexJournalTranslator( items.dispose() prompts.dispose() genericFrames.dispose() - subagents.dispose() turnBoundaries.clear() compactions.clear() goals.dispose() diff --git a/src/main/codex/codex-subagent-activity.test.ts b/src/main/codex/codex-subagent-activity.test.ts new file mode 100644 index 00000000000..7e074c07e91 --- /dev/null +++ b/src/main/codex/codex-subagent-activity.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + codexSubagentLabel, + isCodexRootAgentActivity, + readCodexSubagentAnnouncements +} from './codex-subagent-activity' + +const activity = (agentPath: string | null) => ({ + kind: 'started', + agentThreadId: 'child', + agentPath +}) + +describe('a Codex subagent read from its agent path', () => { + it.each(['/root', '/root/', '/root//'])('reads %s as the turn itself, never a child', (path) => { + expect(isCodexRootAgentActivity(activity(path))).toBe(true) + }) + + it('counts a single-segment path other than root, and a path-less one, as a child', () => { + expect(isCodexRootAgentActivity(activity('/morpheus'))).toBe(false) + expect(isCodexRootAgentActivity(activity(null))).toBe(false) + }) + + it('labels a child by its trimmed last segment, and by nothing when that is blank', () => { + expect(codexSubagentLabel(activity('/root//list_directory'))).toBe('list_directory') + expect(codexSubagentLabel(activity('/root/ read '))).toBe('read') + expect(codexSubagentLabel(activity('/root/ '))).toBeNull() + }) + + it('announces no child for the tree root or the session’s own thread', () => { + const item = (agentThreadId: string, agentPath: string) => ({ + type: 'subAgentActivity', + id: `activity-${agentThreadId}`, + kind: 'started', + agentThreadId, + agentPath + }) + expect(readCodexSubagentAnnouncements(item('root-thread', '/root'), 'primary')).toEqual([]) + expect(readCodexSubagentAnnouncements(item('primary', '/root/x'), 'primary')).toEqual([]) + expect(readCodexSubagentAnnouncements(item('child', '/root/x'), 'primary')).toEqual([ + { agentThreadId: 'child', label: 'x', spawned: true } + ]) + }) +}) diff --git a/src/main/codex/codex-subagent-activity.ts b/src/main/codex/codex-subagent-activity.ts index 0a00297dff8..09e8272be1d 100644 --- a/src/main/codex/codex-subagent-activity.ts +++ b/src/main/codex/codex-subagent-activity.ts @@ -96,18 +96,10 @@ export function codexSubagentLabel(activity: CodexSubagentActivity): string | nu export type CodexSubagentAnnouncement = { agentThreadId: string label: string | null - /** The item's turn is the one the child was spawned or messaged from. */ - namesParentTurn: boolean /** The item is the spawn itself, so the thread that carried it spawned the child. */ spawned: boolean } -/** Collab calls that hand a helper work from the caller's turn, as an `interacted` activity does. */ -const CODEX_COLLAB_TOOLS_THAT_TASK_A_HELPER: ReadonlySet = new Set([ - 'spawnAgent', - 'sendInput' -]) - /** The children an item announces: a `subAgentActivity`'s, or every helper a collab call names, * keyed by thread id so repeats announce one child. Neither the tree root nor the session's own * thread is a child. Any call counts, since a helper whose spawn was never seen (compacted out of @@ -130,7 +122,6 @@ function readAnnouncements(item: CodexThreadItem): CodexSubagentAnnouncement[] { { agentThreadId: activity.agentThreadId, label: codexSubagentLabel(activity), - namesParentTurn: activity.kind === 'started' || activity.kind === 'interacted', spawned: activity.kind === 'started' } ] @@ -143,13 +134,7 @@ function readAnnouncements(item: CodexThreadItem): CodexSubagentAnnouncement[] { // Only a spawn's prompt describes its helper; another call's prompt is a later message to it, // so a helper no spawn named reads as any unnamed subagent does. const label = spawned ? codexCollabHelperLabel(call.prompt) : null - const namesParentTurn = CODEX_COLLAB_TOOLS_THAT_TASK_A_HELPER.has(call.tool) - return call.helperThreadIds.map((agentThreadId) => ({ - agentThreadId, - label, - namesParentTurn, - spawned - })) + return call.helperThreadIds.map((agentThreadId) => ({ agentThreadId, label, spawned })) } export type CodexThreadTokenTotal = { threadId: string; totalTokens: number } diff --git a/src/main/codex/codex-subagent-execution-projection.test.ts b/src/main/codex/codex-subagent-execution-projection.test.ts index c3564b5e3c7..8c2fb39de8a 100644 --- a/src/main/codex/codex-subagent-execution-projection.test.ts +++ b/src/main/codex/codex-subagent-execution-projection.test.ts @@ -75,23 +75,9 @@ function harness() { } return admission } - function state(parentTurn: string): string | undefined { - for (const body of rows.values()) { - if (body.kind !== 'message') { - continue - } - for (const block of body.blocks) { - if (block.type === 'subagent-group' && block.groupId === `${PRIMARY}:${parentTurn}`) { - return block.agents[0]?.state - } - } - } - return undefined - } return { send, tracker, - state, refuse: (value: boolean) => { refused = value }, @@ -111,14 +97,12 @@ describe('shared child execution projection', () => { const h = harness() h.send(activity('parent-1')) h.send(activity('parent-2', 'interacted')) - expect(h.state('parent-1')).toBeUndefined() - expect(h.state('parent-2')).toBeUndefined() expect(h.tracker.state).toBeNull() h.dispose() }) it.each(['parent-1', 'parent-2'])( - 'reopens a child for real follow-up in %s in its own row and fences old execution events', + 'reopens a child for real follow-up in %s and fences old execution events', (parent) => { const h = harness() firstRun(h) @@ -130,19 +114,13 @@ 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)) - // One entry per child: the follow-up reopens the row that spawned it. - expect(h.state('parent-1')).toBe('working') + // One entry per child: the follow-up reopens the child that was spawned. 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-1')).toBe('working') expect(h.tracker.state?.tasks).toHaveLength(1) - if (parent === 'parent-2') { - expect(h.state('parent-2')).toBeUndefined() - } h.send(turn('turn/completed', CHILD, 'child-2')) - expect(h.state('parent-1')).toBe('completed') expect(h.tracker.state).toBeNull() h.dispose() } @@ -154,8 +132,6 @@ describe('shared child execution projection', () => { h.send(turn('turn/completed', PRIMARY, 'parent-1')) h.send(activity('parent-2', 'interacted')) h.send(turn('turn/completed', PRIMARY, 'parent-2')) - expect(h.state('parent-1')).toBe('completed') - expect(h.state('parent-2')).toBeUndefined() expect(h.tracker.state).toBeNull() h.dispose() }) @@ -167,8 +143,6 @@ 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('working') - expect(h.state('parent-2')).toBeUndefined() expect(h.tracker.state?.tasks).toHaveLength(1) h.dispose() }) @@ -184,28 +158,20 @@ describe('shared child execution projection', () => { h.send(turn('turn/started', CHILD, 'child-1')) h.send(turn('turn/completed', PRIMARY, 'parent-2')) expect(h.tracker.state?.tasks).toHaveLength(1) - expect(h.state('parent-2')).toBeUndefined() h.send(turn('turn/completed', CHILD, 'child-1')) - expect(h.state('parent-1')).toBe('completed') - expect(h.state('parent-2')).toBeUndefined() expect(h.tracker.state).toBeNull() h.dispose() }) - it('does not expose pending child settlement before journal admission and retries the same fact', () => { + it('settles a child on its own completion, which writes no row the journal could hold back', () => { const h = harness() h.send(turn('turn/started', PRIMARY, 'parent-1')) h.send(activity('parent-1')) h.send(turn('turn/started', CHILD, 'child-1')) h.send(turn('turn/completed', PRIMARY, 'parent-1')) - const complete = turn('turn/completed', CHILD, 'child-1') - h.refuse(true) - expect(h.send(complete)).toEqual({ accepted: false, reason: 'backpressure' }) expect(h.tracker.state?.tasks).toHaveLength(1) - expect(h.state('parent-1')).toBe('working') - h.refuse(false) - expect(h.send(complete)).toEqual({ accepted: true }) - expect(h.state('parent-1')).toBe('completed') + h.refuse(true) + expect(h.send(turn('turn/completed', CHILD, 'child-1'))).toEqual({ accepted: true }) expect(h.tracker.state).toBeNull() h.dispose() }) diff --git a/src/main/codex/codex-subagent-executions.test.ts b/src/main/codex/codex-subagent-executions.test.ts index c7f7955bb23..d298d2d351a 100644 --- a/src/main/codex/codex-subagent-executions.test.ts +++ b/src/main/codex/codex-subagent-executions.test.ts @@ -4,12 +4,12 @@ import { CodexSubagentExecutions } from './codex-subagent-executions' describe('CodexSubagentExecutions retention and identity', () => { it('bounds settled history through repeated execution without evicting live children', () => { const executions = new CodexSubagentExecutions() - executions.register('long-lived', 'long-lived', 'parent') + executions.register('long-lived', 'long-lived') executions.observeTurn('long-lived', 'long-lived-turn', 'working') for (let index = 0; index < 1_000; index++) { const id = `child-${index}` executions.observeTurn(id, id, 'working') - executions.register(id, id, 'parent') + executions.register(id, id) executions.observeTurn(id, id, 'completed') } expect(executions.workingChildren().map((child) => child.agentThreadId)).toEqual(['long-lived']) @@ -18,27 +18,60 @@ describe('CodexSubagentExecutions retention and identity', () => { expect(settledTurns).toBeLessThanOrEqual(256) }) - it('retains early live owner events at capacity and makes room only after settlement', () => { + it('keeps every running child past the settled-history bound', () => { const executions = new CodexSubagentExecutions() - for (let index = 0; index < 128; index++) { + for (let index = 0; index < 200; index++) { + expect(executions.observeTurn(`child-${index}`, `turn-${index}`, 'working')).not.toBeNull() + executions.register(`child-${index}`, `child-${index}`) + } + expect(executions.workingChildren()).toHaveLength(200) + executions.observeTurn('child-0', 'turn-0', 'completed') + executions.register('next', 'next') + expect(executions.find('child-0')).toBeUndefined() + expect(executions.workingChildren()).toHaveLength(199) + }) + + it('evicts a child that ended before one announced but not yet started, which keeps its spawner', () => { + const executions = new CodexSubagentExecutions() + executions.register('announced', 'announced', 'spawner') + for (let index = 0; index < 127; index++) { executions.observeTurn(`child-${index}`, `turn-${index}`, 'working') } - expect(executions.observeTurn('overflow', 'overflow', 'working')).toBeNull() - for (let index = 0; index < 128; index++) { - executions.register(`child-${index}`, `child-${index}`, 'parent') + executions.observeTurn('child-5', 'turn-5', 'completed') + executions.register('next', 'next') + expect(executions.find('child-5')).toBeUndefined() + expect(executions.find('announced')).toMatchObject({ + registered: true, + spawnerThreadId: 'spawner' + }) + executions.observeTurn('announced', 'turn-a', 'working') + expect(executions.workingChildren().map((child) => child.agentThreadId)).toContain('announced') + }) + + it('past the bound, evicts the oldest never-started child once no child has ended', () => { + const executions = new CodexSubagentExecutions() + for (let index = 0; index < 200; index++) { + executions.register(`restored-${index}`, `restored-${index}`) } - expect(executions.workingChildren()).toHaveLength(128) - executions.observeTurn('child-0', 'turn-0', 'completed') - expect(executions.observeTurn('overflow', 'overflow', 'working')).not.toBeNull() - executions.register('overflow', 'overflow', 'parent') - expect(executions.workingChildren()).toHaveLength(128) + expect(executions.retentionSizes().children).toBe(128) + expect(executions.find('restored-0')).toBeUndefined() + expect(executions.find('restored-199')).toBeDefined() + }) + + it('collapses a label’s whitespace, and keeps the first label a child was given', () => { + const executions = new CodexSubagentExecutions() + executions.register('child', ' audit \n the build ') + executions.register('child', 'later name') + executions.register('blank', ' ') + expect(executions.label('child')).toBe('audit the build') + expect(executions.label('blank')).toBeNull() }) it('corrects an unverifiable execution with its own terminal event and ignores stale starts', () => { const executions = new CodexSubagentExecutions() - executions.register('child', 'child', 'parent') + executions.register('child', 'child') executions.observeTurn('child', 'turn', 'working') - executions.settleSession() + executions.closeThread('child') expect(executions.workingChildren()).toEqual([]) expect(executions.observeTurn('child', 'turn', 'working')).toBeNull() expect(executions.observeTurn('child', 'turn', 'completed')?.execution.state).toBe('completed') diff --git a/src/main/codex/codex-subagent-executions.ts b/src/main/codex/codex-subagent-executions.ts index 2bdcb3a35bf..bd9623821bb 100644 --- a/src/main/codex/codex-subagent-executions.ts +++ b/src/main/codex/codex-subagent-executions.ts @@ -4,6 +4,8 @@ import { canReplaceSubagentState } from '../../shared/native-chat-subagent-summary' +/** Children kept past this evict the oldest that ran and ended, else the oldest never started; a + * running child is always kept, however many run. */ const MAX_CHILDREN = 128 const MAX_SETTLED_TURNS = 256 /** Turn ordinals remembered per child; a row from an older run reads as its first. */ @@ -18,7 +20,6 @@ export type CodexExecutionChild = { agentThreadId: string registered: boolean label: string | null - parentTurnId: string | null /** The thread whose stream carried this child's `started` activity. Codex * emits that item on the spawning agent's own session, so it names the parent. */ spawnerThreadId: string | null @@ -28,34 +29,17 @@ export type CodexExecutionChild = { turnCount: number } -/** Told when a child's current execution changes, whichever frame changed it. */ -export type CodexExecutionListener = (child: Readonly) => void - /** Child turn events own execution; activity items only identify the child. */ export class CodexSubagentExecutions { private readonly children = new Map() private readonly settledTurns = new Map() - private readonly listeners = new Set() - - /** Every reader of a child's execution follows it here, so none needs the frames that end it. */ - onExecutionChanged(listener: CodexExecutionListener): () => void { - this.listeners.add(listener) - return () => this.listeners.delete(listener) - } register( agentThreadId: string, label: string | null, - parentTurnId: string | null | undefined, spawnerThreadId?: string - ): CodexExecutionChild | undefined { + ): CodexExecutionChild { const child = this.child(agentThreadId) - if (!child) { - return undefined - } - if (!child.registered || parentTurnId !== undefined) { - child.parentTurnId = parentTurnId ?? null - } // A child is spawned once; its announcement is delivered twice, never by another thread. child.spawnerThreadId ??= spawnerThreadId ?? null child.registered = true @@ -79,9 +63,6 @@ export class CodexSubagentExecutions { return null } const child = this.child(agentThreadId) - if (!child) { - return null - } this.numberTurn(child, turnId) if ( state === 'working' && @@ -107,7 +88,6 @@ export class CodexSubagentExecutions { } if (state === 'working' || !child.execution || child.execution.turnId === turnId) { child.execution = execution - this.changed(child) } return { child, execution } } @@ -168,15 +148,6 @@ export class CodexSubagentExecutions { ) } - settleSession(): void { - for (const child of this.children.values()) { - if (child.execution?.state === 'working') { - child.execution = { ...child.execution, state: 'unverifiable' } - this.changed(child) - } - } - } - clear(): void { this.children.clear() this.settledTurns.clear() @@ -187,29 +158,26 @@ export class CodexSubagentExecutions { return { children: this.children.size, settledTurns: this.settledTurns.size } } - private changed(child: CodexExecutionChild): void { - for (const listener of this.listeners) { - listener(child) - } - } - - private child(agentThreadId: string): CodexExecutionChild | undefined { + private child(agentThreadId: string): CodexExecutionChild { const existing = this.children.get(agentThreadId) if (existing) { return existing } if (this.children.size >= MAX_CHILDREN) { - const settled = [...this.children].find(([, child]) => child.execution?.state !== 'working') - if (!settled) { - return undefined + // Refusing a running child would hide it while it runs. One announced but not started is + // about to run, so it goes only when no child has ended; a restored history announces many. + const children = [...this.children.values()] + const evicted = + children.find((child) => child.execution !== null && child.execution.state !== 'working') ?? + children.find((child) => child.execution === null) + if (evicted) { + this.children.delete(evicted.agentThreadId) } - this.children.delete(settled[0]) } const child: CodexExecutionChild = { agentThreadId, registered: false, label: null, - parentTurnId: null, spawnerThreadId: null, execution: null, turnOrdinals: new Map(), diff --git a/src/main/codex/codex-subagent-roster-characterization.test.ts b/src/main/codex/codex-subagent-roster-characterization.test.ts deleted file mode 100644 index b0a86bace27..00000000000 --- a/src/main/codex/codex-subagent-roster-characterization.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -// 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 deleted file mode 100644 index 6808820713f..00000000000 --- a/src/main/codex/codex-subagent-roster-restart.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -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-retention.test.ts b/src/main/codex/codex-subagent-roster-retention.test.ts deleted file mode 100644 index 92de0fc2aea..00000000000 --- a/src/main/codex/codex-subagent-roster-retention.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' -import { isSubagentGroupBlock, type NativeChatSubagentState } from '../../shared/native-chat-types' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' -import { - createDeferredStructuredAgentSessionEventSink, - type StructuredAgentSessionEventSink -} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' -import { CodexSubagentRoster } from './codex-subagent-roster' -import { MAX_CODEX_SUBAGENT_GROUPS } from './codex-structured-journal-limits' - -const THREAD = 'parent' -const ORIGINAL = 'original' -const journals = createTrackedJournalOpener() -const cleanups: (() => Promise)[] = [] -afterEach(async () => { - for (const cleanup of cleanups.splice(0)) { - await cleanup() - } - await journals.closeAll() -}) - -async function session(refuse?: 'append' | 'publish') { - const root = await mkdtemp(join(tmpdir(), 'orca-codex-roster-retention-')) - const journal = await journals.open({ - identity: { - sessionId: 'session-codex-roster-retention', - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: codexProviderHandle(THREAD) - }, - stateDirectory: root, - now: () => 1_000 - }) - const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - deferred.bind({ journal, fence: 1, publish: () => {} }) - const append = deferred.sink.tryAppendItem - const publish = deferred.sink.tryPublish - if (!append || !publish) { - throw new Error('the deferred sink must expose append and publish admission') - } - let refusing = false - let writingOriginal = false - const refusal = { accepted: false, reason: 'backpressure' } as const - const sink: StructuredAgentSessionEventSink = { - ...deferred.sink, - tryAppendItem: (identity, body, options) => { - writingOriginal = - identity.provider === 'orca' && - identity.clientMessageId === `codex-subagents:${THREAD}:${ORIGINAL}` - return refusing && writingOriginal && refuse === 'append' - ? refusal - : append(identity, body, options) - }, - tryPublish: () => (refusing && writingOriginal && refuse === 'publish' ? refusal : publish()) - } - let parentTurn = ORIGINAL - let clock = 1_000 - const roster = new CodexSubagentRoster({ - sink, - primaryThreadId: () => THREAD, - activeTurn: () => parentTurn, - turnScopeFor: (_thread, turnId) => ({ kind: 'turn', turnItemId: turnId ?? 'outside-turn' }), - now: () => ++clock - }) - cleanups.push(async () => { - roster.dispose() - deferred.close() - await journal.close() - await rm(root, { recursive: true, force: true }) - }) - const turn = (id: string, state: NativeChatSubagentState, turnId = `execution:${id}`) => - roster.handleTurn({ threadId: id, turnId, state }) - const announce = (id: string, kind = 'started') => - roster.handleItem({ - threadId: THREAD, - turnId: parentTurn, - item: { - type: 'subAgentActivity', - id: `activity:${id}:${kind}`, - agentThreadId: id, - agentPath: '/root/task', - kind - } - }) - function spawn(id: string, parent = ORIGINAL) { - parentTurn = parent - expect(turn(id, 'working')).toEqual({ accepted: true }) - expect(announce(id)).toEqual({ accepted: true }) - } - async function rows() { - await deferred.drained() - return journal - .snapshot() - .items.flatMap((row) => - row.body.kind === 'message' - ? row.body.blocks.filter(isSubagentGroupBlock).map((group) => ({ row, group })) - : [] - ) - } - return { - roster, - turn, - announce, - spawn, - rows, - original: async () => - (await rows()).find(({ group }) => group.groupId === `${THREAD}:${ORIGINAL}`), - parent: (turnId: string) => { - parentTurn = turnId - }, - refuse: (value: boolean) => { - refusing = value - } - } -} - -type Session = Awaited> -async function laterGroups(s: Session, settled: boolean, count = MAX_CODEX_SUBAGENT_GROUPS + 2) { - for (let index = 0; index < count; index++) { - const id = `later-child-${index}` - s.spawn(id, `later-parent-${index}`) - if (settled) { - expect(s.turn(id, 'completed')).toEqual({ accepted: true }) - } - if (index % 32 === 31) { - await s.rows() - } - } - await s.rows() -} - -function states(s: Awaited>) { - return s?.group.agents.map(({ id, state }) => ({ id, state })) -} - -describe('Codex roster retention through the real sink and journal', () => { - it.each([ - [false, 'completed'], - [true, 'completed'], - [false, 'sweep'], - [true, 'sweep'] - ] as const)( - 'preserves all oldest live children when later groups settled=%s and final event=%s', - async (settled, final) => { - const s = await session() - for (const id of ['old-a', 'old-b', 'old-c']) { - s.spawn(id) - } - const before = await s.original() - expect(before?.group.agents).toHaveLength(3) - await laterGroups(s, settled) - s.parent('current') - s.announce('old-a', 'interacted') - expect(states(await s.original())).toEqual( - ['old-a', 'old-b', 'old-c'].map((id) => ({ id, state: 'working' })) - ) - if (final === 'sweep') { - expect(s.roster.settleSession()).toEqual({ accepted: true }) - } else { - for (const id of ['old-a', 'old-b', 'old-c']) { - s.turn(id, 'completed') - } - } - const after = await s.original() - expect(after?.row.itemId).toBe(before?.row.itemId) - expect(after?.row.turnScope).toEqual(before?.row.turnScope) - expect(states(after)).toEqual( - ['old-a', 'old-b', 'old-c'].map((id) => ({ - id, - state: final === 'sweep' ? 'unverifiable' : 'completed' - })) - ) - expect(after?.group.agents.every((entry) => entry.settledAt !== undefined)).toBe(true) - expect( - (await s.rows()).flatMap(({ group }) => - group.agents.filter((entry) => entry.id === 'old-a') - ) - ).toHaveLength(1) - } - ) - - it('ignores late reports for evicted settled executions even after the execution cache has pruned them', async () => { - const s = await session() - s.spawn('old-a') - s.spawn('old-b') - s.turn('old-a', 'stopped') - s.turn('old-b', 'completed') - const before = await s.original() - await laterGroups(s, true, 270) - expect(s.roster.executions.find('old-a')).toBeUndefined() - expect(s.roster.retentionSizes().groups).toBe(MAX_CODEX_SUBAGENT_GROUPS) - s.parent('current') - s.turn('old-a', 'working') - s.announce('old-a') - s.announce('old-a', 'interacted') - s.turn('old-a', 'completed') - s.announce('old-a', 'completed') - s.roster.handleTokenUsage({ threadId: 'old-a', tokenUsage: { total: { totalTokens: 123 } } }) - expect(await s.original()).toEqual(before) - expect(s.roster.executions.find('old-a')?.execution).toBeNull() - expect( - (await s.rows()).flatMap(({ group }) => group.agents.filter((entry) => entry.id === 'old-a')) - ).toHaveLength(1) - }) - - it.each([ - ['before', false], - ['after', false], - ['before', true], - ['after', true] - ] as const)( - 'admits a genuine new child turn when the announcement is %s its start and execution cache pruned=%s', - async (order, pruned) => { - const s = await session() - s.spawn('old-a') - s.turn('old-a', 'completed') - const before = await s.original() - await laterGroups(s, true, pruned ? 270 : MAX_CODEX_SUBAGENT_GROUPS + 2) - s.parent('follow-up') - if (order === 'before') { - s.announce('old-a', 'interacted') - } - s.turn('old-a', 'working', 'new-execution') - if (order === 'after') { - s.announce('old-a', 'interacted') - } - const followup = () => - s.rows().then((rows) => rows.find(({ group }) => group.groupId === `${THREAD}:follow-up`)) - expect(states(await followup())).toEqual([{ id: 'old-a', state: 'working' }]) - s.turn('old-a', 'working') - s.turn('old-a', 'completed') - expect(states(await followup())).toEqual([{ id: 'old-a', state: 'working' }]) - s.turn('old-a', 'completed', 'new-execution') - expect(states(await followup())).toEqual([{ id: 'old-a', state: 'completed' }]) - expect(await s.original()).toEqual(before) - } - ) - - it('suppresses both settled executions after same-group reuse, eviction and execution-cache churn', async () => { - const s = await session() - s.spawn('old-a') - s.spawn('old-b') - s.turn('old-a', 'completed') - s.announce('old-a', 'interacted') - s.turn('old-a', 'working', 'second-execution') - s.turn('old-a', 'completed', 'second-execution') - s.turn('old-b', 'completed') - const original = await s.original() - await laterGroups(s, true, 270) - expect(s.roster.executions.find('old-a')).toBeUndefined() - expect(s.roster.retentionSizes().settledIdentities).toBeLessThan(2048) - const before = await s.rows() - s.parent('current') - for (const turnId of ['execution:old-a', 'second-execution']) { - s.turn('old-a', 'working', turnId) - s.announce('old-a') - s.turn('old-a', 'completed', turnId) - s.announce('old-a', 'completed') - } - expect(await s.rows()).toEqual(before) - s.announce('old-a', 'interacted') - s.turn('old-a', 'working', 'third-execution') - const current = (await s.rows()).find(({ group }) => group.groupId === `${THREAD}:current`) - expect(states(current)).toEqual([{ id: 'old-a', state: 'working' }]) - s.turn('old-a', 'completed', 'third-execution') - expect(await s.original()).toEqual(original) - }) - - it.each(['append', 'publish'] as const)( - 'retains final retry ownership after %s refuses settlement under group pressure', - async (refuse) => { - const s = await session(refuse) - s.spawn('old-a') - s.spawn('old-b') - const before = await s.original() - await laterGroups(s, false) - s.turn('old-b', 'completed') - s.refuse(true) - expect(s.turn('old-a', 'completed')).toEqual({ accepted: false, reason: 'backpressure' }) - s.spawn('more-live', 'current') - expect(s.roster.retentionSizes().groups).toBeGreaterThan(MAX_CODEX_SUBAGENT_GROUPS) - s.refuse(false) - expect(s.roster.settleSession()).toEqual({ accepted: true }) - const after = await s.original() - expect(after?.row.itemId).toBe(before?.row.itemId) - expect(states(after)).toEqual([ - { id: 'old-a', state: 'completed' }, - { id: 'old-b', state: 'completed' } - ]) - expect(s.roster.retentionSizes().groups).toBe(MAX_CODEX_SUBAGENT_GROUPS) - } - ) - - it.each(['append', 'publish'] as const)( - 'retries a refused final sweep %s after more live groups arrive', - async (refuse) => { - const s = await session(refuse) - s.spawn('old-a') - s.spawn('old-b') - const before = await s.original() - await laterGroups(s, false) - s.refuse(true) - expect(s.roster.settleSession()).toEqual({ accepted: false, reason: 'backpressure' }) - s.spawn('more-live', 'current') - s.refuse(false) - expect(s.roster.settleSession()).toEqual({ accepted: true }) - const after = await s.original() - expect(after?.row.itemId).toBe(before?.row.itemId) - expect(states(after)).toEqual([ - { id: 'old-a', state: 'unverifiable' }, - { id: 'old-b', state: 'unverifiable' } - ]) - expect(s.roster.retentionSizes().groups).toBe(MAX_CODEX_SUBAGENT_GROUPS) - } - ) -}) diff --git a/src/main/codex/codex-subagent-roster-state.ts b/src/main/codex/codex-subagent-roster-state.ts deleted file mode 100644 index 531a5e38195..00000000000 --- a/src/main/codex/codex-subagent-roster-state.ts +++ /dev/null @@ -1,23 +0,0 @@ -// The ids a Codex spawn group's journal row is keyed on. - -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' - -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}` -} - -/** 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 - * across a restart, so replay finds the same row instead of appending a new one. */ -export function codexSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity { - return { provider: 'orca', clientMessageId: `codex-subagents:${groupId}` } -} diff --git a/src/main/codex/codex-subagent-roster.test.ts b/src/main/codex/codex-subagent-roster.test.ts deleted file mode 100644 index ac56c042ba2..00000000000 --- a/src/main/codex/codex-subagent-roster.test.ts +++ /dev/null @@ -1,779 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' -import { describe, expect, it } from 'vitest' -import { isAdmissibleAgentJournalItemBody } from '../../shared/agent-session-journal-schemas' -import type { - AgentJournalItemBody, - AgentJournalItemIdentity -} from '../../shared/agent-session-journal-types' -import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary' -import { isSubagentGroupBlock, type NativeChatSubagentEntry } from '../../shared/native-chat-types' -import { - createDeferredStructuredAgentSessionEventSink, - type StructuredAgentSessionEventSink, - type StructuredAgentSessionEventTarget -} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { withJournalQueueMembers } from '../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support' -import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' -import { - CodexSubagentRoster, - codexSubagentGroupIdentity, - codexSubagentGroupId -} from './codex-subagent-roster' -import type { CodexThreadItem } from './codex-structured-item-translation' -import { - MAX_CODEX_SUBAGENT_GROUPS, - MAX_CODEX_SUBAGENTS_PER_GROUP, - MAX_CODEX_TOKEN_USAGE_THREADS -} from './codex-structured-journal-limits' - -const THREAD = 'thread-parent' -const TURN = 'turn-1' - -type Appended = { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } - -function createHarness(options: { threadId?: string | null } = {}): { - roster: CodexSubagentRoster - appended: Appended[] - agents: () => NativeChatSubagentEntry[] - latest: () => Appended | undefined -} { - const appended: Appended[] = [] - let clock = 1_000 - const sink: StructuredAgentSessionEventSink = { - appendItem: () => {}, - appendTombstone: () => {}, - publish: () => {}, - tryAppendItem: (identity, body) => { - appended.push({ identity, body }) - return { accepted: true } - }, - tryPublish: () => ({ accepted: true }) - } - const roster = new CodexSubagentRoster({ - turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, - sink, - primaryThreadId: () => (options.threadId === undefined ? THREAD : options.threadId), - activeTurn: () => TURN, - now: () => (clock += 1) - }) - const agents = (): NativeChatSubagentEntry[] => { - const body = appended.at(-1)?.body - if (!body || body.kind !== 'message') { - return [] - } - const block = body.blocks.find(isSubagentGroupBlock) - return block ? block.agents : [] - } - return { roster, appended, agents, latest: () => appended.at(-1) } -} - -function latestIdentity(appended: Appended[]): AgentJournalItemIdentity | undefined { - return appended.at(-1)?.identity -} - -function activity(input: { - id?: string - kind: string - agentThreadId: string - agentPath: string | null -}): CodexThreadItem { - return { - type: 'subAgentActivity', - id: input.id ?? `item-${input.agentThreadId}-${input.kind}`, - kind: input.kind, - agentThreadId: input.agentThreadId, - agentPath: input.agentPath - } -} - -function deliver( - roster: CodexSubagentRoster, - item: CodexThreadItem, - turnId: string | null = TURN -): void { - // The fixture includes the child's owner event separately from its activity metadata. - const state = - item.kind === 'started' - ? 'working' - : item.kind === 'completed' - ? 'completed' - : item.kind === 'interrupted' - ? 'stopped' - : null - if (state && typeof item.agentThreadId === 'string') { - roster.handleTurn({ - threadId: item.agentThreadId, - turnId: `execution:${item.agentThreadId}`, - state - }) - } - // Every activity item reaches the wire twice: item/started, then item/completed. - roster.handleItem({ threadId: THREAD, turnId, item }) - roster.handleItem({ threadId: THREAD, turnId, item }) -} - -/** The real event sink, unbound until `drain`, over a journal double that records appends. */ -function createQueuedHarness(): { - roster: CodexSubagentRoster - appended: Appended[] - drain: () => void -} { - const appended: Appended[] = [] - let clock = 1_000 - const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - const journal = withJournalQueueMembers({ - appendItem: async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { - appended.push({ identity, body }) - return { cursor: { epoch: 'e', sequence: appended.length } } - } - }) - const roster = new CodexSubagentRoster({ - turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, - sink: deferred.sink, - primaryThreadId: () => THREAD, - activeTurn: () => TURN, - now: () => (clock += 1) - }) - return { - roster, - appended, - drain: () => - deferred.bind({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the roster's rows reach only appendItem and the in-order members the helper adds. - journal: journal as unknown as StructuredAgentSessionEventTarget['journal'], - fence: 1, - publish: () => {} - }) - } -} - -describe('CodexSubagentRoster', () => { - it('lands the roster append its own publish was buffered beside', () => { - const { roster, appended, drain } = createQueuedHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - drain() - - // A publish once shared the append's coalescing key and spliced it out of the queue, and - // `lastSerialized` then suppressed every retry. - expect(appended).toHaveLength(1) - }) - - it('counts a /morpheus agent as a child — only /root is the turn itself', () => { - const { roster, agents } = createHarness() - - deliver(roster, activity({ kind: 'started', agentThreadId: 'child-m', agentPath: '/morpheus' })) - - expect(agents()).toMatchObject([{ id: 'child-m', label: 'morpheus', state: 'working' }]) - }) - - // `codexSubagentPathSegments` already defines what a path means for the label, - // and the root check has to agree with it: a path that normalizes to the same - // node must classify the same way, or one string is both the turn itself and a - // child of it — a phantom row labelled `root` inflating the group by one. - it('reads a root path with a trailing or doubled separator as the turn itself', () => { - for (const agentPath of ['/root/', '/root//', '//root']) { - const { roster, appended } = createHarness() - - deliver(roster, activity({ kind: 'started', agentThreadId: THREAD, agentPath })) - - expect(appended).toEqual([]) - } - }) - - it('keeps a doubled separator inside a child path off the label', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root//read/' }) - ) - - expect(agents()).toMatchObject([{ id: 'child-1', label: 'read' }]) - }) - - // An all-whitespace trailing segment survives the empty-segment filter and - // would draw a row with no visible name at all. - it('falls back to the placeholder when the trailing segment has nothing to show', () => { - const { roster, agents } = createHarness() - - deliver(roster, activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/ ' })) - - expect(agents()).toMatchObject([{ id: 'child-1', label: 'subagent' }]) - }) - - // The collision ordinal keys on the label, so two segments that render - // identically must collide rather than both draw as `read`. - it('collides labels that differ only in surrounding whitespace', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-2', agentPath: '/root/ read ' }) - ) - - expect(agents().map((agent) => agent.label)).toEqual(['read', 'read 2']) - }) - - it('ignores the root node so a turn is not its own subagent', () => { - const { roster, appended } = createHarness() - - deliver(roster, activity({ kind: 'started', agentThreadId: THREAD, agentPath: '/root' })) - - expect(appended).toEqual([]) - }) - - it('writes an admissible journal body carrying a plain-text fallback block', () => { - const { roster, latest } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/list_directory' }) - ) - - const body = latest()?.body - expect(body?.kind).toBe('message') - expect(isAdmissibleAgentJournalItemBody(body)).toBe(true) - expect(body?.kind === 'message' ? body.blocks.map((block) => block.type) : []).toEqual([ - 'text', - 'subagent-group' - ]) - expect( - body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' - ).toBe('Kicked off 1 subagent') - }) - - it('keys the durable identity by the parent turn so a revision lands on one row', () => { - const { roster, appended } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - deliver( - roster, - activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - - const expected = codexSubagentGroupIdentity(codexSubagentGroupId(THREAD, TURN)) - expect(new Set(appended.map((entry) => JSON.stringify(entry.identity)))).toEqual( - new Set([JSON.stringify(expected)]) - ) - }) - - it('rule 1 — a duplicate delivery writes no second revision', () => { - const { roster, appended } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - - expect(appended).toHaveLength(1) - }) - - it('registers a child after its owner already reported completion', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'completed', agentThreadId: 'child-late', agentPath: '/root/search' }) - ) - - expect(agents()).toMatchObject([{ id: 'child-late', label: 'search', state: 'completed' }]) - }) - - it('rule 3 — a terminal state latches against a late or duplicate start', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - deliver( - roster, - activity({ kind: 'interacted', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - - expect(agents()).toMatchObject([{ state: 'completed' }]) - }) - - it('rule 4 — the session sweep settles a lost child as unverifiable, not exited', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - deliver( - roster, - activity({ kind: 'completed', agentThreadId: 'child-2', agentPath: '/root/search' }) - ) - roster.settleSession() - - expect(agents()).toMatchObject([ - { id: 'child-1', state: 'unverifiable' }, - { id: 'child-2', state: 'completed' } - ]) - }) - - it('lets a swept child still report what it actually did', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - roster.settleSession() - expect(agents()[0]?.state).toBe('unverifiable') - - // Contact can return — a reconnected provider replays the child's own - // verdict. Latching the sweep would report a child that finished as one we - // never saw finish. - deliver( - roster, - activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - expect(agents()[0]?.state).toBe('completed') - }) - - it('refuses to put a swept child back to working', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - roster.settleSession() - // A straggler progress tick after we gave up must not re-light the row. - deliver( - roster, - activity({ kind: 'interacted', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - expect(agents()[0]?.state).toBe('unverifiable') - }) - - it('keeps a real verdict when a later frame disagrees', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - deliver( - roster, - activity({ kind: 'interrupted', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - expect(agents()[0]?.state).toBe('completed') - }) - - it('rule 4 — the session sweep settles every group and never un-terminals one', () => { - const { roster, agents, appended } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - roster.settleSession() - const afterFirstSweep = appended.length - roster.settleSession() - - expect(agents()).toMatchObject([{ state: 'unverifiable' }]) - expect(appended).toHaveLength(afterFirstSweep) - }) - - it('rule 5 — the whole roster is persisted in the carrier, not just a count', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 40661 } } }) - - expect(agents()).toMatchObject([ - { id: 'child-1', label: 'read', state: 'working', tokens: 40661 } - ]) - }) - - it('rule 6 — the group id names the parent turn, or says there was none', () => { - const { roster, appended } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-2', agentPath: '/root/search' }), - null - ) - - expect(appended.map((entry) => entry.identity)).toEqual([ - { provider: 'orca', clientMessageId: `codex-subagents:${THREAD}:${TURN}` }, - { provider: 'orca', clientMessageId: `codex-subagents:${THREAD}:outside-turn` } - ]) - }) - - it('disambiguates two children that share a trailing path segment', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-2', agentPath: '/root/read' }) - ) - - expect(agents().map((agent) => agent.label)).toEqual(['read', 'read 2']) - }) - - it('takes the latest token snapshot per child and never accumulates updates', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 100 } } }) - roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 250 } } }) - - expect(agents()).toMatchObject([{ tokens: 250 }]) - }) - - it('retains a usage frame that arrives before the child is known', () => { - const { roster, agents } = createHarness() - - roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 900 } } }) - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - - expect(agents()).toMatchObject([{ tokens: 900 }]) - }) - - it('never attributes the parent thread its own usage', () => { - const { roster, agents, appended } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - const beforeParentUsage = appended.length - roster.handleTokenUsage({ threadId: THREAD, tokenUsage: { total: { totalTokens: 26099 } } }) - - expect(appended).toHaveLength(beforeParentUsage) - expect(agents()).toHaveLength(1) - expect(agents()[0]).not.toHaveProperty('tokens') - }) - - // The row is durable and both readers clip these fields to the same cap, so - // writing more than that is bytes replayed on every reconnect and then thrown - // away. The marker is an ellipsis, not the tool-output truncation sentence: - // `id` is the roster key and the renderer's React key. - it('bounds the provider strings the roster row carries into the journal', () => { - const { roster, agents, latest } = createHarness() - const oversized = 'a'.repeat(20 * 1024) - - deliver( - roster, - activity({ kind: 'started', agentThreadId: oversized, agentPath: `/root/${oversized}` }) - ) - - const entry = agents()[0] - expect(entry?.label.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS) - expect(entry?.label).toMatch(/…~0$/) - expect(entry?.id.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS) - expect(entry?.id).toMatch(/…~0$/) - expect(JSON.stringify(latest()?.body)).not.toContain('output truncated') - expect(isAdmissibleAgentJournalItemBody(latest()?.body)).toBe(true) - }) - - // The clip cuts UTF-16 code units, so a boundary landing inside a surrogate - // pair left a LONE high surrogate in a durable row — malformed, and replaced - // with U+FFFD through any non-JSON UTF-8 hop. - it('never clips a provider string mid surrogate pair', () => { - const { roster, agents } = createHarness() - const astral = '😀'.repeat(400) - - deliver( - roster, - activity({ kind: 'started', agentThreadId: astral, agentPath: `/root/${astral}` }) - ) - - const entry = agents()[0] - expect(entry?.id.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS) - expect(Buffer.from(entry?.id ?? '', 'utf8').toString('utf8')).toBe(entry?.id) - expect(Buffer.from(entry?.label ?? '', 'utf8').toString('utf8')).toBe(entry?.label) - }) - - // The clip removes exactly the tail that told two children apart: `id` is the - // renderer's React key, and `claimLabel` writes its repeat ordinal at the end. - // Two clipped children collapsing to one key drew two rows under one identity. - it('keeps clipped ids and labels distinct between children', () => { - const { roster, agents } = createHarness() - const prefix = 'p'.repeat(MAX_SUBAGENT_FIELD_CHARS) - const sharedPath = `/root/${'q'.repeat(640)}` - - deliver( - roster, - activity({ kind: 'started', agentThreadId: `${prefix}AAAA`, agentPath: sharedPath }) - ) - deliver( - roster, - activity({ kind: 'started', agentThreadId: `${prefix}BBBB`, agentPath: sharedPath }) - ) - - const entries = agents() - expect(entries).toHaveLength(2) - expect(new Set(entries.map((agent) => agent.id)).size).toBe(2) - expect(new Set(entries.map((agent) => agent.label)).size).toBe(2) - for (const agent of entries) { - expect(agent.id.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS) - expect(agent.label.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS) - } - }) - - it('caps the children one spawn group admits', () => { - const { roster, agents, appended } = createHarness() - for (let index = 0; index < MAX_CODEX_SUBAGENTS_PER_GROUP; index++) { - deliver( - roster, - activity({ kind: 'started', agentThreadId: `child-${index}`, agentPath: '/root/read' }) - ) - } - const atCap = appended.length - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-over-cap', agentPath: '/root/read' }) - ) - - expect(agents()).toHaveLength(MAX_CODEX_SUBAGENTS_PER_GROUP) - expect(agents().map((agent) => agent.id)).not.toContain('child-over-cap') - // Refusing the child must not burn a revision either. - expect(appended).toHaveLength(atCap) - }) - - it('retains live spawn groups beyond the settled history budget', () => { - const { roster, appended, agents } = createHarness() - for (let index = 0; index <= MAX_CODEX_SUBAGENT_GROUPS; index++) { - deliver( - roster, - activity({ kind: 'started', agentThreadId: `child-${index}`, agentPath: '/root/read' }), - `turn-${index}` - ) - } - const original = codexSubagentGroupIdentity(codexSubagentGroupId(THREAD, 'turn-0')) - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-late', agentPath: '/root/search' }), - 'turn-0' - ) - - expect(latestIdentity(appended)).toEqual(original) - expect(agents().map((agent) => agent.id)).toEqual(['child-0', 'child-late']) - expect(roster.retentionSizes().groups).toBe(MAX_CODEX_SUBAGENT_GROUPS + 1) - }) - - it('keeps a token count a later thread-map eviction would otherwise retract', () => { - const { roster, agents } = createHarness() - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 4242 } } }) - expect(agents()).toMatchObject([{ tokens: 4242 }]) - - for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS; index++) { - roster.handleTokenUsage({ - threadId: `other-${index}`, - tokenUsage: { total: { totalTokens: index } } - }) - } - deliver( - roster, - activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - - expect(agents()).toMatchObject([{ state: 'completed', tokens: 4242 }]) - }) - - it('caps retained usage threads, so a frame evicted before its child is dropped', () => { - const { roster, agents } = createHarness() - roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 900 } } }) - for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS; index++) { - roster.handleTokenUsage({ - threadId: `other-${index}`, - tokenUsage: { total: { totalTokens: index } } - }) - } - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - - expect(agents()[0]).not.toHaveProperty('tokens') - }) - - it('declines a payload that is not a subagent item or a usage frame', () => { - const { roster } = createHarness() - - expect( - roster.handleItem({ - threadId: THREAD, - turnId: TURN, - item: { type: 'commandExecution', id: 'item-9' } - }) - ).toBeNull() - expect(roster.handleTokenUsage({ threadId: 'child-1' })).toBeNull() - }) - - // A refusal must never advance the duplicate-suppression state: an identical - // replay would short-circuit and the revision would never be retried. The - // append and the publish are the two ways to be refused, so both are covered. - it.each([{ refuse: 'append' as const }, { refuse: 'publish' as const }])( - 'retries the same revision after the $refuse is refused', - ({ refuse }) => { - let refusing = true - const appended: Appended[] = [] - const published: number[] = [] - const refusal = { accepted: false, reason: 'backpressure' } as const - const roster = new CodexSubagentRoster({ - turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, - sink: { - appendItem: () => {}, - appendTombstone: () => {}, - publish: () => {}, - tryAppendItem: (identity, body) => { - if (refusing && refuse === 'append') { - return refusal - } - appended.push({ identity, body }) - return { accepted: true } - }, - tryPublish: () => { - if (refusing && refuse === 'publish') { - return refusal - } - published.push(1) - return { accepted: true } - } - }, - primaryThreadId: () => THREAD, - activeTurn: () => TURN, - now: () => 1_000 - }) - const item = activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - roster.handleTurn({ threadId: 'child-1', turnId: 'child-turn', state: 'working' }) - - expect(roster.handleItem({ threadId: THREAD, turnId: TURN, item })).toEqual(refusal) - - // The wire redelivers the very same item; nothing about the roster changed, - // so only a cleared suppression state can get the revision out. - refusing = false - expect(roster.handleItem({ threadId: THREAD, turnId: TURN, item })).toEqual({ - accepted: true - }) - // The retry re-appends when the publish was the half that failed; the real - // sink writes both, as revisions of the one group row. What must not happen - // is the revision never being published at all. - expect(published).toHaveLength(1) - const body = appended.at(-1)?.body - expect( - body?.kind === 'message' ? body.blocks.filter(isSubagentGroupBlock) : [] - ).toMatchObject([{ agents: [{ id: 'child-1', state: 'working' }] }]) - } - ) - - // The sweep is the last event a group ever gets. A refusal there, left - // unretried, strands the settled roster's final revision — the exact "row - // stays stale forever" this row exists to prevent. - it('republishes the settled roster when the sweep publish was refused', () => { - let refusing = false - const appended: Appended[] = [] - const published: number[] = [] - const roster = new CodexSubagentRoster({ - turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, - sink: { - appendItem: () => {}, - appendTombstone: () => {}, - publish: () => {}, - tryAppendItem: (identity, body) => { - appended.push({ identity, body }) - return { accepted: true } - }, - tryPublish: () => { - if (refusing) { - return { accepted: false, reason: 'backpressure' } - } - published.push(1) - return { accepted: true } - } - }, - primaryThreadId: () => THREAD, - activeTurn: () => TURN, - now: () => 1_000 - }) - roster.handleTurn({ threadId: 'child-1', turnId: 'child-turn', state: 'working' }) - roster.handleItem({ - threadId: THREAD, - turnId: TURN, - item: activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - }) - const publishedBeforeSweep = published.length - - refusing = true - expect(roster.settleSession()).toEqual({ accepted: false, reason: 'backpressure' }) - - // The retry sweep flips no state — every child already latched — so only a - // cleared suppression state can carry the unverifiable roster out. - refusing = false - expect(roster.settleSession()).toEqual({ accepted: true }) - expect(published.length).toBe(publishedBeforeSweep + 1) - const body = appended.at(-1)?.body - expect(body?.kind === 'message' ? body.blocks.filter(isSubagentGroupBlock) : []).toMatchObject([ - { agents: [{ id: 'child-1', state: 'unverifiable' }] } - ]) - }) - - it('propagates sink backpressure instead of reporting the row as written', () => { - const roster = new CodexSubagentRoster({ - turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, - sink: { - appendItem: () => {}, - appendTombstone: () => {}, - publish: () => {}, - tryAppendItem: () => ({ accepted: false, reason: 'backpressure' }), - tryPublish: () => ({ accepted: true }) - }, - primaryThreadId: () => THREAD, - activeTurn: () => TURN - }) - - roster.handleTurn({ threadId: 'child-1', turnId: 'child-turn', state: 'working' }) - expect( - roster.handleItem({ - threadId: THREAD, - turnId: TURN, - item: activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - }) - ).toEqual({ accepted: false, reason: 'backpressure' }) - }) -}) diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts deleted file mode 100644 index ac00a0cafbc..00000000000 --- a/src/main/codex/codex-subagent-roster.ts +++ /dev/null @@ -1,241 +0,0 @@ -// The Codex subagent roster: one journal row per spawn group, revised in place. -// -// 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: 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 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, - type CodexSubagentAnnouncement, - readCodexThreadTokenTotal -} from './codex-subagent-activity' -import { - CodexSubagentExecutions, - codexChildTurnState, - type CodexChildExecution, - type CodexExecutionChild -} from './codex-subagent-executions' -import { readRecord } from './codex-item-field-readers' -import { readCodexTurnId } from './codex-structured-thread-facts' -import { CodexSubagentLinkage } from './codex-subagent-linkage' -import { - codexSubagentGroupId, - codexSubagentGroupIdentity, - isCodexOutsideTurnGroup -} from './codex-subagent-roster-state' -import type { CodexThreadItem } from './codex-structured-item-translation' -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 } - -export type CodexSubagentRosterDeps = { - sink: StructuredAgentSessionEventSink - /** The thread that owns the agent tree; falls back to the event's thread. */ - primaryThreadId: () => string | null - activeTurn: (threadId: string) => string | null - turnScopeFor: (threadId: string, turnId: string | null) => AgentJournalTurnScope - now?: () => number - executions?: CodexSubagentExecutions -} - -export class CodexSubagentRoster { - 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() - /** 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 - /** Who produced a row, from what this roster learned about each child thread. */ - readonly linkage: CodexSubagentLinkage - - constructor(private readonly deps: CodexSubagentRosterDeps) { - 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. - this.unfollow = this.executions.onExecutionChanged( - (child) => child.execution && this.follow(child, child.execution) - ) - this.linkage = new CodexSubagentLinkage({ - primaryThreadId: deps.primaryThreadId, - executions: this.executions - }) - } - - /** Consume an item that announces children. Null means the item is not this roster's to render: - * a `subAgentActivity` item renders as the roster row alone, while a collab call keeps its own - * row, so it is claimed only to hand back a refused write. */ - handleItem(input: { - threadId: string - turnId: string | null - item: CodexThreadItem - }): StructuredAgentSessionSinkAdmission | null { - const refused = readCodexSubagentAnnouncements(input.item, this.deps.primaryThreadId()) - .map((announcement) => this.announce(input, announcement)) - .find((admission) => !admission.accepted) - return refused ?? (readCodexSubagentActivity(input.item) !== null ? ADMITTED : null) - } - - private announce( - input: { threadId: string; turnId: string | null }, - announcement: CodexSubagentAnnouncement - ): StructuredAgentSessionSinkAdmission { - const child = this.executions.register( - announcement.agentThreadId, - announcement.label, - announcement.namesParentTurn ? input.turnId : undefined, - // Only a spawn names the spawner: other announcements ride whichever agent acted. - announcement.spawned ? input.threadId : undefined - ) - return child?.execution - ? this.report(child, child.execution, true, this.groupFor(input.threadId, input.turnId)) - : ADMITTED - } - - handleTurnEvent(event: { - method: string - threadId: string - params: unknown - }): StructuredAgentSessionSinkAdmission { - const turnId = readCodexTurnId(event.params) - return turnId - ? this.handleTurn({ - threadId: event.threadId, - turnId, - state: - event.method === 'turn/started' - ? 'working' - : codexChildTurnState(readRecord(readRecord(event.params).turn).status) - }) - : ADMITTED - } - - handleTurn(input: { - threadId: string - turnId: string - state: NativeChatSubagentState - }): StructuredAgentSessionSinkAdmission { - if ( - input.threadId === this.deps.primaryThreadId() || - this.tracker.hasSettled(input.threadId, input.turnId) - ) { - return ADMITTED - } - const observed = this.executions.observeTurn(input.threadId, input.turnId, input.state) - // Followed already if the execution changed; re-derived (idempotently) for its admission. - 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) { - 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. */ - handleTokenUsage(params: unknown): StructuredAgentSessionSinkAdmission | null { - const usage = readCodexThreadTokenTotal(params) - if (!usage) { - return null - } - this.tokensByThread.record(usage.threadId, usage.totalTokens) - 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 - } - - /** - * The provider is gone, so any child still reported as working will never be - * settled by an event: it becomes `unverifiable` — contact was lost, which is - * NOT evidence the child exited. - * - * This is the ONLY sweep. A turn ending is not one: `spawn_agent` children - * routinely outlive their turn and keep reporting into the same group. - */ - settleSession(): StructuredAgentSessionSinkAdmission { - this.executions.settleSession() - return this.tracker.settleSession() - } - - dispose(): void { - this.unfollow() - this.tracker.dispose() - this.tokensByThread.clear() - } - - retentionSizes(): { groups: number; settledIdentities: number } { - return this.tracker.sizes() - } - - 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 - }) - } - - /** 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) - return { - id: codexSubagentGroupId(ownerThreadId, ownerTurnId), - placement: () => this.deps.turnScopeFor(ownerThreadId, ownerTurnId) - } - } -} diff --git a/src/main/codex/codex-thread-token-totals.test.ts b/src/main/codex/codex-thread-token-totals.test.ts deleted file mode 100644 index 1dab7dc6f6e..00000000000 --- a/src/main/codex/codex-thread-token-totals.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { MAX_CODEX_TOKEN_USAGE_THREADS } from './codex-structured-journal-limits' -import { CodexThreadTokenTotals } from './codex-thread-token-totals' - -describe('CodexThreadTokenTotals', () => { - it("replaces a thread's total with its newest report instead of summing reports", () => { - const totals = new CodexThreadTokenTotals() - totals.record('child-1', 100) - totals.record('child-1', 250) - expect(totals.get('child-1')).toBe(250) - }) - - it('evicts by recency of report, so a thread still reporting outlives quieter ones', () => { - const totals = new CodexThreadTokenTotals() - totals.record('child-1', 1) - for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS - 1; index++) { - totals.record(`other-${index}`, index) - } - totals.record('child-1', 2) - totals.record('newest', 3) - expect(totals.get('child-1')).toBe(2) - expect(totals.get('other-0')).toBeUndefined() - }) -}) diff --git a/src/main/codex/codex-thread-token-totals.ts b/src/main/codex/codex-thread-token-totals.ts deleted file mode 100644 index 23bc6a9d199..00000000000 --- a/src/main/codex/codex-thread-token-totals.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { MAX_CODEX_TOKEN_USAGE_THREADS } from './codex-structured-journal-limits' - -/** Latest reported token total per thread, kept regardless of roster membership: a - * usage frame can arrive before the child's first activity item, and filtering at - * receipt would lose it permanently. LRU-capped at MAX_CODEX_TOKEN_USAGE_THREADS. */ -export class CodexThreadTokenTotals { - private readonly totals = new Map() - - /** A running total: the newest frame REPLACES the previous one. Summing updates - * would multiply a single child's usage by its frame count. */ - record(threadId: string, totalTokens: number): void { - // Re-insert so eviction sees recency: `set` on an existing key keeps its - // original position, which would age out an active thread. - this.totals.delete(threadId) - this.totals.set(threadId, totalTokens) - while (this.totals.size > MAX_CODEX_TOKEN_USAGE_THREADS) { - const oldest = this.totals.keys().next().value - if (typeof oldest !== 'string') { - break - } - this.totals.delete(oldest) - } - } - - get(threadId: string): number | undefined { - return this.totals.get(threadId) - } - - clear(): void { - this.totals.clear() - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-group-body-fallback.test.ts b/src/main/native-chat/agent-session-journal/journal-subagent-group-body-fallback.test.ts deleted file mode 100644 index e59e882e915..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-subagent-group-body-fallback.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from 'vitest' -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 } -} - -/** 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 = subagentGroupJournalBody('turn-1', agents) - const block = body.kind === 'message' ? body.blocks[0] : undefined - return block && block.type === 'text' ? block.text : '' -} - -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') - }) - - it('keeps the singular noun for a lone child', () => { - expect(sentence([entry('a', 'completed')])).toBe('Ran 1 subagent') - expect(sentence([entry('a', 'working')])).toBe('Kicked off 1 subagent') - }) - - it('names an unverifiable child instead of claiming the group ran', () => { - expect(sentence([entry('a', 'completed'), entry('b', 'unverifiable')])).toBe( - 'Ran 2 subagents (1 unverifiable)' - ) - }) - - it('ranks the adverse outcome worst-first', () => { - expect( - sentence([entry('a', 'failed'), entry('b', 'unverifiable'), entry('c', 'completed')]) - ).toBe('Ran 3 subagents (1 failed)') - expect(sentence([entry('a', 'stopped'), entry('b', 'unverifiable')])).toBe( - 'Ran 2 subagents (1 stopped)' - ) - }) - - it('shows the adverse outcome while a sibling still works', () => { - expect( - sentence([entry('a', 'working'), entry('b', 'working'), entry('c', 'unverifiable')]) - ).toBe('Kicked off 3 subagents (1 unverifiable)') - }) - - it('leaves a benign settled state out of the sentence', () => { - expect(sentence([entry('a', 'idle'), entry('b', 'completed')])).toBe('Ran 2 subagents') - }) - - it('counts every child holding the worst adverse state', () => { - expect(sentence([entry('a', 'failed'), entry('b', 'failed'), entry('c', 'stopped')])).toBe( - 'Ran 3 subagents (2 failed)' - ) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-group-body.ts b/src/main/native-chat/agent-session-journal/journal-subagent-group-body.ts deleted file mode 100644 index 6261f3919cd..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-subagent-group-body.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types' -import { - MAX_SUBAGENT_FIELD_CHARS, - subagentGroupFallbackText -} from '../../../shared/native-chat-subagent-summary' - -/** 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 subagentGroupJournalBody( - groupId: string, - agents: readonly NativeChatSubagentEntry[] -): AgentJournalMessageItem { - const bounded = agents.map((agent, index) => ({ - ...agent, - id: boundSubagentField(agent.id, index), - label: boundSubagentField(agent.label, index) - })) - return { - kind: 'message', - role: 'system', - blocks: [ - { type: 'text', text: subagentGroupFallbackText(bounded) }, - { type: 'subagent-group', groupId, agents: bounded } - ] - } -} - -/** `id` and `label` are provider strings, so they take the bound both readers of - * this row already clip them to. A plain length check, not the tool-output - * bound: that one digests the whole value before it checks the length, and this - * runs twice per child on every streamed token-usage frame. - * - * A clip is not identity-preserving, so a clipped value carries the child's - * index: two ids sharing a long prefix collapse to one React key, and - * `claimLabel` writes its ordinal at the very tail the clip removes. The index - * is reserved out of the bound, not appended to it, because both readers - * re-clip to the same cap and would cut a suffix that overflowed it. */ -export function boundSubagentField(value: string, index: number): string { - if (value.length <= MAX_SUBAGENT_FIELD_CHARS) { - return value - } - const suffix = `…~${index}` - const keep = MAX_SUBAGENT_FIELD_CHARS - suffix.length - // Slicing UTF-16 units can split a surrogate pair; a lone surrogate is - // malformed in a durable row and lossy through any non-JSON UTF-8 hop. - const last = value.charCodeAt(keep - 1) - const end = last >= 0xd800 && last <= 0xdbff ? keep - 1 : keep - return `${value.slice(0, end)}${suffix}` -} diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-item.ts b/src/main/native-chat/agent-session-journal/journal-subagent-item.ts index 4c416ec35d9..05596e8353a 100644 --- a/src/main/native-chat/agent-session-journal/journal-subagent-item.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-item.ts @@ -25,7 +25,7 @@ export function subagentJournalBody(block: NativeChatSubagentBlock): AgentJourna const bounded: NativeChatSubagentBlock = { ...block, id: boundSubagentEntryId(block.id), - label: boundLabel(block.label), + label: boundSubagentLabel(block.label), groupId: boundSubagentEntryId(block.groupId), ...(block.parentId !== undefined ? { parentId: boundSubagentEntryId(block.parentId) } : {}), ...(block.run !== undefined ? { run: boundSubagentEntryId(block.run) } : {}) @@ -48,7 +48,7 @@ export function journaledSubagent( /** The bound every reader clips a label to. A clip never splits a surrogate pair: a lone one is * malformed in a durable row. */ -function boundLabel(label: string): string { +export function boundSubagentLabel(label: string): string { if (label.length <= MAX_SUBAGENT_FIELD_CHARS) { return label } diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts index d9362e6008a..47d689fc13f 100644 --- a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts @@ -15,10 +15,7 @@ import type { NativeChatBackgroundTaskBlock, NativeChatSubagentEntry } from '../../../shared/native-chat-types' -import { - codexSubagentGroupBody, - codexSubagentGroupIdentity -} from '../../codex/codex-subagent-roster' +import { subagentGroupFallbackText } from '../../../shared/native-chat-subagent-summary' import type { openAgentSessionJournal } from './journal-store-factory' import { createTrackedJournalOpener } from './journal-host-database-test-support' import { lostLiveWorkJournalBody, staleSubagentRosterRevisions } from './journal-subagent-liveness' @@ -55,11 +52,18 @@ async function open(overrides: Partial { expect(tail.hasOlder).toBe(true) }) + it("loads an older page without renumbering a turn's subagents or moving their row", async () => { + const audit = async (id: string): Promise => { + await journal.appendItem( + subagentJournalItemIdentity(id), + subagentJournalBody({ + type: 'subagent', + id, + label: 'Audit', + state: 'working', + groupId: 't' + }), + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + await append(named('own-', 1, 3)) + await audit('a') + await append(named('own-', 4, 5)) + await audit('b') + await append(named('own-', 9, 2)) + await audit('c') + await append(['own-11']) + const folded = (pages: AgentSessionHistoryPage[]) => + foldSubagentItems( + pages + .flatMap((page) => page.items) + .map((entry) => ({ + id: entry.itemId, + role: 'system' as const, + timestamp: null, + source: 'transcript' as const, + blocks: entry.body.kind === 'message' ? entry.body.blocks : [] + })) + ).flatMap((row) => + row.blocks.flatMap((block) => + block.type === 'subagent-group' + ? [{ row: row.id, labels: block.agents.map((agent) => `${agent.id}=${agent.label}`) }] + : [] + ) + ) + + const tail = read({ direction: 'tail', limit: 5 }) + const older = read({ direction: 'before', cursor: tail.window.nextCursor, limit: 5 }) + + expect(textsOf(tail)[0]).toBe('Kicked off subagent Audit') + expect(textsOf(older)).toEqual(named('own-', 1, 3)) + expect(folded([tail])).toEqual([ + { row: 'orca:subagent%3Aa', labels: ['a=Audit', 'b=Audit 2', 'c=Audit 3'] } + ]) + expect(folded([older, tail])).toEqual(folded([tail])) + }) + + it('reaches back for a first sibling no further than one more page of conversation', async () => { + for (const [id, own] of [ + ['a', named('own-', 1, 10)], + ['b', ['own-11']] + ] as const) { + await journal.appendItem( + subagentJournalItemIdentity(id), + subagentJournalBody({ + type: 'subagent', + id, + label: id, + state: 'working', + groupId: 'thread' + }), + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await append([...own]) + } + + const tail = read({ direction: 'tail', limit: 2 }) + + expect(textsOf(tail)).toEqual(['own-8', 'own-9', 'own-10', 'Kicked off subagent b', 'own-11']) + }) + it('still bounds a page by bytes, and paging back still reaches every row', async () => { const large = 'x'.repeat(250 * 1024) await append(['own-1']) diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page-bounds.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page-bounds.ts index 3e1c574f9cd..7157f1799b1 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page-bounds.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page-bounds.ts @@ -8,6 +8,7 @@ import type { } from '../../../shared/agent-session-journal-types' import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' import { agentSessionHostStatusBody } from '../../../shared/agent-session-host-status-rows' +import { subagentFoldKey, subagentItemBlock } from '../../../shared/native-chat-subagent-items' export const AGENT_SESSION_HISTORY_MAX_PAGE_BYTES = REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES / 2 @@ -126,3 +127,29 @@ export function newestWholeSequenceGroups( } return selected.toReversed().flat() } + +/** `start` moved back to the first sibling of every subagent group the window holds part of, but + * never past `floor`: a group spread over a long turn, or helpers outside any turn, may still + * split rather than grow the page without bound. */ +export function wholeSubagentGroupsStart( + items: readonly AgentJournalRenderItem[], + start: number, + floor: number +): number { + const firstOf = new Map() + for (const item of items) { + const block = item.body.kind === 'message' ? subagentItemBlock(item.body) : null + if (block !== null && !firstOf.has(subagentFoldKey(block))) { + firstOf.set(subagentFoldKey(block), item.sequence) + } + } + let whole = start + for (let index = items.length - 1; index >= 0 && items[index]!.sequence >= whole; index--) { + const item = items[index]! + const block = item.body.kind === 'message' ? subagentItemBlock(item.body) : null + if (block !== null) { + whole = Math.max(floor, Math.min(whole, firstOf.get(subagentFoldKey(block))!)) + } + } + return whole +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts index acd0240c23f..64e9070d93c 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts @@ -33,6 +33,7 @@ import { HISTORY_PAGE_CONTENT_BUDGET_BYTES, historyEntryBytes, newestWholeSequenceGroups, + wholeSubagentGroupsStart, oversizedHistoryItem, submissionBytesByItemId } from './agent-session-history-page-bounds' @@ -61,7 +62,8 @@ export type AgentSessionHistoryScope = 'every-agent' | 'own-agent' * the conversation off the page. The page stays contiguous and starts at its first * item, as the cursor means; the byte budget still bounds it. Fewer own rows than * `limit` reach back to the start. With no subagent rows this is the newest `limit` - * rows, as before. + * rows, as before. A page reaches back to the first sibling of each subagent group it holds + * part of, so loading an older page does not renumber a group's ordinals or move its row. */ function conversationWindow( items: readonly AgentJournalRenderItem[], @@ -73,7 +75,13 @@ function conversationWindow( } const ownWindow = newestWholeSequenceGroups(own, limit) const start = ownWindow.length < own.length ? ownWindow[0]?.sequence : undefined - return start === undefined ? [...items] : items.filter((item) => item.sequence >= start) + if (start === undefined) { + return [...items] + } + const reach = newestWholeSequenceGroups(own, 2 * limit) + const floor = reach.length < own.length ? reach[0]!.sequence : Number.NEGATIVE_INFINITY + const whole = wholeSubagentGroupsStart(items, start, floor) + return items.filter((item) => item.sequence >= whole) } function isSubagentRow(item: AgentJournalRenderItem): boolean { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 958c71d80fc..bddcd06c964 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -14,7 +14,10 @@ import type { // take this?" — and it answers `unknown` rather than guessing, because the // journal renders that as delivery unconfirmed instead of as failure. -import type { AgentSessionBackgroundTaskStops } from '../../../shared/agent-child-work-stop-targets' +import type { + AgentChildWorkStopAcknowledgement, + AgentSessionBackgroundTaskStops +} from '../../../shared/agent-child-work-stop-targets' import type { AgentJournalAnsweredTurnIdentity, AgentJournalItemIdentity, @@ -362,11 +365,14 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & }): Promise<{ ok: true } | { ok: false; rejected: string }> /** Stops exactly the tasks `taskIds` names, which the host resolves from its child records. * `stillRunning` is the provider's own answer that one survived its stop; a throw leaves the - * effect unknown. */ + * effect unknown. `acknowledge` names each task the provider accepted the stop of as its answer + * lands, so a later failure in the batch keeps it; the host alone turns it into that run's + * provisional ending. */ stopBackgroundTasks?(input: { sessionId: string fence: number taskIds: readonly string[] + acknowledge?: (acknowledgement: AgentChildWorkStopAcknowledgement) => void }): Promise<{ cancelled: boolean; stillRunning?: true }> /** The stops this provider honours for a live session's background work; undefined when the * adapter holds no live session for it. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-stop.ts new file mode 100644 index 00000000000..0cde17fc81c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-stop.ts @@ -0,0 +1,62 @@ +// A background Stop: the strip's Stop of one task, or of every task the records offer one. + +import { + agentChildWorkStopEvidence, + agentChildWorkStopTargets, + type AgentChildWorkStopAcknowledgement +} from '../../../shared/agent-child-work-stop-targets' +import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' + +export type StructuredAgentSessionBackgroundStopInput = { + taskId?: string + /** The session's child records: a background Stop reaches the tasks they offer a stop. */ + childWork?: () => readonly AgentChildWorkView[] | undefined + /** Hands the host the stops the provider accepted, as evidence about the runs it sent them to. + * Never throws: recording it is bookkeeping, and cannot fail the Stop. */ + acknowledgeStops?: (evidence: AgentChildWorkEvidence[]) => void + /** Work the host could not list, read inside the Stop's step, and the provider child's end that + * reaches it; for an untargeted Stop only. */ + unlisted?: { present: () => boolean; stop: () => Promise } +} + +/** `atRest`: no turn runs, so ending the agent ends only the work no record lists. */ +export async function stopStructuredAgentSessionBackgroundWork( + ctx: Pick, + input: StructuredAgentSessionBackgroundStopInput, + atRest: () => boolean +): Promise<{ cancelled: boolean; stillRunning?: true }> { + // Captured before the stop goes out: its acknowledgement may end only these runs. + const targets = agentChildWorkStopTargets(input.childWork?.(), input.taskId) + const acknowledged: AgentChildWorkStopAcknowledgement[] = [] + // Nothing listed has a stop to send; an agent without a stop route would refuse even none. + const background = + targets.length === 0 + ? undefined + : await ctx.adapter + .stopBackgroundTasks?.({ + sessionId: ctx.sessionId, + fence: ctx.fence, + taskIds: targets.map((target) => target.providerId), + acknowledge: (acknowledgement) => void acknowledged.push(acknowledgement) + }) + // A batch that fails part way still ended each run it was answered for first. + .finally(() => { + if (acknowledged.length > 0) { + input.acknowledgeStops?.( + agentChildWorkStopEvidence(targets, acknowledged, Date.now()) + ) + } + }) + if (background?.stillRunning) { + return background + } + // Work no record lists is reached only by ending the agent, never under a turn it runs. Read after + // the listed stops: one ending can wake the agent into a turn of its own. + if (input.unlisted?.present() && atRest()) { + await input.unlisted.stop() + return { cancelled: true } + } + return { cancelled: background?.cancelled === true } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts index 79d6850c559..67bea7e7971 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts @@ -28,7 +28,8 @@ type Stops = { supportsTaskStop: boolean; supportsStopAll: boolean } | undefined function channelOver( children: () => AgentChildWorkView[] = () => [child], - stops: () => Stops = () => ({ supportsTaskStop: true, supportsStopAll: true }) + stops: () => Stops = () => ({ supportsTaskStop: true, supportsStopAll: true }), + incomplete: () => boolean = () => false ) { const sessions = new StructuredAgentSessionConversations({ deliver: () => {}, @@ -48,7 +49,8 @@ function channelOver( async () => { throw new Error('not opened here') }, - children + children, + incomplete ) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the map reads only the journal's commit observer and the session's provider. const session = { @@ -72,6 +74,24 @@ describe('the background-task channel', () => { // A task that finished while no pane listened, then an idle sweep that stopped the provider: // a pane resuming must read "none", not silence it would take as "unchanged" (#24227). + // An idle parent whose live background child the store had no room for: no row lists it, so + // the strip must stay and say so rather than read as nothing running. + it('keeps the strip, with its Stop, for work the host could not record', () => { + const { sessions, channel, session } = channelOver( + () => [], + () => ({ supportsTaskStop: true, supportsStopAll: true }), + () => true + ) + sessions.set('session-1', session) + + expect(channel.read('session-1')).toEqual({ + state: 'monitoring', + supportsTaskStop: true, + children: [], + incomplete: true + }) + }) + it('answers "none" for a conversation no provider holds, not "unknown"', () => { const { sessions, channel, session } = channelOver( () => [], diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts index cf44fc3d66b..1675a8d5a75 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts @@ -25,8 +25,8 @@ import { structuredAgentSessionConversationFence } from './structured-agent-sess /** The chat strip's roster: the host's running child records for one session, the same selection * the session list carries, with the legacy task rows an older client reads derived from them. No - * running child is `null`, and the strip hides. Each subscriber's frames carry it; see - * `agent-session-subscriber-frame-fields`. */ + * running child, and none the host failed to record, is `null`, and the strip hides. Each + * subscriber's frames carry it; see `agent-session-subscriber-frame-fields`. */ export class StructuredAgentSessionBackgroundTaskChannel { constructor( private readonly deps: StructuredAgentSessionHostDeps, @@ -36,7 +36,8 @@ export class StructuredAgentSessionBackgroundTaskChannel { private readonly conversation: ( sessionId: string ) => Promise, - private readonly readChildWork: (sessionId: string) => AgentChildWorkView[] | undefined + private readonly readChildWork: (sessionId: string) => AgentChildWorkView[] | undefined, + private readonly childWorkIncomplete: (sessionId: string) => boolean = () => false ) {} /** `scope` is for in-process readers; a wire request reads every agent's rows. */ @@ -101,7 +102,9 @@ export class StructuredAgentSessionBackgroundTaskChannel { read(sessionId: string): AgentSessionBackgroundTaskState | null { const session = this.sessions.get(sessionId) const views = structuredRunningChildWork((session && this.readChildWork(sessionId)) ?? []) - if (!session || views.length === 0) { + // Untracked work keeps the strip, and its Stop, even with nothing listed. + const incomplete = this.childWorkIncomplete(sessionId) + if (!session || (views.length === 0 && !incomplete)) { return null } const stops = this.deps.adapter.backgroundTaskStops?.(sessionId) @@ -113,7 +116,8 @@ export class StructuredAgentSessionBackgroundTaskChannel { ...(stops?.supportsTaskStop ? { supportsTaskStop: true } : {}), // A session no live provider holds has nothing a stop could reach. ...(stops?.supportsStopAll ? {} : { supportsStopAll: false }), - children: views + children: views, + ...(incomplete ? { incomplete: true } : {}) } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index 9e55397a38f..7664fe3c548 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -493,8 +493,8 @@ it('ends background work Claude runs when the Stop ends the child', async () => ...background, state: 'done', membership: 'settled', - // Stopped with the chat, as a task's own stop reads: Interrupted, not an unknown ending. - outcome: 'cancelled' + // Claude accepted no Stop of this task: ending its process is loss of contact. + outcome: 'unknown' }) ]) expect(await agentStatus()).toEqual({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts new file mode 100644 index 00000000000..eadb81b9812 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts @@ -0,0 +1,300 @@ +// A Claude background helper through the shipping adapter, host and child records: its saved row +// stays working past its spawn result and its turn's end until its own end frame; and a helper the +// store had no room for keeps the strip incomplete until the strip's Stop ends the agent. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { AGENT_STATUS_STORE_LIMITS } from '../../../shared/agent-status-store-contract' +import { makeStructuredAgentStatusSubject } from '../../../shared/agent-status-subject' +import { isSubagentBlock } from '../../../shared/native-chat-types' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { acpChildWorkStatusSink } from '../../acp/acp-structured-child-work.test-support' +import { RESUMED_BY_MESSAGE } from '../../claude/claude-captured-task-frames.test-fixture' +import { WAKES_AFTER_ITS_SHELL } from '../../claude/claude-captured-wake-frames.test-fixture' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + claudeStartupSettled, + fakeClaude, + PROVIDER_SESSION_ID, + type FakeConnection +} from '../../claude/claude-structured-session-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let claude: ReturnType +let records: ReturnType +/** The host's clock, which stamps every child's start and end. */ +let clock = NOW + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-subagent-row-')) + clock = NOW + resetHostTestOperationIds() + const log = recordingStructuredAgentSessionLogger() + claude = fakeClaude({ replayUuid: null }) + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + void host.handleAdapterEvent(mapped) + } + }, + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + persistHandle: async () => undefined, + logger: log.logger, + onChildWorkEvidence: (sessionId, evidence) => + host.publishChildWorkEvidence(sessionId, evidence), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => clock + }) + store = await openTestAgentSessionRecordStore(root) + records = acpChildWorkStatusSink() + host = new StructuredAgentSessionHost({ + agents: claudeAndCodexAgents(adapter), + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + logger: log.logger, + // The host's child records live in a real hook server's store, as in production. + statusSink: records.sink, + now: () => clock + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + await claudeStartupSettled(adapter, SESSION) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function eventually(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +function frame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +function envelope(method: 'agentSession.send' | 'agentSession.cancel', fields: Fields) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +type Fields = Parameters[0]['fields'] + +async function sendAndEcho(connection: FakeConnection, text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + expect(sent.ok).toBe(true) + await eventually(() => + expect(connection.sent.some((message) => JSON.stringify(message).includes(text))).toBe(true) + ) + frame(connection, { type: 'system', subtype: 'init', uuid: 'init-1', model: 'claude-sonnet-5' }) + frame(connection, { ...connection.sent.at(-1)! }) +} + +async function savedRows() { + await host.flushStreamedEvents(SESSION) + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'message' ? item.body.blocks.filter(isSubagentBlock) : [] + ) +} + +it("keeps a background helper's row working past its spawn result and its turn's end, until its own end", async () => { + const connection = claude.connections[0]! + await sendAndEcho(connection, 'Run it in the background.') + // Spawn, roster, task start, the spawn's "launched" result, and the parent turn's result. + for (const [index, captured] of RESUMED_BY_MESSAGE.filter(({ at }) => at <= 4_765).entries()) { + frame(connection, { uuid: `launch-${index}`, ...captured.frame }) + } + await eventually(async () => expect(await savedRows()).toHaveLength(1)) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(await savedRows()).toEqual([ + expect.objectContaining({ id: 'agent-1', state: 'working', background: true }) + ]) + const { items } = await host.journalSnapshot(SESSION) + expect(activeStructuredAgentSessionTurnId(items)).toBeNull() + expect(items.map((item) => readAgentJournalTurn(item.body)?.state)).toContain('completed') + + // Its own progress, then its own completion. + for (const [index, captured] of RESUMED_BY_MESSAGE.filter( + ({ at }) => at > 4_765 && at <= 7_427 + ).entries()) { + frame(connection, { uuid: `run-${index}`, ...captured.frame }) + } + await eventually(async () => + expect(await savedRows()).toEqual([ + expect.objectContaining({ id: 'agent-1', state: 'completed', tokens: 15_971 }) + ]) + ) +}) + +it("ends the agent from the strip's Stop when the store had no room for its helper, never under a running turn", async () => { + // Another session holds every child record the store has room for. + const busy = makeStructuredAgentStatusSubject( + { executionHostId: 'local', wslDistro: null, workspaceId: 'busy', workspaceKind: 'folder' }, + 'busy-session' + ) + records.sink.publish( + { + sessionId: 'busy-session', + workspaceId: 'busy', + agent: 'claude', + status: 'working', + latestPrompt: 'Busy', + updatedAt: 1 + }, + busy + ) + records.sink.publishChildWork?.( + busy, + Array.from({ length: AGENT_STATUS_STORE_LIMITS.children }, (_unused, index) => ({ + type: 'live' as const, + observedAt: 1, + child: { + handle: { idKind: 'task_id' as const, id: `busy-${index}` }, + kind: 'agent' as const, + residency: 'background' as const, + state: 'working' as const, + stoppable: true + } + })), + 'claude' + ) + const connection = claude.connections[0]! + await sendAndEcho(connection, 'Run it in the background.') + for (const [index, captured] of RESUMED_BY_MESSAGE.filter(({ at }) => at <= 3_349).entries()) { + frame(connection, { uuid: `launch-${index}`, ...captured.frame }) + } + const strip = () => host['backgroundTasks'].read(SESSION) + await eventually(() => expect(strip()).toMatchObject({ children: [], incomplete: true })) + const stopStrip = () => { + const fields = { turnId: 'background-tasks', scope: 'background-tasks' as const } + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) + } + + // The turn that spawned it still runs: the strip's Stop leaves the agent and its turn alone. + await stopStrip() + expect(connection.closed).toBe(false) + expect(strip()).toMatchObject({ incomplete: true }) + + frame(connection, { + uuid: 'turn-end', + ...RESUMED_BY_MESSAGE.find(({ at }) => at === 4_765)!.frame + }) + await host.flushStreamedEvents(SESSION) + await expect(stopStrip()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + expect(connection.closed).toBe(true) + await eventually(() => expect(strip()?.incomplete).toBeUndefined()) +}) + +it('keeps a helper working while its own shell runs and through its wake-up, ending on its answer', async () => { + const connection = claude.connections[0]! + await sendAndEcho(connection, 'Sleep in the background.') + const start = clock + let next = 0 + const replay = async (until: number) => { + for (; WAKES_AFTER_ITS_SHELL[next] && WAKES_AFTER_ITS_SHELL[next]!.at <= until; next++) { + const captured = WAKES_AFTER_ITS_SHELL[next]! + clock = start + captured.at + frame(connection, { uuid: `wake-${next}`, ...captured.frame }) + } + await host.flushStreamedEvents(SESSION) + } + const helper = async () => (await savedRows()).find(({ id }) => id === 'agent-1') + const strip = () => + host['backgroundTasks'].read(SESSION)?.children?.map((child) => child.providerId) ?? [] + + // The CLI calls the helper completed while the shell it started runs on. + await replay(11_913) + await eventually(async () => expect(await helper()).toMatchObject({ state: 'working' })) + expect(strip()).toEqual(['agent-1', 'shell-1']) + + // The shell ends, and the CLI starts the same run again under its spawn call. + await replay(44_460) + await eventually(async () => expect(await helper()).toMatchObject({ state: 'working' })) + expect(strip()).toEqual(['agent-1']) + + await replay(46_199) + await eventually(async () => + expect(await helper()).toMatchObject({ + state: 'completed', + tokens: 18_181, + startedAt: start + 15, + // Its own end, not its first turn's, sets how long it ran. + settledAt: start + 46_198 + }) + ) + expect(strip()).toEqual([]) + // One saved row for the helper, revised in place across both runs. + expect((await savedRows()).filter(({ id }) => id === 'agent-1')).toHaveLength(1) +}) + +it('settles a helper still running when its session closes as unverifiable, claiming no Stop', async () => { + const connection = claude.connections[0]! + await sendAndEcho(connection, 'Run it in the background.') + for (const [index, captured] of RESUMED_BY_MESSAGE.filter(({ at }) => at <= 4_765).entries()) { + frame(connection, { uuid: `launch-${index}`, ...captured.frame }) + } + await eventually(async () => + expect(await savedRows()).toEqual([expect.objectContaining({ state: 'working' })]) + ) + await adapter.closeSession(SESSION) + expect(connection.closed).toBe(true) + await eventually(async () => + expect(await savedRows()).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + ) + expect((await savedRows())[0]).not.toHaveProperty('outcomeBasis') +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index c369ce1a560..b6b66744f66 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -106,6 +106,9 @@ export class StructuredAgentSessionClientDelivery { readChildWork = (sessionId: string): AgentChildWorkView[] | undefined => this.statusFeed.readChildWork(sessionId) + childWorkIncomplete = (sessionId: string): boolean => + this.statusFeed.childWorkIncomplete(sessionId) + publishStatusAndSettlement = (sessionId: string): void => { this.statusFeed.publish(sessionId) const journal = this.sessions.get(sessionId)?.journal diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index b44909d01d7..78002aa6f6c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -106,11 +106,25 @@ export function cancelStructuredAgentSessionTurn( } ): Promise> { if (params.scope) { + const { sessionId } = params.envelope return mutateStructuredAgentSession( context, caller, params.envelope, - cancelPlan({ ...params, childWork: () => context.readChildWork(params.envelope.sessionId) }), + cancelPlan({ + ...params, + childWork: () => context.readChildWork(sessionId), + acknowledgeStops: (evidence) => context.publishChildWork(sessionId, evidence), + // Untargeted, with work no record lists: the idle sweep's stop ends it with the agent. + ...(params.taskId === undefined + ? { + unlisted: { + present: () => context.childWorkIncomplete(sessionId), + stop: () => context.stopAgent(sessionId, { cause: 'evict', resting: true }) + } + } + : {}) + }), openForProviderWrite(context, params.envelope) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts index 25d475abb46..7e1c4927693 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts @@ -8,8 +8,11 @@ import { type StructuredAgentSessionStatusFeedDeps, type StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' +import { StructuredAgentSessionSubagentRows } from './structured-agent-session-subagent-rows' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -/** Wire the host's own deps into a feed; keeps the host at one call site. +/** Wire the host's own deps into a feed, and each subagent's saved row onto its child records; + * keeps the host at one call site. * `deps` is a thunk because the host builds the feed in a field initializer, * before its constructor parameters are assigned. */ export function createStructuredAgentSessionHostStatusFeed(args: { @@ -24,7 +27,31 @@ export function createStructuredAgentSessionHostStatusFeed(args: { onAgentStarted?: (sessionId: string) => void onChildWorkChanged?: (sessionId: string) => void }): StructuredAgentSessionStatusFeed { - return new StructuredAgentSessionStatusFeed({ + const subagentRows = new StructuredAgentSessionSubagentRows({ + journal: (sessionId) => args.sessions.get(sessionId)?.journal, + fence: (sessionId) => structuredAgentSessionConversationFence(args.deps().store, sessionId), + // The views, with the two host-only facts a saved row also keeps from their records. + readChildWork: (sessionId) => { + const records = new Map( + feed.readChildWorkRecords(sessionId)?.map((record) => [record.childWorkId, record]) + ) + return feed.readChildWork(sessionId)?.map((view) => { + const { residency, outcomeBasis } = records.get(view.id) ?? {} + return { + ...view, + ...(residency ? { residency } : {}), + ...(outcomeBasis ? { outcomeBasis } : {}) + } + }) + }, + onWriteFailed: (sessionId, error) => + args.deps().logger.warn('writing a subagent row failed', { + scope: 'subagent-rows', + sessionId, + error + }) + }) + const feed = new StructuredAgentSessionStatusFeed({ sessions: args.sessions, getRecord: (sessionId) => args.deps().store.getRecord(sessionId), now: args.now, @@ -34,6 +61,10 @@ export function createStructuredAgentSessionHostStatusFeed(args: { // field initializer, before its constructor parameters are assigned. statusSink: () => args.deps().statusSink, ...(args.onAgentStarted ? { onAgentStarted: args.onAgentStarted } : {}), - ...(args.onChildWorkChanged ? { onChildWorkChanged: args.onChildWorkChanged } : {}) + onChildWorkChanged: (sessionId) => { + subagentRows.changed(sessionId) + args.onChildWorkChanged?.(sessionId) + } }) + return feed } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 1b66064df5f..62568e18a18 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -101,7 +101,8 @@ export class StructuredAgentSessionHost { this.sessions, this.subscribers, (sessionId) => this.lifetime.conversation(sessionId), - this.clientDelivery.readChildWork + this.clientDelivery.readChildWork, + this.clientDelivery.childWorkIncomplete ) this.runtimeState = new StructuredAgentSessionHostRuntimeState( deps, @@ -278,6 +279,8 @@ export class StructuredAgentSessionHost { publish: (sessionId, journal) => this.subscribers.publish(sessionId, journal), conversation: this.lifetime.conversation, readChildWork: this.clientDelivery.readChildWork, + publishChildWork: this.clientDelivery.publishChildWork, + childWorkIncomplete: this.clientDelivery.childWorkIncomplete, serialize: (sessionId, task) => this.serialize(sessionId, task), openConversation: this.conversationDelivery.open, ensureAgent: (sessionId) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index f9b83c3b4f3..f384fa866ea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -1,6 +1,7 @@ // The context every client mutation of a session runs with, and the one path each takes: admit the // envelope against the lease, then run its plan inside the session's serialize. +import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import type { AgentSessionMutationEnvelope, AgentSessionMutationResult @@ -29,6 +30,9 @@ export type StructuredAgentSessionMutationContext = { conversation: (sessionId: string) => Promise /** The session's child records, as the strip reads them; what command admission decides on. */ readChildWork: (sessionId: string) => AgentChildWorkView[] | undefined + publishChildWork: (sessionId: string, evidence: AgentChildWorkEvidence[]) => void + /** Some of the session's running child work has no record. */ + childWorkIncomplete: (sessionId: string) => boolean serialize: (sessionId: string, task: () => Promise) => Promise /** The session's conversation, opened when closed; inside the caller's serialize. */ openConversation: (sessionId: string) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 04688a8c491..2725533af41 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -5,6 +5,7 @@ // prompt answer's replay answers from the resolution its receipt keeps; the journal projects the // others' current answer. +import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { AGENT_MESSAGE_SOURCE, @@ -23,6 +24,7 @@ import type { } from '../../../shared/agent-session-wire' import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command' import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import type { StructuredAgentSessionBackgroundStopInput } from './structured-agent-session-background-stop' import { agentSessionMessagePayload, agentSessionSendBodyFingerprint @@ -281,6 +283,8 @@ export function cancelPlan(params: { prompt?: { itemId: string; expectedRevision: number } /** The session's child records, which name the tasks a background Stop reaches. */ childWork?: () => readonly AgentChildWorkView[] | undefined + acknowledgeStops?: (evidence: AgentChildWorkEvidence[]) => void + unlisted?: StructuredAgentSessionBackgroundStopInput['unlisted'] }): MutationPlan { return { method: 'agentSession.cancel', @@ -301,7 +305,9 @@ export function cancelPlan(params: { ...(params.scope ? { scope: params.scope } : {}), ...(params.taskId ? { taskId: params.taskId } : {}), ...(params.prompt ? { prompt: params.prompt } : {}), - ...(params.childWork ? { childWork: params.childWork } : {}) + ...(params.childWork ? { childWork: params.childWork } : {}), + ...(params.acknowledgeStops ? { acknowledgeStops: params.acknowledgeStops } : {}), + ...(params.unlisted ? { unlisted: params.unlisted } : {}) }), // Interrupting twice would kill a turn the client never asked to stop, so a // replay reports the turn as already handled. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts index 24546a97bcc..41c830ecc4e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts @@ -73,6 +73,24 @@ beforeEach(async () => { journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', + // One live background task, which the background Stops below reach. + statusSink: { + publish: () => {}, + forget: () => {}, + readChildWork: () => [ + { + id: 'child-task-1', + providerId: 'task-1', + kind: 'agent', + state: 'working', + membership: 'live', + firstObservedAt: 1, + observedAt: 1, + stoppable: true, + invocation: { invocationId: 'spawn-task-1', generation: 1 } + } + ] + }, now: () => NOW }) expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts index 79efe4a0bc5..f0ec3c5dabc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts @@ -72,7 +72,9 @@ async function feedWithChildren(provider: AgentSessionHandleProvider = 'codex') const sink: StructuredAgentSessionStatusSink = { publish: () => {}, forget: () => {}, - publishChildWork: (_subject, evidence) => admitted.push(evidence), + publishChildWork: (_subject, evidence) => { + admitted.push(evidence) + }, readChildWork: () => views.current() } let now = 1_000 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 2a26af994dc..48d2cfb0cd8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -17,6 +17,7 @@ import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import type { AgentChildWorkRecord } from '../../../shared/agent-status-child-work' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' @@ -327,6 +328,14 @@ export class StructuredAgentSessionStatusFeed { } } + childWorkIncomplete = (sessionId: string): boolean => + this.ownership.childWorkIncomplete(sessionId) + + /** The records the views project. Read only from the child-work observer, which already + * reports a failure. */ + readChildWorkRecords = (sessionId: string): AgentChildWorkRecord[] | undefined => + this.ownership.readChildWorkRecords(sessionId) + private admitChildWork( sessionId: string, session: StatusFeedSession, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts index 09fcb481e0e..629a59979fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import { AGENT_STATUS_STORE_LIMITS } from '../../../shared/agent-status-store-contract' import { makeStructuredAgentStatusSubject } from '../../../shared/agent-status-subject' +import { AgentHookServer } from '../../agent-hooks/server' import { StructuredAgentSessionStatusOwnership } from './structured-agent-session-status-ownership' const location: AgentSessionExecutionLocation = { @@ -120,3 +123,115 @@ describe('structured status owner address retention', () => { expect(sink.publishChildWork).toHaveBeenCalledOnce() }) }) + +describe('child work the host had no room to record', () => { + const agentLive = (id: string): AgentChildWorkEvidence => ({ + type: 'live', + observedAt: 200, + child: { + handle: { idKind: 'task_id', id }, + kind: 'agent', + residency: 'background', + state: 'working', + stoppable: true + } + }) + + function owners() { + const server = new AgentHookServer() + const owner = new StructuredAgentSessionStatusOwnership(() => ({ + publish: (published, subject) => server.ingestStructuredStatus(published, subject), + forget: (subject) => server.dropStructuredStatus(subject), + publishChildWork: (subject, evidence, provider) => + server.ingestStructuredChildWork(subject, evidence, provider), + readChildWork: (subject) => server.getStructuredChildWorkViews(subject) + })) + return { owner } + } + + const ended = (id: string): AgentChildWorkEvidence => ({ + type: 'ended', + observedAt: 250, + handle: { idKind: 'task_id', id }, + outcome: 'succeeded' + }) + + /** Another session holds every child record the store has room for; idle parent beside it. */ + function storeFull() { + const { owner } = owners() + owner.publish({ ...summary, sessionId: 'structured-busy' }, location) + owner.publishChildWork( + 'structured-busy', + Array.from({ length: AGENT_STATUS_STORE_LIMITS.children }, (_unused, index) => + agentLive(`busy-${index}`) + ), + 'claude' + ) + owner.publish({ ...summary, status: 'idle' }, location) + return owner + } + + it('marks an idle parent incomplete when the store refuses its live background child', () => { + const owner = storeFull() + owner.publishChildWork(summary.sessionId, [agentLive('background-agent')], 'claude') + + expect(owner.readChildWork(summary.sessionId)).toEqual([]) + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(true) + // Refused again, still untracked. + owner.publishChildWork(summary.sessionId, [agentLive('background-agent')], 'claude') + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(true) + owner.publishChildWork( + summary.sessionId, + [{ type: 'session-ended', observedAt: 300 }], + 'claude' + ) + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(false) + }) + + it('is whole again once the refused child is admitted on a later report', () => { + const owner = storeFull() + owner.publishChildWork(summary.sessionId, [agentLive('background-agent')], 'claude') + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(true) + // The other session goes, and with it the records that filled the store. + owner.forget('structured-busy') + owner.publishChildWork(summary.sessionId, [agentLive('background-agent')], 'claude') + expect(owner.readChildWork(summary.sessionId)).toHaveLength(1) + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(false) + }) + + it('is whole again once every refused child reports its own end', () => { + const owner = storeFull() + owner.publishChildWork(summary.sessionId, [agentLive('first'), agentLive('second')], 'claude') + owner.publishChildWork(summary.sessionId, [ended('first')], 'claude') + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(true) + owner.publishChildWork(summary.sessionId, [ended('second')], 'claude') + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(false) + }) + + it('marks a parent the store refused incomplete, since none of its children is recorded', () => { + const { owner } = owners() + for (let index = 0; index < AGENT_STATUS_STORE_LIMITS.parents; index++) { + owner.publish({ ...summary, sessionId: `structured-other-${index}` }, location) + } + owner.publish({ ...summary, status: 'idle' }, location) + owner.publishChildWork(summary.sessionId, [agentLive('background-agent')], 'claude') + expect(owner.readChildWork(summary.sessionId)).toEqual([]) + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(true) + owner.publishChildWork(summary.sessionId, [ended('background-agent')], 'claude') + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(false) + }) + + it('past its bound, holds incomplete until the provider session ends', () => { + const owner = storeFull() + const many = Array.from({ length: 513 }, (_unused, index) => `refused-${index}`) + owner.publishChildWork(summary.sessionId, many.map(agentLive), 'claude') + owner.publishChildWork(summary.sessionId, many.map(ended), 'claude') + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(true) + owner.publishChildWork( + summary.sessionId, + [{ type: 'session-ended', observedAt: 300 }], + 'claude' + ) + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts index faf9022f586..0bae091ff8c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts @@ -4,6 +4,8 @@ import type { } from '../../../shared/agent-session-record' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import type { AgentChildWorkReconcileOutcome } from '../../../shared/agent-status-child-work-evidence-admission' +import type { AgentChildWorkRecord } from '../../../shared/agent-status-child-work' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { parseAgentStatusSubject, @@ -22,11 +24,20 @@ export type StructuredAgentSessionStatusSink = { subject: AgentStatusStructuredSessionSubject, evidence: AgentChildWorkEvidence[], provider: AgentSessionRecord['provider'] - ) => void + ) => AgentChildWorkReconcileOutcome | null | void /** The child records the sink holds for that subject, as the views every surface reads. */ readChildWork?: (subject: AgentStatusStructuredSessionSubject) => AgentChildWorkView[] + /** The records those views project, with the host-only facts no view carries. */ + readChildWorkRecords?: (subject: AgentStatusStructuredSessionSubject) => AgentChildWorkRecord[] } +/** The store's limits refusing a record: the per-session one, and the store's own. */ +const ROOM_REFUSALS: ReadonlySet = new Set(['ingestion-limit', 'store-rejected']) +/** Refused children remembered per session; past it only the session's end clears the flag. */ +const MAX_REFUSED_CHILDREN = 512 + +type RefusedChildren = { handles: Set; overflowed: boolean } + /** Retain the owner address because record removal may precede the final status callback. */ export class StructuredAgentSessionStatusOwnership { private readonly subjects = new Map() @@ -34,6 +45,10 @@ export class StructuredAgentSessionStatusOwnership { // forget a row that did land, but "we hold an address" is not evidence the row is there. Only a // publish that returned proves that, and only that proof may suppress the re-offer below. private readonly landed = new Set() + /** Per session, the children the store refused for room, by handle. Each leaves once a later + * report of it is admitted or it reports its own end; the provider session's end, which ends + * every child it ran, clears them all. */ + private readonly refused = new Map() constructor(private readonly sink: () => StructuredAgentSessionStatusSink | undefined) {} @@ -84,8 +99,49 @@ export class StructuredAgentSessionStatusOwnership { provider: AgentSessionRecord['provider'] ): void { const subject = this.subjects.get(sessionId) - if (subject && this.landed.has(sessionId)) { - this.sink()?.publishChildWork?.(subject, evidence, provider) + if (!subject || !this.landed.has(sessionId)) { + return + } + const outcome = this.sink()?.publishChildWork?.(subject, evidence, provider) + if (evidence.some((edge) => edge.type === 'session-ended')) { + this.refused.delete(sessionId) + } else if (typeof outcome === 'object') { + this.followRefusals(sessionId, evidence, outcome) + } + } + + /** Whether some of the session's running child work has no record. */ + childWorkIncomplete(sessionId: string): boolean { + return this.refused.has(sessionId) + } + + /** `null`: the sink holds no parent row, so it recorded none of these children. */ + private followRefusals( + sessionId: string, + evidence: readonly AgentChildWorkEvidence[], + outcome: AgentChildWorkReconcileOutcome | null + ): void { + const refusedNow = new Set( + outcome?.rejected.filter(({ reason }) => ROOM_REFUSALS.has(reason)).map((r) => r.handleId) + ) + const refused = this.refused.get(sessionId) ?? { handles: new Set(), overflowed: false } + for (const edge of evidence) { + if (edge.type === 'live' && (outcome === null || refusedNow.has(edge.child.handle.id))) { + if (refused.handles.size < MAX_REFUSED_CHILDREN) { + refused.handles.add(edge.child.handle.id) + } else { + refused.overflowed = true + } + } else if (edge.type === 'live') { + refused.handles.delete(edge.child.handle.id) + } else if (edge.type === 'ended' || edge.type === 'removed') { + refused.handles.delete(edge.handle.id) + } + } + if (refused.handles.size > 0 || refused.overflowed) { + this.refused.set(sessionId, refused) + } else { + this.refused.delete(sessionId) } } @@ -95,12 +151,20 @@ export class StructuredAgentSessionStatusOwnership { return subject && this.landed.has(sessionId) ? this.sink()?.readChildWork?.(subject) : undefined } + readChildWorkRecords(sessionId: string): AgentChildWorkRecord[] | undefined { + const subject = this.subjects.get(sessionId) + return subject && this.landed.has(sessionId) + ? this.sink()?.readChildWorkRecords?.(subject) + : undefined + } + forget(sessionId: string): void { const subject = this.subjects.get(sessionId) if (!subject) { return } this.landed.delete(sessionId) + this.refused.delete(sessionId) this.sink()?.forget(subject) this.subjects.delete(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts index 3dcc899b081..2c1f3b0373b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts @@ -17,6 +17,10 @@ import { AgentHookServer, _internals } from '../../agent-hooks/server' import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation' import { createCodexJournalTranslator } from '../../codex/codex-structured-journal-translation' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { + subagentJournalBody, + subagentJournalItemIdentity +} from '../agent-session-journal/journal-subagent-item' import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' @@ -185,6 +189,20 @@ describe("a subagent's work and the recency of the session that spawned it", () const translator = createClaudeJournalTranslator({ sink: session.sink }) const handle = (event: ReturnType): void => translator.handle({ ...event, observedAt: session.tick() }) + /** The child's row as the host's writer revises it from the child's record. */ + const hostRow = (label: string, state: 'working' | 'completed') => + session.journal.appendItem( + subagentJournalItemIdentity('task-1'), + subagentJournalBody({ + type: 'subagent', + id: 'task-1', + label, + state, + groupId: 'thread', + run: 'toolu_1' + }), + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await session.prompt('prompt-1', 'fan out') handle(claudeUserTurn('user-1', 'fan out')) handle( @@ -230,10 +248,11 @@ describe("a subagent's work and the recency of the session that spawned it", () const sequence = session.journal.cursor().sequence // The session's own agent has settled. Its backgrounded child renames itself and then - // finishes, and each edge revises the session's roster row. + // finishes, and the host revises the child's own saved row for each edge. handle( claudeTask('task_updated', { task_id: 'task-1', patch: { description: 'Build watched' } }) ) + await hostRow('Build watched', 'working') handle( claudeTask('task_notification', { task_id: 'task-1', @@ -242,6 +261,7 @@ describe("a subagent's work and the recency of the session that spawned it", () summary: 'green' }) ) + await hostRow('Build watched', 'completed') await session.drain() // Controls, so the holds below are not vacuous: the child's edges DID reach the journal, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-rows.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-rows.test.ts new file mode 100644 index 00000000000..40d1e2d2284 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-rows.test.ts @@ -0,0 +1,279 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' +import { isSubagentBlock } from '../../../shared/native-chat-types' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { + StructuredAgentSessionSubagentRows, + type AgentChildWorkRowView +} from './structured-agent-session-subagent-rows' + +const journals = createTrackedJournalOpener() +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-subagent-rows-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +function child(overrides: Partial = {}): AgentChildWorkRowView { + return { + id: 'record-1', + providerId: 'agent-1', + kind: 'agent', + description: 'Audit the build', + state: 'working', + membership: 'live', + firstObservedAt: 100, + observedAt: 100, + stoppable: true, + invocation: { invocationId: 'toolu_1', generation: 1 }, + residency: 'background', + ...overrides + } +} + +async function rig() { + const journal: AgentSessionJournal = await journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude', + providerHandle: claudeProviderHandle('claude-session', null) + }, + now: () => 9_000, + stateDirectory: root + }) + let children: AgentChildWorkRowView[] = [] + const failed = vi.fn() + const rows = new StructuredAgentSessionSubagentRows({ + journal: () => journal, + fence: () => 1, + readChildWork: () => children, + onWriteFailed: failed + }) + const report = async (next: AgentChildWorkRowView[]) => { + children = next + rows.changed('session-1') + // The writer is a queued loop; one macrotask lets its writes land. + await new Promise((resolve) => setTimeout(resolve, 0)) + } + const saved = () => + journal + .snapshot() + .items.flatMap((item) => + item.body.kind === 'message' + ? item.body.blocks.filter(isSubagentBlock).map((block) => ({ item, block })) + : [] + ) + const openTurn = (turnId: string) => + journal.appendItem( + { provider: 'legacy', agent: 'claude', sessionId: 'session-1', recordId: `turn:${turnId}` }, + agentJournalTurnBody({ turnId, state: 'running', startedAt: 50 }), + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return { + journal, + report, + saved, + openTurn, + failed, + fail: (value: boolean) => { + if (value) { + vi.spyOn(journal, 'appendItem').mockRejectedValue(new Error('disk full')) + } else { + vi.mocked(journal.appendItem).mockRestore() + } + } + } +} + +describe("each subagent's saved row", () => { + it('writes one row per subagent, where it was spawned, and none for other work', async () => { + const r = await rig() + await r.openTurn('turn-1') + await r.report([ + child(), + child({ id: 'record-2', providerId: 'agent-2', description: 'Read the docs' }), + child({ id: 'record-3', providerId: 'shell-1', kind: 'command' }) + ]) + const rows = r.saved() + expect(rows.map(({ block }) => [block.id, block.label, block.state])).toEqual([ + ['agent-1', 'Audit the build', 'working'], + ['agent-2', 'Read the docs', 'working'] + ]) + expect(rows.map(({ item }) => item.turnScope?.kind)).toEqual(['turn', 'turn']) + expect(rows[0]?.block).toMatchObject({ run: 'toolu_1', background: true, startedAt: 100 }) + }) + + it('names a subagent the provider gave no name or description as any unnamed one', async () => { + const r = await rig() + await r.report([child({ description: undefined })]) + const [row] = r.saved() + expect(row?.block.label).toBe('subagent') + expect(row?.item.body).toMatchObject({ + blocks: [{ type: 'text', text: 'Kicked off subagent subagent' }, { label: 'subagent' }] + }) + }) + + it("names a nested helper's parent by the parent's own id, and keeps it once that record is gone", async () => { + const r = await rig() + const parent = child({ id: 'record-parent', providerId: 'agent-parent' }) + const nested = child({ + id: 'record-nested', + providerId: 'agent-nested', + parentChildWorkId: 'record-parent' + }) + await r.report([parent, nested]) + const parentOf = (id: string) => r.saved().find(({ block }) => block.id === id)?.block.parentId + expect(parentOf('agent-nested')).toBe('agent-parent') + expect(parentOf('agent-parent')).toBeUndefined() + + // The parent's record retired; the nested helper's next change keeps the parent its row names. + await r.report([{ ...nested, parentChildWorkId: undefined, totalTokens: 30 }]) + expect(r.saved().find(({ block }) => block.id === 'agent-nested')?.block).toMatchObject({ + parentId: 'agent-parent', + tokens: 30 + }) + }) + + it('reads a helper that finished while its own shell runs as working, as the strip does', async () => { + const r = await rig() + const finished = child({ + membership: 'settled', + state: 'done', + outcome: 'succeeded', + settledAt: 150 + }) + const shell = (overrides: Partial) => + child({ + id: 'record-shell', + providerId: 'shell-1', + kind: 'command', + parentChildWorkId: 'record-1', + ...overrides + }) + const helper = () => r.saved().find(({ block }) => block.id === 'agent-1')?.block + await r.report([finished, shell({})]) + expect(helper()).toMatchObject({ state: 'working' }) + expect(helper()).not.toHaveProperty('settledAt') + + // The shell ends and no wake-up follows: the helper reads its own ending again. + await r.report([ + finished, + shell({ membership: 'settled', state: 'done', outcome: 'succeeded', settledAt: 400 }) + ]) + expect(helper()).toMatchObject({ state: 'completed', settledAt: 150 }) + + // The session ends while the shell runs: the shell's unknown ending is not the helper's. + await r.report([finished, shell({})]) + expect(helper()).toMatchObject({ state: 'working' }) + await r.report([ + finished, + shell({ membership: 'settled', state: 'done', outcome: 'unknown', settledAt: 500 }) + ]) + expect(helper()).toMatchObject({ state: 'completed', settledAt: 150 }) + }) + + it('writes nothing for a record its row already says', async () => { + const r = await rig() + await r.report([child()]) + const [first] = r.saved() + await r.report([child({ observedAt: 150, stoppable: false })]) + expect(r.saved()).toEqual([first]) + }) + + it('revises the row in place: same item, same spawn place, the new ending', async () => { + const r = await rig() + await r.openTurn('turn-1') + await r.report([child()]) + const [before] = r.saved() + await r.openTurn('turn-2') + await r.report([ + child({ + membership: 'settled', + state: 'done', + outcome: 'cancelled', + outcomeBasis: 'stop-acknowledged', + settledAt: 300 + }) + ]) + const [after] = r.saved() + expect(after?.item.itemId).toBe(before?.item.itemId) + expect(after?.item.sequence).toBe(before?.item.sequence) + expect(after?.item.turnScope).toEqual(before?.item.turnScope) + expect(after?.block).toMatchObject({ + groupId: before?.block.groupId, + state: 'stopped', + settledAt: 300, + outcomeBasis: 'stop-acknowledged' + }) + }) + + it('starts a new run’s clock when the host first saw that run', async () => { + const r = await rig() + await r.report([child()]) + await r.report([ + child({ observedAt: 400, invocation: { invocationId: 'toolu_2', generation: 2 } }) + ]) + expect(r.saved()[0]?.block).toMatchObject({ run: 'toolu_2', startedAt: 400 }) + }) + + it('reports a failed write and writes the row on the next change', async () => { + const r = await rig() + r.fail(true) + await r.report([child()]) + expect(r.failed).toHaveBeenCalledWith('session-1', expect.any(Error)) + expect(r.saved()).toEqual([]) + r.fail(false) + await r.report([child({ totalTokens: 20 })]) + expect(r.saved().map(({ block }) => block.tokens)).toEqual([20]) + }) + + it('reads the live turn once per change, and never for subagents already saved', async () => { + const r = await rig() + await r.openTurn('turn-1') + const scan = vi.spyOn(r.journal, 'liveTurnScope') + const three = ['agent-1', 'agent-2', 'agent-3'] + await r.report(three.map((id) => child({ id: `record-${id}`, providerId: id }))) + expect(scan).toHaveBeenCalledTimes(1) + + scan.mockClear() + await r.report( + three.map((id) => child({ id: `record-${id}`, providerId: id, totalTokens: 40 })) + ) + expect(scan).not.toHaveBeenCalled() + expect(r.saved().map(({ block }) => block.tokens)).toEqual([40, 40, 40]) + }) + + it('keeps the spawn turn of a first row still being written when a turn opens', async () => { + const r = await rig() + await r.openTurn('turn-1') + const append = r.journal.appendItem.bind(r.journal) + let release = () => {} + const held = new Promise((resolve) => (release = resolve)) + vi.spyOn(r.journal, 'appendItem').mockImplementationOnce(async (...args) => { + await held + return append(...args) + }) + await r.report([child()]) + await r.openTurn('turn-2') + await r.report([child({ totalTokens: 40 })]) + release() + await new Promise((resolve) => setTimeout(resolve, 0)) + + const [row] = r.saved() + expect(row?.block.tokens).toBe(40) + expect(row?.item.turnScope).toMatchObject({ kind: 'turn', turnItemId: row?.block.groupId }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-rows.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-rows.ts new file mode 100644 index 00000000000..03236370e8e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-rows.ts @@ -0,0 +1,180 @@ +// Each subagent's own saved row, written from the host's record of it. +// +// The host's child-work records are the one account of a subagent, for every agent: what started +// it, which run is current and how that run ended. Its journal row is their durable copy, written +// here each time a record changes, where the subagent was spawned. The saved row is also the +// dedupe: a record that reads as its row already does writes nothing, and a write that fails is +// tried again by the next change after its batch settles, so nothing here holds an obligation of +// its own. What a lost write leaves saying `working` the journal's reopen pass settles as +// unverifiable. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import type { AgentChildWorkInput } from '../../../shared/agent-status-child-work' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import type { + NativeChatSubagentBlock, + NativeChatSubagentState +} from '../../../shared/native-chat-types' +import { structuredRunningChildWork } from '../../../shared/agent-child-work-listing' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + journaledSubagent, + subagentJournalBody, + subagentJournalItemIdentity +} from '../agent-session-journal/journal-subagent-item' + +const UNNAMED = 'subagent' +const THREAD_GROUP = 'thread' + +/** A child's view, with the host-only facts its saved row also keeps. */ +export type AgentChildWorkRowView = AgentChildWorkView & + Pick + +type Row = { block: NativeChatSubagentBlock; turnScope: AgentJournalTurnScope } + +export type StructuredAgentSessionSubagentRowsDeps = { + journal: (sessionId: string) => AgentSessionJournal | undefined + fence: (sessionId: string) => number + readChildWork: (sessionId: string) => AgentChildWorkRowView[] | undefined + onWriteFailed: (sessionId: string, error: unknown) => void +} + +export class StructuredAgentSessionSubagentRows { + /** Rows waiting for the session's writer, newest per subagent. */ + private readonly queued = new Map>() + /** The batch each session's writer has in flight: written, but not yet readable by key. */ + private readonly writing = new Map>() + + constructor(private readonly deps: StructuredAgentSessionSubagentRowsDeps) {} + + /** The session's child records changed: queue each subagent whose row says otherwise. */ + changed(sessionId: string): void { + const journal = this.deps.journal(sessionId) + const children = this.deps.readChildWork(sessionId) + if (!journal || !children?.some((child) => child.kind === 'agent')) { + return + } + const queued = this.queued.get(sessionId) ?? new Map() + const providerIds = new Map(children.map((child) => [child.id, child.providerId])) + // The strip's rule: a child that finished while work it started runs is still running. + const running = new Set(structuredRunningChildWork(children).map((child) => child.id)) + // A full journal pass, so read once, and only for a first row: a revision keeps the scope + // its first write froze. + let live: AgentJournalTurnScope | undefined + for (const child of children) { + const id = child.providerId + if (child.kind !== 'agent' || id === undefined) { + continue + } + const pending = queued.get(id) ?? this.writing.get(sessionId)?.get(id) + const saved = pending?.block ?? journaledSubagent(journal, id) + const turnScope = + pending?.turnScope ?? + (saved ? AGENT_JOURNAL_THREAD_SCOPE : (live ??= journal.liveTurnScope())) + const parentId = + child.parentChildWorkId === undefined ? undefined : providerIds.get(child.parentChildWorkId) + const block = subagentRowBlock(child, id, parentId, saved, turnScope, running.has(child.id)) + if (!saved || rowText(saved) !== rowText(block)) { + queued.set(id, { block, turnScope }) + } + } + if (queued.size > 0) { + this.queued.set(sessionId, queued) + void this.write(sessionId) + } + } + + private async write(sessionId: string): Promise { + if (this.writing.has(sessionId)) { + return + } + try { + for (let rows = this.take(sessionId); rows; rows = this.take(sessionId)) { + const journal = this.deps.journal(sessionId) + // A closed conversation's unwritten rows are the reopen pass's to settle. + if (!journal) { + return + } + this.writing.set(sessionId, rows) + // The journal serializes its writes; these land in the order they were queued. + await Promise.all( + [...rows].map(([id, row]) => + journal + .appendItem(subagentJournalItemIdentity(id), subagentJournalBody(row.block), { + fence: this.deps.fence(sessionId), + turnScope: row.turnScope + }) + .catch((error: unknown) => this.deps.onWriteFailed(sessionId, error)) + ) + ) + } + } finally { + this.writing.delete(sessionId) + } + } + + private take(sessionId: string): Map | undefined { + const rows = this.queued.get(sessionId) + this.queued.delete(sessionId) + return rows + } +} + +/** The record as its row says it. A saved row keeps where it was spawned, and what a record of a + * later run no longer carries: its name, its tokens, its parent. */ +function subagentRowBlock( + child: AgentChildWorkRowView, + id: string, + parentId: string | undefined, + saved: NativeChatSubagentBlock | null, + scope: AgentJournalTurnScope, + running: boolean +): NativeChatSubagentBlock { + const run = child.invocation.invocationId + const state = running ? (child.state === 'idle' ? 'idle' : 'working') : subagentEnding(child) + const tokens = child.totalTokens ?? saved?.tokens + const parent = parentId ?? saved?.parentId + // A new run starts its clock when the host first saw it; the first run when the child was. + const startedAt = + saved === null + ? child.firstObservedAt + : saved.run === run + ? (saved.startedAt ?? child.firstObservedAt) + : child.observedAt + return { + type: 'subagent', + id, + label: child.description ?? child.name ?? saved?.label ?? UNNAMED, + state, + ...(tokens !== undefined ? { tokens } : {}), + startedAt, + ...(state !== 'working' && child.settledAt !== undefined ? { settledAt: child.settledAt } : {}), + groupId: saved?.groupId ?? (scope.kind === 'turn' ? scope.turnItemId : THREAD_GROUP), + ...(parent !== undefined ? { parentId: parent } : {}), + run, + ...(!running && child.outcomeBasis !== undefined ? { outcomeBasis: child.outcomeBasis } : {}), + ...(child.residency === 'background' ? { background: true as const } : {}) + } +} + +function subagentEnding(child: AgentChildWorkRowView): NativeChatSubagentState { + switch (child.outcome) { + case 'succeeded': + return 'completed' + case 'failed': + return 'failed' + case 'cancelled': + return 'stopped' + case 'unknown': + case undefined: + return 'unverifiable' + } +} + +/** Compared as written, so a row the journal bounded still matches the record it came from. */ +function rowText(block: NativeChatSubagentBlock): string { + return JSON.stringify(subagentJournalBody(block).blocks[1]) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index a588fa7168a..b8151213595 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -1,6 +1,4 @@ -import { agentChildWorkStopTargets } from '../../../shared/agent-child-work-stop-targets' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentJournalStatusItem, @@ -32,6 +30,10 @@ import { structuredAgentSessionFailedStopMark } from './structured-agent-session import { sendStopCanTakeBack } from './structured-agent-session-unopened-send-withdrawal' import { settleTakenStop } from './structured-agent-session-taken-stop-settle' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' +import { + stopStructuredAgentSessionBackgroundWork, + type StructuredAgentSessionBackgroundStopInput +} from './structured-agent-session-background-stop' /** Whether the fold reads working. Every write has landed by its call's return, so a Stop reads it * without waiting on the write queue. */ @@ -123,12 +125,11 @@ export async function performCancel( } } -type PerformCancelInput = { +type PerformCancelInput = StructuredAgentSessionBackgroundStopInput & { clientOperationId: string /** Absent: whatever the conversation has in flight; present: only while that turn is current. */ turnId?: string scope?: 'background-tasks' - taskId?: string prompt?: { itemId: string; expectedRevision: number } /** Ends the provider child, for a running command the provider did not take the Stop on, or a * turn whose interrupt failed. */ @@ -144,8 +145,6 @@ type PerformCancelInput = { /** Whether the host's withdrawal of queued messages for this Stop withdrew any; awaited only * after the interrupt. */ withdrewQueued?: Promise - /** The session's child records: a background Stop reaches the tasks they offer a stop. */ - childWork?: () => readonly AgentChildWorkView[] | undefined /** The latest Stop event is this press's own, or the in-force one it repeats: its settle binds. */ opensSettle?: true } @@ -197,11 +196,11 @@ async function cancelAndNote( const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) const background = input.scope && !stoppedBefore - ? await ctx.adapter.stopBackgroundTasks?.({ - sessionId: ctx.sessionId, - fence: ctx.fence, - taskIds: agentChildWorkStopTargets(input.childWork?.(), input.taskId) - }) + ? await stopStructuredAgentSessionBackgroundWork( + ctx, + input, + () => ctx.journal.activeTurnId() === null && !isMainAgentWorking(ctx) + ) : undefined // A background Stop writes no row, so a task the agent shows still running after it goes back // to the client that pressed it. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 3fe9542e7b6..65c21b5e1bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -5,6 +5,8 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' @@ -222,7 +224,8 @@ describe('performCancel', () => { expect(stopBackgroundTasks).toHaveBeenCalledWith({ sessionId: 'session-1', fence: 1, - taskIds: ['task-1'] + taskIds: ['task-1'], + acknowledge: expect.any(Function) }) expect(cancelTurn).not.toHaveBeenCalled() expect(journal.snapshot().items).toEqual([]) @@ -262,7 +265,8 @@ describe('performCancel', () => { expect(stopBackgroundTasks).toHaveBeenCalledWith({ sessionId: 'session-1', fence: 1, - taskIds: ['task-2'] + taskIds: ['task-2'], + acknowledge: expect.any(Function) }) expect(cancelTurn).not.toHaveBeenCalled() expect(journal.snapshot().items).toEqual([]) @@ -270,10 +274,18 @@ describe('performCancel', () => { async function backgroundStopWith( stopBackgroundTasks: StructuredAgentSessionAdapter['stopBackgroundTasks'], - operation: string + operation: string, + acknowledgeStops?: (evidence: AgentChildWorkEvidence[]) => void, + /** An untargeted press, as production sends with unlisted work: `listed` is what the records list. */ + untargeted?: { + unlisted: { present: () => boolean; stop: () => Promise } + listed: AgentChildWorkView[] + onJournal?: (journal: AgentSessionJournal) => void + } ) { root = await mkdtemp(join(tmpdir(), 'orca-background-task-failed-cancel-')) const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + untargeted?.onJournal?.(journal) const cancelTurn = vi.fn(async () => ({ cancelled: true })) const ctx: AgentSessionTurnContext = { logger: createStructuredAgentSessionLogger(), @@ -292,11 +304,14 @@ describe('performCancel', () => { clientOperationId: operation, turnId: 'background-tasks', scope: 'background-tasks', - taskId: 'task-1', - childWork: () => [liveTask('task-1')] + ...(untargeted + ? { unlisted: untargeted.unlisted, childWork: () => untargeted.listed } + : { taskId: 'task-1', childWork: () => [liveTask('task-1')] }), + ...(acknowledgeStops ? { acknowledgeStops } : {}) }) expect(cancelTurn).not.toHaveBeenCalled() - expect(journal.snapshot().items).toEqual([]) + // A background Stop writes no row; only what the agent itself wrote is there. + expect(journal.snapshot().items).toHaveLength(untargeted?.onJournal ? 1 : 0) return result } @@ -327,6 +342,94 @@ describe('performCancel', () => { }) expect(result.ok ? undefined : result.refusal.details).toBeUndefined() }) + + it('ends the agent for unlisted work only when, after the listed stops, no turn runs', async () => { + const stop = vi.fn(async () => {}) + // Unlisted work is read inside the Stop's step: here it appears while the listed stop is out. + let incomplete = false + const atRest = await backgroundStopWith( + async () => { + incomplete = true + return { cancelled: false } + }, + 'cancel-background-unlisted', + undefined, + { unlisted: { present: () => incomplete, stop }, listed: [liveTask('task-1')] } + ) + expect(atRest).toEqual({ ok: true, value: { turnId: 'background-tasks', cancelled: true } }) + expect(stop).toHaveBeenCalledOnce() + + // A turn the agent opens while the listed stops are out is not the Stop's to end. + let journal: AgentSessionJournal | undefined + const woke = await backgroundStopWith( + async () => { + await journal!.appendItem( + { + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: 'turn-lifecycle:woke' + }, + { + kind: 'status', + text: 'Agent is working…', + turnLifecycle: { turnId: 'woke', state: 'running' } + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return { cancelled: false } + }, + 'cancel-background-unlisted-woke', + undefined, + { + unlisted: { present: () => true, stop }, + listed: [liveTask('task-1')], + onJournal: (opened) => (journal = opened) + } + ) + expect(woke).toEqual({ ok: true, value: { turnId: 'background-tasks', cancelled: false } }) + expect(stop).toHaveBeenCalledOnce() + }) + + it('ends the agent for unlisted work with nothing listed, never asking an agent that has no stop', async () => { + const stop = vi.fn(async () => {}) + // As an ACP agent with no cancellation route answers any stop. + const stopBackgroundTasks = vi.fn(async () => { + throw new Error('ACP child cancellation is unavailable') + }) + const result = await backgroundStopWith( + stopBackgroundTasks, + 'cancel-background-unlisted-acp', + undefined, + { + unlisted: { present: () => true, stop }, + listed: [] + } + ) + expect(result).toEqual({ ok: true, value: { turnId: 'background-tasks', cancelled: true } }) + expect(stopBackgroundTasks).not.toHaveBeenCalled() + expect(stop).toHaveBeenCalledOnce() + }) + + it('ends a run the agent accepted the stop of, even when a later stop in the batch fails', async () => { + const acknowledgeStops = vi.fn() + const result = await backgroundStopWith( + async ({ acknowledge }) => { + acknowledge?.({ taskId: 'task-1' }) + throw new Error('claude control request timed out') + }, + 'cancel-background-task-partial', + acknowledgeStops + ) + + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown' } + }) + expect(acknowledgeStops).toHaveBeenCalledWith([ + expect.objectContaining({ type: 'stop-acknowledged', childWorkId: 'child-task-1' }) + ]) + }) }) /** A live child record the strip would offer a stop, named by the provider as `providerId`. */ diff --git a/src/main/native-chat/subagent-roster-retention.test.ts b/src/main/native-chat/subagent-roster-retention.test.ts deleted file mode 100644 index c89831e2263..00000000000 --- a/src/main/native-chat/subagent-roster-retention.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { - NativeChatSubagentEntry, - NativeChatSubagentState -} from '../../shared/native-chat-types' -import { SubagentRosterRetention } from './subagent-roster-retention' - -type Group = { - groupId: string - entries: Map - lastSerialized: string | null -} - -function group(groupId: string, state: NativeChatSubagentState, children = 64): Group { - return { - groupId, - lastSerialized: 'admitted', - entries: new Map( - Array.from({ length: children }, (_, index) => { - const id = `${groupId}:${index}` - return [id, { id, label: id, state }] - }) - ) - } -} - -describe('host subagent roster settled retention', () => { - it('shares one bound between superseded executions and evicted group identities', () => { - const groups = new Map() - const retention = new SubagentRosterRetention(groups, { - maxGroups: 1, - maxSettledIdentities: 2048, - entries: (group) => group.entries.values(), - identities: (roster) => roster.entries.keys() - }) - const live = group('live', 'working', 1) - groups.set(live.groupId, live) - for (let index = 0; index < 2100; index++) { - retention.rememberSettled(`superseded-${index}`) - } - const settled = group('settled', 'completed', 1) - groups.set(settled.groupId, settled) - retention.trim([settled]) - expect(retention.sizes()).toEqual({ groups: 1, settledIdentities: 2048 }) - expect(retention.hasSettled('superseded-52')).toBe(false) - expect(retention.hasSettled('superseded-2099')).toBe(true) - expect(retention.hasSettled('settled:0')).toBe(true) - retention.clear() - expect(retention.sizes().settledIdentities).toBe(0) - }) - - it('bounds settled groups and recent identities while retaining every live group', () => { - const groups = new Map() - const retention = new SubagentRosterRetention(groups, { - maxGroups: 32, - maxSettledIdentities: 32 * 64, - entries: (group) => group.entries.values(), - identities: (roster) => roster.entries.keys() - }) - for (let index = 0; index < 35; index++) { - const live = group(`live-${index}`, 'working') - groups.set(live.groupId, live) - retention.trim([live]) - } - for (let index = 0; index < 70; index++) { - const settled = group(`settled-${index}`, 'completed') - groups.set(settled.groupId, settled) - retention.trim([settled]) - } - expect(retention.sizes()).toEqual({ groups: 35, settledIdentities: 32 * 64 }) - expect(retention.hasSettled('settled-0:0')).toBe(false) - expect(retention.hasSettled('settled-69:0')).toBe(true) - expect([...groups.keys()].every((id) => id.startsWith('live-'))).toBe(true) - for (let index = 0; index < 70; index++) { - const settled = group(`more-settled-${index}`, 'completed') - const live = groups.get(`live-${index}`) - if (live) { - for (const entry of live.entries.values()) { - entry.state = 'completed' - } - retention.trim([live]) - } - groups.set(settled.groupId, settled) - retention.trim([settled]) - } - expect(retention.sizes()).toEqual({ groups: 32, settledIdentities: 32 * 64 }) - }) - - it('re-derives settled eligibility when a retained child runs again or its write is refused', () => { - const groups = new Map() - const retention = new SubagentRosterRetention(groups, { - maxGroups: 1, - maxSettledIdentities: 64, - entries: (group) => group.entries.values(), - identities: (roster) => roster.entries.keys() - }) - const old = group('old', 'completed', 1) - groups.set(old.groupId, old) - retention.trim([old]) - old.entries.set('old:0', { id: 'old:0', label: 'old', state: 'working' }) - retention.trim([old]) - const newGroup = group('new', 'working', 1) - groups.set(newGroup.groupId, newGroup) - retention.trim([newGroup]) - expect(groups.has('old')).toBe(true) - old.entries.set('old:0', { id: 'old:0', label: 'old', state: 'completed' }) - old.lastSerialized = null - retention.trim([old]) - expect(groups.has('old')).toBe(true) - old.lastSerialized = 'admitted' - retention.trim([old]) - expect(groups.has('old')).toBe(false) - expect(retention.hasSettled('old:0')).toBe(true) - groups.clear() - retention.clear() - expect(retention.sizes()).toEqual({ groups: 0, settledIdentities: 0 }) - }) -}) - -it('keeps only the accessed inherited row during a trim and re-derives it on the next event', () => { - const groups = new Map() - const retention = new SubagentRosterRetention(groups, { - maxGroups: 32, - maxSettledIdentities: 2048, - entries: (roster) => roster.entries.values(), - identities: (roster) => roster.entries.keys() - }) - for (let index = 0; index < 32; index++) { - const live = group(`live-${index}`, 'working', 1) - groups.set(live.groupId, live) - retention.trim([live]) - } - for (let index = 0; index < 70; index++) { - const inherited = group(`inherited-${index}`, 'completed', 1) - groups.set(inherited.groupId, inherited) - retention.trim([inherited], (groupId) => groupId === inherited.groupId) - expect(groups.size).toBe(33) - expect(groups.has(inherited.groupId)).toBe(true) - } - retention.trim([]) - expect(groups.size).toBe(32) - expect(retention.hasSettled('inherited-69:0')).toBe(true) - const resumed = group('resumed', 'completed', 1) - groups.set(resumed.groupId, resumed) - 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) - expect(groups.size).toBe(33) -}) diff --git a/src/main/native-chat/subagent-roster-retention.ts b/src/main/native-chat/subagent-roster-retention.ts deleted file mode 100644 index 3e3584a4efb..00000000000 --- a/src/main/native-chat/subagent-roster-retention.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { BoundedMap } from '../../shared/bounded-map' -import { isTerminalSubagentState } from '../../shared/native-chat-subagent-summary' -import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' - -type RetainedRosterGroup = { - groupId: string - lastSerialized: string | null -} - -/** Bounds settled history; live children and refused final writes keep their ownership. */ -export class SubagentRosterRetention { - private readonly settledGroups = new Set() - private readonly settledIdentities: BoundedMap - - constructor( - private readonly groups: Map, - private readonly options: { - maxGroups: number - maxSettledIdentities: number - entries: (group: Group) => Iterable - identities: (group: Group) => Iterable - onEvict?: (group: Group) => void - } - ) { - this.settledIdentities = new BoundedMap({ maxEntries: options.maxSettledIdentities }) - } - - hasSettled(identity: string): boolean { - return this.settledIdentities.has(identity) - } - - /** Superseded executions share the evicted history's single bounded budget. */ - rememberSettled(identity: string): void { - this.settledIdentities.set(identity, true) - } - - /** `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 && - [...this.options.entries(group)].every((entry) => isTerminalSubagentState(entry.state)) - ) { - this.settledGroups.add(group.groupId) - } else { - this.settledGroups.delete(group.groupId) - } - } - for (const groupId of this.settledGroups) { - if (this.groups.size <= this.options.maxGroups) { - break - } - if (kept?.(groupId)) { - continue - } - const group = this.groups.get(groupId) - if (group) { - for (const identity of this.options.identities(group)) { - this.rememberSettled(identity) - } - this.options.onEvict?.(group) - } - this.groups.delete(groupId) - this.settledGroups.delete(groupId) - } - } - - sizes(): { groups: number; settledIdentities: number } { - return { groups: this.groups.size, settledIdentities: this.settledIdentities.size } - } - - clear(): void { - this.settledGroups.clear() - this.settledIdentities.clear() - } -} diff --git a/src/main/native-chat/subagent-tracker/journaled-subagent-groups.ts b/src/main/native-chat/subagent-tracker/journaled-subagent-groups.ts deleted file mode 100644 index 32ea45200b9..00000000000 --- a/src/main/native-chat/subagent-tracker/journaled-subagent-groups.ts +++ /dev/null @@ -1,112 +0,0 @@ -// 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 deleted file mode 100644 index c596e367d5d..00000000000 --- a/src/main/native-chat/subagent-tracker/subagent-row-sink-port.ts +++ /dev/null @@ -1,37 +0,0 @@ -// 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 deleted file mode 100644 index e752b23c4d7..00000000000 --- a/src/main/native-chat/subagent-tracker/subagent-row-writes.ts +++ /dev/null @@ -1,29 +0,0 @@ -// 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 deleted file mode 100644 index 3efe65091d1..00000000000 --- a/src/main/native-chat/subagent-tracker/subagent-tracker-entries.ts +++ /dev/null @@ -1,69 +0,0 @@ -// 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 deleted file mode 100644 index f7ea43906a0..00000000000 --- a/src/main/native-chat/subagent-tracker/subagent-tracker-groups.ts +++ /dev/null @@ -1,167 +0,0 @@ -// 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 deleted file mode 100644 index e53fd11a9b0..00000000000 --- a/src/main/native-chat/subagent-tracker/subagent-tracker-types.ts +++ /dev/null @@ -1,88 +0,0 @@ -// 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 deleted file mode 100644 index 995fd0e49e0..00000000000 --- a/src/main/native-chat/subagent-tracker/subagent-tracker.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -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 deleted file mode 100644 index cedbda67266..00000000000 --- a/src/main/native-chat/subagent-tracker/subagent-tracker.ts +++ /dev/null @@ -1,308 +0,0 @@ -// 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 - } -} diff --git a/src/main/runtime/orchestration/worker-transcript-payload.test.ts b/src/main/runtime/orchestration/worker-transcript-payload.test.ts index d2b1523a96c..7288af52a76 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.test.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import { MAX_CODEX_SUBAGENTS_PER_GROUP } from '../../codex/codex-structured-journal-limits' import { projectStructuredItemsToNativeChat } from '../../../shared/structured-agent-session-projection' import { boundWorkerTranscriptMessages, @@ -87,9 +86,7 @@ describe('worker transcript wire bounds', () => { const block = result.messages[0]?.blocks[0] expect(block?.type).toBe('subagent-group') - expect(block?.type === 'subagent-group' ? block.agents : []).toHaveLength( - MAX_CODEX_SUBAGENTS_PER_GROUP - ) + expect(block?.type === 'subagent-group' ? block.agents : []).toHaveLength(64) expect(JSON.stringify(result.messages)).not.toContain('dcap_') expect(result.limited).toBe(true) expect(result.warnings).toEqual( diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts index bd29562320e..e292c271e3e 100644 --- a/src/main/runtime/structured-codex-child-work-runtime.test.ts +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -93,7 +93,9 @@ describe('structured Codex child work through the production runtime', () => { statusSink: { publish: () => {}, forget: () => {}, - publishChildWork: (...args) => childWork.push(args) + publishChildWork: (...args) => { + childWork.push(args) + } } }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) diff --git a/src/renderer/src/components/agent-child-row-parity.test.tsx b/src/renderer/src/components/agent-child-row-parity.test.tsx index 5c49e83efab..87603115dc6 100644 --- a/src/renderer/src/components/agent-child-row-parity.test.tsx +++ b/src/renderer/src/components/agent-child-row-parity.test.tsx @@ -153,7 +153,7 @@ function stripRows( childViews={sent} childRowContext={childRowContext} supportsTaskStop - supportsStopAll + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} indicatorActive @@ -583,7 +583,7 @@ describe('the chat strip from views', () => { settledTasks={[]} childViews={children} supportsTaskStop - supportsStopAll + offersStopAll={false} stoppingTaskIds={new Set(['task-shell'])} stoppingAll={false} indicatorActive diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx index e3da66df444..5d885534707 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx @@ -37,7 +37,7 @@ const TASKS: AgentSessionBackgroundTask[] = [ { id: 'codex-command:exec-1', kind: 'command', description: 'sleep 90' } ] -function renderStrip(props: { supportsTaskStop: boolean; supportsStopAll: boolean }): { +function renderStrip(props: { supportsTaskStop: boolean; offersStopAll: boolean }): { onStop: ReturnType } { const onStop = vi.fn() @@ -48,7 +48,7 @@ function renderStrip(props: { supportsTaskStop: boolean; supportsStopAll: boolea settledTasks={[]} indicatorActive supportsTaskStop={props.supportsTaskStop} - supportsStopAll={props.supportsStopAll} + offersStopAll={props.offersStopAll} stoppingTaskIds={new Set()} stoppingAll={false} onStop={onStop} @@ -60,13 +60,13 @@ function renderStrip(props: { supportsTaskStop: boolean; supportsStopAll: boolea describe('NativeChatBackgroundTasksStatus stop affordances', () => { it('offers a per-task stop on a host that accepts targeted stops', () => { - renderStrip({ supportsTaskStop: true, supportsStopAll: true }) + renderStrip({ supportsTaskStop: true, offersStopAll: false }) expect(screen.getByLabelText('Stop count_a')).toBeInTheDocument() expect(screen.queryByLabelText('Stop background tasks')).not.toBeInTheDocument() }) it('falls back to a stop-all on a host that only accepts an untargeted stop', () => { - renderStrip({ supportsTaskStop: false, supportsStopAll: true }) + renderStrip({ supportsTaskStop: false, offersStopAll: true }) expect(screen.getByLabelText('Stop background tasks')).toBeInTheDocument() }) @@ -84,7 +84,7 @@ describe('NativeChatBackgroundTasksStatus stop affordances', () => { settledTasks={[]} indicatorActive supportsTaskStop - supportsStopAll + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} onStop={vi.fn()} @@ -131,7 +131,7 @@ describe('NativeChatBackgroundTasksStatus stop affordances', () => { ]} indicatorActive supportsTaskStop - supportsStopAll + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} onStop={onStop} @@ -173,7 +173,7 @@ describe('NativeChatBackgroundTasksStatus stop affordances', () => { ]} indicatorActive supportsTaskStop - supportsStopAll={false} + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} onStop={onStop} @@ -189,7 +189,7 @@ describe('NativeChatBackgroundTasksStatus stop affordances', () => { it('offers no stop at all when the provider exposes none', () => { // An older Codex: a Stop button here would be a control that cannot act. - renderStrip({ supportsTaskStop: false, supportsStopAll: false }) + renderStrip({ supportsTaskStop: false, offersStopAll: false }) expect(screen.queryByLabelText('Stop background tasks')).not.toBeInTheDocument() expect(screen.queryByLabelText('Stop count_a')).not.toBeInTheDocument() expect(screen.getByText('count_a')).toBeInTheDocument() @@ -206,7 +206,7 @@ describe('background-tasks strip header', () => { settledTasks={[]} indicatorActive supportsTaskStop={false} - supportsStopAll={false} + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} onStop={() => {}} @@ -253,7 +253,7 @@ describe('background-tasks strip header', () => { settledTasks={[]} indicatorActive={false} supportsTaskStop={false} - supportsStopAll={false} + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} onStop={() => {}} @@ -360,7 +360,7 @@ describe('settled rows beside their live siblings', () => { ]} indicatorActive supportsTaskStop - supportsStopAll + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} onStop={() => {}} @@ -387,7 +387,7 @@ describe('background-task row reasons', () => { settledTasks={[]} indicatorActive supportsTaskStop={false} - supportsStopAll={false} + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} onStop={() => {}} @@ -429,7 +429,7 @@ it('stops elapsed renders in a hidden pane and catches up on reveal', () => { settledTasks={[]} indicatorActive supportsTaskStop={false} - supportsStopAll={false} + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} onStop={() => {}} @@ -481,7 +481,7 @@ it('lets the 1 Hz tick sleep while every row has settled, with each run frozen', childViews={[finished('a', 400_000, 520_000), finished('b', 700_000, 760_000)]} indicatorActive supportsTaskStop - supportsStopAll + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} onStop={() => {}} diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx index cd08ca546b9..fd22fa01a22 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx @@ -145,9 +145,11 @@ export function NativeChatBackgroundTasksStatus(props: { * it every live claim stands as reported. */ childRowContext?: AgentChildRowContext supportsTaskStop: boolean - /** False when the provider exposes no honest stop at all; the fallback - * control is hidden rather than offering a button that cannot act. */ - supportsStopAll: boolean + /** The shared view's rule for the untargeted Stop; hidden when the provider exposes no honest + * stop at all, rather than offering a button that cannot act. */ + offersStopAll: boolean + /** Some running work is not listed, so the rows are not the whole roster. */ + incomplete?: boolean stoppingTaskIds: ReadonlySet stoppingAll: boolean /** True while the session is idle: only then may the strip speak as the @@ -270,7 +272,10 @@ export function NativeChatBackgroundTasksStatus(props: { ) : (

{say('detailsUnavailable')}

)} - {!props.supportsTaskStop && props.supportsStopAll ? ( + {props.incomplete && groups.length > 0 ? ( +

{say('detailsUnavailable')}

+ ) : null} + {props.offersStopAll ? (
0 ? 'mt-2 border-t border-border pt-2' : 'mt-2'}>