From acf8e679ea694081862464be3cd2af8cb5059826 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:50:50 -0700 Subject: [PATCH] feat(native-chat): Claude sessions write their subagents into the host status store (#22536) * refactor(native-chat): the host hands out client delivery's status subscriptions as they are subscribeStatus and subscribeTurnCompletions wrapped client delivery's bound methods in forwarding lambdas; they are now the same members, the way waitForSendSettlement already is. The host is at its size limit, and the next channel it hands out needs the line. * feat(native-chat): Claude sessions write their subagents into the host status store The Claude background-task tracker queues child-work evidence at each decision it already makes (start, update, progress, terminal frame, roster replacement, turn end, session end), plus the two facts its legacy row ignores: a foreground child's progress and a foreground spawn call's result. The adapter drains that evidence after the journal handled the frame and the parent row was republished, and the host folds it into one record per child in its canonical store. Nothing reads the records yet; the strip and sidebar keep their current sources. * test(native-chat): pin the Claude child-work evidence and the host reduction of it * test(native-chat): prove every hop from a Claude frame to the host's child record The adapter delivers evidence after the frame's journal rows and the parent's republished row; the frame script keeps the parent state today reads while the records add outcome and activity; the runtime hands the evidence to the status sink under the session's own address; both entry points wire the sink to the ingest. * test(native-chat): read an optional task list as optional in the producer script * test(native-chat): an address whose publish threw carries no child work * test(agent-status): a foreign record differs from ours by producer alone * feat(native-chat): a foreground Claude child's own tool call is what its record says it is doing A child's tool traffic reaches the parent stream only for a foreground child. Read after the journal handled the frame, the child's newest call still awaiting a result becomes its open operation, previewed the way a hook-reported row previews its own tool; the result closes it. The open call is derived from the journal's own bookkeeping, not held a second time. * fix(native-chat): a Claude child restarted under a new spawn call keeps reporting to its record A task that ended and starts again stays hidden from the legacy row until a roster lists it, so the tracker held no run for it: the new run's progress reached nothing and a foreground re-run's own spawn result settled nothing. The run is now held beside the live map, where the legacy row never reads it, until a roster hands it back or it ends. A parity test pins the record's run count to the journal roster's attempt on a new spawn call, the one event both count. * refactor(native-chat): the Claude child-tool queries and translator contract get their own homes The translator's child-tool queries move into claude-child-tool-queries.ts and its contract type into claude-journal-translator-contract.ts. Brings the translator back under the size limit. * refactor(native-chat): Claude child evidence carries only its own edge's facts Admission now keeps what a child's record already knows: labels, model, owner, residency, the last message within an invocation, and a token count that never shrinks. The evidence side copied all of those forward itself, a second owner of the same rule. It now sends only what this edge observed, and a task's token count comes from the frame that reported it. * refactor(native-chat): Claude child evidence hands admission its raw labels Admission now folds provider text to one line and drops a malformed fact instead of refusing the record, so the evidence side no longer folds labels itself. The description keeps admission's longer bound. * fix(agent-status): admission alone decides a settled child's second ending The reconciliation returned before admission whenever a record had already settled with a definite outcome. That dropped the evidence an `unknown` ending carries (its last message and tokens), which admission's refine-only rule keeps, so that rule never ran for the structured producers. The latch goes. Admission keeps the definite outcome, lands the late evidence, and refuses a conflicting definite ending as `stale-invocation`, which the host ingest already counts as the fence doing its job, not a fault. Pinned through the real Claude producer and the host's own ingest. * perf(agent-status): keep child records off the status hot paths Child records made every store write and every status notification scale with the whole store. Each Claude child progress frame cost about 2 ms with 5 chats holding ~200 child records (about 14 ms at ~1,400), and every status change on any lane re-parsed every child record just to list parent rows. - The store derives each frozen record's key once instead of re-parsing it on every mutation's validation and every alias lookup. - Settled history is trimmed only when a batch settles something. - Parent listing and the structured row's revision stamp read the parents and the revision directly instead of building a full snapshot. A progress frame now costs about 0.3 ms at the same size, and listing parent rows no longer depends on how many child records the store holds. * fix(native-chat): an errored Claude spawn result no longer decides how the child ended Interrupting a foreground Claude agent while it runs a tool delivers the spawn call's errored result before the child's own killed/stopped frames. The spawn result settled the record `failed` first, and admission then refused the later `cancelled` as a conflicting ending, so an interrupted child read as a failure. An errored spawn result now settles the child `unknown`; the child's own terminal frame refines it to `cancelled` or `failed`. A successful spawn result still settles `succeeded`. The test replays both frame orders the real CLI produced when interrupted. * test(native-chat): pin a Claude foreground child's real finishing order The real CLI ends a foreground agent with its own completed update, then a notification carrying the final summary and usage, and only then the spawn call's result. Existing tests modeled the spawn result arriving first, so nothing checked that the notification's summary and tokens still land on a record the update already settled. * perf(agent-status): a store write costs what it touches, not the whole store With child records on the host, every mutation copied all five store maps and re-validated every record, and reads scanned every child and alias. A parent status publish cost about 10 ms with 4,000 child records in the store, and a child update about 13 ms. - A mutation writes into drafts over the committed maps and lands in place; a refused one is dropped with nothing to undo. The drafts keep the exact map order a copy would have. - Only what a mutation touched is re-validated: touched parents, children, aliases, facts and tombstones, plus every alias of a touched child and whatever a removed parent owned. The full validation stays for snapshot restore. - The snapshot byte budget is a running total instead of a re-measure. - Children by parent, facts by parent, aliases by child, aliases by identity and retired aliases are indexed, so reads return stored records without scanning or re-parsing. - The memoized alias identity and tombstone-key checks are gone: indexes derive them once. A parent publish now costs about 0.015 ms and a child update about 0.06 ms at 40, 1,000 and 4,000 children alike. A seeded fuzz holds the store to the copy-and-validate-everything path decision for decision, snapshot for snapshot and read for read, and a replica fed the envelopes ends identical. * fix(native-chat): a Claude child ends only on its own terminal frame The child records were fed from the legacy background-task tracker's display decisions, so they inherited rules that are not truth: a turn ending swept foreground children, a roster omitting a background child settled it, a foreground spawn call's result ended the child, and a new background start after any roster produced no record. Captured from the real CLI, an agent moved to the background keeps its own shell running for 40 s after the parent's turn ends, and that shell was settled `unknown` at the parent's `result`. Replayed with the spawn result ahead of the roster, the same agent settled as a false success and was then revived as a spurious second run. A new decoder reads the task frames directly. `task_started` opens a child (a start for an ended task id is a restart, the way messaging a finished agent resumes it), progress and a live `task_updated` update it, and a terminal `task_updated` or `task_notification` ends it. Rosters, turn ends and spawn results say nothing about a child. Every child in every capture gets its own terminal frame, so no evidenced ending is lost. The notification's `tool_use_id` names the run that ended (captured on a resumed agent's second run), so an ending from a run that is already over no longer ends the current one; a run id the record never saw still ends it, so nothing strands. The tracker, its settled-task retention and the frame readers are back to exactly what main has: the aggregate-roster split and the restart holding map are deleted, and the legacy row is unchanged by construction. * fix(agent-status): a session's end settles its live children instead of erasing them When a structured session ended, the reducer removed every child record it held, finished or not, so a reader could no longer tell how the session's work had ended. Now a child still live when its session ends settles `unknown` (nothing reported how it ended), and a child that had already ended keeps its outcome. The records still die with their parent: closing or releasing the session drops the parent row, and the store drops its children with it. A child's own outcome arriving after the session ended still refines the `unknown`. The `inventory` and `turn-ended` edges, and the rules that settled children on a roster omission or at a turn boundary, are deleted: no producer sends them any more. A restart is now its own flag on a live edge, which is what a producer reports when a finished child starts again under the same run handle. * test(native-chat): replay the real Claude CLI's frame orders into a real host Scrubbed cuts of five Claude CLI 2.1.280 stream-json captures (ids, paths and prompts replaced, frame order and relative clock kept), replayed through the adapter into a hook server: - an agent moved to the background keeps its own shell live past the parent's turn, and the shell settles at its own notification's time; - the same capture with the spawn result ahead of the move ends the agent once, from its own notification, with no second run; - a roster that omits a background child without its own ending leaves it live; - a session that ends settles what still runs `unknown` and keeps every record; - messaging a finished background agent opens its second run, which ends from its own frame; - interrupts in both captured orders end `cancelled`, and a finished foreground agent keeps its summary and usage. * test(agent-status): hold the store's running indexes and byte total to a rebuild The copying-store fuzz never reaches the snapshot byte budget, so a drift in the running byte total (or any index the public reads do not surface) passed it. After every fuzzed step, including refusals, compare every index with one rebuilt from the committed maps. --- .../server-ingest-structured-children.test.ts | 125 +++++ .../server/server-ingest-remote.ts | 4 +- .../server-ingest-structured-children.ts | 60 +++ .../server/server-ingest-structured.ts | 2 +- .../agent-hooks/server/server-listeners.ts | 2 +- ...captured-foreground-frames.test-fixture.ts | 147 ++++++ ...de-captured-frame-builders.test-fixture.ts | 61 +++ ...laude-captured-task-frames.test-fixture.ts | 228 ++++++++++ src/main/claude/claude-child-tool-queries.ts | 40 ++ .../claude/claude-child-work-decoder.test.ts | 204 +++++++++ src/main/claude/claude-child-work-decoder.ts | 258 +++++++++++ src/main/claude/claude-child-work-evidence.ts | 128 ++++++ ...hild-work-producer-harness.test-fixture.ts | 174 +++++++ .../claude-journal-translator-contract.ts | 41 ++ ...ude-structured-child-work-captures.test.ts | 180 ++++++++ ...ude-structured-child-work-producer.test.ts | 349 ++++++++++++++ ...claude-structured-dispatch-test-support.ts | 2 + .../claude-structured-journal-translation.ts | 51 +-- .../claude/claude-structured-options.test.ts | 2 + .../claude-structured-session-adapter.ts | 42 +- .../claude/claude-structured-session-close.ts | 1 + .../claude-structured-session-publication.ts | 2 + .../claude/claude-structured-session-state.ts | 6 + ...tructured-agent-session-client-delivery.ts | 4 + .../structured-agent-session-host.ts | 10 +- ...ent-session-status-feed-child-work.test.ts | 86 ++++ .../structured-agent-session-status-feed.ts | 14 + ...red-agent-session-status-ownership.test.ts | 25 + ...ructured-agent-session-status-ownership.ts | 25 +- src/main/orcad/orcad-entry.ts | 4 +- ...ude-structured-session-integration.test.ts | 44 +- ...time-structured-status-sink-wiring.test.ts | 3 + .../structured-agent-session-runtime.ts | 2 + .../structured-claude-runtime-adapter.ts | 2 + .../startup/main-process-runtime-service.ts | 4 +- ...t-status-child-work-codec-boundary.test.ts | 3 +- ...nt-status-child-work-evidence-admission.ts | 227 ++++++++++ ...t-status-child-work-evidence-resolution.ts | 190 ++++++++ .../agent-status-child-work-evidence.ts | 81 ++++ ...t-status-child-work-reconciliation.test.ts | 426 ++++++++++++++++++ .../agent-status-child-work-reconciliation.ts | 157 +++++++ src/shared/agent-status-store-bounds.test.ts | 60 ++- src/shared/agent-status-store-byte-budget.ts | 22 +- .../agent-status-store-child-queries.ts | 67 ++- src/shared/agent-status-store-commit.ts | 199 ++++++++ ...tatus-store-copying-oracle.test-fixture.ts | 105 +++++ .../agent-status-store-in-place.test.ts | 222 +++++++++ src/shared/agent-status-store-indexes.ts | 221 +++++++++ src/shared/agent-status-store-mutation.ts | 125 ++--- src/shared/agent-status-store-record-keys.ts | 61 +++ src/shared/agent-status-store-scaling.test.ts | 120 +++++ src/shared/agent-status-store-state.ts | 152 +++++-- src/shared/agent-status-store-table.ts | 116 +++++ src/shared/agent-status-store.ts | 58 +-- src/shared/agent-status-subject.ts | 13 +- 55 files changed, 4728 insertions(+), 229 deletions(-) create mode 100644 src/main/agent-hooks/server-ingest-structured-children.test.ts create mode 100644 src/main/agent-hooks/server/server-ingest-structured-children.ts create mode 100644 src/main/claude/claude-captured-foreground-frames.test-fixture.ts create mode 100644 src/main/claude/claude-captured-frame-builders.test-fixture.ts create mode 100644 src/main/claude/claude-captured-task-frames.test-fixture.ts create mode 100644 src/main/claude/claude-child-tool-queries.ts create mode 100644 src/main/claude/claude-child-work-decoder.test.ts create mode 100644 src/main/claude/claude-child-work-decoder.ts create mode 100644 src/main/claude/claude-child-work-evidence.ts create mode 100644 src/main/claude/claude-child-work-producer-harness.test-fixture.ts create mode 100644 src/main/claude/claude-journal-translator-contract.ts create mode 100644 src/main/claude/claude-structured-child-work-captures.test.ts create mode 100644 src/main/claude/claude-structured-child-work-producer.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts create mode 100644 src/shared/agent-status-child-work-evidence-admission.ts create mode 100644 src/shared/agent-status-child-work-evidence-resolution.ts create mode 100644 src/shared/agent-status-child-work-evidence.ts create mode 100644 src/shared/agent-status-child-work-reconciliation.test.ts create mode 100644 src/shared/agent-status-child-work-reconciliation.ts create mode 100644 src/shared/agent-status-store-commit.ts create mode 100644 src/shared/agent-status-store-copying-oracle.test-fixture.ts create mode 100644 src/shared/agent-status-store-in-place.test.ts create mode 100644 src/shared/agent-status-store-indexes.ts create mode 100644 src/shared/agent-status-store-record-keys.ts create mode 100644 src/shared/agent-status-store-scaling.test.ts create mode 100644 src/shared/agent-status-store-table.ts 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 {