diff --git a/src/main/agent-hooks/server-ingest-structured-children.test.ts b/src/main/agent-hooks/server-ingest-structured-children.test.ts new file mode 100644 index 00000000000..92efc8ee553 --- /dev/null +++ b/src/main/agent-hooks/server-ingest-structured-children.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' +import { + makeStructuredAgentStatusSubject, + type AgentStatusStructuredSessionSubject +} from '../../shared/agent-status-subject' +import { AgentHookServer } from './server' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) + +const SUBJECT = makeStructuredAgentStatusSubject( + { + executionHostId: 'ssh:build-box', + wslDistro: null, + workspaceId: 'workspace-one', + workspaceKind: 'folder' + }, + 'structured-session-one' +) + +function summary( + subject: AgentStatusStructuredSessionSubject = SUBJECT +): AgentSessionStatusSummary { + return { + sessionId: subject.sessionId, + workspaceId: subject.workspaceId, + agent: 'claude', + status: 'working', + hostExecutionOwned: true, + latestPrompt: 'find the flaky tests', + updatedAt: 100 + } +} + +const started: AgentChildWorkEvidence[] = [ + { + type: 'live', + observedAt: 200, + child: { + handle: { idKind: 'task_id', id: 'agent-1', runId: 'toolu_1' }, + kind: 'agent', + residency: 'background', + state: 'working', + description: 'Audit the build', + stoppable: true + } + } +] + +afterEach(() => vi.restoreAllMocks()) + +describe('structured child-work ingest', () => { + it('refuses children of a session whose own row the store does not hold', () => { + const server = new AgentHookServer() + expect(server.ingestStructuredChildWork(SUBJECT, started, 'claude')).toBeNull() + expect(server.getStructuredChildWork(SUBJECT)).toEqual([]) + expect(server.getCanonicalStatusSnapshot().parents).toEqual([]) + }) + + it('holds a child under the subject its parent row landed under, and leaves that row alone', () => { + const server = new AgentHookServer() + const changed = vi.fn() + server.ingestStructuredStatus(summary(), SUBJECT) + const rows = server.getStatusSnapshot() + server.subscribeStatusChanges(changed) + expect(server.ingestStructuredChildWork(SUBJECT, started, 'claude')).toMatchObject({ + admitted: 1, + rejected: [] + }) + expect(server.getStructuredChildWork(SUBJECT)).toEqual([ + expect.objectContaining({ + parent: SUBJECT, + provider: 'claude', + description: 'Audit the build', + membership: 'live' + }) + ]) + // Nothing reads the records yet: every published row is exactly what it was. + expect(server.getStatusSnapshot()).toEqual(rows) + expect(changed).not.toHaveBeenCalled() + }) + + it('drops the children with their parent row', () => { + const server = new AgentHookServer() + server.ingestStructuredStatus(summary(), SUBJECT) + server.ingestStructuredChildWork(SUBJECT, started, 'claude') + server.dropStructuredStatus(SUBJECT) + expect(server.getStructuredChildWork(SUBJECT)).toEqual([]) + expect(server.getCanonicalStatusSnapshot().children).toEqual([]) + }) + + it('keeps sessions apart even when their provider ids collide', () => { + const server = new AgentHookServer() + const other = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-two', + workspaceKind: 'git-worktree' + }, + 'structured-session-two' + ) + server.ingestStructuredStatus(summary(), SUBJECT) + server.ingestStructuredStatus(summary(other), other) + server.ingestStructuredChildWork(SUBJECT, started, 'claude') + server.ingestStructuredChildWork(other, started, 'claude') + const [first] = server.getStructuredChildWork(SUBJECT) + const [second] = server.getStructuredChildWork(other) + expect(first.childWorkId).not.toBe(second.childWorkId) + server.ingestStructuredChildWork(other, [{ type: 'session-ended', observedAt: 300 }], 'claude') + expect(server.getStructuredChildWork(other)).toEqual([ + expect.objectContaining({ childWorkId: second.childWorkId, outcome: 'unknown' }) + ]) + expect(server.getStructuredChildWork(SUBJECT)).toEqual([first]) + }) + + it('rejects an address that names no session', () => { + const server = new AgentHookServer() + expect(() => + server.ingestStructuredChildWork({ ...SUBJECT, sessionId: '' }, started, 'claude') + ).toThrow('Structured child work requires its exact owner subject') + }) +}) diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index 0bc8d7947a9..e2e1c91fe80 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -21,9 +21,9 @@ import { } from '../../../shared/agent-status-legacy-adapter' import { isValidPiProviderSessionOnly } from './server-status-identity' import { normalizeRemoteEnvelopeFields } from './server-remote-envelope-normalization' -import { AgentHookServerIngestStructured } from './server-ingest-structured' +import { AgentHookServerIngestStructuredChildren } from './server-ingest-structured-children' -export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestStructured { +export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestStructuredChildren { /** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */ ingestRemote( envelope: { diff --git a/src/main/agent-hooks/server/server-ingest-structured-children.ts b/src/main/agent-hooks/server/server-ingest-structured-children.ts new file mode 100644 index 00000000000..ecc58b998d7 --- /dev/null +++ b/src/main/agent-hooks/server/server-ingest-structured-children.ts @@ -0,0 +1,60 @@ +import { randomUUID } from 'node:crypto' + +import { createAgentChildWorkAdmission } from '../../../shared/agent-status-child-work-admission' +import type { AgentChildWorkRecord } from '../../../shared/agent-status-child-work' +import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import { + reconcileAgentChildWorkEvidence, + type AgentChildWorkReconcileOutcome +} from '../../../shared/agent-status-child-work-reconciliation' +import { + parseAgentStatusSubject, + type AgentStatusStructuredSessionSubject +} from '../../../shared/agent-status-subject' +import { AgentHookServerIngestStructured } from './server-ingest-structured' + +/** Refusals that are the fence doing its job (late or superseded evidence), not a fault. */ +const EXPECTED_REFUSALS: ReadonlySet = new Set(['stale-invocation']) + +export abstract class AgentHookServerIngestStructuredChildren extends AgentHookServerIngestStructured { + /** + * Admit one structured session's child-work evidence. The parent publication owns the subject + * and lands first; this refuses to act on a subject the store does not already hold, so a + * child can never conjure a parent row. + */ + ingestStructuredChildWork( + subject: AgentStatusStructuredSessionSubject, + evidence: AgentChildWorkEvidence[], + provider: string + ): AgentChildWorkReconcileOutcome | null { + const parent = parseAgentStatusSubject(subject) + if (!parent || parent.kind !== 'structured-session') { + throw new Error('Structured child work requires its exact owner subject') + } + const store = this.canonicalStatusStore + if (!store.getParent(parent)) { + return null + } + const outcome = reconcileAgentChildWorkEvidence({ + store, + admission: createAgentChildWorkAdmission(store, { mintChildWorkId: () => randomUUID() }), + parent, + provider, + evidence + }) + const unexpected = outcome.rejected.filter(({ reason }) => !EXPECTED_REFUSALS.has(reason)) + if (unexpected.length > 0) { + console.warn( + '[agent-status-child-work] refused structured child evidence', + unexpected.map(({ handleId, reason }) => `${handleId}:${reason}`) + ) + } + return outcome + } + + /** Every child record this host holds for one structured session. */ + getStructuredChildWork(subject: AgentStatusStructuredSessionSubject): AgentChildWorkRecord[] { + const parent = parseAgentStatusSubject(subject) + return parent ? this.canonicalStatusStore.getChildren(parent) : [] + } +} diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts index b936d28b0e2..5a5e2ce5ecf 100644 --- a/src/main/agent-hooks/server/server-ingest-structured.ts +++ b/src/main/agent-hooks/server/server-ingest-structured.ts @@ -61,7 +61,7 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng if (this.state.lastStatusByPaneKey.has(paneKey)) { throw new Error('Structured status address conflicts with legacy evidence') } - const snapshot = this.canonicalStatusStore.getSnapshot() + const snapshot = this.canonicalStatusStore.getRevision() const observedAt = Math.max(Date.now(), priorStatus?.receivedAt ?? 0) const status: AgentStatusIpcPayload = { paneKey, diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index 1a1fe52e41f..961d3a3e9c8 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -53,7 +53,7 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { order: getLegacyStatusListingOrder(this.state, paneKey) ?? UNORDERED_STATUS_ROW }) } - for (const parent of this.canonicalStatusStore.getSnapshot().parents) { + for (const parent of this.canonicalStatusStore.getParents()) { if (!parent.status) { continue } diff --git a/src/main/claude/claude-captured-foreground-frames.test-fixture.ts b/src/main/claude/claude-captured-foreground-frames.test-fixture.ts new file mode 100644 index 00000000000..485c29ae7ae --- /dev/null +++ b/src/main/claude/claude-captured-foreground-frames.test-fixture.ts @@ -0,0 +1,147 @@ +// Claude CLI 2.1.280 stream-json captures, cut to the frames and fields the child-work path reads. +// Ids, paths and prompts are replaced; the frame order and the relative clock (`at`, ms after the +// first user message) are the captured ones. + +import { + result, + spawn, + system, + toolResult, + type CapturedFrame +} from './claude-captured-frame-builders.test-fixture' + +const interruptedAgentStart = (at: number, toolUseId: string): CapturedFrame[] => [ + { + at, + frame: spawn(toolUseId, 'Agent', { description: 'Run sleep command and report' }, null) + }, + { + at: at + 16, + frame: system('task_started', { + task_id: 'agent-1', + tool_use_id: toolUseId, + description: 'Run sleep command and report', + subagent_type: 'general-purpose', + is_backgrounded: false, + task_type: 'local_agent' + }) + }, + { + at: at + 4_357, + frame: system('task_progress', { + task_id: 'agent-1', + tool_use_id: toolUseId, + usage: { total_tokens: 18_844, tool_uses: 1, duration_ms: 4_342 }, + last_tool_name: 'Bash' + }) + } +] + +const REJECTED = "The user doesn't want to proceed with this tool use. The tool use was rejected." + +/** An interrupt while the foreground agent sits between tools: its own stop comes first. */ +export const INTERRUPTED_BETWEEN_TOOLS: CapturedFrame[] = [ + ...interruptedAgentStart(5_000, 'toolu_agent'), + { + at: 11_019, + frame: system('task_updated', { task_id: 'agent-1', patch: { status: 'killed' } }) + }, + { + at: 11_019, + frame: system('task_notification', { + task_id: 'agent-1', + tool_use_id: 'toolu_agent', + status: 'stopped', + summary: 'Run sleep command and report' + }) + }, + { at: 11_020, frame: toolResult('toolu_agent', REJECTED, null, true) }, + { at: 11_026, frame: result('error_during_execution') } +] + +/** An interrupt while the foreground agent's own shell runs: the spawn result precedes its stop. */ +export const INTERRUPTED_IN_OWN_SHELL: CapturedFrame[] = [ + ...interruptedAgentStart(2_876, 'toolu_agent'), + { + at: 6_317, + frame: spawn('toolu_shell', 'Bash', { command: 'sleep 45; echo 1' }, 'toolu_agent') + }, + { + at: 9_424, + frame: system('task_started', { + task_id: 'shell-1', + owned_by_subagent: true, + tool_use_id: 'toolu_shell', + description: 'Sleep 45 seconds then print 1', + is_backgrounded: false, + task_type: 'local_bash' + }) + }, + { + at: 11_321, + frame: system('task_notification', { + task_id: 'shell-1', + tool_use_id: 'toolu_shell', + status: 'stopped', + summary: 'Sleep 45 seconds then print 1' + }) + }, + { at: 11_324, frame: toolResult('toolu_agent', REJECTED, null, true) }, + { + at: 11_325, + frame: system('task_updated', { task_id: 'agent-1', patch: { status: 'killed' } }) + }, + { + at: 11_325, + frame: system('task_notification', { + task_id: 'agent-1', + tool_use_id: 'toolu_agent', + status: 'stopped', + summary: 'Run sleep command and report' + }) + }, + { at: 11_328, frame: result('error_during_execution') } +] + +/** A foreground agent that finishes: its own ending, then its summary, then the spawn result. */ +export const FOREGROUND_SUCCESS: CapturedFrame[] = [ + { at: 3_695, frame: spawn('toolu_agent', 'Agent', { description: 'Run echo hi command' }, null) }, + { + at: 3_709, + frame: system('task_started', { + task_id: 'agent-1', + tool_use_id: 'toolu_agent', + description: 'Run echo hi command', + subagent_type: 'general-purpose', + is_backgrounded: false, + task_type: 'local_agent' + }) + }, + { + at: 5_501, + frame: system('task_progress', { + task_id: 'agent-1', + tool_use_id: 'toolu_agent', + usage: { total_tokens: 14_576, tool_uses: 1, duration_ms: 1_792 }, + last_tool_name: 'Bash' + }) + }, + { at: 5_505, frame: spawn('toolu_echo', 'Bash', { command: 'echo hi' }, 'toolu_agent') }, + { at: 5_652, frame: toolResult('toolu_echo', 'hi', 'toolu_agent', false) }, + { + at: 7_101, + frame: system('task_updated', { task_id: 'agent-1', patch: { status: 'completed' } }) + }, + { + at: 7_101, + frame: system('task_notification', { + task_id: 'agent-1', + tool_use_id: 'toolu_agent', + status: 'completed', + summary: 'The command executed successfully. Output: `hi`', + usage: { total_tokens: 16_908, tool_uses: 1, duration_ms: 3_393 } + }) + }, + { at: 7_113, frame: toolResult('toolu_agent', 'hi', null) }, + { at: 8_952, frame: result('success') } +] diff --git a/src/main/claude/claude-captured-frame-builders.test-fixture.ts b/src/main/claude/claude-captured-frame-builders.test-fixture.ts new file mode 100644 index 00000000000..7fd6f8124e0 --- /dev/null +++ b/src/main/claude/claude-captured-frame-builders.test-fixture.ts @@ -0,0 +1,61 @@ +// The frame shapes a captured Claude stream is rebuilt from, keeping only what the child-work path reads. + +export type CapturedFrame = { at: number; frame: Record } + +export function spawn( + id: string, + name: string, + input: Record, + parent: string | null +) { + return { + type: 'assistant', + parent_tool_use_id: parent, + message: { + id: `msg-${id}`, + role: 'assistant', + content: [{ type: 'tool_use', id, name, input }] + } + } +} + +export function toolResult( + toolUseId: string, + content: string, + parent: string | null, + isError?: boolean +) { + return { + type: 'user', + parent_tool_use_id: parent, + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: toolUseId, + content, + ...(isError === undefined ? {} : { is_error: isError }) + } + ] + } + } +} + +export function says(id: string, text: string, parent: string | null) { + return { + type: 'assistant', + parent_tool_use_id: parent, + message: { id: `msg-${id}`, role: 'assistant', content: [{ type: 'text', text }] } + } +} + +export function system(subtype: string, fields: Record) { + return { type: 'system', subtype, ...fields } +} + +export const result = (subtype: string) => ({ + type: 'result', + subtype, + is_error: subtype !== 'success' +}) diff --git a/src/main/claude/claude-captured-task-frames.test-fixture.ts b/src/main/claude/claude-captured-task-frames.test-fixture.ts new file mode 100644 index 00000000000..304cf6c2bcf --- /dev/null +++ b/src/main/claude/claude-captured-task-frames.test-fixture.ts @@ -0,0 +1,228 @@ +// Claude CLI 2.1.280 stream-json captures, cut to the frames and fields the child-work path reads. +// Ids, paths and prompts are replaced; the frame order and the relative clock (`at`, ms after the +// first user message) are the captured ones. + +import { + result, + says, + spawn, + system, + toolResult, + type CapturedFrame +} from './claude-captured-frame-builders.test-fixture' + +/** A foreground agent runs a 45 s shell; the user moves the agent to the background mid-run. + * The parent's turn ends at +12,610 ms while the agent's own shell runs until +51,275 ms. */ +export const MOVED_TO_BACKGROUND: CapturedFrame[] = [ + { + at: 3_259, + frame: spawn( + 'toolu_agent', + 'Agent', + { description: 'Run 45s sleep command', run_in_background: false }, + null + ) + }, + { + at: 3_275, + frame: system('task_started', { + task_id: 'agent-1', + tool_use_id: 'toolu_agent', + description: 'Run 45s sleep command', + subagent_type: 'general-purpose', + is_backgrounded: false, + task_type: 'local_agent' + }) + }, + { + at: 6_126, + frame: system('task_progress', { + task_id: 'agent-1', + tool_use_id: 'toolu_agent', + description: 'Running Sleep 45 seconds then print 1', + usage: { total_tokens: 18_837, tool_uses: 1, duration_ms: 2_852 }, + last_tool_name: 'Bash' + }) + }, + { + at: 6_127, + frame: spawn( + 'toolu_shell', + 'Bash', + { command: 'sleep 45; echo 1', description: 'Sleep 45 seconds then print 1' }, + 'toolu_agent' + ) + }, + { + at: 9_249, + frame: system('task_started', { + task_id: 'shell-1', + owned_by_subagent: true, + tool_use_id: 'toolu_shell', + description: 'Sleep 45 seconds then print 1', + is_backgrounded: false, + task_type: 'local_bash' + }) + }, + { + at: 11_128, + frame: system('background_tasks_changed', { + tasks: [ + { task_id: 'agent-1', task_type: 'local_agent', description: 'Run 45s sleep command' } + ] + }) + }, + { + at: 11_128, + frame: system('task_updated', { task_id: 'agent-1', patch: { is_backgrounded: true } }) + }, + { + at: 11_139, + frame: toolResult('toolu_agent', 'Async agent launched successfully.', null) + }, + { at: 12_563, frame: says('parent-1', 'The agent has been launched.', null) }, + { at: 12_610, frame: result('success') }, + { + at: 51_275, + frame: system('task_notification', { + task_id: 'shell-1', + tool_use_id: 'toolu_shell', + status: 'completed', + summary: 'Sleep 45 seconds then print 1' + }) + }, + { at: 51_292, frame: toolResult('toolu_shell', '1', 'toolu_agent', false) }, + { at: 52_398, frame: says('agent-1-final', 'Exact stdout: `1`', 'toolu_agent') }, + { at: 52_441, frame: system('background_tasks_changed', { tasks: [] }) }, + { + at: 52_441, + frame: system('task_updated', { task_id: 'agent-1', patch: { status: 'completed' } }) + }, + { + at: 52_441, + frame: system('task_notification', { + task_id: 'agent-1', + tool_use_id: 'toolu_agent', + status: 'completed', + summary: 'The command completed after ~45 seconds. Exact stdout: `1`', + usage: { total_tokens: 22_160, tool_uses: 1, duration_ms: 49_167 } + }) + }, + { at: 54_303, frame: says('parent-2', 'The agent ran the command.', null) }, + { at: 54_450, frame: result('success') } +] + +/** A background agent finishes; the next turn asks Claude to message it, which resumes it. The + * revived run starts again under the message call, after a roster that lists it. */ +export const RESUMED_BY_MESSAGE: CapturedFrame[] = [ + { + at: 3_279, + frame: spawn( + 'toolu_agent', + 'Agent', + { description: 'Run echo first-run command', run_in_background: true }, + null + ) + }, + { + at: 3_320, + frame: system('background_tasks_changed', { + tasks: [ + { task_id: 'agent-1', task_type: 'local_agent', description: 'Run echo first-run command' } + ] + }) + }, + { + at: 3_322, + frame: system('task_started', { + task_id: 'agent-1', + tool_use_id: 'toolu_agent', + description: 'Run echo first-run command', + subagent_type: 'general-purpose', + is_backgrounded: true, + task_type: 'local_agent' + }) + }, + { at: 3_349, frame: toolResult('toolu_agent', 'Async agent launched successfully.', null) }, + { at: 4_765, frame: result('success') }, + { + at: 5_077, + frame: system('task_progress', { + task_id: 'agent-1', + tool_use_id: 'toolu_agent', + usage: { total_tokens: 13_615, tool_uses: 1, duration_ms: 1_759 }, + last_tool_name: 'Bash' + }) + }, + { at: 7_427, frame: system('background_tasks_changed', { tasks: [] }) }, + { + at: 7_427, + frame: system('task_updated', { task_id: 'agent-1', patch: { status: 'completed' } }) + }, + { + at: 7_427, + frame: system('task_notification', { + task_id: 'agent-1', + tool_use_id: 'toolu_agent', + status: 'completed', + summary: 'The command executed successfully. Output: `first-run`', + usage: { total_tokens: 15_971, tool_uses: 1, duration_ms: 4_110 } + }) + }, + { at: 10_960, frame: result('success') }, + { + at: 16_118, + frame: spawn( + 'toolu_message', + 'SendMessage', + { to: 'agent-1', message: 'Run echo second-run and report.' }, + null + ) + }, + { + at: 16_142, + frame: system('background_tasks_changed', { + tasks: [ + { task_id: 'agent-1', task_type: 'local_agent', description: 'Run echo first-run command' } + ] + }) + }, + { + at: 16_143, + frame: system('task_started', { + task_id: 'agent-1', + tool_use_id: 'toolu_message', + description: 'Run echo first-run command', + subagent_type: 'general-purpose', + is_backgrounded: true, + task_type: 'local_agent' + }) + }, + { at: 16_166, frame: toolResult('toolu_message', '{"success":true}', null) }, + { at: 17_420, frame: result('success') }, + { + at: 17_889, + frame: system('task_progress', { + task_id: 'agent-1', + tool_use_id: 'toolu_message', + usage: { total_tokens: 16_064, tool_uses: 2, duration_ms: 14_568 }, + last_tool_name: 'Bash' + }) + }, + { at: 19_568, frame: system('background_tasks_changed', { tasks: [] }) }, + { + at: 19_568, + frame: system('task_updated', { task_id: 'agent-1', patch: { status: 'completed' } }) + }, + { + at: 19_568, + frame: system('task_notification', { + task_id: 'agent-1', + tool_use_id: 'toolu_message', + status: 'completed', + summary: 'The command executed successfully. Output: `second-run`', + usage: { total_tokens: 16_259, tool_uses: 2, duration_ms: 16_249 } + }) + }, + { at: 20_956, frame: result('success') } +] diff --git a/src/main/claude/claude-child-tool-queries.ts b/src/main/claude/claude-child-tool-queries.ts new file mode 100644 index 00000000000..7a1e28b94d6 --- /dev/null +++ b/src/main/claude/claude-child-tool-queries.ts @@ -0,0 +1,40 @@ +// Which agent a tool call or a frame belongs to, answered from the journal's own linkage, so a +// child's record and its open operation name the agent its rows name. + +import type { ClaudeToolUse } from './claude-structured-item-translation' +import type { ClaudeSubagentLinkageSource } from './claude-subagent-linkage' +import type { ClaudeToolOriginRegistry } from './claude-tool-origin-registry' + +export type ClaudeChildToolQueries = { + /** The agent (its canonical task id) whose own traffic made a tool call; null when the session's + * own agent made it, or it was never seen. The same answer a row that call produced carries. */ + childToolOwner: (toolUseId: string) => string | null + /** The child a frame's `parent_tool_use_id` names, and its newest call still awaiting a result. */ + childActivity: (parentToolUseId: string) => { agentId: string; openTool: ClaudeToolUse | null } +} + +export function claudeChildToolQueries(deps: { + tools: ReadonlyMap + toolOrigins: Pick + linkage: Pick +}): ClaudeChildToolQueries { + const childToolOwner = (toolUseId: string): string | null => { + const ownerRef = deps.toolOrigins.childOwnerRef(toolUseId) + return ownerRef === null + ? null + : (deps.linkage.settledLinkageFor(ownerRef).linkage.agentId ?? null) + } + return { + childToolOwner, + childActivity: (parentToolUseId) => { + let openTool: ClaudeToolUse | null = null + for (const tool of deps.tools.values()) { + if (deps.toolOrigins.childOwnerRef(tool.id) === parentToolUseId) { + openTool = tool + } + } + const { agentId } = deps.linkage.settledLinkageFor(parentToolUseId).linkage + return { agentId: agentId ?? parentToolUseId, openTool } + } + } +} diff --git a/src/main/claude/claude-child-work-decoder.test.ts b/src/main/claude/claude-child-work-decoder.test.ts new file mode 100644 index 00000000000..9891f6c3f84 --- /dev/null +++ b/src/main/claude/claude-child-work-decoder.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest' +import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' + +function system(subtype: string, fields: Record): Record { + return { type: 'system', subtype, session_id: 'provider-1', uuid: crypto.randomUUID(), ...fields } +} + +const foregroundAgent = system('task_started', { + task_id: 'agent-fg', + tool_use_id: 'toolu_fg', + task_type: 'local_agent', + subagent_type: 'Explore', + description: 'Find flaky tests', + is_backgrounded: false +}) +const backgroundAgent = system('task_started', { + task_id: 'agent-bg', + tool_use_id: 'toolu_bg', + task_type: 'local_agent', + description: 'Audit the build', + is_backgrounded: true +}) + +function decoderWith(...messages: Record[]): ClaudeChildWorkDecoder { + const decoder = new ClaudeChildWorkDecoder() + for (const message of messages) { + decoder.observe(message) + } + decoder.drain(0) + return decoder +} + +describe('Claude child-work decoder', () => { + it('names a started child by its task id and the spawn call of this run', () => { + const decoder = new ClaudeChildWorkDecoder() + decoder.observe(foregroundAgent) + expect(decoder.drain(500)).toEqual([ + { + type: 'live', + observedAt: 500, + child: { + handle: { idKind: 'task_id', id: 'agent-fg', runId: 'toolu_fg' }, + kind: 'agent', + residency: 'foreground', + state: 'working', + name: 'Explore', + agentType: 'Explore', + description: 'Find flaky tests', + stoppable: false + } + } + ]) + expect(decoder.drain(600)).toEqual([]) + }) + + it("carries a child's progress: the tool it last ran, its summary and its usage", () => { + const decoder = decoderWith(foregroundAgent) + decoder.observe( + system('task_progress', { + task_id: 'agent-fg', + description: 'Running Bash', + last_tool_name: 'Bash', + summary: 'Reproducing the flake', + usage: { total_tokens: 1_200, tool_uses: 3, duration_ms: 900 } + }) + ) + expect(decoder.drain(600)).toEqual([ + expect.objectContaining({ + type: 'live', + observedAt: 600, + child: expect.objectContaining({ + handle: { idKind: 'task_id', id: 'agent-fg', runId: 'toolu_fg' }, + // The progress description restates the tool; the task keeps its own. + description: 'Find flaky tests', + operation: { toolName: 'Bash', basis: 'reported', observedAt: 600 }, + lastMessage: 'Reproducing the flake', + totalTokens: 1_200 + }) + }) + ]) + }) + + it('reports how a child ended in the outcome vocabulary, naming the run that ended', () => { + const decoder = decoderWith(backgroundAgent) + decoder.observe( + system('task_notification', { + task_id: 'agent-bg', + tool_use_id: 'toolu_bg', + status: 'failed', + summary: 'Build broke', + usage: { total_tokens: 900 } + }) + ) + expect(decoder.drain(500)).toEqual([ + { + type: 'ended', + observedAt: 500, + handle: { idKind: 'task_id', id: 'agent-bg', runId: 'toolu_bg' }, + outcome: 'failed', + lastMessage: 'Build broke', + totalTokens: 900 + } + ]) + for (const [status, outcome] of [ + ['completed', 'succeeded'], + ['killed', 'cancelled'], + ['stopped', 'cancelled'], + ['whatever', 'unknown'] + ]) { + decoder.observe(system('task_notification', { task_id: 'agent-bg', status })) + expect(decoder.drain(500)).toEqual([expect.objectContaining({ type: 'ended', outcome })]) + } + decoder.observe( + system('task_updated', { task_id: 'agent-bg', patch: { status: 'failed', error: 'OOM' } }) + ) + expect(decoder.drain(500)).toEqual([ + expect.objectContaining({ type: 'ended', outcome: 'failed', lastMessage: 'OOM' }) + ]) + }) + + it("reads nothing from a roster, a turn's end or a spawn call's result", () => { + const decoder = decoderWith(foregroundAgent, backgroundAgent) + decoder.observe(system('background_tasks_changed', { tasks: [] })) + decoder.observe({ type: 'result', subtype: 'success' }) + decoder.observe({ + type: 'user', + parent_tool_use_id: null, + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_fg', content: 'done', is_error: true }] + } + }) + expect(decoder.drain(500)).toEqual([]) + }) + + it('moves a child to the background when the provider says it moved', () => { + const decoder = decoderWith(foregroundAgent) + decoder.observe( + system('task_updated', { task_id: 'agent-fg', patch: { is_backgrounded: true } }) + ) + expect(decoder.drain(500)).toEqual([ + expect.objectContaining({ + child: expect.objectContaining({ residency: 'background', stoppable: true }) + }) + ]) + }) + + it('reports a start for an ended child as a restart, and a late start of its run as nothing', () => { + const decoder = decoderWith( + backgroundAgent, + system('task_notification', { + task_id: 'agent-bg', + tool_use_id: 'toolu_bg', + status: 'completed' + }) + ) + 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([]) + decoder.observe({ ...backgroundAgent, tool_use_id: 'toolu_resume' }) + decoder.observe(system('task_progress', { task_id: 'agent-bg', last_tool_name: 'Read' })) + expect(decoder.drain(600)).toEqual([ + expect.objectContaining({ + type: 'live', + restart: true, + child: expect.objectContaining({ + handle: { idKind: 'task_id', id: 'agent-bg', runId: 'toolu_resume' } + }) + }), + expect.objectContaining({ + type: 'live', + child: expect.objectContaining({ + handle: { idKind: 'task_id', id: 'agent-bg', runId: 'toolu_resume' }, + operation: expect.objectContaining({ toolName: 'Read' }) + }) + }) + ]) + }) + + it('never records work the CLI hides from its transcript', () => { + const decoder = new ClaudeChildWorkDecoder() + decoder.observe({ ...backgroundAgent, ambient: true }) + decoder.observe({ ...foregroundAgent, skip_transcript: true }) + expect(decoder.drain(500)).toEqual([]) + }) + + it('bounds the live children it tracks', () => { + const decoder = new ClaudeChildWorkDecoder() + for (let index = 0; index < 257; index += 1) { + decoder.observe({ ...backgroundAgent, task_id: `agent-${index}` }) + } + expect(decoder.drain(500)).toHaveLength(256) + }) + + it('marks the end of the provider session', () => { + const decoder = decoderWith(backgroundAgent) + decoder.clear() + expect(decoder.drain(500)).toEqual([{ type: 'session-ended', observedAt: 500 }]) + // Nothing of the old session is remembered: a start is a first run again. + decoder.observe(backgroundAgent) + expect(decoder.drain(600)).toEqual([expect.not.objectContaining({ restart: true })]) + }) +}) diff --git a/src/main/claude/claude-child-work-decoder.ts b/src/main/claude/claude-child-work-decoder.ts new file mode 100644 index 00000000000..6de305194b0 --- /dev/null +++ b/src/main/claude/claude-child-work-decoder.ts @@ -0,0 +1,258 @@ +// Claude task frames, read as each child's own account of itself. +// +// 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 the session ends, the +// host settles whatever is still live. Edges wait here until the frame is journaled, then take +// the host clock. + +import type { + AgentChildWorkKind, + AgentChildWorkOperation +} from '../../shared/agent-status-child-work' +import type { + AgentChildWorkEvidence, + AgentChildWorkLiveObservation +} from '../../shared/agent-status-child-work-evidence' +import { + classifyClaudeBackgroundTaskKind, + liveClaudeTaskRunState, + record, + taskAliasId, + taskDescription, + taskId, + taskName, + taskText, + taskUsageTotalTokens, + terminalClaudeTaskRunState +} from './claude-background-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 +const MAX_ENDED_TASKS = 256 + +type DecodedClaudeTask = { + kind: AgentChildWorkKind + backgrounded: boolean + /** The provider has reported it running; a monitor that never has reads as monitoring. */ + running: boolean + name?: string + description?: string + /** The spawn call of the current run. */ + toolUseId?: string +} + +type PendingEdge = (observedAt: number) => AgentChildWorkEvidence + +function observation( + id: string, + task: DecodedClaudeTask, + facts: ClaudeTaskFacts, + observedAt: number +): AgentChildWorkLiveObservation { + const operation: AgentChildWorkOperation | undefined = facts.toolName + ? { toolName: facts.toolName, basis: 'reported', observedAt } + : undefined + return { + handle: { + idKind: 'task_id', + id, + ...(task.toolUseId !== undefined ? { runId: task.toolUseId } : {}) + }, + kind: task.kind, + residency: task.backgrounded ? 'background' : 'foreground', + state: task.running || task.kind !== 'monitor' ? 'working' : 'monitoring', + // The published row names a task's type as both its name and its agent type. + ...(task.name ? { name: task.name, agentType: task.name } : {}), + ...(task.description ? { description: task.description } : {}), + ...(facts.totalTokens !== undefined ? { totalTokens: facts.totalTokens } : {}), + ...(operation ? { operation } : {}), + ...(facts.lastMessage ? { lastMessage: facts.lastMessage } : {}), + // Only a backgrounded task has a stop the host can target. + stoppable: task.backgrounded + } +} + +export class ClaudeChildWorkDecoder { + private readonly live = new Map() + /** Ended task ids, with the spawn call each ended under. */ + private readonly ended = new Map() + private pending: PendingEdge[] = [] + + observe(message: Record): void { + if (message.type !== 'system') { + return + } + const id = taskId(message) + if (!id) { + return + } + switch (message.subtype) { + case 'task_started': + this.started(id, message) + return + case 'task_progress': + this.progressed(id, message) + return + case 'task_updated': + this.updated(id, record(message.patch)) + return + case 'task_notification': + // Its `tool_use_id` names the run that ended (captured on a resumed agent's second run). + this.end(id, message.status, { + runId: taskAliasId(message.tool_use_id), + lastMessage: taskText(message.summary), + totalTokens: taskUsageTotalTokens(message) + }) + } + } + + /** The provider session is gone: the host settles what it still holds live. */ + clear(): void { + this.live.clear() + this.ended.clear() + this.pending.push((observedAt) => ({ type: 'session-ended', observedAt })) + } + + drain(observedAt: number): AgentChildWorkEvidence[] { + const pending = this.pending + this.pending = [] + return pending.map((edge) => edge(observedAt)) + } + + private started(id: string, message: Record): void { + // Work the CLI hides from its own transcript is not a child the user sees. + if (message.ambient === true || message.skip_transcript === true) { + return + } + 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) { + return + } + const kind = classifyClaudeBackgroundTaskKind(message.task_type) + this.upsert( + id, + { + kind: kind !== 'unknown' ? kind : (existing?.kind ?? 'unknown'), + backgrounded: + message.is_backgrounded === true || + kind === 'workflow' || + kind === 'monitor' || + existing?.backgrounded === true, + running: liveClaudeTaskRunState(message.status) !== null || existing?.running === true, + name: taskName(message) ?? existing?.name, + description: taskDescription(message.description) ?? existing?.description, + toolUseId: toolUseId ?? existing?.toolUseId + }, + {}, + restart + ) + } + + private progressed(id: string, message: Record): void { + const task = this.live.get(id) + if (!task) { + return + } + // Progress `description` is the current activity ("Running "), not the task's name. + this.upsert( + id, + { ...task, name: task.name ?? taskName(message) }, + claudeTaskProgressFacts(message) + ) + } + + private updated(id: string, patch: Record | null): void { + if (!patch) { + return + } + if (terminalClaudeTaskRunState(patch.status) !== null) { + this.end(id, patch.status, { lastMessage: taskText(patch.error) }) + return + } + const existing = this.live.get(id) + // Only a start re-opens an ended task; a late update from its run does not. + if (!existing && this.ended.has(id)) { + return + } + const kind = + 'task_type' in patch ? classifyClaudeBackgroundTaskKind(patch.task_type) : 'unknown' + const running = liveClaudeTaskRunState(patch.status) !== null + const name = taskName(patch) + const description = taskDescription(patch.description) + if (patch.is_backgrounded !== true && !name && !description && !running && kind === 'unknown') { + return + } + this.upsert( + id, + { + kind: kind !== 'unknown' ? kind : (existing?.kind ?? 'unknown'), + backgrounded: patch.is_backgrounded === true || existing?.backgrounded === true, + running: running || existing?.running === true, + name: name ?? existing?.name, + description: description ?? existing?.description, + toolUseId: existing?.toolUseId + }, + {}, + false + ) + } + + private upsert( + id: string, + task: DecodedClaudeTask, + 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.pending.push((observedAt) => ({ + type: 'live', + observedAt, + child: observation(id, task, facts, observedAt), + ...(restart ? { restart: true } : {}) + })) + } + + private end( + id: string, + status: unknown, + reported: { runId?: string; lastMessage?: string; totalTokens?: number } + ): void { + const task = this.live.get(id) + this.live.delete(id) + this.ended.delete(id) + this.ended.set(id, reported.runId ?? task?.toolUseId) + 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, + handle: { + idKind: 'task_id', + id, + ...(reported.runId !== undefined ? { runId: reported.runId } : {}) + }, + outcome, + ...(reported.lastMessage ? { lastMessage: reported.lastMessage } : {}), + ...(reported.totalTokens !== undefined ? { totalTokens: reported.totalTokens } : {}) + })) + } +} diff --git a/src/main/claude/claude-child-work-evidence.ts b/src/main/claude/claude-child-work-evidence.ts new file mode 100644 index 00000000000..32270e35d6f --- /dev/null +++ b/src/main/claude/claude-child-work-evidence.ts @@ -0,0 +1,128 @@ +// Claude child work as the host records it: the outcome vocabulary, what a progress frame says, +// the owner of each child through the journal's own linkage, and the tool a child has open. +// The task frames themselves are read by `claude-child-work-decoder`; everything here is drained +// after the journal handled the frame, so the host never admits evidence ahead of its rows. + +import type { AgentChildWorkOutcome } from '../../shared/agent-status-child-work' +import type { + AgentChildWorkEvidence, + AgentChildWorkLiveObservation +} from '../../shared/agent-status-child-work-evidence' +import { taskText, taskUsageTotalTokens } from './claude-background-task-frames' +import type { ClaudeSession } from './claude-structured-session-state' +import { deriveToolInputPreview } from '../../shared/agent-hook-listener/tool-input-preview' +import { + claudeToolResults, + claudeToolUses, + readClaudeMessageEnvelope, + type ClaudeToolUse +} from './claude-structured-item-translation' + +export type ClaudeTaskFacts = { + /** The tool the provider last reported; stamped at drain. */ + toolName?: string + lastMessage?: string + totalTokens?: number +} + +/** Provider status → how the child ended. `killed` and `stopped` are deliberate stops; a status + * a terminal frame does not state is an ending nobody classified, never a success. */ +export function claudeChildWorkOutcome(status: unknown): AgentChildWorkOutcome { + switch (status) { + case 'completed': + return 'succeeded' + case 'failed': + return 'failed' + case 'killed': + case 'stopped': + return 'cancelled' + default: + return 'unknown' + } +} + +/** What a `task_progress` frame says: the tool the child last ran, its newest summary, usage. + * Its `description` restates the tool ("Running Bash") and is not the task's own. */ +export function claudeTaskProgressFacts(message: Record): ClaudeTaskFacts { + const toolName = taskText(message.last_tool_name) + const lastMessage = taskText(message.summary) + const totalTokens = taskUsageTotalTokens(message) + return { + ...(toolName ? { toolName } : {}), + ...(lastMessage ? { lastMessage } : {}), + ...(totalTokens !== undefined ? { totalTokens } : {}) + } +} + +/** Name the child that owns each live child: the agent whose own traffic made the spawn (or + * shell) call. A call the session's own agent made has no owner. */ +export function withClaudeChildWorkOwners( + evidence: AgentChildWorkEvidence[], + ownerOf: ((toolUseId: string) => string | null) | undefined +): AgentChildWorkEvidence[] { + if (!ownerOf) { + return evidence + } + const owned = (child: AgentChildWorkLiveObservation): AgentChildWorkLiveObservation => { + const ownerId = child.handle.runId === undefined ? null : ownerOf(child.handle.runId) + return ownerId !== null && ownerId !== child.handle.id ? { ...child, ownerId } : child + } + return evidence.map((edge) => + edge.type === 'live' ? { ...edge, child: owned(edge.child) } : edge + ) +} + +/** + * A child's own tool traffic, read after the journal handled the frame: the call the child has + * open now, previewed as a hook-reported row previews its own tool. A frame that only delivers + * the caller's own spawn result belongs to the caller, not the child it names. + */ +export function claudeChildOperation( + message: Record, + activityOf: + | ((parentToolUseId: string) => { agentId: string; openTool: ClaudeToolUse | null }) + | undefined, + observedAt: number +): AgentChildWorkEvidence[] { + const envelope = activityOf ? readClaudeMessageEnvelope(message) : null + const parentRef = envelope?.parentToolUseId + if (!envelope || !parentRef || !activityOf) { + return [] + } + const toolTraffic = + claudeToolUses(envelope).length > 0 || + claudeToolResults(envelope).some((result) => result.toolUseId !== parentRef) + if (!toolTraffic) { + return [] + } + const { agentId, openTool } = activityOf(parentRef) + const input = openTool ? deriveToolInputPreview(openTool.name, openTool.input) : undefined + return [ + { + type: 'operation', + observedAt, + childId: agentId, + operation: openTool + ? { toolName: openTool.name, ...(input ? { input } : {}), basis: 'open', observedAt } + : null + } + ] +} + +/** Everything one frame (or a close) said about the session's child work, owners named. */ +export function drainClaudeChildWork( + session: Pick | null | undefined, + message: Record | null, + observedAt: number +): AgentChildWorkEvidence[] { + if (!session) { + return [] + } + return [ + ...withClaudeChildWorkOwners( + session.childWork.drain(observedAt), + session.translator?.childToolOwner + ), + ...(message ? claudeChildOperation(message, session.translator?.childActivity, observedAt) : []) + ] +} diff --git a/src/main/claude/claude-child-work-producer-harness.test-fixture.ts b/src/main/claude/claude-child-work-producer-harness.test-fixture.ts new file mode 100644 index 00000000000..8b1a8b0ee91 --- /dev/null +++ b/src/main/claude/claude-child-work-producer-harness.test-fixture.ts @@ -0,0 +1,174 @@ +// A Claude session driven through the real adapter, with its child-work evidence reconciled into +// a store of its own or ingested by a real hook server. + +import { expect } from 'vitest' +import { createAgentChildWorkAdmission } from '../../shared/agent-status-child-work-admission' +import type { AgentChildWorkRecord } from '../../shared/agent-status-child-work' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' +import { + reconcileAgentChildWorkEvidence, + type AgentChildWorkReconcileOutcome +} from '../../shared/agent-status-child-work-reconciliation' +import { createAgentStatusStore } from '../../shared/agent-status-store' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import { AgentHookServer } from '../agent-hooks/server' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { CapturedFrame } from './claude-captured-frame-builders.test-fixture' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import { + fakeClaude, + identityFor, + PROVIDER_SESSION_ID +} from './claude-structured-session-test-support' + +export const parent = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + 'session-1' +) + +let uuid = 0 +export function frame(fields: Record): Record { + return { session_id: PROVIDER_SESSION_ID, uuid: `frame-${++uuid}`, ...fields } +} +export function system(subtype: string, fields: Record) { + return frame({ type: 'system', subtype, ...fields }) +} +export function toolUse( + id: string, + name: string, + input: unknown, + parentToolUseId: string | null = null +) { + return frame({ + type: 'assistant', + parent_tool_use_id: parentToolUseId, + message: { + id: `msg-${id}`, + role: 'assistant', + content: [{ type: 'tool_use', id, name, input }] + } + }) +} +export function toolResult( + toolUseId: string, + text: string, + parentToolUseId: string | null = null, + isError = false +) { + return frame({ + type: 'user', + parent_tool_use_id: parentToolUseId, + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: toolUseId, content: text, is_error: isError }] + } + }) +} + +type Delivery = { kind: 'journal' | 'legacy' | 'evidence'; detail: string } + +/** The host clock the adapter stamps evidence with; a replay moves it to each frame's time. */ +export const T0 = 1_700_000_000_500 + +/** A real hook server already holding the session's parent row. */ +export function hostWithParent(): AgentHookServer { + const host = new AgentHookServer() + host.ingestStructuredStatus( + { + sessionId: parent.sessionId, + workspaceId: parent.workspaceId, + agent: 'claude', + status: 'working', + hostExecutionOwned: true, + latestPrompt: 'find the flaky tests', + updatedAt: 100 + }, + parent + ) + return host +} + +/** With `host`, evidence goes through the host's own ingest instead of straight to reconciliation. */ +export async function producer(host?: AgentHookServer) { + const claude = fakeClaude() + const store = createAgentStatusStore({ epoch: 'epoch-1', mode: 'authority' }) + expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull() + const admission = createAgentChildWorkAdmission(store, { + mintChildWorkId: (() => { + let minted = 0 + return () => `child-${++minted}` + })() + }) + let clock = T0 + const deliveries: Delivery[] = [] + /** The producer linkage the journal stamped on each child row. */ + const stamps: { providerParentRef?: string; attempt?: number }[] = [] + const evidenceLog: AgentChildWorkEvidence[][] = [] + const ingested: (AgentChildWorkReconcileOutcome | null)[] = [] + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => clock, + persistHandle: async () => {}, + onBackgroundTasksChanged: (_sessionId, state) => + deliveries.push({ kind: 'legacy', detail: String(state?.tasks?.length ?? 0) }), + onChildWorkEvidence: (sessionId, evidence) => { + expect(sessionId).toBe('session-1') + deliveries.push({ kind: 'evidence', detail: evidence.map((edge) => edge.type).join(',') }) + evidenceLog.push(evidence) + if (host) { + ingested.push(host.ingestStructuredChildWork(parent, evidence, 'claude')) + } else { + reconcileAgentChildWorkEvidence({ store, admission, parent, provider: 'claude', evidence }) + } + } + }) + const journal: StructuredAgentSessionEventSink = { + appendItem: (identity, _body, options) => { + deliveries.push({ kind: 'journal', detail: JSON.stringify(identity) }) + if (options?.agentId !== undefined) { + stamps.push(options) + } + }, + appendTombstone: () => {}, + publish: () => {} + } + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: journal + }) + const send = (message: Record): Delivery[] => { + const from = deliveries.length + claude.connections[0]!.handlers.onMessage?.(message) + return deliveries.slice(from) + } + /** Captured frames, each at its captured time after `T0`. */ + const replay = (frames: readonly CapturedFrame[]) => { + for (const { at, frame: captured } of frames) { + clock = T0 + at + send(frame(captured)) + } + } + const records = (): AgentChildWorkRecord[] => + host ? host.getStructuredChildWork(parent) : store.getChildren(parent) + const byDescription = (description: string) => + records().find((record) => record.description === description) + return { adapter, store, send, replay, records, byDescription, evidenceLog, stamps, ingested } +} diff --git a/src/main/claude/claude-journal-translator-contract.ts b/src/main/claude/claude-journal-translator-contract.ts new file mode 100644 index 00000000000..7a7fc4291c7 --- /dev/null +++ b/src/main/claude/claude-journal-translator-contract.ts @@ -0,0 +1,41 @@ +// What the rest of a Claude session asks of its journal translator. + +import type { AgentSessionContextReport } from '../../shared/agent-session-context-usage' +import type { StructuredAgentSessionSinkAdmission } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudeChildToolQueries } from './claude-child-tool-queries' +import type { ClaudeContextReportPart, ClaudeContextReportTarget } from './claude-context-facts' +import type { ClaudeJournalPrompts } from './claude-structured-journal-prompts' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' + +export type ClaudeJournalTranslator = { + handle: (event: ClaudeStructuredSessionEvent) => void + journalPrompts: Pick + /** The open turn's provider id — the same id its journal row carries, and the one + * a client's Stop names. Sole owner: no reader keeps a copy to disagree with. */ + readonly currentTurnId: string | null + flush: () => void + childToolOwner?: ClaudeChildToolQueries['childToolOwner'] + childActivity?: ClaudeChildToolQueries['childActivity'] + retryPendingTaskRows?: () => StructuredAgentSessionSinkAdmission + /** Streamed blocks still awaiting a final frame. A settled turn leaves none. */ + readonly pendingStreamedBlocks: number + /** Moves with the main conversation and each accepted send; a context report + * asked for before it moved may no longer describe the context. */ + readonly contextActivity: number + markContextActivity: () => void + /** Fires with the turn a fresh `/context` breakdown should be recorded on. */ + subscribeContextUsageRequests: ( + listener: (target: ClaudeContextReportTarget) => void + ) => () => void + /** Record a requested breakdown, or only its window, on the turn its request named. */ + recordContextReport: ( + target: ClaudeContextReportTarget, + report: AgentSessionContextReport, + part: ClaudeContextReportPart + ) => void + /** After a write that can change the model or its window; the ring waits for the new window. */ + modelMayHaveChanged: () => void + /** After a model write the child applied; its name sizes estimates until a window is measured. */ + modelWritten: (model: string) => void + dispose: () => void +} diff --git a/src/main/claude/claude-structured-child-work-captures.test.ts b/src/main/claude/claude-structured-child-work-captures.test.ts new file mode 100644 index 00000000000..d69ba120846 --- /dev/null +++ b/src/main/claude/claude-structured-child-work-captures.test.ts @@ -0,0 +1,180 @@ +// Real Claude CLI frame orders, replayed through the adapter into a real host: each child ends on +// its own terminal frame, at that frame's time, and on nothing the parent does. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CapturedFrame } from './claude-captured-frame-builders.test-fixture' +import { + FOREGROUND_SUCCESS, + INTERRUPTED_BETWEEN_TOOLS, + INTERRUPTED_IN_OWN_SHELL +} from './claude-captured-foreground-frames.test-fixture' +import { MOVED_TO_BACKGROUND, RESUMED_BY_MESSAGE } from './claude-captured-task-frames.test-fixture' +import { hostWithParent, producer, T0 } from './claude-child-work-producer-harness.test-fixture' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) +afterEach(() => vi.restoreAllMocks()) + +function until(frames: CapturedFrame[], at: number): [CapturedFrame[], CapturedFrame[]] { + const split = frames.findIndex((captured) => captured.at >= at) + return [frames.slice(0, split), frames.slice(split)] +} + +describe('Claude child work from captured frame orders', () => { + it("keeps an agent's own shell live past the parent's turn, until the shell's own ending", async () => { + const run = await producer(hostWithParent()) + const [beforeEnding, fromEnding] = until(MOVED_TO_BACKGROUND, 51_275) + run.replay(beforeEnding) + // The parent's `result` (+12,610 ms) and every roster since have passed. + expect(run.byDescription('Sleep 45 seconds then print 1')).toMatchObject({ + membership: 'live', + residency: 'foreground' + }) + run.replay(fromEnding) + const agent = run.byDescription('Run 45s sleep command') + expect(run.byDescription('Sleep 45 seconds then print 1')).toMatchObject({ + membership: 'settled', + outcome: 'succeeded', + settledAt: T0 + 51_275, + parentChildWorkId: agent?.childWorkId + }) + expect(agent).toMatchObject({ + membership: 'settled', + outcome: 'succeeded', + residency: 'background', + settledAt: T0 + 52_441, + lastMessage: 'The command completed after ~45 seconds. Exact stdout: `1`', + totalTokens: 22_160, + invocation: { invocationId: 'toolu_agent', generation: 1 } + }) + expect(run.ingested.flatMap((outcome) => outcome?.rejected ?? [])).toEqual([]) + }) + + it("does not end a moved agent on its spawn call's early result", async () => { + const run = await producer(hostWithParent()) + // The same capture with the spawn call's result delivered before the move is announced. + const launched = MOVED_TO_BACKGROUND.findIndex( + ({ frame }) => frame.type === 'user' && frame.parent_tool_use_id === null + ) + const moved = MOVED_TO_BACKGROUND.findIndex( + ({ frame }) => frame.subtype === 'background_tasks_changed' + ) + const reordered = [...MOVED_TO_BACKGROUND] + const [spawnResult] = reordered.splice(launched, 1) + reordered.splice(moved, 0, { ...spawnResult!, at: MOVED_TO_BACKGROUND[moved]!.at }) + run.replay(reordered) + const agent = run.byDescription('Run 45s sleep command') + expect(agent).toMatchObject({ + membership: 'settled', + outcome: 'succeeded', + settledAt: T0 + 52_441, + invocation: { invocationId: 'toolu_agent', generation: 1 } + }) + expect(agent?.previousInvocations).toBeUndefined() + }) + + it('keeps a background child live when a roster omits it without its own ending', async () => { + const run = await producer(hostWithParent()) + const [launch] = until(RESUMED_BY_MESSAGE, 5_077) + run.replay(launch) + // A roster that no longer lists the agent, and no terminal frame from it. + run.replay([ + { at: 6_000, frame: { type: 'system', subtype: 'background_tasks_changed', tasks: [] } } + ]) + expect(run.byDescription('Run echo first-run command')).toMatchObject({ + membership: 'live', + residency: 'background' + }) + }) + + it('settles what still runs when the session ends, and keeps every record', async () => { + const run = await producer(hostWithParent()) + const [running] = until(MOVED_TO_BACKGROUND, 51_275) + run.replay(running) + await run.adapter.closeSession('session-1') + expect( + run.records().map(({ description, membership, outcome }) => ({ + description, + membership, + outcome + })) + ).toEqual([ + { description: 'Run 45s sleep command', membership: 'settled', outcome: 'unknown' }, + { description: 'Sleep 45 seconds then print 1', membership: 'settled', outcome: 'unknown' } + ]) + }) + + it('opens a second run when a finished agent is started again', async () => { + const run = await producer(hostWithParent()) + const [firstRun, secondRun] = until(RESUMED_BY_MESSAGE, 16_118) + run.replay(firstRun) + expect(run.byDescription('Run echo first-run command')).toMatchObject({ + membership: 'settled', + outcome: 'succeeded', + invocation: { invocationId: 'toolu_agent', generation: 1 } + }) + const [restart, rest] = until(secondRun, 16_166) + run.replay(restart) + expect(run.byDescription('Run echo first-run command')).toMatchObject({ + membership: 'live', + invocation: { invocationId: 'toolu_message', generation: 2 }, + previousInvocations: [expect.objectContaining({ outcome: 'succeeded' })] + }) + run.replay(rest) + expect(run.byDescription('Run echo first-run command')).toMatchObject({ + membership: 'settled', + outcome: 'succeeded', + settledAt: T0 + 19_568, + lastMessage: 'The command executed successfully. Output: `second-run`', + totalTokens: 16_259, + invocation: { invocationId: 'toolu_message', generation: 2 } + }) + expect(run.records()).toHaveLength(1) + }) + + it('ends an agent interrupted between tools as cancelled, its own stop first', async () => { + const run = await producer(hostWithParent()) + run.replay(INTERRUPTED_BETWEEN_TOOLS) + expect(run.records()).toEqual([ + expect.objectContaining({ + description: 'Run sleep command and report', + membership: 'settled', + outcome: 'cancelled', + settledAt: T0 + 11_019 + }) + ]) + expect(run.ingested.flatMap((outcome) => outcome?.rejected ?? [])).toEqual([]) + }) + + it('ends an agent interrupted in its own shell, and the shell, as cancelled', async () => { + const run = await producer(hostWithParent()) + run.replay(INTERRUPTED_IN_OWN_SHELL) + const agent = run.byDescription('Run sleep command and report') + expect(agent).toMatchObject({ + membership: 'settled', + outcome: 'cancelled', + settledAt: T0 + 11_325 + }) + expect(run.byDescription('Sleep 45 seconds then print 1')).toMatchObject({ + membership: 'settled', + outcome: 'cancelled', + settledAt: T0 + 11_321, + parentChildWorkId: agent?.childWorkId + }) + expect(run.ingested.flatMap((outcome) => outcome?.rejected ?? [])).toEqual([]) + }) + + it("keeps a finished foreground agent's final summary and usage", async () => { + const run = await producer(hostWithParent()) + run.replay(FOREGROUND_SUCCESS) + expect(run.byDescription('Run echo hi command')).toMatchObject({ + membership: 'settled', + outcome: 'succeeded', + settledAt: T0 + 7_101, + lastMessage: 'The command executed successfully. Output: `hi`', + totalTokens: 16_908, + invocation: { invocationId: 'toolu_agent', generation: 1 } + }) + expect(run.ingested.flatMap((outcome) => outcome?.rejected ?? [])).toEqual([]) + }) +}) diff --git a/src/main/claude/claude-structured-child-work-producer.test.ts b/src/main/claude/claude-structured-child-work-producer.test.ts new file mode 100644 index 00000000000..d47d07b4335 --- /dev/null +++ b/src/main/claude/claude-structured-child-work-producer.test.ts @@ -0,0 +1,349 @@ +// A Claude session's frames, through the real adapter, into the host's child records: the order +// the host receives them in, and the record each child ends up with. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + frame, + hostWithParent, + producer, + system, + toolResult, + toolUse +} from './claude-child-work-producer-harness.test-fixture' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) +afterEach(() => vi.restoreAllMocks()) + +describe('Claude structured child-work producer', () => { + it('delivers evidence only after the journal wrote the frame and the legacy row republished', async () => { + const { send, records } = await producer() + send(toolUse('toolu_bg', 'Agent', { description: 'Audit the build' })) + const deliveries = send( + system('task_started', { + task_id: 'agent-bg', + tool_use_id: 'toolu_bg', + task_type: 'local_agent', + subagent_type: 'general-purpose', + description: 'Audit the build', + 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. + expect(kinds.filter((kind) => kind === 'journal').length).toBeGreaterThan(0) + expect(kinds.slice(kinds.indexOf('legacy'))).toEqual(['legacy', 'evidence']) + expect(records()).toEqual([ + expect.objectContaining({ description: 'Audit the build', membership: 'live' }) + ]) + }) + + it("settles each child only on its own ending, whatever the parent's turn or roster does", async () => { + const { adapter, send, records, byDescription } = await producer() + const steps: { message: Record; check?: () => void }[] = [ + { message: toolUse('toolu_fg', 'Agent', { description: 'Find flaky tests' }) }, + { + message: system('task_started', { + task_id: 'agent-fg', + tool_use_id: 'toolu_fg', + task_type: 'local_agent', + subagent_type: 'Explore', + description: 'Find flaky tests', + is_backgrounded: false + }) + }, + // The foreground child starts a background shell of its own. + { + message: toolUse( + 'toolu_bash', + 'Bash', + { command: 'npm test', run_in_background: true }, + 'toolu_fg' + ), + // Its own call is what it is doing, previewed as a CLI row previews Bash. + check: () => + expect(byDescription('Find flaky tests')?.operation).toMatchObject({ + toolName: 'Bash', + input: 'npm test', + basis: 'open' + }) + }, + { + message: system('task_started', { + task_id: 'shell-1', + tool_use_id: 'toolu_bash', + task_type: 'local_bash', + description: 'npm test', + is_backgrounded: true + }), + check: () => + expect(byDescription('npm test')?.parentChildWorkId).toBe( + byDescription('Find flaky tests')?.childWorkId + ) + }, + { + message: system('background_tasks_changed', { + tasks: [{ task_id: 'shell-1', task_type: 'local_bash', description: 'npm test' }] + }) + }, + { + message: toolResult('toolu_bash', 'Command running in background', 'toolu_fg'), + check: () => expect(byDescription('Find flaky tests')?.operation).toBeUndefined() + }, + { + message: system('task_progress', { + task_id: 'agent-fg', + description: 'Running Bash', + last_tool_name: 'Bash', + usage: { total_tokens: 1_200, tool_uses: 2, duration_ms: 800 } + }), + check: () => + expect(byDescription('Find flaky tests')).toMatchObject({ + operation: { toolName: 'Bash', basis: 'reported' }, + totalTokens: 1_200 + }) + }, + // The spawn call returning is the parent's view; the child's own frame ends it. + { + message: toolResult('toolu_fg', 'Two tests flake on CI'), + check: () => expect(byDescription('Find flaky tests')?.membership).toBe('live') + }, + { + message: system('task_notification', { + task_id: 'agent-fg', + tool_use_id: 'toolu_fg', + status: 'completed', + summary: 'Two tests flake on CI' + }), + check: () => + expect(byDescription('Find flaky tests')).toMatchObject({ + membership: 'settled', + outcome: 'succeeded', + lastMessage: 'Two tests flake on CI' + }) + }, + { message: toolUse('toolu_bg', 'Agent', { description: 'Audit the build' }) }, + { + message: system('task_started', { + task_id: 'agent-bg', + tool_use_id: 'toolu_bg', + task_type: 'local_agent', + description: 'Audit the build', + is_backgrounded: true + }) + }, + { + message: system('background_tasks_changed', { + tasks: [ + { task_id: 'shell-1', task_type: 'local_bash', description: 'npm test' }, + { task_id: 'agent-bg', task_type: 'local_agent', description: 'Audit the build' } + ] + }) + }, + { message: frame({ type: 'result', subtype: 'success', is_error: false }) }, + // Claude drops a finished task from the roster BEFORE its outcome frame arrives. + { + message: system('background_tasks_changed', { + tasks: [{ task_id: 'shell-1', task_type: 'local_bash', description: 'npm test' }] + }), + check: () => expect(byDescription('Audit the build')?.membership).toBe('live') + }, + { + message: system('task_notification', { + task_id: 'agent-bg', + status: 'failed', + summary: 'Build broke', + usage: { total_tokens: 900 } + }), + check: () => + expect(byDescription('Audit the build')).toMatchObject({ + membership: 'settled', + outcome: 'failed', + lastMessage: 'Build broke' + }) + }, + { + message: system('background_tasks_changed', { tasks: [] }), + check: () => expect(byDescription('npm test')?.membership).toBe('live') + }, + { + message: system('task_updated', { task_id: 'shell-1', patch: { status: 'killed' } }), + check: () => expect(byDescription('npm test')).toMatchObject({ outcome: 'cancelled' }) + }, + // Messaging a finished background agent starts it again under the message call. + { message: toolUse('toolu_msg', 'SendMessage', { to: 'agent-bg', message: 'Again' }) }, + { + message: system('task_started', { + task_id: 'agent-bg', + tool_use_id: 'toolu_msg', + task_type: 'local_agent', + description: 'Audit the build', + is_backgrounded: true + }), + check: () => + expect(byDescription('Audit the build')).toMatchObject({ + membership: 'live', + invocation: { invocationId: 'toolu_msg', generation: 2 }, + previousInvocations: [expect.objectContaining({ outcome: 'failed' })] + }) + } + ] + for (const step of steps) { + send(step.message) + step.check?.() + } + await adapter.closeSession('session-1') + // The session is gone: what still ran settles unreported, and every record stays. + const summary = records().map((record) => ({ + description: record.description, + membership: record.membership, + outcome: record.outcome, + generation: record.invocation.generation + })) + expect(summary).toEqual([ + { + description: 'Find flaky tests', + membership: 'settled', + outcome: 'succeeded', + generation: 1 + }, + { description: 'npm test', membership: 'settled', outcome: 'cancelled', generation: 1 }, + { description: 'Audit the build', membership: 'settled', outcome: 'unknown', generation: 2 } + ]) + expect(adapter.backgroundTaskState('session-1')).toBeUndefined() + }) + + it("counts a child's runs the way the journal does, and ends each run on its own frame", async () => { + const { adapter, send, byDescription, stamps } = await producer() + const start = (toolUseId: string) => + system('task_started', { + task_id: 'agent-fg', + tool_use_id: toolUseId, + task_type: 'local_agent', + description: 'Find flaky tests', + is_backgrounded: false + }) + const childSays = (toolUseId: string, text: string) => + frame({ + type: 'assistant', + parent_tool_use_id: toolUseId, + message: { id: `msg-${text}`, role: 'assistant', content: [{ type: 'text', text }] } + }) + // The journal stamps a child row with its roster attempt only once it is past the first. + const runs = (toolUseId: string) => ({ + generation: byDescription('Find flaky tests')?.invocation.generation, + attempt: stamps.findLast((stamp) => stamp.providerParentRef === toolUseId)?.attempt ?? 1 + }) + send(toolUse('toolu_1', 'Agent', { description: 'Find flaky tests' })) + send(start('toolu_1')) + send(childSays('toolu_1', 'first run')) + expect(runs('toolu_1')).toEqual({ generation: 1, attempt: 1 }) + send(toolResult('toolu_1', 'Found it')) + send(system('task_notification', { task_id: 'agent-fg', status: 'completed' })) + + // The provider runs the finished child again under a new spawn call. + send(toolUse('toolu_2', 'Agent', { description: 'Find flaky tests' })) + const legacy = adapter.backgroundTaskState('session-1') + send(start('toolu_2')) + // The legacy row still waits for a roster; the record hears the new run now. + expect(adapter.backgroundTaskState('session-1')).toEqual(legacy) + send(childSays('toolu_2', 'second run')) + expect(runs('toolu_2')).toEqual({ generation: 2, attempt: 2 }) + send( + system('task_progress', { + task_id: 'agent-fg', + last_tool_name: 'Grep', + usage: { total_tokens: 300, tool_uses: 1, duration_ms: 10 } + }) + ) + expect(byDescription('Find flaky tests')).toMatchObject({ + membership: 'live', + operation: { toolName: 'Grep', basis: 'reported' }, + totalTokens: 300 + }) + // An errored spawn result says nothing about how the child ended. + send(toolResult('toolu_2', 'Could not reproduce', null, true)) + expect(byDescription('Find flaky tests')?.membership).toBe('live') + send(system('task_notification', { task_id: 'agent-fg', status: 'failed' })) + expect(byDescription('Find flaky tests')).toMatchObject({ + membership: 'settled', + outcome: 'failed', + invocation: { invocationId: 'toolu_2', generation: 2 }, + previousInvocations: [expect.objectContaining({ outcome: 'succeeded' })] + }) + }) + + describe('a second ending for a settled child', () => { + async function settledForegroundChild() { + const host = hostWithParent() + const warn = vi.spyOn(console, 'warn') + const error = vi.spyOn(console, 'error') + const run = await producer(host) + run.send(toolUse('toolu_fg', 'Agent', { description: 'Find flaky tests' })) + run.send( + system('task_started', { + task_id: 'agent-fg', + tool_use_id: 'toolu_fg', + task_type: 'local_agent', + description: 'Find flaky tests', + is_backgrounded: false + }) + ) + run.send( + system('task_notification', { + task_id: 'agent-fg', + tool_use_id: 'toolu_fg', + status: 'completed', + summary: 'Two tests flake on CI' + }) + ) + expect(run.byDescription('Find flaky tests')).toMatchObject({ + membership: 'settled', + outcome: 'succeeded', + lastMessage: 'Two tests flake on CI' + }) + return { ...run, warn, error } + } + + it('keeps a definite outcome through an unclassified ending and lands its evidence', async () => { + const { send, byDescription } = await settledForegroundChild() + // A notification with no status the host can classify still carries the final summary. + send( + system('task_notification', { + task_id: 'agent-fg', + summary: 'Two tests flake on CI; both time out on the shared runner', + usage: { total_tokens: 1_500 } + }) + ) + expect(byDescription('Find flaky tests')).toMatchObject({ + membership: 'settled', + outcome: 'succeeded', + lastMessage: 'Two tests flake on CI; both time out on the shared runner', + totalTokens: 1_500 + }) + }) + + it('keeps the first definite outcome over a conflicting one, and reports no fault', async () => { + const { send, byDescription, ingested, warn, error } = await settledForegroundChild() + send( + system('task_notification', { + task_id: 'agent-fg', + status: 'failed', + summary: 'Crashed after returning' + }) + ) + expect(byDescription('Find flaky tests')).toMatchObject({ + membership: 'settled', + outcome: 'succeeded', + lastMessage: 'Two tests flake on CI' + }) + // Admission refuses it; the host counts that refusal as the fence doing its job. + expect(ingested.at(-1)).toMatchObject({ + settled: 0, + rejected: [{ handleId: 'agent-fg', reason: 'stale-invocation' }] + }) + expect(warn).not.toHaveBeenCalled() + expect(error).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/main/claude/claude-structured-dispatch-test-support.ts b/src/main/claude/claude-structured-dispatch-test-support.ts index c9e16084a7d..a309bd9d0b9 100644 --- a/src/main/claude/claude-structured-dispatch-test-support.ts +++ b/src/main/claude/claude-structured-dispatch-test-support.ts @@ -3,6 +3,7 @@ import type { AgentJournalMessageItem } from '../../shared/agent-session-journal import { retireClaudeDispatchWaiters } from './claude-structured-dispatch' import type { ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' +import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' import { createClaudeSessionStartupGate } from './claude-structured-session-startup-gate' @@ -19,6 +20,7 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C retiredDispatchWaiters: [], replayContentFallbackBlocked: false, backgroundTasks: new ClaudeBackgroundTaskTracker(), + childWork: new ClaudeChildWorkDecoder(), commands: new ClaudeSlashCommandCatalog(), dispatchSequence: 0, optionMutationSequence: 0, diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 8dacc26ce55..c18392e1aa9 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -1,10 +1,6 @@ import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' -import type { AgentSessionContextReport } from '../../shared/agent-session-context-usage' -import type { - StructuredAgentSessionEventSink, - StructuredAgentSessionSinkAdmission -} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import { claudeStreamingMessageBody, type ClaudeToolUse @@ -32,15 +28,14 @@ import { } from './claude-turn-opening' import { claudeTurnEndForResult } from './claude-turn-lifecycle-item' import { ClaudeOpenTurn } from './claude-open-turn' -import { - ClaudeContextFacts, - type ClaudeContextReportPart, - type ClaudeContextReportTarget -} from './claude-context-facts' +import { ClaudeContextFacts } from './claude-context-facts' import { claudeSessionStateEndsTurn } from './claude-session-state-turn-over' import { ClaudeJournalPrompts } from './claude-structured-journal-prompts' +import { claudeChildToolQueries } from './claude-child-tool-queries' import { journalClaudeMessage, type ClaudeMessageJournalContext } from './claude-message-journaling' +export type { ClaudeJournalTranslator } from './claude-journal-translator-contract' + export type ClaudeJournalTranslatorDeps = { sink: StructuredAgentSessionEventSink bindPromptItemId?: (journalItemId: string, promptKey: string, questionId?: string) => void @@ -50,37 +45,6 @@ export type ClaudeJournalTranslatorDeps = { onBackgroundTaskJournalFailure?: (error: Error) => void } -export type ClaudeJournalTranslator = { - handle: (event: ClaudeStructuredSessionEvent) => void - journalPrompts: Pick - /** The open turn's provider id — the same id its journal row carries, and the one - * a client's Stop names. Sole owner: no reader keeps a copy to disagree with. */ - readonly currentTurnId: string | null - flush: () => void - retryPendingTaskRows?: () => StructuredAgentSessionSinkAdmission - /** Streamed blocks still awaiting a final frame. A settled turn leaves none. */ - readonly pendingStreamedBlocks: number - /** Moves with the main conversation and each accepted send; a context report - * asked for before it moved may no longer describe the context. */ - readonly contextActivity: number - markContextActivity: () => void - /** Fires with the turn a fresh `/context` breakdown should be recorded on. */ - subscribeContextUsageRequests: ( - listener: (target: ClaudeContextReportTarget) => void - ) => () => void - /** Record a requested breakdown, or only its window, on the turn its request named. */ - recordContextReport: ( - target: ClaudeContextReportTarget, - report: AgentSessionContextReport, - part: ClaudeContextReportPart - ) => void - /** After a write that can change the model or its window; the ring waits for the new window. */ - modelMayHaveChanged: () => void - /** After a model write the child applied; its name sizes estimates until a window is measured. */ - modelWritten: (model: string) => void - dispose: () => void -} - export function createClaudeSessionJournalTranslator( sink: StructuredAgentSessionEventSink | undefined, prompts: ClaudePromptRegistry, @@ -124,6 +88,7 @@ export function createClaudeJournalTranslator( // still owed is never coming; the rows keep the stamp they already have. onIdentitiesFinal: () => corrections.abandon() }) + const childQueries = claudeChildToolQueries({ tools, toolOrigins, linkage: subagents.linkage }) const corrections = new ClaudeProvisionalRowCorrections({ ...subagents.linkage, rewrite: (identity, body, options) => { @@ -318,6 +283,8 @@ export function createClaudeJournalTranslator( return turn.id }, flush: streamedText.flush, + childToolOwner: childQueries.childToolOwner, + childActivity: childQueries.childActivity, retryPendingTaskRows: () => backgroundTasks.retryPendingWrites(), get pendingStreamedBlocks() { return streamedText.pending diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index ec5e5477f45..e02647f3625 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -9,6 +9,7 @@ import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-requests' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' +import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' import { createClaudeSessionStartupGate } from './claude-structured-session-startup-gate' import { @@ -35,6 +36,7 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe retiredDispatchWaiters: [], replayContentFallbackBlocked: false, backgroundTasks: new ClaudeBackgroundTaskTracker(), + childWork: new ClaudeChildWorkDecoder(), commands: new ClaudeSlashCommandCatalog(), dispatchSequence: 0, optionMutationSequence: 0, diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index ded925bf631..835757dc730 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -31,6 +31,7 @@ import { } from './claude-structured-session-exit-lifecycle' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { resolveClaudeProviderHistoryWindow } from './claude-structured-history-window' +import { drainClaudeChildWork } from './claude-child-work-evidence' import { admitClaudePromptCancellation, answerClaudeStructuredPrompt, @@ -130,6 +131,11 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda }) private emit(session: ClaudeSession | null, event: ClaudeStructuredSessionEvent): void { + if (event.type === 'ended') { + session?.childWork.clear() + } else if (event.type === 'message') { + session?.childWork.observe(event.message) + } const backgroundTasksChanged = event.type === 'ended' ? (session?.backgroundTasks.clear() ?? false) @@ -147,6 +153,20 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda session ? backgroundTaskState(session) : null ) } + this.publishChildWork(event.sessionId, session, event.type === 'message' ? event.message : null) + } + + /** After the journal handled the frame and the parent's own row was republished: the host + * never holds a child record ahead of the rows that frame wrote, and never before its parent. */ + private publishChildWork( + sessionId: string, + session: ClaudeSession | null | undefined, + message: Record | null = null + ): void { + const evidence = drainClaudeChildWork(session, message, this.deps.now?.() ?? Date.now()) + if (evidence.length > 0) { + this.deps.onChildWorkEvidence?.(sessionId, evidence) + } } bindPromptItemId( @@ -229,8 +249,21 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda ] releaseAcquisition = (input: { sessionId: string }): Promise => + this.afterClose(input.sessionId, () => this.releaseProviderSession(input.sessionId)) + + /** A close clears the session's tasks outside `emit`; its ending still reaches the host. */ + private async afterClose(sessionId: string, close: () => Promise): Promise { + const session = this.sessions.get(sessionId) + try { + return await close() + } finally { + this.publishChildWork(sessionId, session) + } + } + + private releaseProviderSession = (sessionId: string): Promise => releaseClaudeAcquisition({ - sessionId: input.sessionId, + sessionId, sessions: this.sessions, acquisitions: this.acquisitions, exits: this.exits, @@ -251,7 +284,11 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda if (this.exits.has(sessionId)) { return this.releaseAcquisition({ sessionId }) } - return closeClaudeSession({ + return this.afterClose(sessionId, () => this.closeProviderSession(sessionId)) + } + + private closeProviderSession = (sessionId: string): Promise => + closeClaudeSession({ sessionId, sessions: this.sessions, acquisitions: this.acquisitions, @@ -261,7 +298,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda : {}), ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}) }) - } closeAll = (): Promise => closeAllClaudeSessions({ diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 4ef0adff149..f0bba7ae2b0 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -116,6 +116,7 @@ async function finalizeClaudePublishedSession( } rootExitVerdict = cleanupError } + session.childWork.clear() if (session.backgroundTasks.clear()) { input.onBackgroundTasksChanged?.(input.sessionId, null) } diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index 1d4228ed837..d64273f9b8b 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -4,6 +4,7 @@ import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' import type { ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' +import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' import { createClaudeSessionStartupGate } from './claude-structured-session-startup-gate' @@ -52,6 +53,7 @@ export function createClaudeSessionPublication(input: { retiredDispatchWaiters: [], replayContentFallbackBlocked: false, backgroundTasks: new ClaudeBackgroundTaskTracker(), + childWork: new ClaudeChildWorkDecoder(), // Undefined until init: an unread catalog is unavailable, not empty. commands: new ClaudeSlashCommandCatalog(), dispatchSequence: 0, diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index fc7e8f8601e..7d99aff577b 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -21,7 +21,9 @@ import type { AgentSessionBackgroundTaskState, AgentSessionFastModeState } from '../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' +import type { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import type { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' import type { ClaudeSessionStartupGate } from './claude-structured-session-startup-gate' @@ -93,6 +95,8 @@ export type ClaudeStructuredSessionAdapterDeps = { sessionId: string, state: AgentSessionBackgroundTaskState | null ) => void + /** What the session's child work did, delivered after the journal handled the frame. */ + onChildWorkEvidence?: (sessionId: string, evidence: AgentChildWorkEvidence[]) => void openConnection?: typeof openClaudeStreamJsonConnection readProcessStartTime?: (pid: number) => Promise mintLinkId?: () => string @@ -169,6 +173,8 @@ export type ClaudeSession = { /** CLI-advertised protocol capabilities from init; gates interrupt-receipt handling. */ capabilities: readonly string[] backgroundTasks: ClaudeBackgroundTaskTracker + /** Each child's own task frames, as evidence for the host's child records. */ + childWork: ClaudeChildWorkDecoder /** The `/` surface the CLI reports for itself; seeded from init, kept current * by later init and `commands_changed` frames. */ commands: ClaudeSlashCommandCatalog 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 7681e0f3436..aa3f298a077 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 @@ -1,3 +1,4 @@ +import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' import type { @@ -43,6 +44,9 @@ export class StructuredAgentSessionClientDelivery { publishStatus = (sessionId: string): void => this.statusFeed.publish(sessionId) + publishChildWork = (sessionId: string, evidence: AgentChildWorkEvidence[]): void => + this.statusFeed.publishChildWork(sessionId, evidence) + 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.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index ba508b67bcf..4ba2e6db478 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 @@ -41,8 +41,6 @@ import type { StructuredAgentSessionHostSession, StructuredAgentSessionReveal } from './structured-agent-session-host-types' -import type { StructuredAgentSessionStatusSubscriber } from './structured-agent-session-status-feed' -import type { StructuredAgentSessionTurnCompletionSubscriber } from './structured-agent-session-turn-completion-feed' import { StructuredAgentSessionEventRecovery } from './structured-agent-session-event-recovery' import { StructuredAgentSessionBackgroundTaskChannel } from './structured-agent-session-background-task-channel' import { StructuredAgentSessionClientDelivery } from './structured-agent-session-client-delivery' @@ -301,16 +299,14 @@ export class StructuredAgentSessionHost { publishBackgroundTaskState: StructuredAgentSessionBackgroundTaskChannel['publish'] = (...args) => this.backgroundTasks.publish(...args) + publishChildWorkEvidence = this.clientDelivery.publishChildWork unsubscribe = (sessionId: string, id: string): void => this.subscribers.close(sessionId, id) /** Every session's projected status for session lists; unlike `subscribe`, retains nothing. */ - subscribeStatus = (subscriber: StructuredAgentSessionStatusSubscriber): (() => void) => - this.clientDelivery.subscribeStatus(subscriber) + subscribeStatus = this.clientDelivery.subscribeStatus /** Turns that settle from now on. Live-only: nothing missed is replayed. */ - subscribeTurnCompletions = ( - subscriber: StructuredAgentSessionTurnCompletionSubscriber - ): (() => void) => this.clientDelivery.subscribeTurnCompletions(subscriber) + subscribeTurnCompletions = this.clientDelivery.subscribeTurnCompletions private requireSession(sessionId: string): StructuredAgentSessionHostSession { const session = this.sessions.get(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts new file mode 100644 index 00000000000..1731674baad --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts @@ -0,0 +1,86 @@ +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 type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import { makeStructuredAgentStatusSubject } from '../../../shared/agent-status-subject' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { + StructuredAgentSessionStatusFeed, + type StructuredAgentSessionStatusSink +} from './structured-agent-session-status-feed' +import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' + +const SESSION = 'status-session' +const EVIDENCE: AgentChildWorkEvidence[] = [{ type: 'session-ended', observedAt: 5 }] +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-agent-status-feed-children-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +async function feedWith(sink: StructuredAgentSessionStatusSink) { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, SESSION) + }) + const session = indexedStatusFeedSession({ journal }) + return new StructuredAgentSessionStatusFeed({ + sessions: new Map([[SESSION, session]]), + getRecord: () => null, + now: () => 1_000, + statusSink: () => sink + }) +} + +describe('structured status feed child work', () => { + it("hands a session's child work to the sink with the session's provider, once its row landed", async () => { + const sink = { publish: vi.fn(), forget: vi.fn(), publishChildWork: vi.fn() } + const feed = await feedWith(sink) + feed.publishChildWork(SESSION, EVIDENCE) + expect(sink.publishChildWork).not.toHaveBeenCalled() + feed.publish(SESSION) + feed.publishChildWork(SESSION, EVIDENCE) + feed.publishChildWork('another-session', EVIDENCE) + expect(sink.publishChildWork).toHaveBeenCalledExactlyOnceWith( + makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + SESSION + ), + EVIDENCE, + 'codex' + ) + }) + + it('never lets a failing child-work sink throw into the provider stream', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const feed = await feedWith({ + publish: vi.fn(), + forget: vi.fn(), + publishChildWork: () => { + throw new Error('store down') + } + }) + feed.publish(SESSION) + expect(() => feed.publishChildWork(SESSION, EVIDENCE)).not.toThrow() + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() + }) +}) 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 29bf29a00f1..eef1c0e06f9 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 @@ -21,6 +21,7 @@ import { type AgentSessionStatusEvent, type AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection' import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -296,6 +297,19 @@ export class StructuredAgentSessionStatusFeed { } } + /** Child-work evidence for a session this feed publishes; a failing sink costs nothing else. */ + publishChildWork(sessionId: string, evidence: AgentChildWorkEvidence[]): void { + const session = this.deps.sessions.get(sessionId) + if (!session) { + return + } + try { + this.ownership.publishChildWork(sessionId, evidence, session.params.provider) + } catch (error) { + console.warn('[structured-session-status] child work publish failed', error) + } + } + /** A failing sink must never cost the subscribers their status event. */ private sink( summary: AgentSessionStatusSummary, 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 7e9e6da84a3..09fcb481e0e 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 @@ -94,4 +94,29 @@ describe('structured status owner address retention', () => { expect(() => unavailable.publish(summary, location)).not.toThrow() expect(() => unavailable.forget(summary.sessionId)).not.toThrow() }) + + it('offers child work only under the address its parent row landed under', () => { + const sink = { publish: vi.fn(), forget: vi.fn(), publishChildWork: vi.fn() } + const owner = new StructuredAgentSessionStatusOwnership(() => sink) + const subject = makeStructuredAgentStatusSubject(location, summary.sessionId) + const evidence = [{ type: 'session-ended' as const, observedAt: 1 }] + owner.publishChildWork(summary.sessionId, evidence, 'claude') + expect(sink.publishChildWork).not.toHaveBeenCalled() + owner.publish(summary, location) + owner.publishChildWork(summary.sessionId, evidence, 'claude') + expect(sink.publishChildWork).toHaveBeenCalledExactlyOnceWith(subject, evidence, 'claude') + owner.forget(summary.sessionId) + owner.publishChildWork(summary.sessionId, evidence, 'claude') + expect(sink.publishChildWork).toHaveBeenCalledOnce() + // An address held after a publish that threw is not a row that landed. + const unlanded = new StructuredAgentSessionStatusOwnership(() => ({ + ...sink, + publish: () => { + throw new Error('store down') + } + })) + expect(() => unlanded.publish(summary, location)).toThrow('store down') + unlanded.publishChildWork(summary.sessionId, evidence, 'claude') + expect(sink.publishChildWork).toHaveBeenCalledOnce() + }) }) 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 088879f8ddc..e7a270d10c4 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 @@ -1,5 +1,9 @@ -import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { + AgentSessionExecutionLocation, + AgentSessionRecord +} from '../../../shared/agent-session-record' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import { parseAgentStatusSubject, serializeAgentStatusSubject, @@ -12,6 +16,12 @@ export type StructuredAgentSessionStatusSink = { subject: AgentStatusStructuredSessionSubject ) => void forget: (subject: AgentStatusStructuredSessionSubject) => void + /** The session's child-work evidence, addressed by the subject its parent row landed under. */ + publishChildWork?: ( + subject: AgentStatusStructuredSessionSubject, + evidence: AgentChildWorkEvidence[], + provider: AgentSessionRecord['provider'] + ) => void } /** Retain the owner address because record removal may precede the final status callback. */ @@ -63,6 +73,19 @@ export class StructuredAgentSessionStatusOwnership { this.landed.add(summary.sessionId) } + /** Children ride the address the parent landed under: without that proof the store would + * refuse them anyway, and offering them earlier would race the parent row. */ + publishChildWork( + sessionId: string, + evidence: AgentChildWorkEvidence[], + provider: AgentSessionRecord['provider'] + ): void { + const subject = this.subjects.get(sessionId) + if (subject && this.landed.has(sessionId)) { + this.sink()?.publishChildWork?.(subject, evidence, provider) + } + } + forget(sessionId: string): void { const subject = this.subjects.get(sessionId) if (!subject) { diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index fb22f92c392..edc4426a2a9 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -245,7 +245,9 @@ async function startOrcadRuntime( readObservedAgentStatusPaneIdentity: (paneKey) => observedPaneIdentities.read(paneKey), structuredAgentStatusSink: { publish: (summary, subject) => agentHookServer.ingestStructuredStatus(summary, subject), - forget: (subject) => agentHookServer.dropStructuredStatus(subject) + forget: (subject) => agentHookServer.dropStructuredStatus(subject), + publishChildWork: (subject, evidence, provider) => + agentHookServer.ingestStructuredChildWork(subject, evidence, provider) }, reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 50527f01ac5..480720da93b 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -14,6 +14,7 @@ import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launc import { CLAUDE_SPAWN_TOKEN_ENV } from '../claude/claude-structured-owner-identity' import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' import type { OrcaRuntimeService } from './orca-runtime' import type { RpcRequest, RpcResponse } from './rpc/core' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' @@ -135,6 +136,8 @@ let claudeAuthPolicy: ClaudeStructuredAuthPolicy let claudeLaunchEnv: Record let shellEnv: NodeJS.ProcessEnv let shellEnvironmentPolicy: NativeChatShellEnvironmentPolicy +/** What the host handed its status sink as child work. */ +let childWork: Parameters>[] async function call(method: string, params: unknown): Promise { const replies: RpcResponse[] = [] @@ -214,6 +217,7 @@ beforeEach(async () => { resolveSessionFilePath.mockResolvedValue(transcriptPath) claude = fakeClaude(PROVIDER_SESSION) cleanups = new Map() + childWork = [] const runtime = { getRuntimeId: () => 'runtime-1', getClientSettings: () => ({ experimentalStructuredNativeChat: true }), @@ -238,7 +242,12 @@ beforeEach(async () => { resolveEnvironment: async () => shellEnv, resolveShellEnvironmentPolicy: () => shellEnvironmentPolicy, resolveClaudeAuthPolicy: () => claudeAuthPolicy, - openClaudeConnection: claude.openConnection + openClaudeConnection: claude.openConnection, + statusSink: { + publish: () => {}, + forget: () => {}, + publishChildWork: (...args) => childWork.push(args) + } }).then(() => undefined), registerSubscriptionCleanup: (id: string, dispose: () => void) => cleanups.set(id, dispose), cleanupSubscription: (id: string) => cleanups.get(id)?.(), @@ -257,6 +266,39 @@ afterEach(async () => { }) describe('a structured Claude session over agentSession.*', () => { + it("hands its subagents to the status sink under the session's own address", async () => { + const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Audit it' }] } + await ok('agentSession.send', { + envelope: envelope('agentSession.send', { body }, created.fence), + body + }) + claude.live().handlers.onMessage?.({ + type: 'system', + subtype: 'task_started', + session_id: PROVIDER_SESSION, + uuid: 'task-start', + task_id: 'agent-1', + tool_use_id: 'toolu_1', + task_type: 'local_agent', + description: 'Audit the build', + is_backgrounded: true + }) + expect(childWork).toContainEqual([ + expect.objectContaining({ kind: 'structured-session', sessionId: SESSION }), + [ + expect.objectContaining({ + type: 'live', + child: expect.objectContaining({ + handle: { idKind: 'task_id', id: 'agent-1', runId: 'toolu_1' }, + description: 'Audit the build' + }) + }) + ], + 'claude' + ]) + }) + it('strips ambient Anthropic auth from the child once a managed account is pinned', async () => { claudeAuthPolicy = { stripAuthEnv: true } claudeLaunchEnv = { ANTHROPIC_BASE_URL: 'https://gateway.example.test' } diff --git a/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts index decaa5cc599..8b048f168d4 100644 --- a/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts +++ b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts @@ -66,6 +66,9 @@ describe('every host that constructs a runtime wires the agent-status store', () for (const dep of AGENT_STATUS_STORE_DEPS) { expect(construction).toContain(`${dep}:`) } + // A sink without it leaves the host holding no child records for that entry point. + expect(construction).toContain('publishChildWork: (subject, evidence, provider) =>') + expect(construction).toContain('ingestStructuredChildWork(subject, evidence, provider)') } ) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 91595eb124f..a51da3f1c3c 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -284,6 +284,8 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise lifecycle.deliver(event), onBackgroundTasksChanged: (sessionId, state) => host?.publishBackgroundTaskState(sessionId, state), + onChildWorkEvidence: (sessionId, evidence) => + host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index e6ece3b3582..b0cb51eb183 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -40,6 +40,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { state: AgentSessionBackgroundTaskState | null ) => void onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'] + onChildWorkEvidence?: ClaudeStructuredSessionAdapterDeps['onChildWorkEvidence'] } /** The adapter events the host's lifecycle handler consumes, in the host's vocabulary. */ @@ -131,6 +132,7 @@ export function createStructuredClaudeRuntimeAdapter( ? { onBackgroundTasksChanged: deps.onBackgroundTasksChanged } : {}), ...(deps.onDispatchSettledLate ? { onDispatchSettledLate: deps.onDispatchSettledLate } : {}), + ...(deps.onChildWorkEvidence ? { onChildWorkEvidence: deps.onChildWorkEvidence } : {}), ...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), ...(deps.modelCatalog ? { modelCatalog: deps.modelCatalog } : {}) diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index a1c20cd00aa..20ea8262c9a 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -99,7 +99,9 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { // snapshot above then lists them for the CLI and mobile without a second store. structuredAgentStatusSink: { publish: (summary, subject) => agentHookServer.ingestStructuredStatus(summary, subject), - forget: (subject) => agentHookServer.dropStructuredStatus(subject) + forget: (subject) => agentHookServer.dropStructuredStatus(subject), + publishChildWork: (subject, evidence, provider) => + agentHookServer.ingestStructuredChildWork(subject, evidence, provider) }, // Why captured rather than resolved at read: the fleet snapshot remints cached rows on every // read, so a row observed under one process otherwise acquires whatever the pane owns now. diff --git a/src/shared/agent-status-child-work-codec-boundary.test.ts b/src/shared/agent-status-child-work-codec-boundary.test.ts index 8affca519f6..aa4ac50c7ad 100644 --- a/src/shared/agent-status-child-work-codec-boundary.test.ts +++ b/src/shared/agent-status-child-work-codec-boundary.test.ts @@ -14,8 +14,7 @@ const HOST_INTERNAL_IMPORTERS = [ 'shared/agent-status-child-work-resume.ts', 'shared/agent-status-store-codec.ts', 'shared/agent-status-store-mutation.ts', - 'shared/agent-status-store-state.ts', - 'shared/agent-status-store.ts' + 'shared/agent-status-store-state.ts' ] describe('child-work record codec boundary', () => { diff --git a/src/shared/agent-status-child-work-evidence-admission.ts b/src/shared/agent-status-child-work-evidence-admission.ts new file mode 100644 index 00000000000..b78859ff840 --- /dev/null +++ b/src/shared/agent-status-child-work-evidence-admission.ts @@ -0,0 +1,227 @@ +// One evidence edge about one child, turned into the admission request that records it. + +import type { + AgentChildWorkAdmission, + AgentChildWorkAdmissionResult, + AgentChildWorkObservationFields +} from './agent-status-child-work-admission' +import type { + AgentChildWorkOperation, + AgentChildWorkOutcome, + AgentChildWorkRecord +} from './agent-status-child-work' +import type { AgentChildWorkLiveObservation } from './agent-status-child-work-evidence' +import { + agentChildWorkHandleAliases, + currentAgentChildWorkAliases, + isPreviousAgentChildWorkRun, + ownedStructuredChildWork, + resolveAgentChildWorkHandle, + resolveAgentChildWorkOwner, + STRUCTURED_CHILD_WORK_PROVENANCE, + type AgentChildWorkEvidenceScope +} from './agent-status-child-work-evidence-resolution' + +/** Live children admitted per session, sized to the provider trackers' own retention. */ +export const STRUCTURED_CHILD_WORK_MAX_LIVE = 256 +/** A child's first run, matching the provider roster's first attempt. */ +const FIRST_GENERATION = 1 + +export type AgentChildWorkReconcileOutcome = { + admitted: number + settled: number + removed: number + /** Refusals are facts about one child, never a reason to drop the rest of the evidence. */ + rejected: { handleId: string; reason: string }[] +} + +export type AgentChildWorkEvidenceContext = AgentChildWorkEvidenceScope & { + admission: AgentChildWorkAdmission + outcome: AgentChildWorkReconcileOutcome +} + +function counted( + ctx: AgentChildWorkEvidenceContext, + handleId: string, + result: AgentChildWorkAdmissionResult, + key: 'admitted' | 'settled' +): void { + if (result.accepted) { + ctx.outcome[key] += 1 + } else { + ctx.outcome.rejected.push({ handleId, reason: result.reason }) + } +} + +/** An `open` operation ends on its own edge; a `reported` one lasts until the next report. */ +function nextOperation( + reported: AgentChildWorkOperation | null | undefined, + current: AgentChildWorkOperation | undefined +): AgentChildWorkOperation | undefined { + if (reported === undefined) { + return current + } + if (reported === null) { + return current?.basis === 'open' ? undefined : current + } + return reported +} + +/** Which run a run handle names: the current one (or one the record has no handle for yet), a + * run that is already over, or a new one the provider started. */ +export function agentChildWorkRunVerdict( + ctx: AgentChildWorkEvidenceContext, + existing: AgentChildWorkRecord, + runId: string | undefined +): 'current' | 'previous' | 'new' { + const current = currentAgentChildWorkAliases(ctx, existing).runId + if (runId === undefined || runId === current) { + return 'current' + } + if (isPreviousAgentChildWorkRun(ctx, existing, runId)) { + return 'previous' + } + return current === undefined ? 'current' : 'new' +} + +/** `prior` is the record when this evidence continues its current run; a new run starts bare. + * Admission folds provider text and keeps what an edge leaves unsaid (labels and tokens always, + * owner and last message within a run), so only this edge's own raw facts go in. */ +function liveFields( + ctx: AgentChildWorkEvidenceContext, + child: AgentChildWorkLiveObservation, + observedAt: number, + existing: AgentChildWorkRecord | null, + prior: AgentChildWorkRecord | null +): AgentChildWorkObservationFields { + // A record's own clock never runs backwards; a host clock that does must not cost the update. + const at = existing ? Math.max(observedAt, existing.observedAt) : observedAt + const owner = + child.ownerId === undefined ? undefined : resolveAgentChildWorkOwner(ctx, child.ownerId) + const operation = nextOperation(child.operation, prior?.operation) + return { + kind: child.kind, + state: child.state, + membership: 'live', + ...(child.name !== undefined ? { name: child.name } : {}), + ...(child.description !== undefined ? { description: child.description } : {}), + ...(child.agentType !== undefined ? { agentType: child.agentType } : {}), + ...(child.totalTokens !== undefined ? { totalTokens: child.totalTokens } : {}), + ...(owner !== undefined ? { parentChildWorkId: owner } : {}), + residency: child.residency, + ...(operation ? { operation } : {}), + ...(child.lastMessage !== undefined ? { lastMessage: child.lastMessage } : {}), + observedAt: at, + stoppable: child.stoppable, + provenance: STRUCTURED_CHILD_WORK_PROVENANCE + } +} + +export function applyAgentChildWorkLive( + ctx: AgentChildWorkEvidenceContext, + child: AgentChildWorkLiveObservation, + observedAt: number, + restart: boolean +): void { + const { handle } = child + const resolution = resolveAgentChildWorkHandle(ctx, [handle.idKind], handle.id) + if (!resolution || resolution.ambiguous) { + ctx.outcome.rejected.push({ handleId: handle.id, reason: resolution ? 'ambiguous' : 'invalid' }) + return + } + const existing = resolution.child + const request = { parent: ctx.parent, provider: ctx.provider } + const aliases = agentChildWorkHandleAliases(handle) + if (!existing) { + const live = ownedStructuredChildWork(ctx).filter((record) => record.membership === 'live') + if (live.length >= STRUCTURED_CHILD_WORK_MAX_LIVE) { + ctx.outcome.rejected.push({ handleId: handle.id, reason: 'ingestion-limit' }) + return + } + // Bindings with no record left are retired lifetimes: a forgotten session took its children + // with it. The id is free again, under a generation past the retired one. + const retired = resolution.highestGeneration + const fence = { + invocationId: handle.runId ?? handle.id, + generation: retired === null ? FIRST_GENERATION : retired + 1 + } + const result = ctx.admission.announce({ + ...liveFields(ctx, child, observedAt, null, null), + ...request, + aliases, + fence, + lifetime: retired === null ? 'current' : 'proven-new' + }) + counted(ctx, handle.id, result, 'admitted') + return + } + const run = agentChildWorkRunVerdict(ctx, existing, handle.runId) + if (run === 'previous') { + return + } + // A different spawn call for the same child is the provider starting it again, and so is a + // start the producer reports for a child that had ended. + if (run === 'new' || (existing.membership === 'settled' && restart)) { + const result = ctx.admission.resume({ + // A reclassification lands on the next edge: resume keeps the kind its bindings carry. + ...liveFields(ctx, { ...child, kind: existing.kind }, observedAt, existing, null), + ...request, + childWorkId: existing.childWorkId, + expectedFence: existing.invocation, + nextFence: { + invocationId: handle.runId ?? existing.invocation.invocationId, + generation: existing.invocation.generation + 1 + }, + aliases + }) + counted(ctx, handle.id, result, 'admitted') + return + } + if (existing.membership === 'settled') { + // Late evidence for a run that already ended. + return + } + const fields = { ...liveFields(ctx, child, observedAt, existing, existing), ...request, aliases } + const result = + existing.kind !== child.kind + ? ctx.admission.adopt({ + ...fields, + childWorkId: existing.childWorkId, + expectedFence: existing.invocation + }) + : ctx.admission.announce({ ...fields, fence: existing.invocation, lifetime: 'current' }) + counted(ctx, handle.id, result, 'admitted') +} + +export function settleAgentChildWork( + ctx: AgentChildWorkEvidenceContext, + existing: AgentChildWorkRecord, + outcome: AgentChildWorkOutcome, + observedAt: number, + reported: { lastMessage?: string; totalTokens?: number } = {} +): void { + const current = currentAgentChildWorkAliases(ctx, existing) + const handleId = current.stableId ?? existing.childWorkId + if (current.aliases.length === 0) { + ctx.outcome.rejected.push({ handleId, reason: 'unbound-child' }) + return + } + // Admission keeps what the record already knows; the ending adds only what it reported. + const result = ctx.admission.announce({ + kind: existing.kind, + state: 'done', + membership: 'settled', + outcome, + ...(reported.totalTokens !== undefined ? { totalTokens: reported.totalTokens } : {}), + ...(reported.lastMessage !== undefined ? { lastMessage: reported.lastMessage } : {}), + observedAt: Math.max(observedAt, existing.observedAt), + stoppable: false, + provenance: STRUCTURED_CHILD_WORK_PROVENANCE, + parent: ctx.parent, + provider: ctx.provider, + aliases: current.aliases, + fence: existing.invocation, + lifetime: 'current' + }) + counted(ctx, handleId, result, 'settled') +} diff --git a/src/shared/agent-status-child-work-evidence-resolution.ts b/src/shared/agent-status-child-work-evidence-resolution.ts new file mode 100644 index 00000000000..50c80e5bdbd --- /dev/null +++ b/src/shared/agent-status-child-work-evidence-resolution.ts @@ -0,0 +1,190 @@ +// Finding the record a provider handle names, among the records one structured producer owns. + +import type { AgentChildWorkObservationAlias } from './agent-status-child-work-admission' +import type { + AgentChildWorkAliasInput, + AgentChildWorkAliasKind, + AgentChildWorkAliasRecord +} from './agent-status-child-work-alias' +import { + AGENT_CHILD_WORK_KINDS, + agentChildWorkFencesEqual, + type AgentChildWorkProvenance, + type AgentChildWorkRecord +} from './agent-status-child-work' +import type { AgentChildWorkEvidenceHandle } from './agent-status-child-work-evidence' +import type { AgentStatusStore } from './agent-status-store' +import { agentStatusSubjectsEqual, type AgentStatusSubject } from './agent-status-subject' + +/** One producer per structured session: its provider's lane. The segment scopes its aliases, so + * a handle is unique per parent and provider without being unique across producers. */ +export const STRUCTURED_CHILD_WORK_PRODUCER_ID = 'structured-session-child-work' +const SEGMENT_ID = STRUCTURED_CHILD_WORK_PRODUCER_ID +const RUN_ALIAS_KIND: AgentChildWorkAliasKind = 'tool_use_id' + +export const STRUCTURED_CHILD_WORK_PROVENANCE: AgentChildWorkProvenance = { + source: 'structured-session', + producerId: STRUCTURED_CHILD_WORK_PRODUCER_ID +} + +export type AgentChildWorkEvidenceScope = { + store: AgentStatusStore + parent: AgentStatusSubject + provider: string +} + +export type AgentChildWorkHandleResolution = { + child: AgentChildWorkRecord | null + ambiguous: boolean + /** Highest generation any binding for the handle holds, live or retired. A retired binding + * outlives the record it named and must fence that lifetime's late evidence, without banning + * the provider id for ever. */ + highestGeneration: number | null +} + +/** Only this producer's records are its to change; another producer's rows are not. */ +export function isOwnedStructuredChildWork( + scope: AgentChildWorkEvidenceScope, + child: AgentChildWorkRecord +): boolean { + return ( + child.provenance.source === STRUCTURED_CHILD_WORK_PROVENANCE.source && + child.provenance.producerId === STRUCTURED_CHILD_WORK_PRODUCER_ID && + child.provider === scope.provider && + agentStatusSubjectsEqual(child.parent, scope.parent) + ) +} + +export function ownedStructuredChildWork( + scope: AgentChildWorkEvidenceScope +): AgentChildWorkRecord[] { + return scope.store + .getChildren(scope.parent) + .filter((child) => isOwnedStructuredChildWork(scope, child)) +} + +/** The aliases one handle registers: the stable id, and the run's own handle when it has one. */ +export function agentChildWorkHandleAliases( + handle: AgentChildWorkEvidenceHandle +): AgentChildWorkObservationAlias[] { + return [ + { segmentId: SEGMENT_ID, aliasKind: handle.idKind, alias: handle.id }, + ...(handle.runId !== undefined && handle.runId !== handle.id + ? [{ segmentId: SEGMENT_ID, aliasKind: RUN_ALIAS_KIND, alias: handle.runId }] + : []) + ] +} + +function probes( + scope: AgentChildWorkEvidenceScope, + aliasKinds: readonly AgentChildWorkAliasKind[], + alias: string +): AgentChildWorkAliasInput[] { + // The alias key carries `kind`, so a reclassified child answers under its old kind alone; + // probing every kind is what keeps a reclassification from minting a second child. + return aliasKinds.flatMap((aliasKind) => + AGENT_CHILD_WORK_KINDS.map((kind) => ({ + parent: scope.parent, + provider: scope.provider, + segmentId: SEGMENT_ID, + kind, + aliasKind, + alias, + childWorkId: 'unresolved-child', + fence: { invocationId: 'unresolved', generation: 0 } + })) + ) +} + +/** Null when the handle cannot be an alias at all (the store's alias bounds refuse it). */ +export function resolveAgentChildWorkHandle( + scope: AgentChildWorkEvidenceScope, + aliasKinds: readonly AgentChildWorkAliasKind[], + alias: string +): AgentChildWorkHandleResolution | null { + let bindings: AgentChildWorkAliasRecord[] + try { + bindings = scope.store.resolveChildAliases(probes(scope, aliasKinds, alias)) + } catch { + return null + } + let highestGeneration: number | null = null + const owned = new Map() + for (const binding of bindings) { + highestGeneration = Math.max(highestGeneration ?? 0, binding.fence.generation) + const child = scope.store.getChild(binding.childWorkId) + if (child && isOwnedStructuredChildWork(scope, child)) { + owned.set(child.childWorkId, child) + } + } + const [child] = owned.values() + return owned.size > 1 + ? { child: null, ambiguous: true, highestGeneration } + : { child: child ?? null, ambiguous: false, highestGeneration } +} + +/** The owner a handle id names, by its stable id or by the run handle it spawned under. */ +export function resolveAgentChildWorkOwner( + scope: AgentChildWorkEvidenceScope, + ownerId: string +): string | undefined { + const resolution = resolveAgentChildWorkHandle( + scope, + ['task_id', 'thread_id', RUN_ALIAS_KIND], + ownerId + ) + return resolution?.child?.childWorkId +} + +function isStableAliasKind( + kind: AgentChildWorkAliasKind +): kind is AgentChildWorkEvidenceHandle['idKind'] { + return kind === 'task_id' || kind === 'thread_id' +} + +/** The handles a record answers to for its current run. */ +export function currentAgentChildWorkAliases( + scope: AgentChildWorkEvidenceScope, + child: AgentChildWorkRecord +): { + stable?: AgentChildWorkEvidenceHandle + stableId?: string + runId?: string + aliases: AgentChildWorkObservationAlias[] +} { + const current = scope.store + .getAliasesForChild(child.childWorkId) + .filter((alias) => agentChildWorkFencesEqual(alias.fence, child.invocation)) + let stable: AgentChildWorkEvidenceHandle | undefined + for (const alias of current) { + if (!stable && isStableAliasKind(alias.aliasKind)) { + stable = { idKind: alias.aliasKind, id: alias.alias } + } + } + return { + ...(stable ? { stable } : {}), + stableId: stable?.id, + runId: current.find((alias) => alias.aliasKind === RUN_ALIAS_KIND)?.alias, + aliases: current.map((alias) => ({ + segmentId: alias.segmentId, + aliasKind: alias.aliasKind, + alias: alias.alias + })) + } +} + +/** A run handle this child answered to before its current run: evidence from a run that is over. */ +export function isPreviousAgentChildWorkRun( + scope: AgentChildWorkEvidenceScope, + child: AgentChildWorkRecord, + runId: string +): boolean { + return scope.store + .getAliasesForChild(child.childWorkId) + .some( + (alias) => + alias.aliasKind === RUN_ALIAS_KIND && + alias.alias === runId && + !agentChildWorkFencesEqual(alias.fence, child.invocation) + ) +} diff --git a/src/shared/agent-status-child-work-evidence.ts b/src/shared/agent-status-child-work-evidence.ts new file mode 100644 index 00000000000..06fe05d38a8 --- /dev/null +++ b/src/shared/agent-status-child-work-evidence.ts @@ -0,0 +1,81 @@ +// What a structured provider said about its child work, in the child-work vocabulary. +// +// A producer decodes provider frames into these edges and the host folds them into the one +// record per child it owns. Edges carry facts, not records: which child is live, what it is +// doing, how it ended. Only a child's own ending settles it, or the end of its session. + +import type { AgentChildWorkAliasKind } from './agent-status-child-work-alias' +import type { + AgentChildWorkKind, + AgentChildWorkOperation, + AgentChildWorkOutcome, + AgentChildWorkResidency, + AgentChildWorkState +} from './agent-status-child-work' + +/** How the provider names one child. `id` is the stable handle today's wire already publishes + * (a Claude task id); `runId` names the current run when the provider mints one per run (the + * spawn call), and a different one is the provider starting the child again. */ +export type AgentChildWorkEvidenceHandle = { + idKind: Extract + id: string + runId?: string +} + +/** A child the provider reports live, with every descriptive fact the producer holds for it, so + * an edge the host could not admit is healed by the child's next one. */ +export type AgentChildWorkLiveObservation = { + handle: AgentChildWorkEvidenceHandle + kind: AgentChildWorkKind + residency: AgentChildWorkResidency + state: Exclude + name?: string + description?: string + agentType?: string + totalTokens?: number + /** `null`: the operation that was open has ended. Absent: this edge says nothing about it. */ + operation?: AgentChildWorkOperation | null + lastMessage?: string + /** Handle id (either alias) of the child that owns this work; absent for the main agent. */ + ownerId?: string + stoppable: boolean +} + +export type AgentChildWorkLiveEvidence = { + type: 'live' + observedAt: number + child: AgentChildWorkLiveObservation + /** The provider started a child that had ended: a new run, even under the same run handle. */ + restart?: true +} + +/** A child's own tool traffic: the call it has open now, or that none is open any more. Applies + * only to a child already recorded live; it never creates one. */ +export type AgentChildWorkOperationEvidence = { + type: 'operation' + observedAt: number + /** Any handle the child answers to: its stable id, or the spawn call of its run. */ + childId: string + /** `null`: the call it had open has ended. */ + operation: AgentChildWorkOperation | null +} + +/** The child's own terminal frame. `unknown` is an ending whose status the provider did not say. */ +export type AgentChildWorkEndedEvidence = { + type: 'ended' + observedAt: number + handle: AgentChildWorkEvidenceHandle + outcome: AgentChildWorkOutcome + lastMessage?: string + totalTokens?: number +} + +/** The provider session is gone: a child still live can no longer end on its own, so it settles + * with an outcome nobody reported. Settled children stay; the parent's removal drops them. */ +export type AgentChildWorkSessionEndedEvidence = { type: 'session-ended'; observedAt: number } + +export type AgentChildWorkEvidence = + | AgentChildWorkLiveEvidence + | AgentChildWorkOperationEvidence + | AgentChildWorkEndedEvidence + | AgentChildWorkSessionEndedEvidence diff --git a/src/shared/agent-status-child-work-reconciliation.test.ts b/src/shared/agent-status-child-work-reconciliation.test.ts new file mode 100644 index 00000000000..377c580fc41 --- /dev/null +++ b/src/shared/agent-status-child-work-reconciliation.test.ts @@ -0,0 +1,426 @@ +import { describe, expect, it } from 'vitest' +import { createAgentChildWorkAdmission } from './agent-status-child-work-admission' +import type { AgentChildWorkRecord } from './agent-status-child-work' +import type { + AgentChildWorkEvidence, + AgentChildWorkLiveObservation +} from './agent-status-child-work-evidence' +import { + reconcileAgentChildWorkEvidence, + STRUCTURED_CHILD_WORK_MAX_SETTLED +} from './agent-status-child-work-reconciliation' +import { STRUCTURED_CHILD_WORK_MAX_LIVE } from './agent-status-child-work-evidence-admission' +import { createAgentStatusStore, type AgentStatusStore } from './agent-status-store' +import { makeStructuredAgentStatusSubject } from './agent-status-subject' + +const parent = makeStructuredAgentStatusSubject( + { executionHostId: 'local', wslDistro: null, workspaceId: 'ws-1', workspaceKind: 'folder' }, + 'session-1' +) + +function child( + id: string, + overrides: Partial = {} +): AgentChildWorkLiveObservation { + return { + handle: { idKind: 'task_id', id, runId: `toolu_${id}` }, + kind: 'agent', + residency: 'background', + state: 'working', + name: 'general-purpose', + description: `Task ${id}`, + stoppable: true, + ...overrides + } +} + +function live( + observation: AgentChildWorkLiveObservation, + observedAt = 100 +): AgentChildWorkEvidence { + return { type: 'live', observedAt, child: observation } +} + +function harness() { + const store = createAgentStatusStore({ epoch: 'epoch-1', mode: 'authority' }) + expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull() + let minted = 0 + const admission = createAgentChildWorkAdmission(store, { + mintChildWorkId: () => `child-${++minted}` + }) + const apply = (...evidence: AgentChildWorkEvidence[]) => + reconcileAgentChildWorkEvidence({ store, admission, parent, provider: 'claude', evidence }) + return { store, apply } +} + +function records(store: AgentStatusStore): AgentChildWorkRecord[] { + return store.getChildren(parent) +} + +function only(store: AgentStatusStore): AgentChildWorkRecord { + const [record, ...rest] = records(store) + expect(rest).toEqual([]) + return record +} + +describe('structured child-work reconciliation', () => { + it('records a live child once, under its task id and the spawn call of its first run', () => { + const { store, apply } = harness() + expect(apply(live(child('task-a')))).toMatchObject({ admitted: 1, rejected: [] }) + expect(only(store)).toMatchObject({ + childWorkId: 'child-1', + kind: 'agent', + state: 'working', + membership: 'live', + residency: 'background', + firstObservedAt: 100, + observedAt: 100, + invocation: { invocationId: 'toolu_task-a', generation: 1 } + }) + expect( + store + .getAliasesForChild('child-1') + .map(({ aliasKind, alias }) => `${aliasKind}:${alias}`) + .sort() + ).toEqual(['task_id:task-a', 'tool_use_id:toolu_task-a']) + }) + + it('keeps one child when it is first named before its spawn call is known', () => { + const { store, apply } = harness() + apply(live(child('task-a', { handle: { idKind: 'task_id', id: 'task-a' } }))) + apply(live(child('task-a'), 110)) + // The first spawn call it reports belongs to the run already recorded, not a new one. + expect(only(store)).toMatchObject({ + childWorkId: 'child-1', + invocation: { invocationId: 'task-a', generation: 1 }, + observedAt: 110 + }) + // The raw spawn id and the task id name the same child to an owner lookup. + apply(live(child('shell-1', { kind: 'command', ownerId: 'toolu_task-a' }), 120)) + apply(live(child('shell-2', { kind: 'command', ownerId: 'task-a' }), 120)) + expect(records(store).map((record) => record.parentChildWorkId)).toEqual([ + undefined, + 'child-1', + 'child-1' + ]) + }) + + it('resumes the same child as a new run when the provider spawns it again', () => { + const { store, apply } = harness() + apply(live(child('task-a'))) + apply({ + type: 'ended', + observedAt: 200, + handle: { idKind: 'task_id', id: 'task-a' }, + outcome: 'succeeded' + }) + apply( + live(child('task-a', { handle: { idKind: 'task_id', id: 'task-a', runId: 'toolu_2' } }), 300) + ) + expect(only(store)).toMatchObject({ + childWorkId: 'child-1', + membership: 'live', + firstObservedAt: 100, + invocation: { invocationId: 'toolu_2', generation: 2 }, + previousInvocations: [ + { + fence: { invocationId: 'toolu_task-a', generation: 1 }, + outcome: 'succeeded', + settledAt: 200 + } + ] + }) + // A late frame from the first run neither ends nor restarts the second. + apply(live(child('task-a'), 310)) + apply({ + type: 'ended', + observedAt: 320, + handle: { idKind: 'task_id', id: 'task-a', runId: 'toolu_task-a' }, + outcome: 'failed' + }) + expect(only(store)).toMatchObject({ + membership: 'live', + invocation: { invocationId: 'toolu_2', generation: 2 } + }) + }) + + it('refines an ending nobody classified with the outcome reported after it', () => { + const { store, apply } = harness() + apply(live(child('task-a')), live(child('task-b'), 100)) + apply({ + type: 'ended', + observedAt: 200, + handle: { idKind: 'task_id', id: 'task-a' }, + outcome: 'unknown' + }) + expect(records(store)[0]).toMatchObject({ + membership: 'settled', + state: 'done', + outcome: 'unknown', + settledAt: 200 + }) + apply({ + type: 'ended', + observedAt: 201, + handle: { idKind: 'task_id', id: 'task-a' }, + outcome: 'succeeded', + lastMessage: 'All tests pass', + totalTokens: 19_003 + }) + expect(records(store)[0]).toMatchObject({ + membership: 'settled', + outcome: 'succeeded', + settledAt: 200, + observedAt: 201, + lastMessage: 'All tests pass', + totalTokens: 19_003 + }) + // The second terminal frame for the same ending changes nothing it already said. + apply({ + type: 'ended', + observedAt: 202, + handle: { idKind: 'task_id', id: 'task-a' }, + outcome: 'failed' + }) + expect(records(store)[0]).toMatchObject({ outcome: 'succeeded' }) + expect(records(store)[1]).toMatchObject({ membership: 'live' }) + }) + + it('opens a new run when the producer reports a restart, even under the same run handle', () => { + const { store, apply } = harness() + apply(live(child('task-a')), { + type: 'ended', + observedAt: 200, + handle: { idKind: 'task_id', id: 'task-a' }, + outcome: 'succeeded' + }) + // A late live edge from the run that ended is not a restart. + apply(live(child('task-a'), 250)) + expect(only(store)).toMatchObject({ membership: 'settled', invocation: { generation: 1 } }) + apply({ type: 'live', observedAt: 300, child: child('task-a'), restart: true }) + expect(only(store)).toMatchObject({ + membership: 'live', + invocation: { invocationId: 'toolu_task-a', generation: 2 }, + previousInvocations: [expect.objectContaining({ outcome: 'succeeded' })] + }) + }) + + it('ends the current run on an ending whose run handle it never saw', () => { + const { store, apply } = harness() + apply(live(child('task-a')), { + type: 'ended', + observedAt: 200, + handle: { idKind: 'task_id', id: 'task-a', runId: 'toolu_unseen' }, + outcome: 'failed' + }) + expect(only(store)).toMatchObject({ + membership: 'settled', + outcome: 'failed', + invocation: { invocationId: 'toolu_task-a', generation: 1 } + }) + }) + + it('carries the reported operation until the next report or the ending', () => { + const { store, apply } = harness() + const bash = { toolName: 'Bash', basis: 'reported', observedAt: 110 } as const + apply(live(child('task-a', { operation: bash, lastMessage: 'Running tests' }), 110)) + apply(live(child('task-a'), 120)) + expect(only(store)).toMatchObject({ operation: bash, lastMessage: 'Running tests' }) + // An end-of-operation edge closes an open operation only; a report has no end edge. + apply(live(child('task-a', { operation: null }), 130)) + expect(only(store).operation).toEqual(bash) + apply( + live( + child('task-a', { operation: { toolName: 'Edit', basis: 'open', observedAt: 140 } }), + 140 + ), + live(child('task-a', { operation: null }), 150) + ) + expect(only(store).operation).toBeUndefined() + apply(live(child('task-a', { operation: bash }), 160), { + type: 'ended', + observedAt: 170, + handle: { idKind: 'task_id', id: 'task-a' }, + outcome: 'failed' + }) + expect(only(store)).toMatchObject({ outcome: 'failed', lastMessage: 'Running tests' }) + expect(only(store).operation).toBeUndefined() + }) + + it("sets a live child's open call by any handle it answers to, and never creates a child", () => { + const { store, apply } = harness() + const bash = { toolName: 'Bash', input: 'npm test', basis: 'open', observedAt: 110 } as const + apply({ type: 'operation', observedAt: 110, childId: 'nobody', operation: bash }) + expect(records(store)).toEqual([]) + apply(live(child('task-a')), { + type: 'operation', + observedAt: 110, + childId: 'toolu_task-a', + operation: bash + }) + expect(only(store)).toMatchObject({ + operation: bash, + observedAt: 110, + description: 'Task task-a', + invocation: { generation: 1 } + }) + apply({ type: 'operation', observedAt: 120, childId: 'task-a', operation: null }) + expect(only(store).operation).toBeUndefined() + apply( + { + type: 'ended', + observedAt: 130, + handle: { idKind: 'task_id', id: 'task-a' }, + outcome: 'succeeded' + }, + { type: 'operation', observedAt: 140, childId: 'task-a', operation: bash } + ) + expect(only(store)).toMatchObject({ membership: 'settled', observedAt: 130 }) + expect(only(store).operation).toBeUndefined() + }) + + it('hands raw provider labels to admission, which folds them to one line', () => { + const { store, apply } = harness() + const result = apply( + live(child('task-a', { description: 'Audit\nthe\ttests\u0007 ', name: ' reviewer\n' })) + ) + expect(result.rejected).toEqual([]) + expect(only(store)).toMatchObject({ description: 'Audit the tests', name: 'reviewer' }) + }) + + it('settles every child still live when the provider session ends, and keeps them all', () => { + const { store, apply } = harness() + apply(live(child('task-a')), live(child('task-b'), 110), { + type: 'ended', + observedAt: 150, + handle: { idKind: 'task_id', id: 'task-b' }, + outcome: 'succeeded' + }) + expect(apply({ type: 'session-ended', observedAt: 200 })).toMatchObject({ + settled: 1, + removed: 0 + }) + expect( + records(store).map(({ membership, outcome, settledAt }) => ({ + membership, + outcome, + settledAt + })) + ).toEqual([ + { membership: 'settled', outcome: 'unknown', settledAt: 200 }, + { membership: 'settled', outcome: 'succeeded', settledAt: 150 } + ]) + // Its own outcome, arriving late, still refines the unreported ending. + apply({ + type: 'ended', + observedAt: 210, + handle: { idKind: 'task_id', id: 'task-a' }, + outcome: 'cancelled' + }) + expect(records(store)[0]).toMatchObject({ outcome: 'cancelled', settledAt: 200 }) + }) + + it('fences a removed lifetime with its old bindings, and frees the id for a new one', () => { + const { store, apply } = harness() + apply(live(child('task-a'))) + expect(store.applyMutation({ removeChildren: [only(store).childWorkId] })).not.toBeNull() + expect(records(store)).toEqual([]) + apply(live(child('task-a'), 300)) + expect(only(store)).toMatchObject({ + membership: 'live', + invocation: { invocationId: 'toolu_task-a', generation: 2 } + }) + }) + + it('changes only the records its own producer admitted', () => { + const { store, apply } = harness() + const admission = createAgentChildWorkAdmission(store, { mintChildWorkId: () => 'foreign' }) + admission.announce({ + parent, + provider: 'claude', + aliases: [{ segmentId: 'another-producer', aliasKind: 'task_id', alias: 'task-a' }], + fence: { invocationId: 'toolu_task-a', generation: 1 }, + lifetime: 'current', + kind: 'agent', + state: 'working', + membership: 'live', + residency: 'foreground', + observedAt: 50, + stoppable: true, + // Same source, same parent and provider: only the producer differs. + provenance: { source: 'structured-session', producerId: 'another-producer' } + }) + apply(live(child('task-a')), { type: 'session-ended', observedAt: 170 }) + expect(records(store)).toEqual([ + expect.objectContaining({ childWorkId: 'foreign', membership: 'live' }), + expect.objectContaining({ membership: 'settled', outcome: 'unknown' }) + ]) + }) + + it('refuses a child whose parent the store does not hold', () => { + const { store, apply } = harness() + store.applyMutation({ removeParent: parent }) + expect(apply(live(child('task-a'))).rejected).toEqual([ + { handleId: 'task-a', reason: 'invalid' } + ]) + expect(records(store)).toEqual([]) + }) + + it('refuses a handle two of its own records answer to rather than guessing', () => { + const { store, apply } = harness() + apply(live(child('task-a')), live(child('task-b'))) + // Bind task-b's record to task-a's handle too, the way a corrupted join would. + const [first, second] = records(store) + const bound = store + .getAliasesForChild(first.childWorkId) + .filter((alias) => alias.aliasKind === 'task_id') + .map(({ revision: _revision, ...alias }) => ({ + ...alias, + childWorkId: second.childWorkId, + fence: second.invocation + })) + expect(bound).toHaveLength(1) + expect(store.applyMutation({ aliases: bound })).not.toBeNull() + expect(apply(live(child('task-a'), 200)).rejected).toEqual([ + { handleId: 'task-a', reason: 'ambiguous' } + ]) + }) + + it('bounds the live children one session may admit', () => { + const { store, apply } = harness() + apply( + ...Array.from({ length: STRUCTURED_CHILD_WORK_MAX_LIVE }, (_, index) => + live(child(`task-${index}`)) + ) + ) + expect(apply(live(child('one-too-many'))).rejected).toEqual([ + { handleId: 'one-too-many', reason: 'ingestion-limit' } + ]) + expect(records(store)).toHaveLength(STRUCTURED_CHILD_WORK_MAX_LIVE) + }) + + it('keeps a bounded settled history, never dropping a child that owns live work', () => { + const { store, apply } = harness() + apply(live(child('owner'))) + apply(live(child('shell', { kind: 'command', ownerId: 'owner' }))) + apply({ + type: 'ended', + observedAt: 101, + handle: { idKind: 'task_id', id: 'owner' }, + outcome: 'succeeded' + }) + for (let index = 0; index < STRUCTURED_CHILD_WORK_MAX_SETTLED + 1; index += 1) { + apply(live(child(`done-${index}`), 200 + index), { + type: 'ended', + observedAt: 200 + index, + handle: { idKind: 'task_id', id: `done-${index}` }, + outcome: 'succeeded' + }) + } + const settled = records(store).filter((record) => record.membership === 'settled') + expect(settled).toHaveLength(STRUCTURED_CHILD_WORK_MAX_SETTLED) + expect(settled.map((record) => record.description)).toContain('Task owner') + expect(settled.map((record) => record.description)).not.toContain('Task done-0') + expect(settled.map((record) => record.description)).not.toContain('Task done-1') + }) +}) diff --git a/src/shared/agent-status-child-work-reconciliation.ts b/src/shared/agent-status-child-work-reconciliation.ts new file mode 100644 index 00000000000..08d057afc96 --- /dev/null +++ b/src/shared/agent-status-child-work-reconciliation.ts @@ -0,0 +1,157 @@ +// Fold one structured session's child-work evidence into the host's records. +// +// The store holds the only current record per child; evidence patches it. A child settles on its +// own ending, or `unknown` when its session ends while it is still live. It owns only the records +// its own producer admitted, and never claims an outcome the evidence did not report. + +import type { AgentChildWorkAdmission } from './agent-status-child-work-admission' +import type { + AgentChildWorkEndedEvidence, + AgentChildWorkEvidence, + AgentChildWorkOperationEvidence +} from './agent-status-child-work-evidence' +import { + applyAgentChildWorkLive, + settleAgentChildWork, + agentChildWorkRunVerdict, + type AgentChildWorkEvidenceContext, + type AgentChildWorkReconcileOutcome +} from './agent-status-child-work-evidence-admission' +import { + currentAgentChildWorkAliases, + ownedStructuredChildWork, + resolveAgentChildWorkHandle, + type AgentChildWorkEvidenceScope +} from './agent-status-child-work-evidence-resolution' + +export type { AgentChildWorkReconcileOutcome } from './agent-status-child-work-evidence-admission' + +/** Settled children kept per session. The oldest go first, never one that owns live work. */ +export const STRUCTURED_CHILD_WORK_MAX_SETTLED = 32 + +export type AgentChildWorkReconcileInput = AgentChildWorkEvidenceScope & { + admission: AgentChildWorkAdmission + evidence: readonly AgentChildWorkEvidence[] +} + +type ReconcileContext = AgentChildWorkEvidenceContext + +function applyEnded(ctx: ReconcileContext, edge: AgentChildWorkEndedEvidence): void { + const resolution = resolveAgentChildWorkHandle(ctx, [edge.handle.idKind], edge.handle.id) + const existing = resolution?.child + if (resolution?.ambiguous) { + ctx.outcome.rejected.push({ handleId: edge.handle.id, reason: 'ambiguous' }) + return + } + if (!existing) { + return + } + // A run that is already over cannot end the current one; a run id the record never saw can, + // so an ending is never lost to a spawn call the host missed. + if (agentChildWorkRunVerdict(ctx, existing, edge.handle.runId) === 'previous') { + return + } + // Admission owns what a second ending may change: an `unknown` one keeps a definite outcome and + // lands its evidence; a conflicting definite one is refused as `stale-invocation`. + settleAgentChildWork(ctx, existing, edge.outcome, edge.observedAt, { + ...(edge.lastMessage !== undefined ? { lastMessage: edge.lastMessage } : {}), + ...(edge.totalTokens !== undefined ? { totalTokens: edge.totalTokens } : {}) + }) +} + +/** A live child's current operation, and nothing else about it. */ +function applyOperation(ctx: ReconcileContext, edge: AgentChildWorkOperationEvidence): void { + const resolution = resolveAgentChildWorkHandle( + ctx, + ['task_id', 'thread_id', 'tool_use_id'], + edge.childId + ) + const record = resolution?.ambiguous ? null : resolution?.child + if (!record || record.membership !== 'live' || record.state === 'done' || !record.residency) { + return + } + const { stable, runId } = currentAgentChildWorkAliases(ctx, record) + if (!stable) { + return + } + applyAgentChildWorkLive( + ctx, + { + handle: { ...stable, ...(runId !== undefined ? { runId } : {}) }, + kind: record.kind, + residency: record.residency, + state: record.state, + stoppable: record.stoppable, + operation: edge.operation + }, + edge.observedAt, + false + ) +} + +/** The session is gone: whatever it still ran can no longer report its own ending. */ +function settleLive(ctx: ReconcileContext, observedAt: number): void { + for (const record of ownedStructuredChildWork(ctx)) { + if (record.membership === 'live') { + settleAgentChildWork(ctx, record, 'unknown', observedAt) + } + } +} + +function removeChildren(ctx: ReconcileContext, childWorkIds: string[]): void { + if (childWorkIds.length > 0 && ctx.store.applyMutation({ removeChildren: childWorkIds })) { + ctx.outcome.removed += childWorkIds.length + } +} + +/** Oldest-settled first; a settled child that still owns live work stays so its work keeps an owner. */ +function trimSettled(ctx: ReconcileContext): void { + const owned = ownedStructuredChildWork(ctx) + const settled = owned.filter((record) => record.membership === 'settled') + const excess = settled.length - STRUCTURED_CHILD_WORK_MAX_SETTLED + if (excess <= 0) { + return + } + const owners = new Set( + owned.flatMap((record) => + record.membership === 'live' && record.parentChildWorkId ? [record.parentChildWorkId] : [] + ) + ) + const removable = settled + .filter((record) => !owners.has(record.childWorkId)) + .sort((a, b) => (a.settledAt ?? a.observedAt) - (b.settledAt ?? b.observedAt)) + removeChildren( + ctx, + removable.slice(0, excess).map((record) => record.childWorkId) + ) +} + +/** Apply one batch of evidence. The parent must already be held: the store refuses a child whose + * parent it does not hold, which keeps a producer from inventing a parent of its own. */ +export function reconcileAgentChildWorkEvidence( + input: AgentChildWorkReconcileInput +): AgentChildWorkReconcileOutcome { + const ctx: ReconcileContext = { + ...input, + outcome: { admitted: 0, settled: 0, removed: 0, rejected: [] } + } + for (const edge of input.evidence) { + if (!Number.isFinite(edge.observedAt) || edge.observedAt < 0) { + continue + } + if (edge.type === 'live') { + applyAgentChildWorkLive(ctx, edge.child, edge.observedAt, edge.restart === true) + } else if (edge.type === 'operation') { + applyOperation(ctx, edge) + } else if (edge.type === 'ended') { + applyEnded(ctx, edge) + } else { + settleLive(ctx, edge.observedAt) + } + } + // Only a settle adds settled history; skipping the scan otherwise keeps progress edges cheap. + if (ctx.outcome.settled > 0) { + trimSettled(ctx) + } + return ctx.outcome +} diff --git a/src/shared/agent-status-store-bounds.test.ts b/src/shared/agent-status-store-bounds.test.ts index 45ba1de57d7..454d5ff843d 100644 --- a/src/shared/agent-status-store-bounds.test.ts +++ b/src/shared/agent-status-store-bounds.test.ts @@ -4,9 +4,11 @@ import type { AgentChildWorkInput } from './agent-status-child-work' import { serializeAgentStatusRunAliasIndex } from './agent-status-run-alias-index' import { createAgentStatusStore } from './agent-status-store' import { AGENT_STATUS_STORE_LIMITS } from './agent-status-store-contract' -import { applyAgentStatusStoreMutation } from './agent-status-store-mutation' +import { commitAgentStatusStoreMutation } from './agent-status-store-commit' +import { indexAgentStatusStoreState } from './agent-status-store-indexes' import { agentStatusStoreStateFromSnapshot } from './agent-status-store-state' import { + agentStatusSubjectsEqual, makePtyRunAgentStatusSubject, makeStructuredAgentStatusSubject, type AgentStatusExecutionScope @@ -111,12 +113,15 @@ describe('AgentStatusStore bounds', () => { } }) - it('removes aliases for a child batch with one alias-map pass', () => { + it("removes a child batch's aliases without reading any other child's aliases", () => { const parent = makeStructuredAgentStatusSubject(scope, 'session-1') + const bystander = makeStructuredAgentStatusSubject(scope, 'session-2') const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) - const childRecords = Array.from({ length: 4 }, (_, index) => child(parent, index, 'brief')) - const aliases: AgentChildWorkAliasInput[] = childRecords.map((record, index) => ({ - parent, + const aliasFor = ( + record: ReturnType, + index: number + ): AgentChildWorkAliasInput => ({ + parent: record.parent, provider: record.provider, segmentId: `segment-${index}`, kind: record.kind, @@ -124,36 +129,53 @@ describe('AgentStatusStore bounds', () => { alias: `task-${index}`, childWorkId: record.childWorkId, fence: record.invocation - })) + }) + const removed = Array.from({ length: 4 }, (_, index) => child(parent, index, 'brief')) + const kept = Array.from({ length: 16 }, (_, index) => child(bystander, index + 4, 'brief')) expect( - store.applyMutation({ parent: { subject: parent }, children: childRecords, aliases }) + store.applyMutation({ + parent: { subject: parent }, + children: removed, + aliases: removed.map(aliasFor) + }) + ).not.toBeNull() + expect( + store.applyMutation({ + parent: { subject: bystander }, + children: kept, + aliases: kept.map((record, index) => aliasFor(record, index + 4)) + }) ).not.toBeNull() const state = agentStatusStoreStateFromSnapshot(store.getSnapshot(), 'epoch-a') - expect(state).not.toBeNull() if (!state) { throw new Error('Expected a valid store state') } - let childWorkIdReads = 0 + const indexes = indexAgentStatusStoreState(state) + let bystanderReads = 0 for (const [key, record] of state.aliases) { + if (!agentStatusSubjectsEqual(record.parent, bystander)) { + continue + } const measured = { ...record } Object.defineProperty(measured, 'childWorkId', { enumerable: true, get: () => { - childWorkIdReads += 1 + bystanderReads += 1 return record.childWorkId } }) state.aliases.set(key, measured) } - childWorkIdReads = 0 - const next = applyAgentStatusStoreMutation( - state, - { removeChildren: childRecords.map((record) => record.childWorkId) }, - state.revision + 1 - ) - - expect(next).not.toBeNull() - expect(childWorkIdReads).toBe(aliases.length) + expect( + commitAgentStatusStoreMutation( + state, + indexes, + { removeChildren: removed.map((record) => record.childWorkId) }, + state.revision + 1 + ) + ).toBe(true) + expect(state.aliases.size).toBe(kept.length) + expect(bystanderReads).toBe(0) }) }) diff --git a/src/shared/agent-status-store-byte-budget.ts b/src/shared/agent-status-store-byte-budget.ts index aed2d6602f6..1a64866c3e4 100644 --- a/src/shared/agent-status-store-byte-budget.ts +++ b/src/shared/agent-status-store-byte-budget.ts @@ -34,18 +34,30 @@ function serializedBytes(record: AgentStatusStoreByteBudgetRecord): number { return bytes } -/** Enforce the complete snapshot budget before commit without allocating a full snapshot. */ -export function agentStatusStoreFitsByteBudget(state: AgentStatusStoreState): boolean { - let bytes = serializedBytes({ +/** One stored record's share of the serialized snapshot. */ +export function agentStatusStoreRecordBytes( + record: Exclude +): number { + return serializedBytes(record) +} + +/** The snapshot's own envelope, without any records or the commas between them. */ +export function agentStatusStoreHeaderBytes(epoch: string, revision: number): number { + return serializedBytes({ version: AGENT_STATUS_STORE_SNAPSHOT_VERSION, - epoch: state.epoch, - revision: state.revision, + epoch, + revision, parents: [], children: [], aliases: [], facts: [], tombstones: [] }) +} + +/** Enforce the complete snapshot budget without allocating a full snapshot. */ +export function agentStatusStoreFitsByteBudget(state: AgentStatusStoreState): boolean { + let bytes = agentStatusStoreHeaderBytes(state.epoch, state.revision) for (const records of [ state.parents, state.children, diff --git a/src/shared/agent-status-store-child-queries.ts b/src/shared/agent-status-store-child-queries.ts index 4850c57e82d..d2d7924bd52 100644 --- a/src/shared/agent-status-store-child-queries.ts +++ b/src/shared/agent-status-store-child-queries.ts @@ -3,7 +3,9 @@ import { type AgentChildWorkAliasInput, type AgentChildWorkAliasRecord } from './agent-status-child-work-alias' -import { deserializeAgentChildWorkBindingKey } from './agent-status-child-work-binding' +import type { AgentChildWorkRecord } from './agent-status-child-work' +import { inMapOrder, type AgentStatusStoreIndexes } from './agent-status-store-indexes' +import { storedRetiredAlias } from './agent-status-store-record-keys' import { deepFreezeAgentStatusStoreValue, type AgentStatusStoreState @@ -12,23 +14,66 @@ import { /** Retired bindings fence delayed observations even after their child/history is removed. */ export function resolveAgentStatusChildBindings( state: AgentStatusStoreState, + indexes: AgentStatusStoreIndexes, aliases: AgentChildWorkAliasInput[] ): AgentChildWorkAliasRecord[] { - const keys = new Set(aliases.map(serializeAgentChildWorkAliasKey)) + const identities = new Set(aliases.map(serializeAgentChildWorkAliasKey)) + const bound = new Set() + const retired = new Set() + for (const identity of identities) { + for (const key of indexes.aliasesByIdentity.get(identity) ?? []) { + bound.add(key) + } + for (const key of indexes.retiredAliasesByIdentity.get(identity) ?? []) { + retired.add(key) + } + } const matches: AgentChildWorkAliasRecord[] = [] - for (const alias of state.aliases.values()) { - if (keys.has(serializeAgentChildWorkAliasKey(alias))) { + for (const key of inMapOrder(indexes, 'aliases', bound)) { + const alias = state.aliases.get(key) + if (alias) { matches.push(alias) } } - for (const tombstone of state.tombstones.values()) { - if (tombstone.entity !== 'alias' || state.aliases.has(tombstone.key)) { - continue - } - const alias = deserializeAgentChildWorkBindingKey(tombstone.key) - if (alias && keys.has(serializeAgentChildWorkAliasKey(alias))) { - matches.push(deepFreezeAgentStatusStoreValue({ ...alias, revision: tombstone.revision })) + for (const key of inMapOrder(indexes, 'tombstones', retired)) { + const tombstone = state.tombstones.get(key) + const binding = tombstone ? storedRetiredAlias(tombstone) : null + if (tombstone && binding && !state.aliases.has(tombstone.key)) { + matches.push( + deepFreezeAgentStatusStoreValue({ ...binding.alias, revision: tombstone.revision }) + ) } } return matches } + +/** A parent's children in map order; stored records were parsed and frozen when written. */ +export function agentStatusStoreChildrenOf( + state: AgentStatusStoreState, + indexes: AgentStatusStoreIndexes, + parentKey: string +): AgentChildWorkRecord[] { + const children: AgentChildWorkRecord[] = [] + for (const id of inMapOrder(indexes, 'children', indexes.childrenByParent.get(parentKey))) { + const child = state.children.get(id) + if (child) { + children.push(child) + } + } + return children +} + +export function agentStatusStoreAliasesOf( + state: AgentStatusStoreState, + indexes: AgentStatusStoreIndexes, + childWorkId: string +): AgentChildWorkAliasRecord[] { + const aliases: AgentChildWorkAliasRecord[] = [] + for (const key of inMapOrder(indexes, 'aliases', indexes.aliasesByChild.get(childWorkId))) { + const alias = state.aliases.get(key) + if (alias) { + aliases.push(alias) + } + } + return aliases +} diff --git a/src/shared/agent-status-store-commit.ts b/src/shared/agent-status-store-commit.ts new file mode 100644 index 00000000000..2ea07e07f5d --- /dev/null +++ b/src/shared/agent-status-store-commit.ts @@ -0,0 +1,199 @@ +// A mutation applied in place: its steps write into drafts over the committed maps, only what they +// touched is re-validated, and the drafts land together or not at all. The committed store was +// valid, so a record the mutation did not touch, and whose dependencies it did not touch, still is. + +import type { AgentStatusStoreMutation } from './agent-status-store-contract' +import { agentStatusStoreHeaderBytes } from './agent-status-store-byte-budget' +import { AGENT_STATUS_STORE_LIMITS } from './agent-status-store-contract' +import { + commitAgentStatusStoreIndexes, + draftedRecordBytes, + inMapOrder, + type AgentStatusStoreDrafts, + type AgentStatusStoreIndexes +} from './agent-status-store-indexes' +import { + applyAgentStatusStoreMutationSteps, + type AgentStatusStoreMutationTables +} from './agent-status-store-mutation' +import { + agentStatusStoreSizesFit, + storedAliasIsValid, + storedChildIsValid, + storedFactIsValid, + storedParentIsValid, + storedTombstoneIsValid, + type AgentStatusStoreState +} from './agent-status-store-state' +import { AgentStatusStoreDraftTable } from './agent-status-store-table' +import { serializeAgentStatusSubject } from './agent-status-subject' + +/** Present keys of one draft that an index (or this mutation) places under a query, in map order. */ +function draftQuery( + indexes: AgentStatusStoreIndexes, + name: 'children' | 'aliases' | 'facts', + draft: AgentStatusStoreDraftTable, + indexed: Iterable, + matches: (record: V) => boolean +): string[] { + const inPlace = new Set() + for (const key of indexed) { + if (!draft.removedKeys.has(key)) { + inPlace.add(key) + } + } + for (const { key, next } of draft.touched()) { + if (next !== undefined && !draft.appendedKeys.has(key)) { + inPlace.add(key) + } + } + const present = (key: string) => { + const record = draft.get(key) + return record !== undefined && matches(record) + } + const keys = inMapOrder(indexes, name, [...inPlace].filter(present)) + for (const key of draft.appendedKeys) { + if (present(key)) { + keys.push(key) + } + } + return keys +} + +function draftTables( + state: AgentStatusStoreState, + indexes: AgentStatusStoreIndexes, + revision: number +): { drafts: AgentStatusStoreDrafts; tables: AgentStatusStoreMutationTables } { + const drafts: AgentStatusStoreDrafts = { + parents: new AgentStatusStoreDraftTable(state.parents), + children: new AgentStatusStoreDraftTable(state.children), + aliases: new AgentStatusStoreDraftTable(state.aliases), + facts: new AgentStatusStoreDraftTable(state.facts), + tombstones: new AgentStatusStoreDraftTable(state.tombstones) + } + const tables: AgentStatusStoreMutationTables = { + revision, + ...drafts, + childrenOf: (parentKey) => + draftQuery( + indexes, + 'children', + drafts.children, + indexes.childrenByParent.get(parentKey) ?? [], + (child) => serializeAgentStatusSubject(child.parent) === parentKey + ), + factsOf: (parentKey) => + draftQuery( + indexes, + 'facts', + drafts.facts, + indexes.factsByParent.get(parentKey) ?? [], + (fact) => serializeAgentStatusSubject(fact.subject) === parentKey + ), + aliasesOfChildren: (childWorkIds) => + draftQuery( + indexes, + 'aliases', + drafts.aliases, + [...childWorkIds].flatMap((id) => [...(indexes.aliasesByChild.get(id) ?? [])]), + (alias) => childWorkIds.has(alias.childWorkId) + ) + } + return { drafts, tables } +} + +function touchedAreValid( + drafts: AgentStatusStoreDrafts, + tables: AgentStatusStoreMutationTables +): boolean { + const removedParents: string[] = [] + for (const { key, next } of drafts.parents.touched()) { + if (!next) { + removedParents.push(key) + } else if (!storedParentIsValid(tables, key, next)) { + return false + } + } + // A parent's removal must have taken its children and facts with it. + if ( + removedParents.some( + (key) => tables.childrenOf(key).length > 0 || tables.factsOf(key).length > 0 + ) + ) { + return false + } + const touchedChildren = new Set() + for (const { key, next } of drafts.children.touched()) { + touchedChildren.add(key) + if (next && !storedChildIsValid(tables, key, next)) { + return false + } + } + // An alias is valid against its child, so a touched child re-checks every alias naming it. + const aliases = new Set(tables.aliasesOfChildren(touchedChildren)) + for (const { key, next } of drafts.aliases.touched()) { + if (next) { + aliases.add(key) + } + } + for (const key of aliases) { + const alias = drafts.aliases.get(key) + if (alias && !storedAliasIsValid(tables, key, alias)) { + return false + } + } + for (const { key, next } of drafts.facts.touched()) { + if (next && !storedFactIsValid(tables, key, next)) { + return false + } + } + for (const { next } of drafts.tombstones.touched()) { + if (next && !storedTombstoneIsValid(tables, next)) { + return false + } + } + return true +} + +function fitsByteBudget( + epoch: string, + revision: number, + drafts: AgentStatusStoreDrafts, + recordBytes: Record +): boolean { + let bytes = agentStatusStoreHeaderBytes(epoch, revision) + for (const name of ['parents', 'children', 'aliases', 'facts', 'tombstones'] as const) { + bytes += Math.max(0, drafts[name].size - 1) + recordBytes[name] + } + return bytes <= AGENT_STATUS_STORE_LIMITS.serializedBytes +} + +/** Apply one mutation to the committed store in place; false (and nothing changed) on refusal. */ +export function commitAgentStatusStoreMutation( + state: AgentStatusStoreState, + indexes: AgentStatusStoreIndexes, + mutation: AgentStatusStoreMutation, + revision: number +): boolean { + const { drafts, tables } = draftTables(state, indexes, revision) + if ( + !applyAgentStatusStoreMutationSteps(tables, mutation, revision) || + !agentStatusStoreSizesFit(drafts) || + !touchedAreValid(drafts, tables) + ) { + return false + } + const recordBytes = draftedRecordBytes(indexes, drafts) + if (!fitsByteBudget(state.epoch, revision, drafts, recordBytes)) { + return false + } + commitAgentStatusStoreIndexes(indexes, drafts, recordBytes) + drafts.parents.commitInto(state.parents) + drafts.children.commitInto(state.children) + drafts.aliases.commitInto(state.aliases) + drafts.facts.commitInto(state.facts) + drafts.tombstones.commitInto(state.tombstones) + state.revision = revision + return true +} diff --git a/src/shared/agent-status-store-copying-oracle.test-fixture.ts b/src/shared/agent-status-store-copying-oracle.test-fixture.ts new file mode 100644 index 00000000000..cec2a2556e0 --- /dev/null +++ b/src/shared/agent-status-store-copying-oracle.test-fixture.ts @@ -0,0 +1,105 @@ +// The store as it was before mutations landed in place: every mutation copies every map, applies +// its steps to the copies, and re-validates the whole result; every read scans. Tests hold the +// real store to this, decision for decision and snapshot for snapshot. + +import { + serializeAgentChildWorkAliasKey, + type AgentChildWorkAliasInput, + type AgentChildWorkAliasRecord +} from './agent-status-child-work-alias' +import { deserializeAgentChildWorkBindingKey } from './agent-status-child-work-binding' +import { agentChildWorkBelongsTo, type AgentChildWorkRecord } from './agent-status-child-work' +import { parseAgentStatusStoreMutation } from './agent-status-store-codec' +import type { AgentStatusStoreSnapshot } from './agent-status-store-contract' +import { applyAgentStatusStoreMutationSteps } from './agent-status-store-mutation' +import { + cloneAgentStatusStoreState, + createEmptyAgentStatusStoreState, + snapshotFromAgentStatusStoreState, + validateAgentStatusStoreState, + type AgentStatusStoreState +} from './agent-status-store-state' +import { serializeAgentStatusSubject, type AgentStatusSubject } from './agent-status-subject' + +function keysWhere(map: Map, matches: (record: V) => boolean): string[] { + return [...map].flatMap(([key, record]) => (matches(record) ? [key] : [])) +} + +function applyByCopy(current: AgentStatusStoreState, value: unknown): AgentStatusStoreState | null { + const mutation = parseAgentStatusStoreMutation(value) + if (!mutation) { + return null + } + const revision = current.revision + 1 + const next = cloneAgentStatusStoreState(current) + next.revision = revision + const applied = applyAgentStatusStoreMutationSteps( + { + revision, + parents: next.parents, + children: next.children, + aliases: next.aliases, + facts: next.facts, + tombstones: next.tombstones, + childrenOf: (parentKey) => + keysWhere( + next.children, + (child) => serializeAgentStatusSubject(child.parent) === parentKey + ), + factsOf: (parentKey) => + keysWhere(next.facts, (fact) => serializeAgentStatusSubject(fact.subject) === parentKey), + aliasesOfChildren: (ids) => keysWhere(next.aliases, (alias) => ids.has(alias.childWorkId)) + }, + mutation, + revision + ) + return applied && validateAgentStatusStoreState(next) ? next : null +} + +export type CopyingAgentStatusStoreOracle = { + applyMutation(mutation: unknown): boolean + getSnapshot(): AgentStatusStoreSnapshot + /** The oracle's own state, for checking every invariant after each step. */ + state(): AgentStatusStoreState + getChildren(subject: AgentStatusSubject): AgentChildWorkRecord[] + getAliasesForChild(childWorkId: string): AgentChildWorkAliasRecord[] + resolveChildAliases(aliases: AgentChildWorkAliasInput[]): AgentChildWorkAliasRecord[] +} + +export function createCopyingAgentStatusStoreOracle(epoch: string): CopyingAgentStatusStoreOracle { + let current = createEmptyAgentStatusStoreState(epoch) + return { + applyMutation(mutation) { + const next = applyByCopy(current, mutation) + if (next) { + current = next + } + return next !== null + }, + getSnapshot: () => snapshotFromAgentStatusStoreState(current), + state: () => current, + getChildren: (subject) => + [...current.children.values()].filter((child) => agentChildWorkBelongsTo(child, subject)), + getAliasesForChild: (childWorkId) => + [...current.aliases.values()].filter((alias) => alias.childWorkId === childWorkId), + resolveChildAliases(aliases) { + const keys = new Set(aliases.map(serializeAgentChildWorkAliasKey)) + const matches: AgentChildWorkAliasRecord[] = [] + for (const alias of current.aliases.values()) { + if (keys.has(serializeAgentChildWorkAliasKey(alias))) { + matches.push(alias) + } + } + for (const tombstone of current.tombstones.values()) { + if (tombstone.entity !== 'alias' || current.aliases.has(tombstone.key)) { + continue + } + const alias = deserializeAgentChildWorkBindingKey(tombstone.key) + if (alias && keys.has(serializeAgentChildWorkAliasKey(alias))) { + matches.push({ ...alias, revision: tombstone.revision }) + } + } + return matches + } + } +} diff --git a/src/shared/agent-status-store-in-place.test.ts b/src/shared/agent-status-store-in-place.test.ts new file mode 100644 index 00000000000..e67da48dfdf --- /dev/null +++ b/src/shared/agent-status-store-in-place.test.ts @@ -0,0 +1,222 @@ +// The store applies mutations in place and re-validates only what they touched. Held to the +// copy-everything store it replaced: same decisions, same snapshots, same reads, every step. + +import { describe, expect, it } from 'vitest' +import type { AgentChildWorkAliasInput } from './agent-status-child-work-alias' +import { serializeAgentChildWorkBindingKey } from './agent-status-child-work-binding' +import type { AgentChildWorkInput } from './agent-status-child-work' +import { createAgentStatusStore } from './agent-status-store' +import { parseAgentStatusStoreMutation } from './agent-status-store-codec' +import { commitAgentStatusStoreMutation } from './agent-status-store-commit' +import type { AgentStatusStoreMutation } from './agent-status-store-contract' +import { createCopyingAgentStatusStoreOracle } from './agent-status-store-copying-oracle.test-fixture' +import { + indexAgentStatusStoreState, + type AgentStatusStoreIndexes +} from './agent-status-store-indexes' +import { + createEmptyAgentStatusStoreState, + validateAgentStatusStoreState +} from './agent-status-store-state' +import { + makeStructuredAgentStatusSubject, + serializeAgentStatusSubject, + type AgentStatusSubject +} from './agent-status-subject' + +function seeded(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let t = state + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296 + } +} + +const parents: AgentStatusSubject[] = [0, 1, 2].map((index) => + makeStructuredAgentStatusSubject( + { executionHostId: 'local', wslDistro: null, workspaceId: 'ws-1', workspaceKind: 'folder' }, + `session-${index}` + ) +) +// Wide enough that tombstoned ids (fenced for thousands of revisions) do not exhaust it. +const CHILD_IDS = Array.from({ length: 80 }, (_, index) => `child-${index}`) +const KINDS = ['agent', 'command'] as const +const FENCES = [ + { invocationId: 'run-1', generation: 1 }, + { invocationId: 'run-2', generation: 2 } +] + +function mutations(random: () => number) { + const pick = (items: readonly T[]): T => items[Math.floor(random() * items.length)]! + const child = (): AgentChildWorkInput => { + const invocation = pick(FENCES) + return { + childWorkId: pick(CHILD_IDS), + parent: pick(parents), + provider: 'claude', + kind: pick(KINDS), + state: 'working', + membership: 'live', + // Mostly stable, so updates land; sometimes not, so the store refuses one. + firstObservedAt: random() < 0.9 ? 1 : 2, + observedAt: Math.floor(random() * 50), + stoppable: random() < 0.5, + invocation, + ...(invocation.generation > 1 + ? { previousInvocations: [{ fence: FENCES[0]!, outcome: 'succeeded' as const }] } + : {}), + provenance: { source: 'structured-session', producerId: 'fuzz' }, + description: `d${Math.floor(random() * 4)}` + } + } + const alias = (): AgentChildWorkAliasInput => ({ + parent: pick(parents), + provider: 'claude', + segmentId: 'segment', + kind: pick(KINDS), + aliasKind: pick(['task_id', 'tool_use_id'] as const), + alias: `handle-${Math.floor(random() * 4)}`, + childWorkId: pick(CHILD_IDS), + fence: pick(FENCES) + }) + const aliasOf = (bound: AgentChildWorkInput): AgentChildWorkAliasInput => ({ + ...alias(), + parent: bound.parent, + kind: bound.kind, + childWorkId: bound.childWorkId, + fence: bound.invocation + }) + const fact = () => ({ subject: pick(parents), key: pick(['seen', 'pinned']), value: random() }) + const steps: (() => AgentStatusStoreMutation)[] = [ + () => ({ parent: { subject: pick(parents), firstObservedAt: 1 } }), + () => ({ removeParent: pick(parents) }), + () => ({ children: [child()] }), + () => ({ children: [child(), child()], aliases: [alias()] }), + () => ({ aliases: [alias(), alias()] }), + ...Array.from({ length: 6 }, () => () => { + const bound = child() + return { parent: { subject: bound.parent }, children: [bound], aliases: [aliasOf(bound)] } + }), + () => ({ removeChildren: [pick(CHILD_IDS)] }), + () => ({ removeAliases: [serializeAgentChildWorkBindingKey(alias())] }), + () => ({ facts: [fact()] }), + () => ({ removeFacts: [fact()] }), + () => ({ + tombstones: [ + pick([ + { entity: 'child' as const, key: pick(CHILD_IDS) }, + { entity: 'parent' as const, key: serializeAgentStatusSubject(pick(parents)) }, + { entity: 'alias' as const, key: serializeAgentChildWorkBindingKey(alias()) } + ]) + ] + }), + () => ({ removeParent: pick(parents), parent: { subject: pick(parents) }, children: [child()] }) + ] + return () => pick(steps)() +} + +/** Every index as data: sets sorted, and each table's keys in the order the index ranks them. */ +function indexContents(indexes: AgentStatusStoreIndexes) { + const sets = (index: Map>) => + [...index].map(([key, values]) => [key, [...values].sort()]).sort() + const ranked = (order: Map) => + [...order].sort((left, right) => left[1] - right[1]).map(([key]) => key) + return { + recordBytes: indexes.recordBytes, + childrenByParent: sets(indexes.childrenByParent), + factsByParent: sets(indexes.factsByParent), + aliasesByChild: sets(indexes.aliasesByChild), + aliasesByIdentity: sets(indexes.aliasesByIdentity), + retiredAliasesByIdentity: sets(indexes.retiredAliasesByIdentity), + order: { + children: ranked(indexes.order.children), + aliases: ranked(indexes.order.aliases), + facts: ranked(indexes.order.facts), + tombstones: ranked(indexes.order.tombstones) + } + } +} + +describe('AgentStatusStore applied in place', () => { + it.each([1, 7, 42, 1_234])( + 'matches the copying store decision for decision (seed %i)', + (seed) => { + const random = seeded(seed) + const next = mutations(random) + const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) + const replica = createAgentStatusStore({ epoch: 'epoch-a', mode: 'replica' }) + const oracle = createCopyingAgentStatusStoreOracle('epoch-a') + expect(replica.applySnapshot(store.getSnapshot())).toBe(true) + let accepted = 0 + let mostAliases = 0 + for (let step = 0; step < 1_500; step += 1) { + const mutation = next() + const envelope = store.applyMutation(mutation) + expect({ step, accepted: envelope !== null }).toEqual({ + step, + accepted: oracle.applyMutation(mutation) + }) + if (envelope) { + accepted += 1 + expect(replica.applyTransportEnvelope(envelope)).toBe(true) + } + expect(validateAgentStatusStoreState(oracle.state())).toBe(true) + expect(JSON.stringify(store.getSnapshot())).toBe(JSON.stringify(oracle.getSnapshot())) + for (const parent of parents) { + expect(store.getChildren(parent)).toEqual(oracle.getChildren(parent)) + } + for (const id of CHILD_IDS) { + expect(store.getAliasesForChild(id)).toEqual(oracle.getAliasesForChild(id)) + } + mostAliases = Math.max(mostAliases, oracle.state().aliases.size) + const probe = mutation.aliases ?? [] + expect(store.resolveChildAliases(probe)).toEqual(oracle.resolveChildAliases(probe)) + } + expect(JSON.stringify(replica.getSnapshot())).toBe(JSON.stringify(store.getSnapshot())) + // The run exercises both outcomes, not only refusals. + expect(accepted).toBeGreaterThan(300) + expect(accepted).toBeLessThan(1_450) + expect(mostAliases).toBeGreaterThan(4) + }, + 60_000 + ) + + it.each([3, 99])( + 'keeps every index equal to one rebuilt from the maps, refusals included (seed %i)', + (seed) => { + const next = mutations(seeded(seed)) + const state = createEmptyAgentStatusStoreState('epoch-a') + const indexes = indexAgentStatusStoreState(state) + for (let step = 0; step < 1_500; step += 1) { + const mutation = parseAgentStatusStoreMutation(next()) + if (mutation) { + commitAgentStatusStoreMutation(state, indexes, mutation, state.revision + 1) + } + // The running byte total is only observable at the budget, so it is held to a rebuild. + expect({ step, ...indexContents(indexes) }).toEqual({ + step, + ...indexContents(indexAgentStatusStoreState(state)) + }) + } + }, + 60_000 + ) + + it('compacts tombstones exactly as the copying store does past the retention window', () => { + const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) + const oracle = createCopyingAgentStatusStoreOracle('epoch-a') + const parent = parents[0]! + for (let step = 0; step < 5_000; step += 1) { + const mutation: AgentStatusStoreMutation = + step % 2 === 0 + ? { parent: { subject: parent }, facts: [{ subject: parent, key: `k${step}`, value: 1 }] } + : { removeFacts: [{ subject: parent, key: `k${step - 1}` }] } + expect(store.applyMutation(mutation) !== null).toBe(oracle.applyMutation(mutation)) + } + expect(oracle.getSnapshot().tombstones.length).toBeGreaterThan(1_000) + expect(JSON.stringify(store.getSnapshot())).toBe(JSON.stringify(oracle.getSnapshot())) + }, 60_000) +}) diff --git a/src/shared/agent-status-store-indexes.ts b/src/shared/agent-status-store-indexes.ts new file mode 100644 index 00000000000..c423ab989b4 --- /dev/null +++ b/src/shared/agent-status-store-indexes.ts @@ -0,0 +1,221 @@ +// Lookups the store keeps beside its maps, so a read or a mutation costs what it touches rather +// than the whole store. Every index is derived from the committed maps and moved only at commit. + +import { + serializeAgentChildWorkAliasKey, + type AgentChildWorkAliasRecord +} from './agent-status-child-work-alias' +import type { AgentChildWorkRecord } from './agent-status-child-work' +import { agentStatusStoreRecordBytes } from './agent-status-store-byte-budget' +import type { + AgentStatusFactRecord, + AgentStatusTombstoneRecord +} from './agent-status-store-contract' +import type { AgentStatusParentRecord } from './agent-status-store-parent' +import { storedRetiredAlias } from './agent-status-store-record-keys' +import type { AgentStatusStoreState } from './agent-status-store-state' +import type { AgentStatusStoreDraftTable } from './agent-status-store-table' +import { serializeAgentStatusSubject } from './agent-status-subject' + +const TABLE_NAMES = ['parents', 'children', 'aliases', 'facts', 'tombstones'] as const +type TableName = (typeof TABLE_NAMES)[number] +type OrderedTableName = Exclude + +export type AgentStatusStoreIndexes = { + /** Serialized bytes of every record per table, for the snapshot budget. */ + recordBytes: Record + childrenByParent: Map> + factsByParent: Map> + aliasesByChild: Map> + aliasesByIdentity: Map> + /** Alias identity → tombstone map keys of its retired bindings. */ + retiredAliasesByIdentity: Map> + /** Each key's place in its map, so an index read returns records in map order. */ + order: Record> + nextOrder: number +} + +export type AgentStatusStoreDrafts = { + parents: AgentStatusStoreDraftTable + children: AgentStatusStoreDraftTable + aliases: AgentStatusStoreDraftTable + facts: AgentStatusStoreDraftTable + tombstones: AgentStatusStoreDraftTable +} + +function addTo(index: Map>, key: string, value: string): void { + const values = index.get(key) + if (values) { + values.add(value) + } else { + index.set(key, new Set([value])) + } +} + +function removeFrom(index: Map>, key: string, value: string): void { + const values = index.get(key) + if (values?.delete(value) && values.size === 0) { + index.delete(key) + } +} + +function indexChild( + indexes: AgentStatusStoreIndexes, + id: string, + child: AgentChildWorkRecord, + add: boolean +) { + const parentKey = serializeAgentStatusSubject(child.parent) + if (add) { + addTo(indexes.childrenByParent, parentKey, id) + } else { + removeFrom(indexes.childrenByParent, parentKey, id) + } +} + +function indexAlias( + indexes: AgentStatusStoreIndexes, + key: string, + alias: AgentChildWorkAliasRecord, + add: boolean +) { + const update = add ? addTo : removeFrom + update(indexes.aliasesByChild, alias.childWorkId, key) + update(indexes.aliasesByIdentity, serializeAgentChildWorkAliasKey(alias), key) +} + +function indexFact( + indexes: AgentStatusStoreIndexes, + key: string, + fact: AgentStatusFactRecord, + add: boolean +) { + const update = add ? addTo : removeFrom + update(indexes.factsByParent, serializeAgentStatusSubject(fact.subject), key) +} + +function indexTombstone( + indexes: AgentStatusStoreIndexes, + key: string, + tombstone: AgentStatusTombstoneRecord, + add: boolean +) { + const retired = storedRetiredAlias(tombstone) + if (retired) { + const update = add ? addTo : removeFrom + update(indexes.retiredAliasesByIdentity, retired.key, key) + } +} + +export function indexAgentStatusStoreState(state: AgentStatusStoreState): AgentStatusStoreIndexes { + const indexes: AgentStatusStoreIndexes = { + recordBytes: { parents: 0, children: 0, aliases: 0, facts: 0, tombstones: 0 }, + childrenByParent: new Map(), + factsByParent: new Map(), + aliasesByChild: new Map(), + aliasesByIdentity: new Map(), + retiredAliasesByIdentity: new Map(), + order: { children: new Map(), aliases: new Map(), facts: new Map(), tombstones: new Map() }, + nextOrder: 0 + } + for (const parent of state.parents.values()) { + indexes.recordBytes.parents += agentStatusStoreRecordBytes(parent) + } + for (const [id, child] of state.children) { + indexes.recordBytes.children += agentStatusStoreRecordBytes(child) + indexes.order.children.set(id, indexes.nextOrder++) + indexChild(indexes, id, child, true) + } + for (const [key, alias] of state.aliases) { + indexes.recordBytes.aliases += agentStatusStoreRecordBytes(alias) + indexes.order.aliases.set(key, indexes.nextOrder++) + indexAlias(indexes, key, alias, true) + } + for (const [key, fact] of state.facts) { + indexes.recordBytes.facts += agentStatusStoreRecordBytes(fact) + indexes.order.facts.set(key, indexes.nextOrder++) + indexFact(indexes, key, fact, true) + } + for (const [key, tombstone] of state.tombstones) { + indexes.recordBytes.tombstones += agentStatusStoreRecordBytes(tombstone) + indexes.order.tombstones.set(key, indexes.nextOrder++) + indexTombstone(indexes, key, tombstone, true) + } + return indexes +} + +/** Record bytes per table once the drafts land. */ +export function draftedRecordBytes( + indexes: AgentStatusStoreIndexes, + drafts: AgentStatusStoreDrafts +): Record { + const bytes = { ...indexes.recordBytes } + for (const name of TABLE_NAMES) { + for (const { previous, next } of drafts[name].touched()) { + bytes[name] += + (next ? agentStatusStoreRecordBytes(next) : 0) - + (previous ? agentStatusStoreRecordBytes(previous) : 0) + } + } + return bytes +} + +function moveIndex( + draft: AgentStatusStoreDraftTable, + index: (key: string, record: V, add: boolean) => void +): void { + for (const { key, previous, next } of draft.touched()) { + if (previous) { + index(key, previous, false) + } + if (next) { + index(key, next, true) + } + } +} + +function moveOrder( + indexes: AgentStatusStoreIndexes, + name: OrderedTableName, + draft: AgentStatusStoreDraftTable +): void { + const order = indexes.order[name] + for (const key of draft.removedKeys) { + order.delete(key) + } + for (const key of draft.appendedKeys) { + order.set(key, indexes.nextOrder++) + } +} + +/** Move every index by what the drafts changed; call before the drafts land in the maps. */ +export function commitAgentStatusStoreIndexes( + indexes: AgentStatusStoreIndexes, + drafts: AgentStatusStoreDrafts, + recordBytes: Record +): void { + indexes.recordBytes = recordBytes + moveIndex(drafts.children, (key, child, add) => indexChild(indexes, key, child, add)) + moveIndex(drafts.aliases, (key, alias, add) => indexAlias(indexes, key, alias, add)) + moveIndex(drafts.facts, (key, fact, add) => indexFact(indexes, key, fact, add)) + moveIndex(drafts.tombstones, (key, tombstone, add) => + indexTombstone(indexes, key, tombstone, add) + ) + moveOrder(indexes, 'children', drafts.children) + moveOrder(indexes, 'aliases', drafts.aliases) + moveOrder(indexes, 'facts', drafts.facts) + moveOrder(indexes, 'tombstones', drafts.tombstones) +} + +/** Committed keys an index names, in map order. */ +export function inMapOrder( + indexes: AgentStatusStoreIndexes, + name: OrderedTableName, + keys: Iterable | undefined +): string[] { + if (!keys) { + return [] + } + const order = indexes.order[name] + return [...keys].sort((left, right) => (order.get(left) ?? 0) - (order.get(right) ?? 0)) +} diff --git a/src/shared/agent-status-store-mutation.ts b/src/shared/agent-status-store-mutation.ts index eb1adf604a6..d22f4e17c9f 100644 --- a/src/shared/agent-status-store-mutation.ts +++ b/src/shared/agent-status-store-mutation.ts @@ -1,36 +1,55 @@ import { parseAgentChildWorkAliasRecord } from './agent-status-child-work-alias' +import type { AgentChildWorkAliasRecord } from './agent-status-child-work-alias' +import type { AgentChildWorkRecord } from './agent-status-child-work' import { deserializeAgentChildWorkBindingKey, serializeAgentChildWorkBindingKey } from './agent-status-child-work-binding' import { parseAgentChildWorkRecord } from './agent-status-child-work-codec' import type { + AgentStatusFactRecord, AgentStatusStoreMutation, - AgentStatusTombstoneEntity + AgentStatusTombstoneEntity, + AgentStatusTombstoneRecord } from './agent-status-store-contract' import { AGENT_STATUS_STORE_LIMITS, AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS } from './agent-status-store-contract' import { parseAgentStatusFactRecord } from './agent-status-store-fact-codec' -import { parseAgentStatusParentRecord } from './agent-status-store-parent' +import { + parseAgentStatusParentRecord, + type AgentStatusParentRecord +} from './agent-status-store-parent' import { agentStatusFactMapKey, agentStatusTombstoneMapKey, - cloneAgentStatusStoreState, - deepFreezeAgentStatusStoreValue, - validateAgentStatusStoreState, - type AgentStatusStoreState + deepFreezeAgentStatusStoreValue } from './agent-status-store-state' +import type { AgentStatusStoreTable } from './agent-status-store-table' import { - agentStatusSubjectsEqual, deserializeAgentStatusSubject, serializeAgentStatusSubject, type AgentStatusSubject } from './agent-status-subject' +/** The store as one mutation reads and writes it. The queries return present keys in map order. */ +export type AgentStatusStoreMutationTables = { + revision: number + parents: AgentStatusStoreTable + children: AgentStatusStoreTable + aliases: AgentStatusStoreTable + facts: AgentStatusStoreTable + tombstones: AgentStatusStoreTable + childrenOf(parentKey: string): string[] + factsOf(parentKey: string): string[] + aliasesOfChildren(childWorkIds: ReadonlySet): string[] +} + +type Tables = AgentStatusStoreMutationTables + function addTombstone( - state: AgentStatusStoreState, + state: Tables, entity: AgentStatusTombstoneEntity, key: string, revision: number @@ -41,8 +60,8 @@ function addTombstone( state.tombstones.set(mapKey, record) } -function compactTombstones(state: AgentStatusStoreState): void { - for (const [key, tombstone] of state.tombstones) { +function compactTombstones(state: Tables): void { + for (const [key, tombstone] of state.tombstones.entries()) { if ( state.tombstones.size <= AGENT_STATUS_STORE_LIMITS.tombstones && state.revision - tombstone.revision < AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS @@ -53,18 +72,18 @@ function compactTombstones(state: AgentStatusStoreState): void { } } -function removeAlias(state: AgentStatusStoreState, key: string, revision: number): void { +function removeAlias(state: Tables, key: string, revision: number): void { state.aliases.delete(key) addTombstone(state, 'alias', key, revision) } -function removeFact(state: AgentStatusStoreState, key: string, revision: number): void { +function removeFact(state: Tables, key: string, revision: number): void { state.facts.delete(key) addTombstone(state, 'fact', key, revision) } function removeChild( - state: AgentStatusStoreState, + state: Tables, childWorkId: string, revision: number, removedChildWorkIds: Set @@ -75,19 +94,20 @@ function removeChild( } function removeAliasesForChildren( - state: AgentStatusStoreState, + state: Tables, removedChildWorkIds: ReadonlySet, revision: number ): void { - for (const [key, alias] of state.aliases) { - if (removedChildWorkIds.has(alias.childWorkId)) { - removeAlias(state, key, revision) - } + if (removedChildWorkIds.size === 0) { + return + } + for (const key of state.aliasesOfChildren(removedChildWorkIds)) { + removeAlias(state, key, revision) } } function removeParent( - state: AgentStatusStoreState, + state: Tables, subject: AgentStatusSubject, revision: number, removedChildWorkIds: Set @@ -95,20 +115,16 @@ function removeParent( const key = serializeAgentStatusSubject(subject) state.parents.delete(key) addTombstone(state, 'parent', key, revision) - for (const child of state.children.values()) { - if (agentStatusSubjectsEqual(child.parent, subject)) { - removeChild(state, child.childWorkId, revision, removedChildWorkIds) - } + for (const childWorkId of state.childrenOf(key)) { + removeChild(state, childWorkId, revision, removedChildWorkIds) } - for (const [factMapKey, fact] of state.facts) { - if (agentStatusSubjectsEqual(fact.subject, subject)) { - removeFact(state, factMapKey, revision) - } + for (const factMapKey of state.factsOf(key)) { + removeFact(state, factMapKey, revision) } } function applyExplicitTombstone( - state: AgentStatusStoreState, + state: Tables, tombstone: { entity: AgentStatusTombstoneEntity; key: string }, revision: number, removedChildWorkIds: Set @@ -136,7 +152,7 @@ function applyExplicitTombstone( } function upsertParent( - state: AgentStatusStoreState, + state: Tables, input: NonNullable, revision: number ): boolean { @@ -161,7 +177,7 @@ function upsertParent( } function upsertChildren( - state: AgentStatusStoreState, + state: Tables, children: NonNullable, revision: number ): boolean { @@ -185,7 +201,7 @@ function upsertChildren( } function upsertAliases( - state: AgentStatusStoreState, + state: Tables, aliases: NonNullable, revision: number ): boolean { @@ -203,7 +219,7 @@ function upsertAliases( } function upsertFacts( - state: AgentStatusStoreState, + state: Tables, facts: NonNullable, revision: number ): boolean { @@ -217,47 +233,46 @@ function upsertFacts( return true } -export function applyAgentStatusStoreMutation( - current: AgentStatusStoreState, +/** Apply every step of one mutation; false when a step refuses. Invariants are checked after. */ +export function applyAgentStatusStoreMutationSteps( + state: Tables, mutation: AgentStatusStoreMutation, revision: number -): AgentStatusStoreState | null { - const next = cloneAgentStatusStoreState(current) - next.revision = revision +): boolean { const removedChildWorkIds = new Set() if (mutation.removeParent) { - removeParent(next, mutation.removeParent, revision, removedChildWorkIds) + removeParent(state, mutation.removeParent, revision, removedChildWorkIds) } for (const childWorkId of mutation.removeChildren ?? []) { - removeChild(next, childWorkId, revision, removedChildWorkIds) + removeChild(state, childWorkId, revision, removedChildWorkIds) } for (const key of mutation.removeAliases ?? []) { if (!deserializeAgentChildWorkBindingKey(key)) { - return null + return false } - removeAlias(next, key, revision) + removeAlias(state, key, revision) } for (const identity of mutation.removeFacts ?? []) { - removeFact(next, agentStatusFactMapKey(identity), revision) + removeFact(state, agentStatusFactMapKey(identity), revision) } for (const tombstone of mutation.tombstones ?? []) { - if (!applyExplicitTombstone(next, tombstone, revision, removedChildWorkIds)) { - return null + if (!applyExplicitTombstone(state, tombstone, revision, removedChildWorkIds)) { + return false } } - removeAliasesForChildren(next, removedChildWorkIds, revision) - if (mutation.parent && !upsertParent(next, mutation.parent, revision)) { - return null + removeAliasesForChildren(state, removedChildWorkIds, revision) + if (mutation.parent && !upsertParent(state, mutation.parent, revision)) { + return false } - if (mutation.children && !upsertChildren(next, mutation.children, revision)) { - return null + if (mutation.children && !upsertChildren(state, mutation.children, revision)) { + return false } - if (mutation.aliases && !upsertAliases(next, mutation.aliases, revision)) { - return null + if (mutation.aliases && !upsertAliases(state, mutation.aliases, revision)) { + return false } - if (mutation.facts && !upsertFacts(next, mutation.facts, revision)) { - return null + if (mutation.facts && !upsertFacts(state, mutation.facts, revision)) { + return false } - compactTombstones(next) - return validateAgentStatusStoreState(next) ? next : null + compactTombstones(state) + return true } diff --git a/src/shared/agent-status-store-record-keys.ts b/src/shared/agent-status-store-record-keys.ts new file mode 100644 index 00000000000..7495053eac0 --- /dev/null +++ b/src/shared/agent-status-store-record-keys.ts @@ -0,0 +1,61 @@ +// Keys the store derives from its own records. A stored record is frozen, so a key re-derived for +// every edge that touches the record (an alias re-validated with its child, a retired binding read +// on every lookup) is computed once per record. + +import { + serializeAgentChildWorkAliasKey, + type AgentChildWorkAliasInput, + type AgentChildWorkAliasRecord +} from './agent-status-child-work-alias' +import { + deserializeAgentChildWorkBindingKey, + serializeAgentChildWorkBindingKey +} from './agent-status-child-work-binding' +import type { AgentStatusTombstoneRecord } from './agent-status-store-contract' +import { deserializeAgentStatusFactKey } from './agent-status-store-fact-codec' +import { deserializeAgentStatusSubject } from './agent-status-subject' + +function memoized( + cache: WeakMap, + record: R, + derive: (record: R) => V +): V { + const cached = cache.get(record) + if (cached !== undefined) { + return cached + } + const value = derive(record) + if (Object.isFrozen(record)) { + cache.set(record, value) + } + return value +} + +const bindingKeys = new WeakMap() +type RetiredAlias = { alias: AgentChildWorkAliasInput; key: string } +const retiredAliases = new WeakMap() + +export function storedBindingKey(alias: AgentChildWorkAliasRecord): string { + return memoized(bindingKeys, alias, serializeAgentChildWorkBindingKey) +} + +/** The binding a retired alias's tombstone names, with its alias key; null for any other tombstone. */ +export function storedRetiredAlias(tombstone: AgentStatusTombstoneRecord): RetiredAlias | null { + return memoized(retiredAliases, tombstone, (record) => { + const alias = record.entity === 'alias' ? deserializeAgentChildWorkBindingKey(record.key) : null + return alias ? { alias, key: serializeAgentChildWorkAliasKey(alias) } : null + }) +} + +export function tombstoneKeyIsValid(tombstone: AgentStatusTombstoneRecord): boolean { + switch (tombstone.entity) { + case 'parent': + return deserializeAgentStatusSubject(tombstone.key) !== null + case 'alias': + return deserializeAgentChildWorkBindingKey(tombstone.key) !== null + case 'fact': + return deserializeAgentStatusFactKey(tombstone.key) !== null + case 'child': + return true + } +} diff --git a/src/shared/agent-status-store-scaling.test.ts b/src/shared/agent-status-store-scaling.test.ts new file mode 100644 index 00000000000..737fe1fbeb9 --- /dev/null +++ b/src/shared/agent-status-store-scaling.test.ts @@ -0,0 +1,120 @@ +// A status write costs what it touches: a parent publish or a child update must not grow with +// every other session's children. Ratios of interleaved medians, so a loaded machine slows both. + +import { describe, expect, it } from 'vitest' +import type { AgentChildWorkInput } from './agent-status-child-work' +import { createAgentStatusStore, type AgentStatusStore } from './agent-status-store' +import { makeStructuredAgentStatusSubject, type AgentStatusSubject } from './agent-status-subject' + +const CHILDREN_PER_PARENT = 40 + +function subjectFor(index: number): AgentStatusSubject { + return makeStructuredAgentStatusSubject( + { executionHostId: 'local', wslDistro: null, workspaceId: 'ws-1', workspaceKind: 'folder' }, + `session-${index}` + ) +} + +function childOf(parent: AgentStatusSubject, id: string, observedAt: number): AgentChildWorkInput { + return { + childWorkId: id, + parent, + provider: 'claude', + kind: 'agent', + state: 'working', + membership: 'live', + firstObservedAt: 1, + observedAt, + stoppable: true, + invocation: { invocationId: `toolu-${id}`, generation: 1 }, + provenance: { source: 'structured-session', producerId: 'scaling' }, + description: `Task ${id}` + } +} + +function storeWith(children: number): AgentStatusStore { + const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) + for (let p = 0; p * CHILDREN_PER_PARENT < children; p += 1) { + const parent = subjectFor(p) + const batch = Array.from({ length: CHILDREN_PER_PARENT }, (_, c) => + childOf(parent, `child-${p}-${c}`, 1) + ) + const committed = store.applyMutation({ + parent: { subject: parent }, + children: batch, + aliases: batch.flatMap((child) => + (['task_id', 'tool_use_id'] as const).map((aliasKind) => ({ + parent, + provider: 'claude', + segmentId: 'scaling', + kind: child.kind, + aliasKind, + alias: `${aliasKind}-${child.childWorkId}`, + childWorkId: child.childWorkId, + fence: child.invocation + })) + ) + }) + expect(committed).not.toBeNull() + } + return store +} + +function median(values: number[]): number { + const sorted = [...values].sort((left, right) => left - right) + return sorted[Math.floor(sorted.length / 2)]! +} + +/** Median cost of `write` on each store, measured alternately so load lands on both. */ +function interleavedMedians( + stores: AgentStatusStore[], + write: (store: AgentStatusStore, rep: number) => void +): number[] { + const samples = stores.map((): number[] => []) + for (let rep = 0; rep < 400; rep += 1) { + for (const [index, store] of stores.entries()) { + const started = performance.now() + write(store, rep) + // The first reps warm the JIT for both stores alike. + if (rep >= 50) { + samples[index]!.push(performance.now() - started) + } + } + } + return samples.map(median) +} + +describe('AgentStatusStore write cost', () => { + const parent = subjectFor(0) + const small = storeWith(40) + const large = storeWith(4_000) + + it('publishes a parent at 4,000 children for under 4x its cost at 40', () => { + const [atSmall, atLarge] = interleavedMedians([small, large], (store, rep) => { + store.applyMutation({ + parent: { + subject: parent, + status: { + state: 'working', + prompt: `prompt ${rep}`, + paneKey: 'structured-pane', + connectionId: null, + receivedAt: rep, + evidenceObservedAt: rep, + stateStartedAt: 1, + worktreeId: parent.workspaceId, + structuredHost: 'owned' + } + } + }) + }) + expect(atLarge! / atSmall!).toBeLessThan(4) + }) + + it('updates one child at 4,000 children for under 4x its cost at 40', () => { + const [atSmall, atLarge] = interleavedMedians([small, large], (store, rep) => { + store.applyMutation({ children: [childOf(parent, 'child-0-1', 2 + rep)] }) + }) + expect(atLarge! / atSmall!).toBeLessThan(4) + }) +}) diff --git a/src/shared/agent-status-store-state.ts b/src/shared/agent-status-store-state.ts index 249830a5aa5..ca05745ad3b 100644 --- a/src/shared/agent-status-store-state.ts +++ b/src/shared/agent-status-store-state.ts @@ -2,10 +2,7 @@ import { parseAgentChildWorkAliasRecord, type AgentChildWorkAliasRecord } from './agent-status-child-work-alias' -import { - deserializeAgentChildWorkBindingKey, - serializeAgentChildWorkBindingKey -} from './agent-status-child-work-binding' +import { serializeAgentChildWorkBindingKey } from './agent-status-child-work-binding' import { agentChildWorkBelongsTo, agentChildWorkFencesEqual, @@ -28,7 +25,6 @@ import { parseAgentStatusTombstoneRecord } from './agent-status-store-codec' import { - deserializeAgentStatusFactKey, parseAgentStatusFactRecord, serializeAgentStatusFactKey } from './agent-status-store-fact-codec' @@ -36,7 +32,8 @@ import { parseAgentStatusParentRecord, type AgentStatusParentRecord } from './agent-status-store-parent' -import { deserializeAgentStatusSubject, serializeAgentStatusSubject } from './agent-status-subject' +import { storedBindingKey, tombstoneKeyIsValid } from './agent-status-store-record-keys' +import { serializeAgentStatusSubject } from './agent-status-subject' export type AgentStatusStoreState = { epoch: string @@ -117,70 +114,125 @@ function hasMatchingFence(child: AgentChildWorkRecord, alias: AgentChildWorkAlia ) } +type Lookup = { get(key: string): V | undefined; has(key: string): boolean } + +/** What one record's validity depends on besides itself. */ +export type AgentStatusStoreRecordLookups = { + revision: number + parents: Lookup + children: Lookup + tombstones: Lookup +} + +export function agentStatusStoreSizesFit(sizes: { + parents: { size: number } + children: { size: number } + aliases: { size: number } + facts: { size: number } + tombstones: { size: number } +}): boolean { + return ( + sizes.parents.size <= AGENT_STATUS_STORE_LIMITS.parents && + sizes.children.size <= AGENT_STATUS_STORE_LIMITS.children && + sizes.aliases.size <= AGENT_STATUS_STORE_LIMITS.aliases && + sizes.facts.size <= AGENT_STATUS_STORE_LIMITS.facts && + sizes.tombstones.size <= AGENT_STATUS_STORE_LIMITS.tombstones + ) +} + +export function storedParentIsValid( + lookups: AgentStatusStoreRecordLookups, + key: string, + parent: AgentStatusParentRecord +): boolean { + return ( + key === serializeAgentStatusSubject(parent.subject) && + parent.revision <= lookups.revision && + (lookups.tombstones.get(agentStatusTombstoneMapKey('parent', key))?.revision ?? -1) < + parent.revision + ) +} + +export function storedChildIsValid( + lookups: AgentStatusStoreRecordLookups, + childWorkId: string, + child: AgentChildWorkRecord +): boolean { + return ( + childWorkId === child.childWorkId && + child.revision <= lookups.revision && + lookups.parents.has(serializeAgentStatusSubject(child.parent)) && + !lookups.tombstones.has(agentStatusTombstoneMapKey('child', childWorkId)) + ) +} + +export function storedAliasIsValid( + lookups: AgentStatusStoreRecordLookups, + key: string, + alias: AgentChildWorkAliasRecord +): boolean { + const child = lookups.children.get(alias.childWorkId) + const tombstone = lookups.tombstones.get(agentStatusTombstoneMapKey('alias', key)) + return ( + key === storedBindingKey(alias) && + alias.revision <= lookups.revision && + child !== undefined && + agentChildWorkBelongsTo(child, alias.parent) && + child.provider === alias.provider && + child.kind === alias.kind && + hasMatchingFence(child, alias) && + (tombstone === undefined || tombstone.revision < alias.revision) + ) +} + +export function storedFactIsValid( + lookups: AgentStatusStoreRecordLookups, + key: string, + fact: AgentStatusFactRecord +): boolean { + const tombstone = lookups.tombstones.get(agentStatusTombstoneMapKey('fact', key)) + return ( + key === agentStatusFactMapKey(fact) && + fact.revision <= lookups.revision && + lookups.parents.has(serializeAgentStatusSubject(fact.subject)) && + (tombstone === undefined || tombstone.revision < fact.revision) + ) +} + +export function storedTombstoneIsValid( + lookups: Pick, + tombstone: AgentStatusTombstoneRecord +): boolean { + return tombstone.revision <= lookups.revision && tombstoneKeyIsValid(tombstone) +} + +/** Every invariant over the whole store: for snapshot restore, and the oracle mutations are held to. */ export function validateAgentStatusStoreState(state: AgentStatusStoreState): boolean { - if ( - state.parents.size > AGENT_STATUS_STORE_LIMITS.parents || - state.children.size > AGENT_STATUS_STORE_LIMITS.children || - state.aliases.size > AGENT_STATUS_STORE_LIMITS.aliases || - state.facts.size > AGENT_STATUS_STORE_LIMITS.facts || - state.tombstones.size > AGENT_STATUS_STORE_LIMITS.tombstones - ) { + if (!agentStatusStoreSizesFit(state)) { return false } for (const [key, parent] of state.parents) { - if ( - key !== serializeAgentStatusSubject(parent.subject) || - parent.revision > state.revision || - (state.tombstones.get(agentStatusTombstoneMapKey('parent', key))?.revision ?? -1) >= - parent.revision - ) { + if (!storedParentIsValid(state, key, parent)) { return false } } for (const [childWorkId, child] of state.children) { - if ( - childWorkId !== child.childWorkId || - child.revision > state.revision || - !state.parents.has(serializeAgentStatusSubject(child.parent)) || - state.tombstones.has(agentStatusTombstoneMapKey('child', childWorkId)) - ) { + if (!storedChildIsValid(state, childWorkId, child)) { return false } } for (const [key, alias] of state.aliases) { - const child = state.children.get(alias.childWorkId) - const tombstone = state.tombstones.get(agentStatusTombstoneMapKey('alias', key)) - if ( - key !== serializeAgentChildWorkBindingKey(alias) || - alias.revision > state.revision || - !child || - !agentChildWorkBelongsTo(child, alias.parent) || - child.provider !== alias.provider || - child.kind !== alias.kind || - !hasMatchingFence(child, alias) || - (tombstone !== undefined && tombstone.revision >= alias.revision) - ) { + if (!storedAliasIsValid(state, key, alias)) { return false } } for (const [key, fact] of state.facts) { - const tombstone = state.tombstones.get(agentStatusTombstoneMapKey('fact', key)) - if ( - key !== agentStatusFactMapKey(fact) || - fact.revision > state.revision || - !state.parents.has(serializeAgentStatusSubject(fact.subject)) || - (tombstone !== undefined && tombstone.revision >= fact.revision) - ) { + if (!storedFactIsValid(state, key, fact)) { return false } } for (const item of state.tombstones.values()) { - if ( - item.revision > state.revision || - (item.entity === 'parent' && !deserializeAgentStatusSubject(item.key)) || - (item.entity === 'alias' && !deserializeAgentChildWorkBindingKey(item.key)) || - (item.entity === 'fact' && !deserializeAgentStatusFactKey(item.key)) - ) { + if (!storedTombstoneIsValid(state, item)) { return false } } diff --git a/src/shared/agent-status-store-table.ts b/src/shared/agent-status-store-table.ts new file mode 100644 index 00000000000..798ee0c1031 --- /dev/null +++ b/src/shared/agent-status-store-table.ts @@ -0,0 +1,116 @@ +// One keyed table of the status store, as a mutation sees it. +// +// A mutation writes into a draft over the committed map instead of a copy of it: the draft holds +// only the keys the mutation touched, so a refused mutation is dropped without undoing anything, +// and an accepted one lands in place. Map order is part of the store's observable state (snapshot +// order, tombstone compaction), so the draft reproduces exactly the order a copied map would have. + +export type AgentStatusStoreTable = { + readonly size: number + get(key: string): V | undefined + has(key: string): boolean + set(key: string, value: V): void + delete(key: string): void + /** In map order, tolerating deletes of the entry being visited. */ + entries(): Iterable<[string, V]> +} + +const REMOVED: unique symbol = Symbol('removed') +type Change = V | typeof REMOVED + +export class AgentStatusStoreDraftTable implements AgentStatusStoreTable { + /** Every key this mutation touched, with its latest value. */ + private readonly changes = new Map>() + /** Committed keys this mutation deleted (a later set re-appends them). */ + private readonly removedFromBase = new Set() + /** Keys present now that the committed map does not hold in place, in append order. */ + private readonly appended = new Set() + + constructor(private readonly base: ReadonlyMap) {} + + get size(): number { + return this.base.size - this.removedFromBase.size + this.appended.size + } + + get(key: string): V | undefined { + const change = this.changes.get(key) + if (change === undefined) { + return this.base.get(key) + } + return change === REMOVED ? undefined : change + } + + has(key: string): boolean { + return this.get(key) !== undefined + } + + set(key: string, value: V): void { + if (!this.has(key)) { + // A Map appends a key it does not hold, including one deleted earlier. + this.appended.delete(key) + this.appended.add(key) + } + this.changes.set(key, value) + } + + delete(key: string): void { + if (!this.has(key)) { + return + } + this.changes.set(key, REMOVED) + if (this.base.has(key)) { + this.removedFromBase.add(key) + } + this.appended.delete(key) + } + + *entries(): Iterable<[string, V]> { + for (const [key, value] of this.base) { + if (this.removedFromBase.has(key)) { + continue + } + const change = this.changes.get(key) + yield [key, change === undefined || change === REMOVED ? value : change] + } + for (const key of this.appended) { + const value = this.get(key) + if (value !== undefined) { + yield [key, value] + } + } + } + + /** Each touched key with its committed value and its value after this mutation. */ + *touched(): Iterable<{ key: string; previous: V | undefined; next: V | undefined }> { + for (const [key, change] of this.changes) { + yield { key, previous: this.base.get(key), next: change === REMOVED ? undefined : change } + } + } + + /** Committed keys this mutation deleted, then keys it appended, in the order they now sit. */ + get removedKeys(): ReadonlySet { + return this.removedFromBase + } + + get appendedKeys(): ReadonlySet { + return this.appended + } + + /** Land the draft in the committed map, leaving it in the order a copied map would have. */ + commitInto(base: Map): void { + for (const key of this.removedFromBase) { + base.delete(key) + } + for (const [key, change] of this.changes) { + if (change !== REMOVED && !this.appended.has(key)) { + base.set(key, change) + } + } + for (const key of this.appended) { + const value = this.get(key) + if (value !== undefined) { + base.set(key, value) + } + } + } +} diff --git a/src/shared/agent-status-store.ts b/src/shared/agent-status-store.ts index e59919096ab..a5b416c205c 100644 --- a/src/shared/agent-status-store.ts +++ b/src/shared/agent-status-store.ts @@ -1,19 +1,23 @@ -import { agentChildWorkBelongsTo, type AgentChildWorkRecord } from './agent-status-child-work' +import type { AgentChildWorkRecord } from './agent-status-child-work' import { serializeAgentChildWorkAliasKey, type AgentChildWorkAliasIdentity, type AgentChildWorkAliasInput, type AgentChildWorkAliasRecord } from './agent-status-child-work-alias' -import { parseAgentChildWorkRecord } from './agent-status-child-work-codec' -import { resolveAgentStatusChildBindings } from './agent-status-store-child-queries' +import { + agentStatusStoreAliasesOf, + agentStatusStoreChildrenOf, + resolveAgentStatusChildBindings +} from './agent-status-store-child-queries' +import { commitAgentStatusStoreMutation } from './agent-status-store-commit' import type { AgentStatusStoreSnapshot } from './agent-status-store-contract' import { isAgentStatusStoreEpoch, parseAgentStatusStoreMutation, parseAgentStatusStoreSnapshot } from './agent-status-store-codec' -import { applyAgentStatusStoreMutation } from './agent-status-store-mutation' +import { indexAgentStatusStoreState } from './agent-status-store-indexes' import type { AgentStatusRunAliasIndex } from './agent-status-run-alias-index' import { parseAgentStatusParentRecord, @@ -40,6 +44,9 @@ export type AgentStatusStoreMode = 'authority' | 'replica' export type AgentStatusStore = { getParent(subject: AgentStatusSubject): AgentStatusParentRecord | null + /** Every parent in insertion order, without materializing the child records a snapshot holds. */ + getParents(): AgentStatusParentRecord[] + getRevision(): { epoch: string; revision: number } getChildren(subject: AgentStatusSubject): AgentChildWorkRecord[] getChild(childWorkId: string): AgentChildWorkRecord | null getAlias(identity: AgentChildWorkAliasIdentity): AgentChildWorkAliasRecord | null @@ -62,11 +69,17 @@ export function createAgentStatusStore(options: CreateAgentStatusStoreOptions): throw new Error('Invalid agent status store epoch') } let state = createEmptyAgentStatusStoreState(options.epoch) + let indexes = indexAgentStatusStoreState(state) let snapshotApplied = options.mode === 'authority' + const restore = (restored: typeof state) => { + state = restored + indexes = indexAgentStatusStoreState(restored) + snapshotApplied = true + } const store: AgentStatusStore = { resolveChildAliases(aliases) { - return resolveAgentStatusChildBindings(state, aliases) + return resolveAgentStatusChildBindings(state, indexes, aliases) }, getParent(subject) { const parsed = parseAgentStatusSubject(subject) @@ -76,16 +89,20 @@ export function createAgentStatusStore(options: CreateAgentStatusStoreOptions): const record = state.parents.get(serializeAgentStatusSubject(parsed)) return record ? deepFreezeAgentStatusStoreValue(parseAgentStatusParentRecord(record)) : null }, + getParents() { + return [...state.parents.values()] + }, + getRevision() { + return { epoch: state.epoch, revision: state.revision } + }, getChildren(subject) { const parsed = parseAgentStatusSubject(subject) if (!parsed) { return [] } - const children = [...state.children.values()] - .filter((child) => agentChildWorkBelongsTo(child, parsed)) - .map((child) => parseAgentChildWorkRecord(child)) - .filter((child): child is AgentChildWorkRecord => child !== null) - return deepFreezeAgentStatusStoreValue(children) + return deepFreezeAgentStatusStoreValue( + agentStatusStoreChildrenOf(state, indexes, serializeAgentStatusSubject(parsed)) + ) }, getChild(childWorkId) { return state.children.get(childWorkId) ?? null @@ -94,9 +111,7 @@ export function createAgentStatusStore(options: CreateAgentStatusStoreOptions): return state.aliases.get(serializeAgentChildWorkAliasKey(identity)) ?? null }, getAliasesForChild(childWorkId) { - return deepFreezeAgentStatusStoreValue( - [...state.aliases.values()].filter((alias) => alias.childWorkId === childWorkId) - ) + return deepFreezeAgentStatusStoreValue(agentStatusStoreAliasesOf(state, indexes, childWorkId)) }, getRunAliasIndex() { return deriveAgentStatusStoreRunAliasIndex(state.parents.values()) @@ -113,11 +128,9 @@ export function createAgentStatusStore(options: CreateAgentStatusStoreOptions): return null } const previousRevision = state.revision - const next = applyAgentStatusStoreMutation(state, mutation, previousRevision + 1) - if (!next) { + if (!commitAgentStatusStoreMutation(state, indexes, mutation, previousRevision + 1)) { return null } - state = next return deepFreezeAgentStatusStoreValue({ type: 'mutation', epoch: state.epoch, @@ -139,8 +152,7 @@ export function createAgentStatusStore(options: CreateAgentStatusStoreOptions): if (!restored) { return false } - state = restored - snapshotApplied = true + restore(restored) return true } if ( @@ -154,8 +166,7 @@ export function createAgentStatusStore(options: CreateAgentStatusStoreOptions): if (!mirrored) { return false } - state = mirrored - snapshotApplied = true + restore(mirrored) return true }, applyTransportEnvelope(value) { @@ -177,12 +188,7 @@ export function createAgentStatusStore(options: CreateAgentStatusStoreOptions): ) { return false } - const next = applyAgentStatusStoreMutation(state, envelope.mutation, envelope.revision) - if (!next) { - return false - } - state = next - return true + return commitAgentStatusStoreMutation(state, indexes, envelope.mutation, envelope.revision) } } return store diff --git a/src/shared/agent-status-subject.ts b/src/shared/agent-status-subject.ts index 760fa3dfe32..794ecc4452f 100644 --- a/src/shared/agent-status-subject.ts +++ b/src/shared/agent-status-subject.ts @@ -174,13 +174,24 @@ function subjectKeyTuple(subject: AgentStatusSubject): AgentStatusSubjectKeyTupl ] } +// Stored subjects are frozen, and the status store keys every record by one on each mutation. +const frozenSubjectKeys = new WeakMap() + /** Stable serialized identity for maps, persistence, and snapshot transport. */ export function serializeAgentStatusSubject(subject: AgentStatusSubject): string { + const cached = frozenSubjectKeys.get(subject) + if (cached !== undefined) { + return cached + } const parsed = parseAgentStatusSubject(subject) if (!parsed) { throw new Error('Invalid agent status subject') } - return `${SUBJECT_KEY_PREFIX}${JSON.stringify(subjectKeyTuple(parsed))}` + const key = `${SUBJECT_KEY_PREFIX}${JSON.stringify(subjectKeyTuple(parsed))}` + if (Object.isFrozen(subject)) { + frozenSubjectKeys.set(subject, key) + } + return key } export function deserializeAgentStatusSubject(value: string): AgentStatusSubject | null {