From 63cb07cd8c722a4b0fd5d2914e3eed03ee977c8e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:25:20 -0700 Subject: [PATCH 01/21] 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. --- .../agent-session-wire/structured-agent-session-host.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) 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 a2b03e120cac..439ffe978e11 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 @@ -45,8 +45,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' @@ -341,13 +339,10 @@ export class StructuredAgentSessionHost { 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) From 8d1a96a4ba946348c3188679d7232463db4f2d6d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:59:48 -0700 Subject: [PATCH 02/21] 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. --- .../server/server-ingest-remote.ts | 4 +- .../server-ingest-structured-children.ts | 60 ++++ ...claude-background-task-aggregate-roster.ts | 94 ++++++ .../claude/claude-background-task-tracker.ts | 158 +++++----- src/main/claude/claude-child-work-evidence.ts | 267 +++++++++++++++++ .../claude/claude-settled-background-tasks.ts | 2 + .../claude-structured-journal-translation.ts | 9 + .../claude-structured-session-adapter.ts | 34 ++- .../claude/claude-structured-session-state.ts | 3 + ...tructured-agent-session-client-delivery.ts | 4 + .../structured-agent-session-host.ts | 1 + .../structured-agent-session-status-feed.ts | 14 + ...ructured-agent-session-status-ownership.ts | 25 +- src/main/orcad/orcad-entry.ts | 4 +- .../structured-agent-session-runtime.ts | 2 + .../structured-claude-runtime-adapter.ts | 2 + .../startup/main-process-runtime-service.ts | 4 +- ...nt-status-child-work-evidence-admission.ts | 270 ++++++++++++++++++ ...t-status-child-work-evidence-resolution.ts | 173 +++++++++++ .../agent-status-child-work-evidence.ts | 81 ++++++ .../agent-status-child-work-reconciliation.ts | 149 ++++++++++ 21 files changed, 1273 insertions(+), 87 deletions(-) create mode 100644 src/main/agent-hooks/server/server-ingest-structured-children.ts create mode 100644 src/main/claude/claude-background-task-aggregate-roster.ts create mode 100644 src/main/claude/claude-child-work-evidence.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.ts diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index 0bc8d7947a9a..e2e1c91fe80b 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 000000000000..ecc58b998d77 --- /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/claude/claude-background-task-aggregate-roster.ts b/src/main/claude/claude-background-task-aggregate-roster.ts new file mode 100644 index 000000000000..5742f0397f65 --- /dev/null +++ b/src/main/claude/claude-background-task-aggregate-roster.ts @@ -0,0 +1,94 @@ +// Claude's `background_tasks_changed`: a full replacement of the live BACKGROUND roster. +// Moved out of the tracker unchanged; the tracker owns when it runs and what it publishes. + +import { + classifyClaudeBackgroundTaskKind, + liveClaudeTaskRunState, + record, + taskDescription, + taskId, + taskName +} from './claude-background-task-frames' +import type { + ClaudeSettledBackgroundTasks, + TrackedClaudeBackgroundTask +} from './claude-settled-background-tasks' + +export type ClaudeAggregateRosterInput = { + value: unknown[] + /** The tracker's live tasks; replaced in place. */ + tasks: Map + retention: ClaudeSettledBackgroundTasks + /** Terminal edges already seen; a listed id is live again. */ + terminalTaskIds: Map + /** Read only for a task the roster lists for the first time. */ + now: () => number + maxTasks: number +} + +/** Apply one roster; returns the ids it listed, in the provider's order. */ +export function replaceClaudeAggregateRoster(input: ClaudeAggregateRosterInput): string[] { + const { tasks, retention, terminalTaskIds, maxTasks } = input + const prior = new Map(tasks) + tasks.clear() + const roster = new Map() + for (const valueTask of input.value) { + if (roster.size >= maxTasks) { + break + } + const task = record(valueTask) + if (!task || task.ambient === true) { + continue + } + const id = taskId(task) + if (!id) { + continue + } + // An authoritative live roster supersedes an earlier terminal edge — for + // the ids it actually lists. Wiping the whole set left a finished + // FOREGROUND id undefended, since the start guard now convicts only + // backgrounded starts. + terminalTaskIds.delete(id) + const existing = prior.get(id) ?? retention.resume(id) + const kind = classifyClaudeBackgroundTaskKind(task.task_type) + roster.set(id, { + backgrounded: true, + liveInTurn: true, + kind: kind !== 'unknown' ? kind : (existing?.kind ?? 'unknown'), + description: taskDescription(task.description) ?? existing?.description, + name: taskName(task) ?? existing?.name, + state: liveClaudeTaskRunState(task.status) ?? existing?.state, + startedAt: existing?.startedAt ?? input.now(), + totalTokens: existing?.totalTokens, + ...(existing?.toolUseId !== undefined ? { toolUseId: existing.toolUseId } : {}) + }) + } + // Live foreground work is not in a BACKGROUND roster and is not superseded + // by one. Budget counted up front so eviction drops the STALEST retained + // rows rather than the newest, and roster entries are never starved. + const retainable = [...prior].filter( + ([id, task]) => !task.backgrounded && task.liveInTurn && !roster.has(id) + ) + let evict = Math.max(0, roster.size + retainable.length - maxTasks) + // Retained rows keep their own relative order and stay ahead of the roster, + // so a live row the user is reading does not drop below it when a roster + // frame lands. Within the roster the PROVIDER's order wins — including for + // a task it reports live again, which belongs where the provider lists it + // rather than appended after the rows that outlived it. + for (const [id, task] of retainable) { + if (evict > 0) { + evict -= 1 + continue + } + tasks.set(id, task) + } + for (const [id, task] of roster) { + tasks.set(id, task) + } + for (const [id, task] of prior) { + if (task.backgrounded && !tasks.has(id)) { + retention.rememberRemoved(id, task) + } + } + return [...roster.keys()] +} diff --git a/src/main/claude/claude-background-task-tracker.ts b/src/main/claude/claude-background-task-tracker.ts index 14f4271b7d8a..1d440e541919 100644 --- a/src/main/claude/claude-background-task-tracker.ts +++ b/src/main/claude/claude-background-task-tracker.ts @@ -3,16 +3,31 @@ import type { AgentSessionBackgroundTaskRunState, AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' +import { replaceClaudeAggregateRoster } from './claude-background-task-aggregate-roster' import { classifyClaudeBackgroundTaskKind, liveClaudeTaskRunState, record, + taskAliasId, taskDescription, taskId, taskName, taskUsageTotalTokens, terminalClaudeTaskRunState } from './claude-background-task-frames' +import { + claudeSpawnResults, + claudeTaskProgressFacts, + pendingClaudeInventory, + pendingClaudeNotification, + pendingClaudeRestart, + pendingClaudeSessionEnded, + pendingClaudeTaskLive, + pendingClaudeTerminalUpdate, + pendingClaudeTurnEnded, + type ClaudePendingChildWork +} from './claude-child-work-evidence' import { ClaudeSettledBackgroundTasks, claudeBackgroundTaskDetail, @@ -34,7 +49,10 @@ const MAX_TRACKED_TASKS = 256 export class ClaudeBackgroundTaskTracker { private readonly tasks = new Map() private readonly retention = new ClaudeSettledBackgroundTasks() - private readonly terminalTaskIds = new Set() + /** Terminal edges seen, with the spawn call each ended under. */ + private readonly terminalTaskIds = new Map() + /** Child-work evidence decided since the last drain; see `claude-child-work-evidence`. */ + private readonly childWork: ClaudePendingChildWork[] = [] private aggregateRosterObserved = false private monitoring = false private publishedTasksFingerprint = '' @@ -62,6 +80,11 @@ export class ClaudeBackgroundTaskTracker { return ids } + /** The evidence queued since the last drain, stamped with the host clock of the caller. */ + drainChildWorkEvidence(observedAt: number): AgentChildWorkEvidence[] { + return this.childWork.splice(0).map((edge) => edge(observedAt)) + } + observe(message: Record, startsTurn = false): boolean { // Background work publishes through a foreground turn: the strip stays // honest mid-fan-out and the client alone decides when the idle-only @@ -75,7 +98,10 @@ export class ClaudeBackgroundTaskTracker { // live work. if (startsTurn || message.type === 'result') { this.settleForegroundTasks() + this.childWork.push(pendingClaudeTurnEnded) } + // The legacy row keeps a foreground child until `result`; only the record learns its ending. + this.childWork.push(...claudeSpawnResults(message, this.tasks)) if (message.type === 'system') { if (!this.observeSystemFrame(message) && !startsTurn) { return false @@ -91,6 +117,7 @@ export class ClaudeBackgroundTaskTracker { this.retention.clear() this.terminalTaskIds.clear() this.aggregateRosterObserved = false + this.childWork.push(pendingClaudeSessionEnded) return this.refreshMonitoring() } @@ -129,12 +156,18 @@ export class ClaudeBackgroundTaskTracker { this.settle(id, terminalClaudeTaskRunState(message.status) ?? 'done', { totalTokens: taskUsageTotalTokens(message) }) + this.childWork.push(pendingClaudeNotification(id, message)) return true } if (message.subtype === 'task_progress') { // Progress `description` is the current activity ("Running "), not // the task's name — only usage (and a missing identity) may update. const existing = this.tasks.get(id) + if (existing) { + // Every child's progress reaches its record; the legacy row takes a background one's usage. + const named = { ...existing, name: existing.name ?? taskName(message) } + this.childWork.push(pendingClaudeTaskLive(id, named, claudeTaskProgressFacts(message))) + } const totalTokens = taskUsageTotalTokens(message) if (!existing?.backgrounded || totalTokens === undefined) { return false @@ -145,7 +178,14 @@ export class ClaudeBackgroundTaskTracker { if (message.subtype === 'task_updated') { return this.observeTaskUpdated(id, message) } - if (message.subtype !== 'task_started' || this.terminalTaskIds.has(id)) { + if (message.subtype !== 'task_started') { + return false + } + if (this.terminalTaskIds.has(id)) { + const restart = pendingClaudeRestart(id, message, this.terminalTaskIds.get(id)) + if (restart) { + this.childWork.push(restart) + } return false } if (message.ambient === true || message.skip_transcript === true) { @@ -167,7 +207,8 @@ export class ClaudeBackgroundTaskTracker { description: taskDescription(message.description), name: taskName(message), state: liveClaudeTaskRunState(message.status) ?? undefined, - startedAt: this.now() + startedAt: this.now(), + toolUseId: taskAliasId(message.tool_use_id) }) return true } @@ -180,6 +221,7 @@ export class ClaudeBackgroundTaskTracker { const settledState = terminalClaudeTaskRunState(patch.status) if (settledState) { this.settle(id, settledState) + this.childWork.push(pendingClaudeTerminalUpdate(id, message)) return true } const existing = this.tasks.get(id) @@ -212,67 +254,23 @@ export class ClaudeBackgroundTaskTracker { if (!Array.isArray(value)) { return } - const prior = new Map(this.tasks) this.aggregateRosterObserved = true - this.tasks.clear() - const roster = new Map() - for (const valueTask of value) { - if (roster.size >= MAX_TRACKED_TASKS) { - break - } - const task = record(valueTask) - if (!task || task.ambient === true) { - continue - } - const id = taskId(task) - if (!id) { - continue - } - // An authoritative live roster supersedes an earlier terminal edge — for - // the ids it actually lists. Wiping the whole set left a finished - // FOREGROUND id undefended, since the start guard now convicts only - // backgrounded starts. - this.terminalTaskIds.delete(id) - const existing = prior.get(id) ?? this.retention.resume(id) - const kind = classifyClaudeBackgroundTaskKind(task.task_type) - roster.set(id, { - backgrounded: true, - liveInTurn: true, - kind: kind !== 'unknown' ? kind : (existing?.kind ?? 'unknown'), - description: taskDescription(task.description) ?? existing?.description, - name: taskName(task) ?? existing?.name, - state: liveClaudeTaskRunState(task.status) ?? existing?.state, - startedAt: existing?.startedAt ?? this.now(), - totalTokens: existing?.totalTokens - }) - } - // Live foreground work is not in a BACKGROUND roster and is not superseded - // by one. Budget counted up front so eviction drops the STALEST retained - // rows rather than the newest, and roster entries are never starved. - const retainable = [...prior].filter( - ([id, task]) => !task.backgrounded && task.liveInTurn && !roster.has(id) + const listed = replaceClaudeAggregateRoster({ + value, + tasks: this.tasks, + retention: this.retention, + terminalTaskIds: this.terminalTaskIds, + now: this.now, + maxTasks: MAX_TRACKED_TASKS + }) + this.childWork.push( + pendingClaudeInventory( + listed.flatMap((id) => { + const task = this.tasks.get(id) + return task ? [[id, task] as const] : [] + }) + ) ) - let evict = Math.max(0, roster.size + retainable.length - MAX_TRACKED_TASKS) - // Retained rows keep their own relative order and stay ahead of the roster, - // so a live row the user is reading does not drop below it when a roster - // frame lands. Within the roster the PROVIDER's order wins — including for - // a task it reports live again, which belongs where the provider lists it - // rather than appended after the rows that outlived it. - for (const [id, task] of retainable) { - if (evict > 0) { - evict -= 1 - continue - } - this.tasks.set(id, task) - } - for (const [id, task] of roster) { - this.tasks.set(id, task) - } - for (const [id, task] of prior) { - if (task.backgrounded && !this.tasks.has(id)) { - this.retention.rememberRemoved(id, task) - } - } } private upsert(id: string, task: Omit): void { @@ -291,29 +289,31 @@ export class ClaudeBackgroundTaskTracker { } const existing = this.tasks.get(id) ?? this.retention.resume(id) this.terminalTaskIds.delete(id) - if (existing) { - this.tasks.set(id, { - backgrounded: existing.backgrounded || task.backgrounded, - // A settled foreground task is not revived by a late edge frame. - liveInTurn: existing.liveInTurn, - kind: task.kind !== 'unknown' ? task.kind : existing.kind, - description: task.description ?? existing.description, - name: task.name ?? existing.name, - state: task.state ?? existing.state, - startedAt: existing.startedAt, - totalTokens: existing.totalTokens - }) - return - } - this.tasks.set(id, { ...task, liveInTurn: true }) + const next: TrackedClaudeBackgroundTask = existing + ? { + backgrounded: existing.backgrounded || task.backgrounded, + // A settled foreground task is not revived by a late edge frame. + liveInTurn: existing.liveInTurn, + kind: task.kind !== 'unknown' ? task.kind : existing.kind, + description: task.description ?? existing.description, + name: task.name ?? existing.name, + state: task.state ?? existing.state, + startedAt: existing.startedAt, + totalTokens: existing.totalTokens, + toolUseId: task.toolUseId ?? existing.toolUseId + } + : { ...task, liveInTurn: true } + this.tasks.set(id, next) + this.childWork.push(pendingClaudeTaskLive(id, next)) } private finish(id: string): void { + const toolUseId = this.tasks.get(id)?.toolUseId this.tasks.delete(id) this.terminalTaskIds.delete(id) - this.terminalTaskIds.add(id) + this.terminalTaskIds.set(id, toolUseId) if (this.terminalTaskIds.size > MAX_TRACKED_TASKS) { - const oldest = this.terminalTaskIds.values().next() + const oldest = this.terminalTaskIds.keys().next() if (!oldest.done) { this.terminalTaskIds.delete(oldest.value) } 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 000000000000..fba2614c82ed --- /dev/null +++ b/src/main/claude/claude-child-work-evidence.ts @@ -0,0 +1,267 @@ +// Claude task frames, decoded into child-work evidence for the host's records. +// +// The background-task tracker already reads every task frame and decides when a task is live, +// settled, or dropped by a roster; it queues an edge at each of those decisions. This module +// holds what those edges say, the frames only this path reads (a spawn call's result, the +// progress the tracker's legacy row ignores), and the owner lookup through the journal's +// linkage. Edges are stamped with the host clock when drained, after the journal has handled the +// frame, so the host never admits evidence ahead of the frame's own rows. + +import type { + AgentChildWorkOperation, + AgentChildWorkOutcome +} from '../../shared/agent-status-child-work' +import type { + AgentChildWorkEvidence, + AgentChildWorkEvidenceHandle, + AgentChildWorkLiveObservation +} from '../../shared/agent-status-child-work-evidence' +import { + classifyClaudeBackgroundTaskKind, + record, + taskAliasId, + taskDescription, + taskName, + taskText, + taskUsageTotalTokens +} from './claude-background-task-frames' +import { isAgentChildWorkKind } from '../../shared/agent-status-child-work-liveness' +import type { TrackedClaudeBackgroundTask } from './claude-settled-background-tasks' +import { claudeToolResults, readClaudeMessageEnvelope } from './claude-structured-item-translation' + +/** An edge the tracker decided on, stamped with the host clock once the frame is journaled. */ +export type ClaudePendingChildWork = (observedAt: number) => AgentChildWorkEvidence + +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' + } +} + +function claudeTaskHandle(id: string, toolUseId?: string): AgentChildWorkEvidenceHandle { + return { idKind: 'task_id', id, ...(toolUseId !== undefined ? { runId: toolUseId } : {}) } +} + +/** One tracked task as the host reads it: the tracker's own live row, plus what this frame said. */ +export function claudeTaskObservation( + id: string, + task: TrackedClaudeBackgroundTask, + facts: ClaudeTaskFacts = {}, + observedAt = 0 +): AgentChildWorkLiveObservation { + const totalTokens = facts.totalTokens ?? task.totalTokens + const operation: AgentChildWorkOperation | undefined = facts.toolName + ? { toolName: facts.toolName, basis: 'reported', observedAt } + : undefined + return { + handle: claudeTaskHandle(id, task.toolUseId), + kind: task.kind, + residency: task.backgrounded ? 'background' : 'foreground', + // The legacy row's own state rule, so both read the same child the same way. + state: task.state === 'working' || 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 } : {}), + ...(totalTokens !== undefined ? { totalTokens } : {}), + ...(operation ? { operation } : {}), + ...(facts.lastMessage ? { lastMessage: facts.lastMessage } : {}), + // Only a backgrounded task has a stop the host can target. + stoppable: task.backgrounded + } +} + +export function pendingClaudeTaskLive( + id: string, + task: TrackedClaudeBackgroundTask, + facts: ClaudeTaskFacts = {} +): ClaudePendingChildWork { + const snapshot = { ...task } + return (observedAt) => ({ + type: 'live', + observedAt, + child: claudeTaskObservation(id, snapshot, facts, observedAt) + }) +} + +/** 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 } : {}) + } +} + +function pendingClaudeTaskEnded( + id: string, + outcome: AgentChildWorkOutcome, + reported: { lastMessage?: string; totalTokens?: number; toolUseId?: string } +): ClaudePendingChildWork { + return (observedAt) => ({ + type: 'ended', + observedAt, + handle: claudeTaskHandle(id, reported.toolUseId), + outcome, + ...(reported.lastMessage ? { lastMessage: reported.lastMessage } : {}), + ...(reported.totalTokens !== undefined ? { totalTokens: reported.totalTokens } : {}) + }) +} + +export const pendingClaudeTurnEnded: ClaudePendingChildWork = (observedAt) => ({ + type: 'turn-ended', + observedAt +}) + +export const pendingClaudeSessionEnded: ClaudePendingChildWork = (observedAt) => ({ + type: 'session-ended', + observedAt +}) + +/** A `task_notification`: the child's own ending, with its final summary and usage. */ +export function pendingClaudeNotification( + id: string, + message: Record +): ClaudePendingChildWork { + return pendingClaudeTaskEnded(id, claudeChildWorkOutcome(message.status), { + lastMessage: taskText(message.summary), + totalTokens: taskUsageTotalTokens(message) + }) +} + +/** A terminal `task_updated`: its error, when it has one, is the child's last word. */ +export function pendingClaudeTerminalUpdate( + id: string, + message: Record +): ClaudePendingChildWork { + const patch = record(message.patch) + return pendingClaudeTaskEnded(id, claudeChildWorkOutcome(patch?.status), { + lastMessage: taskText(patch?.error) + }) +} + +/** The roster's complete live BACKGROUND inventory: a background child it omits has ended. */ +export function pendingClaudeInventory( + listed: readonly (readonly [string, TrackedClaudeBackgroundTask])[] +): ClaudePendingChildWork { + const children = listed.map(([id, task]) => [id, { ...task }] as const) + return (observedAt) => ({ + type: 'inventory', + observedAt, + residency: 'background', + children: children.map(([id, task]) => claudeTaskObservation(id, task)) + }) +} + +/** + * A start for a task that already ended. The legacy row waits for the roster to list it again; + * a start under a NEW spawn call is the provider running the child again, and the record hears + * it now. The spawn call it ended under, or none at all, is a late frame for the run that ended. + */ +export function pendingClaudeRestart( + id: string, + message: Record, + endedUnder: string | undefined +): ClaudePendingChildWork | null { + const toolUseId = taskAliasId(message.tool_use_id) + if ( + toolUseId === undefined || + toolUseId === endedUnder || + message.ambient === true || + message.skip_transcript === true + ) { + return null + } + const kind = classifyClaudeBackgroundTaskKind(message.task_type) + return pendingClaudeTaskLive(id, { + backgrounded: message.is_backgrounded === true || kind === 'workflow' || kind === 'monitor', + liveInTurn: true, + kind, + description: taskDescription(message.description), + name: taskName(message), + startedAt: 0, + toolUseId + }) +} + +function foregroundAgentFor( + tasks: ReadonlyMap, + toolUseId: string +): string | null { + for (const [id, task] of tasks) { + if (task.toolUseId === toolUseId && !task.backgrounded && isAgentChildWorkKind(task.kind)) { + return id + } + } + return null +} + +/** + * A spawn call's result, for each FOREGROUND agent task this frame answers. The call blocked on + * the child, so its result is the child's ending; a backgrounded spawn returns at launch and + * proves nothing. + */ +export function claudeSpawnResults( + message: Record, + tasks: ReadonlyMap +): ClaudePendingChildWork[] { + const envelope = message.type === 'user' ? readClaudeMessageEnvelope(message) : null + if (!envelope) { + return [] + } + return claudeToolResults(envelope).flatMap((result) => { + const id = foregroundAgentFor(tasks, result.toolUseId) + if (id === null) { + return [] + } + return [ + pendingClaudeTaskEnded(id, result.failed ? 'failed' : 'succeeded', { + lastMessage: taskText(result.output), + toolUseId: result.toolUseId + }) + ] + }) +} + +/** 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, and neither does work a + * backgrounded child launched: such a child sends no traffic to attribute. */ +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.type === 'inventory' + ? { ...edge, children: edge.children.map(owned) } + : edge + ) +} diff --git a/src/main/claude/claude-settled-background-tasks.ts b/src/main/claude/claude-settled-background-tasks.ts index 73064becec4b..d5f96dc96a1e 100644 --- a/src/main/claude/claude-settled-background-tasks.ts +++ b/src/main/claude/claude-settled-background-tasks.ts @@ -29,6 +29,8 @@ export type TrackedClaudeBackgroundTask = { * so clients can render elapsed and keep a stable first-seen sort. */ startedAt: number totalTokens?: number + /** The spawn call of the current run: the spawn's result and the child's own traffic name it. */ + toolUseId?: string } export function claudeBackgroundTaskDetail( diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 8dacc26ce558..1fcd961da810 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -57,6 +57,9 @@ export type ClaudeJournalTranslator = { * a client's Stop names. Sole owner: no reader keeps a copy to disagree with. */ readonly currentTurnId: string | null flush: () => void + /** 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 retryPendingTaskRows?: () => StructuredAgentSessionSinkAdmission /** Streamed blocks still awaiting a final frame. A settled turn leaves none. */ readonly pendingStreamedBlocks: number @@ -318,6 +321,12 @@ export function createClaudeJournalTranslator( return turn.id }, flush: streamedText.flush, + childToolOwner: (toolUseId) => { + const ownerRef = toolOrigins.childOwnerRef(toolUseId) + return ownerRef === null + ? null + : (subagents.linkage.settledLinkageFor(ownerRef).linkage.agentId ?? null) + }, retryPendingTaskRows: () => backgroundTasks.retryPendingWrites(), get pendingStreamedBlocks() { return streamedText.pending diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index d28b07dc16d3..5136366d9c9b 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -29,6 +29,7 @@ import { } from './claude-structured-session-exit-lifecycle' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { resolveClaudeProviderHistoryWindow } from './claude-structured-history-window' +import { withClaudeChildWorkOwners } from './claude-child-work-evidence' import { admitClaudePromptCancellation, answerClaudeStructuredPrompt, @@ -145,6 +146,17 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda session ? backgroundTaskState(session) : null ) } + this.publishChildWork(event.sessionId, session) + } + + /** 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): void { + const drained = session?.backgroundTasks.drainChildWorkEvidence(this.deps.now?.() ?? Date.now()) + const evidence = withClaudeChildWorkOwners(drained ?? [], session?.translator?.childToolOwner) + if (evidence.length > 0) { + this.deps.onChildWorkEvidence?.(sessionId, evidence) + } } bindPromptItemId( @@ -222,8 +234,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, @@ -244,7 +269,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, @@ -254,7 +283,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-state.ts b/src/main/claude/claude-structured-session-state.ts index 3c6a8413a917..48d1dc1824a5 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -17,6 +17,7 @@ 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 { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' import type { ClaudeSessionStartupGate } from './claude-structured-session-startup-gate' @@ -89,6 +90,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 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 7681e0f34362..aa3f298a0778 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 439ffe978e11..e288d4262746 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 @@ -336,6 +336,7 @@ 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. */ 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 09a6248c0b72..b98b8b0c65bb 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.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts index 088879f8ddc2..e7a270d10c4a 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 fb22f92c392e..edc4426a2a93 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/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 0ec7996bb91b..05645aaac17b 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -283,6 +283,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 6e84eed7eb5c..d290c56b226e 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -39,6 +39,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. */ @@ -130,6 +131,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 } : {}) }) diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 5fe914de2a2a..c2676a3e5f9e 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-evidence-admission.ts b/src/shared/agent-status-child-work-evidence-admission.ts new file mode 100644 index 000000000000..2426775308c2 --- /dev/null +++ b/src/shared/agent-status-child-work-evidence-admission.ts @@ -0,0 +1,270 @@ +// 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' +import { normalizeOptionalField } from './agent-status-field-normalization' + +/** 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 +/** The record codec's label bound. */ +const CHILD_WORK_LABEL_MAX_LENGTH = 512 +const LABEL_SCAN_MAX_LENGTH = CHILD_WORK_LABEL_MAX_LENGTH * 4 + +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 }) + } +} + +/** Labels are one-line text: the record codec refuses raw provider text, and a refused label + * would cost the child its whole update. */ +function childWorkLabel(value: string | undefined): string | undefined { + if (value === undefined) { + return undefined + } + let text = '' + for (const char of value.slice(0, LABEL_SCAN_MAX_LENGTH)) { + const code = char.charCodeAt(0) + text += code <= 0x1f || code === 0x7f ? ' ' : char + } + return normalizeOptionalField(text, CHILD_WORK_LABEL_MAX_LENGTH) +} + +/** 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, + floor: number, + observedAt: number +): AgentChildWorkOperation | undefined { + const next = + reported === undefined + ? current + : reported === null + ? current?.basis === 'open' + ? undefined + : current + : reported + return next + ? { ...next, observedAt: Math.min(Math.max(next.observedAt, floor), observedAt) } + : undefined +} + +/** 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 runVerdict( + 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. */ +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 parentChildWorkId = owner ?? existing?.parentChildWorkId + const name = childWorkLabel(child.name) ?? existing?.name + const description = childWorkLabel(child.description) ?? existing?.description + const agentType = childWorkLabel(child.agentType) ?? existing?.agentType + const totalTokens = child.totalTokens ?? existing?.totalTokens + const lastMessage = child.lastMessage ?? prior?.lastMessage + const operation = nextOperation( + child.operation, + prior?.operation, + existing?.firstObservedAt ?? at, + at + ) + return { + kind: child.kind, + state: child.state, + membership: 'live', + ...(name !== undefined ? { name } : {}), + ...(description !== undefined ? { description } : {}), + ...(agentType !== undefined ? { agentType } : {}), + ...(existing?.model !== undefined ? { model: existing.model } : {}), + ...(totalTokens !== undefined ? { totalTokens } : {}), + ...(parentChildWorkId !== undefined ? { parentChildWorkId } : {}), + residency: child.residency, + ...(operation ? { operation } : {}), + ...(lastMessage !== undefined ? { lastMessage } : {}), + observedAt: at, + stoppable: child.stoppable, + provenance: STRUCTURED_CHILD_WORK_PROVENANCE + } +} + +export function applyAgentChildWorkLive( + ctx: AgentChildWorkEvidenceContext, + child: AgentChildWorkLiveObservation, + observedAt: number, + listed: 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 = runVerdict(ctx, existing, handle.runId) + if (run === 'previous') { + return + } + // A different spawn call for the same child is the provider starting it again; an + // authoritative inventory listing a settled child is the provider saying it runs again. + if (run === 'new' || (existing.membership === 'settled' && listed)) { + 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 + } + const totalTokens = reported.totalTokens ?? existing.totalTokens + const lastMessage = reported.lastMessage ?? existing.lastMessage + const result = ctx.admission.announce({ + kind: existing.kind, + state: 'done', + membership: 'settled', + outcome, + ...(existing.name !== undefined ? { name: existing.name } : {}), + ...(existing.description !== undefined ? { description: existing.description } : {}), + ...(existing.agentType !== undefined ? { agentType: existing.agentType } : {}), + ...(existing.model !== undefined ? { model: existing.model } : {}), + ...(totalTokens !== undefined ? { totalTokens } : {}), + ...(existing.parentChildWorkId !== undefined + ? { parentChildWorkId: existing.parentChildWorkId } + : {}), + ...(existing.residency !== undefined ? { residency: existing.residency } : {}), + ...(lastMessage !== undefined ? { 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 000000000000..d524cb1789bd --- /dev/null +++ b/src/shared/agent-status-child-work-evidence-resolution.ts @@ -0,0 +1,173 @@ +// 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 STABLE_ALIAS_KINDS: ReadonlySet = new Set(['task_id', 'thread_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 +} + +/** The handles a record answers to for its current run. */ +export function currentAgentChildWorkAliases( + scope: AgentChildWorkEvidenceScope, + child: AgentChildWorkRecord +): { stableId?: string; runId?: string; aliases: AgentChildWorkObservationAlias[] } { + const current = scope.store + .getAliasesForChild(child.childWorkId) + .filter((alias) => agentChildWorkFencesEqual(alias.fence, child.invocation)) + return { + stableId: current.find((alias) => STABLE_ALIAS_KINDS.has(alias.aliasKind))?.alias, + 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 000000000000..21e3a126f7b6 --- /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, how one ended, +// which children an authoritative inventory lists. The settlement rules that turn an inventory +// or a turn boundary into endings live with the records, where residency is stored. + +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 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 +} + +/** A complete list of the live children of one residency class. A child of that class the host + * holds live and the list omits has ended, with an outcome nobody reported. */ +export type AgentChildWorkInventoryEvidence = { + type: 'inventory' + observedAt: number + residency: AgentChildWorkResidency + children: AgentChildWorkLiveObservation[] +} + +/** The session's own turn is over: work bound to it can no longer be running. */ +export type AgentChildWorkTurnEndedEvidence = { type: 'turn-ended'; observedAt: number } + +/** The provider session is gone; its children go with it. */ +export type AgentChildWorkSessionEndedEvidence = { type: 'session-ended'; observedAt: number } + +export type AgentChildWorkEvidence = + | AgentChildWorkLiveEvidence + | AgentChildWorkEndedEvidence + | AgentChildWorkInventoryEvidence + | AgentChildWorkTurnEndedEvidence + | AgentChildWorkSessionEndedEvidence 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 000000000000..dac142bcd0d1 --- /dev/null +++ b/src/shared/agent-status-child-work-reconciliation.ts @@ -0,0 +1,149 @@ +// 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. Settlement from +// evidence that names no single child (an inventory, a turn boundary) is decided here from the +// residency the record stores, so every producer shares one rule. 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, + AgentChildWorkInventoryEvidence +} from './agent-status-child-work-evidence' +import { + applyAgentChildWorkLive, + settleAgentChildWork, + runVerdict, + STRUCTURED_CHILD_WORK_MAX_LIVE, + 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 + } + if (runVerdict(ctx, existing, edge.handle.runId) !== 'current') { + return + } + // A reported ending latches. An `unknown` one (an inventory omitted the child before its own + // terminal frame arrived) learns the outcome that frame reports. + if (existing.membership === 'settled' && existing.outcome !== 'unknown') { + return + } + settleAgentChildWork(ctx, existing, edge.outcome, edge.observedAt, { + ...(edge.lastMessage !== undefined ? { lastMessage: edge.lastMessage } : {}), + ...(edge.totalTokens !== undefined ? { totalTokens: edge.totalTokens } : {}) + }) +} + +function applyInventory(ctx: ReconcileContext, edge: AgentChildWorkInventoryEvidence): void { + const listed = new Set() + for (const child of edge.children.slice(0, STRUCTURED_CHILD_WORK_MAX_LIVE)) { + listed.add(child.handle.id) + applyAgentChildWorkLive(ctx, child, edge.observedAt, true) + } + for (const record of ownedStructuredChildWork(ctx)) { + const stableId = currentAgentChildWorkAliases(ctx, record).stableId + if ( + record.membership === 'live' && + record.residency === edge.residency && + (stableId === undefined || !listed.has(stableId)) + ) { + settleAgentChildWork(ctx, record, 'unknown', edge.observedAt) + } + } +} + +function settleResidents(ctx: ReconcileContext, residency: 'foreground', observedAt: number): void { + for (const record of ownedStructuredChildWork(ctx)) { + if (record.membership === 'live' && record.residency === residency) { + 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, false) + } else if (edge.type === 'ended') { + applyEnded(ctx, edge) + } else if (edge.type === 'inventory') { + applyInventory(ctx, edge) + } else if (edge.type === 'turn-ended') { + settleResidents(ctx, 'foreground', edge.observedAt) + } else { + removeChildren( + ctx, + ownedStructuredChildWork(ctx).map((record) => record.childWorkId) + ) + } + } + trimSettled(ctx) + return ctx.outcome +} From d1694df34b43a45c4a2d3ed50f23d952d5d0563f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:03:05 -0700 Subject: [PATCH 03/21] test(native-chat): pin the Claude child-work evidence and the host reduction of it --- .../claude/claude-child-work-evidence.test.ts | 234 +++++++++++ ...nt-status-child-work-evidence-admission.ts | 33 +- ...t-status-child-work-reconciliation.test.ts | 382 ++++++++++++++++++ .../agent-status-child-work-reconciliation.ts | 4 +- 4 files changed, 629 insertions(+), 24 deletions(-) create mode 100644 src/main/claude/claude-child-work-evidence.test.ts create mode 100644 src/shared/agent-status-child-work-reconciliation.test.ts diff --git a/src/main/claude/claude-child-work-evidence.test.ts b/src/main/claude/claude-child-work-evidence.test.ts new file mode 100644 index 000000000000..325135da045a --- /dev/null +++ b/src/main/claude/claude-child-work-evidence.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from 'vitest' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' +import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' + +function system(subtype: string, fields: Record): Record { + return { type: 'system', subtype, session_id: 'provider-1', uuid: crypto.randomUUID(), ...fields } +} + +function spawnResult(toolUseId: string, isError = false): Record { + return { + type: 'user', + session_id: 'provider-1', + uuid: crypto.randomUUID(), + parent_tool_use_id: null, + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: toolUseId, + content: 'Found 3 flaky tests', + is_error: isError + } + ] + } + } +} + +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 drained(tracker: ClaudeBackgroundTaskTracker, at = 500): AgentChildWorkEvidence[] { + return tracker.drainChildWorkEvidence(at) +} + +describe('Claude child-work evidence from the task tracker', () => { + it('names a started child by its task id and the spawn call of this run', () => { + const tracker = new ClaudeBackgroundTaskTracker(() => 100) + tracker.observe(foregroundAgent) + expect(drained(tracker)).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(drained(tracker)).toEqual([]) + }) + + it("carries a FOREGROUND child's progress to its record while its legacy row stays as it was", () => { + const tracker = new ClaudeBackgroundTaskTracker(() => 100) + tracker.observe(foregroundAgent) + drained(tracker) + const before = tracker.state + expect( + tracker.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 } + }) + ) + ).toBe(false) + expect(tracker.state).toEqual(before) + expect(drained(tracker, 600)).toEqual([ + expect.objectContaining({ + type: 'live', + observedAt: 600, + child: expect.objectContaining({ + handle: { idKind: 'task_id', id: 'agent-fg', runId: 'toolu_fg' }, + residency: 'foreground', + // 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, not the legacy run state', () => { + const tracker = new ClaudeBackgroundTaskTracker(() => 100) + tracker.observe(backgroundAgent) + drained(tracker) + tracker.observe( + system('task_notification', { + task_id: 'agent-bg', + status: 'failed', + summary: 'Build broke', + usage: { total_tokens: 900 } + }) + ) + // The legacy row keeps calling a failure `blocked`; the record hears `failed`. + expect(tracker.state?.settledTasks).toBeUndefined() + expect(drained(tracker)).toEqual([ + { + type: 'ended', + observedAt: 500, + handle: { idKind: 'task_id', id: 'agent-bg' }, + outcome: 'failed', + lastMessage: 'Build broke', + totalTokens: 900 + } + ]) + for (const [status, outcome] of [ + ['completed', 'succeeded'], + ['killed', 'cancelled'], + ['stopped', 'cancelled'], + ['whatever', 'unknown'] + ]) { + tracker.observe(system('task_notification', { task_id: 'agent-bg', status })) + expect(drained(tracker)).toEqual([expect.objectContaining({ type: 'ended', outcome })]) + } + tracker.observe( + system('task_updated', { task_id: 'agent-bg', patch: { status: 'failed', error: 'OOM' } }) + ) + expect(drained(tracker)).toEqual([ + expect.objectContaining({ type: 'ended', outcome: 'failed', lastMessage: 'OOM' }) + ]) + }) + + it("ends a foreground child on its spawn call's result, and ignores a backgrounded spawn's", () => { + const tracker = new ClaudeBackgroundTaskTracker(() => 100) + tracker.observe(foregroundAgent) + tracker.observe(backgroundAgent) + drained(tracker) + const rows = tracker.state + expect(tracker.observe(spawnResult('toolu_bg'))).toBe(false) + expect(drained(tracker)).toEqual([]) + expect(tracker.observe(spawnResult('toolu_fg', true))).toBe(false) + expect(tracker.state).toEqual(rows) + expect(drained(tracker)).toEqual([ + { + type: 'ended', + observedAt: 500, + handle: { idKind: 'task_id', id: 'agent-fg', runId: 'toolu_fg' }, + outcome: 'failed', + lastMessage: 'Found 3 flaky tests' + } + ]) + }) + + it('lists the whole background roster as one inventory, after the tracker applied it', () => { + const tracker = new ClaudeBackgroundTaskTracker(() => 100) + tracker.observe(backgroundAgent) + drained(tracker) + tracker.observe( + system('background_tasks_changed', { + tasks: [{ task_id: 'shell-1', task_type: 'local_bash', description: 'sleep 30' }] + }) + ) + expect(drained(tracker)).toEqual([ + { + type: 'inventory', + observedAt: 500, + residency: 'background', + children: [ + expect.objectContaining({ + handle: { idKind: 'task_id', id: 'shell-1' }, + kind: 'command', + residency: 'background', + description: 'sleep 30', + stoppable: true + }) + ] + } + ]) + }) + + it('tells a restart under a new spawn call from a late start of the run that ended', () => { + const tracker = new ClaudeBackgroundTaskTracker(() => 100) + tracker.observe(backgroundAgent) + tracker.observe(system('task_notification', { task_id: 'agent-bg', status: 'completed' })) + drained(tracker) + tracker.observe(backgroundAgent) + expect(drained(tracker)).toEqual([]) + const before = tracker.state + tracker.observe( + system('task_started', { + task_id: 'agent-bg', + tool_use_id: 'toolu_resume', + task_type: 'local_agent', + is_backgrounded: true + }) + ) + expect(tracker.state).toEqual(before) + expect(drained(tracker)).toEqual([ + expect.objectContaining({ + type: 'live', + child: expect.objectContaining({ + handle: { idKind: 'task_id', id: 'agent-bg', runId: 'toolu_resume' }, + residency: 'background' + }) + }) + ]) + }) + + it('marks a turn boundary and the end of the provider session', () => { + const tracker = new ClaudeBackgroundTaskTracker(() => 100) + tracker.observe({ type: 'result', subtype: 'success' }) + tracker.observe({ type: 'user' }, true) + tracker.clear() + expect(drained(tracker)).toEqual([ + { type: 'turn-ended', observedAt: 500 }, + { type: 'turn-ended', observedAt: 500 }, + { type: 'session-ended', observedAt: 500 } + ]) + }) +}) diff --git a/src/shared/agent-status-child-work-evidence-admission.ts b/src/shared/agent-status-child-work-evidence-admission.ts index 2426775308c2..b0623abeff60 100644 --- a/src/shared/agent-status-child-work-evidence-admission.ts +++ b/src/shared/agent-status-child-work-evidence-admission.ts @@ -74,26 +74,20 @@ function childWorkLabel(value: string | undefined): string | undefined { /** 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, - floor: number, - observedAt: number + current: AgentChildWorkOperation | undefined ): AgentChildWorkOperation | undefined { - const next = - reported === undefined - ? current - : reported === null - ? current?.basis === 'open' - ? undefined - : current - : reported - return next - ? { ...next, observedAt: Math.min(Math.max(next.observedAt, floor), observedAt) } - : 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 runVerdict( +export function agentChildWorkRunVerdict( ctx: AgentChildWorkEvidenceContext, existing: AgentChildWorkRecord, runId: string | undefined @@ -126,12 +120,7 @@ function liveFields( const agentType = childWorkLabel(child.agentType) ?? existing?.agentType const totalTokens = child.totalTokens ?? existing?.totalTokens const lastMessage = child.lastMessage ?? prior?.lastMessage - const operation = nextOperation( - child.operation, - prior?.operation, - existing?.firstObservedAt ?? at, - at - ) + const operation = nextOperation(child.operation, prior?.operation) return { kind: child.kind, state: child.state, @@ -189,7 +178,7 @@ export function applyAgentChildWorkLive( counted(ctx, handle.id, result, 'admitted') return } - const run = runVerdict(ctx, existing, handle.runId) + const run = agentChildWorkRunVerdict(ctx, existing, handle.runId) if (run === 'previous') { return } 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 000000000000..35907d0297c5 --- /dev/null +++ b/src/shared/agent-status-child-work-reconciliation.test.ts @@ -0,0 +1,382 @@ +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 a roster names it before its spawn call is known', () => { + const { store, apply } = harness() + const unnamed = child('task-a', { handle: { idKind: 'task_id', id: 'task-a' } }) + apply({ type: 'inventory', observedAt: 100, residency: 'background', children: [unnamed] }) + 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('keeps an outcome that arrives after a roster already dropped the child', () => { + const { store, apply } = harness() + apply(live(child('task-a')), live(child('task-b'), 100)) + apply({ + type: 'inventory', + observedAt: 200, + residency: 'background', + children: [child('task-b')] + }) + 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('settles only background children an inventory omits, and revives one it lists again', () => { + const { store, apply } = harness() + apply(live(child('bg')), live(child('fg', { residency: 'foreground', stoppable: false })), { + type: 'inventory', + observedAt: 200, + residency: 'background', + children: [] + }) + expect(records(store).map(({ membership, outcome }) => ({ membership, outcome }))).toEqual([ + { membership: 'settled', outcome: 'unknown' }, + { membership: 'live', outcome: undefined } + ]) + apply({ type: 'inventory', observedAt: 300, residency: 'background', children: [child('bg')] }) + expect(records(store)[0]).toMatchObject({ + membership: 'live', + invocation: { invocationId: 'toolu_bg', generation: 2 } + }) + }) + + it('reads an empty inventory as no live background work, and no inventory as no evidence', () => { + const { store, apply } = harness() + apply(live(child('bg'))) + apply(live(child('bg'), 150)) + expect(only(store).membership).toBe('live') + apply({ type: 'inventory', observedAt: 200, residency: 'background', children: [] }) + expect(only(store)).toMatchObject({ membership: 'settled', outcome: 'unknown' }) + }) + + it('ends foreground children still open when the turn ends, and leaves the rest', () => { + const { store, apply } = harness() + apply( + live(child('open-fg', { residency: 'foreground' })), + live(child('done-fg', { residency: 'foreground' })), + live(child('bg')), + { + type: 'ended', + observedAt: 150, + handle: { idKind: 'task_id', id: 'done-fg', runId: 'toolu_done-fg' }, + outcome: 'succeeded' + }, + { type: 'turn-ended', observedAt: 200 } + ) + expect(records(store).map(({ membership, outcome }) => ({ membership, outcome }))).toEqual([ + { membership: 'settled', outcome: 'unknown' }, + { membership: 'settled', outcome: 'succeeded' }, + { membership: 'live', outcome: undefined } + ]) + }) + + 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('folds raw provider labels to one line before admission', () => { + 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('drops the session children when the provider session ends', () => { + const { store, apply } = harness() + apply(live(child('task-a')), live(child('task-b'))) + expect(apply({ type: 'session-ended', observedAt: 200 })).toMatchObject({ removed: 2 }) + expect(records(store)).toEqual([]) + // Its old bindings fence the finished lifetime; the id is free again for a new one. + 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, + provenance: { source: 'hook', producerId: 'another-producer' } + }) + apply( + live(child('task-a')), + { type: 'turn-ended', observedAt: 150 }, + { type: 'inventory', observedAt: 160, residency: 'background', children: [] }, + { type: 'session-ended', observedAt: 170 } + ) + expect(records(store)).toEqual([ + expect.objectContaining({ childWorkId: 'foreign', membership: 'live' }) + ]) + }) + + 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 index dac142bcd0d1..4fe85791e096 100644 --- a/src/shared/agent-status-child-work-reconciliation.ts +++ b/src/shared/agent-status-child-work-reconciliation.ts @@ -14,7 +14,7 @@ import type { import { applyAgentChildWorkLive, settleAgentChildWork, - runVerdict, + agentChildWorkRunVerdict, STRUCTURED_CHILD_WORK_MAX_LIVE, type AgentChildWorkEvidenceContext, type AgentChildWorkReconcileOutcome @@ -48,7 +48,7 @@ function applyEnded(ctx: ReconcileContext, edge: AgentChildWorkEndedEvidence): v if (!existing) { return } - if (runVerdict(ctx, existing, edge.handle.runId) !== 'current') { + if (agentChildWorkRunVerdict(ctx, existing, edge.handle.runId) !== 'current') { return } // A reported ending latches. An `unknown` one (an inventory omitted the child before its own From 41a74385f2a744d2ec4fad31b2f39e504a072ace Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:10:41 -0700 Subject: [PATCH 04/21] 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. --- .../server-ingest-structured-children.test.ts | 123 +++++++ ...ude-structured-child-work-producer.test.ts | 305 ++++++++++++++++++ ...ent-session-status-feed-child-work.test.ts | 86 +++++ ...red-agent-session-status-ownership.test.ts | 15 + ...ude-structured-session-integration.test.ts | 44 ++- ...time-structured-status-sink-wiring.test.ts | 3 + 6 files changed, 575 insertions(+), 1 deletion(-) create mode 100644 src/main/agent-hooks/server-ingest-structured-children.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 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 000000000000..4fe2e01e1ff5 --- /dev/null +++ b/src/main/agent-hooks/server-ingest-structured-children.test.ts @@ -0,0 +1,123 @@ +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(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/claude/claude-structured-child-work-producer.test.ts b/src/main/claude/claude-structured-child-work-producer.test.ts new file mode 100644 index 000000000000..31096c52cea3 --- /dev/null +++ b/src/main/claude/claude-structured-child-work-producer.test.ts @@ -0,0 +1,305 @@ +// A Claude session's frames, through the real adapter, into the host's child records: the order +// the host receives them in, and whether the parent row the records imply is today's row. + +import { describe, expect, it } from 'vitest' +import { foldAgentLeadStatus } from '../../shared/agent-lead-status-fold' +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 { agentChildWorkLiveness } from '../../shared/agent-status-child-work-liveness' +import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child-work-reconciliation' +import { createAgentStatusStore } from '../../shared/agent-status-store' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import { + fakeClaude, + identityFor, + PROVIDER_SESSION_ID +} from './claude-structured-session-test-support' + +const parent = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + 'session-1' +) + +let uuid = 0 +function frame(fields: Record): Record { + return { session_id: PROVIDER_SESSION_ID, uuid: `frame-${++uuid}`, ...fields } +} +function system(subtype: string, fields: Record) { + return frame({ type: 'system', subtype, ...fields }) +} +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 }] + } + }) +} +function toolResult(toolUseId: string, text: string, parentToolUseId: string | null = null) { + return frame({ + type: 'user', + parent_tool_use_id: parentToolUseId, + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: toolUseId, content: text }] + } + }) +} + +type Delivery = { kind: 'journal' | 'legacy' | 'evidence'; detail: string } + +async function producer() { + 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}` + })() + }) + const deliveries: Delivery[] = [] + const evidenceLog: AgentChildWorkEvidence[][] = [] + 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: () => 1_700_000_000_500, + 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) + reconcileAgentChildWorkEvidence({ store, admission, parent, provider: 'claude', evidence }) + } + }) + const journal: StructuredAgentSessionEventSink = { + appendItem: (identity) => + deliveries.push({ kind: 'journal', detail: JSON.stringify(identity) }), + 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) + } + const records = (): AgentChildWorkRecord[] => store.getChildren(parent) + const byDescription = (description: string) => + records().find((record) => record.description === description) + return { adapter, store, send, records, byDescription, evidenceLog } +} + +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('records the parent state today reads, frame by frame, while adding outcome and activity', async () => { + const { adapter, send, records, byDescription } = await producer() + const steps: { + message: Record + lead: 'working' | 'done' + check?: () => void + }[] = [ + { + message: toolUse('toolu_fg', 'Agent', { description: 'Find flaky tests' }), + lead: 'working' + }, + { + 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 + }), + lead: 'working' + }, + // The foreground child starts a background shell of its own. + { + message: toolUse( + 'toolu_bash', + 'Bash', + { command: 'npm test', run_in_background: true }, + 'toolu_fg' + ), + lead: 'working' + }, + { + message: system('task_started', { + task_id: 'shell-1', + tool_use_id: 'toolu_bash', + task_type: 'local_bash', + description: 'npm test', + is_backgrounded: true + }), + lead: 'working' + }, + { + message: system('background_tasks_changed', { + tasks: [{ task_id: 'shell-1', task_type: 'local_bash', description: 'npm test' }] + }), + lead: 'working', + check: () => + expect(byDescription('npm test')?.parentChildWorkId).toBe( + byDescription('Find flaky tests')?.childWorkId + ) + }, + { + 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 } + }), + lead: 'working', + check: () => + expect(byDescription('Find flaky tests')).toMatchObject({ + operation: { toolName: 'Bash', basis: 'reported' }, + totalTokens: 1_200 + }) + }, + { + message: toolResult('toolu_fg', 'Two tests flake on CI'), + lead: 'working', + 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' }), + lead: 'working' + }, + { + message: system('task_started', { + task_id: 'agent-bg', + tool_use_id: 'toolu_bg', + task_type: 'local_agent', + description: 'Audit the build', + is_backgrounded: true + }), + lead: 'working' + }, + { + 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' } + ] + }), + lead: 'working' + }, + { message: frame({ type: 'result', subtype: 'success', is_error: false }), lead: 'done' }, + // 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' }] + }), + lead: 'done', + check: () => expect(byDescription('Audit the build')).toMatchObject({ outcome: 'unknown' }) + }, + { + message: system('task_notification', { + task_id: 'agent-bg', + status: 'failed', + summary: 'Build broke', + usage: { total_tokens: 900 } + }), + lead: 'done', + check: () => + expect(byDescription('Audit the build')).toMatchObject({ + membership: 'settled', + outcome: 'failed', + lastMessage: 'Build broke' + }) + }, + { message: system('background_tasks_changed', { tasks: [] }), lead: 'done' }, + { + message: system('task_updated', { task_id: 'shell-1', patch: { status: 'killed' } }), + lead: 'done', + check: () => expect(byDescription('npm test')).toMatchObject({ outcome: 'cancelled' }) + }, + // Claude resumes a finished background agent by listing it again. + { + message: system('background_tasks_changed', { + tasks: [{ task_id: 'agent-bg', task_type: 'local_agent', description: 'Audit the build' }] + }), + lead: 'done', + check: () => + expect(byDescription('Audit the build')).toMatchObject({ + membership: 'live', + invocation: { generation: 2 }, + previousInvocations: [expect.objectContaining({ outcome: 'failed' })] + }) + } + ] + for (const [index, step] of steps.entries()) { + send(step.message) + const legacy = agentChildWorkLiveness(adapter.backgroundTaskState('session-1')?.tasks) + const recorded = agentChildWorkLiveness( + records().filter((record) => record.membership === 'live') + ) + const fold = (childWorkLiveness: typeof legacy) => + foldAgentLeadStatus({ leadState: step.lead, childWorkLiveness }) + expect({ index, parent: fold(recorded) }).toEqual({ index, parent: fold(legacy) }) + if (step.lead === 'done') { + expect({ index, liveness: recorded }).toEqual({ index, liveness: legacy }) + } + step.check?.() + } + await adapter.closeSession('session-1') + expect(records()).toEqual([]) + expect(adapter.backgroundTaskState('session-1')).toBeUndefined() + }) +}) 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 000000000000..c5e101ffebbc --- /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: 'turn-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-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts index 7e9e6da84a36..7b5be4c0ffa8 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,19 @@ 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() + }) }) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index e840da4944bf..ecd334585bf9 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -21,6 +21,7 @@ import type { StructuredAgentSessionHandoffTransport, StructuredTuiOwner } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +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' @@ -153,6 +154,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[] = [] @@ -240,6 +243,7 @@ beforeEach(async () => { claude = fakeClaude(PROVIDER_SESSION) tuiOwner = null cleanups = new Map() + childWork = [] const handoffTransport: StructuredAgentSessionHandoffTransport = { hostLabel: 'Scripted Claude host', launchTui: async ({ record, fence, spawnToken }) => { @@ -320,7 +324,12 @@ beforeEach(async () => { resolveShellEnvironmentPolicy: () => shellEnvironmentPolicy, resolveClaudeAuthPolicy: () => claudeAuthPolicy, openClaudeConnection: claude.openConnection, - handoffTransport + handoffTransport, + 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)?.(), @@ -339,6 +348,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 decaa5cc5994..8b048f168d49 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)') } ) }) From bf038fe5892d75d59a2948f4ff1d281a33eca4f4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:20:56 -0700 Subject: [PATCH 05/21] test(native-chat): read an optional task list as optional in the producer script --- src/main/claude/claude-structured-child-work-producer.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/claude/claude-structured-child-work-producer.test.ts b/src/main/claude/claude-structured-child-work-producer.test.ts index 31096c52cea3..e391ce77da05 100644 --- a/src/main/claude/claude-structured-child-work-producer.test.ts +++ b/src/main/claude/claude-structured-child-work-producer.test.ts @@ -87,7 +87,7 @@ async function producer() { now: () => 1_700_000_000_500, persistHandle: async () => {}, onBackgroundTasksChanged: (_sessionId, state) => - deliveries.push({ kind: 'legacy', detail: String(state?.tasks.length ?? 0) }), + 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(',') }) From 5fd3c3017a3ffb3d9f948ed0ae6f3825769eda14 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:21:47 -0700 Subject: [PATCH 06/21] test(native-chat): an address whose publish threw carries no child work --- .../structured-agent-session-status-ownership.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 7b5be4c0ffa8..09fcb481e0ed 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 @@ -108,5 +108,15 @@ describe('structured status owner address retention', () => { 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() }) }) From 5fd283d626275480fe711e0a305a8859045f8d27 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:23:28 -0700 Subject: [PATCH 07/21] test(agent-status): a foreign record differs from ours by producer alone --- src/shared/agent-status-child-work-reconciliation.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/shared/agent-status-child-work-reconciliation.test.ts b/src/shared/agent-status-child-work-reconciliation.test.ts index 35907d0297c5..0a0379c9ad5d 100644 --- a/src/shared/agent-status-child-work-reconciliation.test.ts +++ b/src/shared/agent-status-child-work-reconciliation.test.ts @@ -300,7 +300,8 @@ describe('structured child-work reconciliation', () => { residency: 'foreground', observedAt: 50, stoppable: true, - provenance: { source: 'hook', producerId: 'another-producer' } + // Same source, same parent and provider: only the producer differs. + provenance: { source: 'structured-session', producerId: 'another-producer' } }) apply( live(child('task-a')), From 7fdf1a3d72e5ee66909966c5df10768b993d3e92 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:32:47 -0700 Subject: [PATCH 08/21] 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. --- src/main/claude/claude-child-work-evidence.ts | 63 ++++++++++++++++++- ...ude-structured-child-work-producer.test.ts | 14 ++++- .../claude-structured-journal-translation.ts | 12 ++++ .../claude-structured-session-adapter.ts | 13 ++-- ...t-status-child-work-evidence-resolution.ts | 23 ++++++- .../agent-status-child-work-evidence.ts | 12 ++++ ...t-status-child-work-reconciliation.test.ts | 32 ++++++++++ .../agent-status-child-work-reconciliation.ts | 35 ++++++++++- 8 files changed, 193 insertions(+), 11 deletions(-) diff --git a/src/main/claude/claude-child-work-evidence.ts b/src/main/claude/claude-child-work-evidence.ts index fba2614c82ed..a07833826b47 100644 --- a/src/main/claude/claude-child-work-evidence.ts +++ b/src/main/claude/claude-child-work-evidence.ts @@ -27,7 +27,14 @@ import { } from './claude-background-task-frames' import { isAgentChildWorkKind } from '../../shared/agent-status-child-work-liveness' import type { TrackedClaudeBackgroundTask } from './claude-settled-background-tasks' -import { claudeToolResults, readClaudeMessageEnvelope } from './claude-structured-item-translation' +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' /** An edge the tracker decided on, stamped with the host clock once the frame is journaled. */ export type ClaudePendingChildWork = (observedAt: number) => AgentChildWorkEvidence @@ -265,3 +272,57 @@ export function withClaudeChildWorkOwners( : edge ) } + +/** + * A child's own tool traffic, read after the journal handled the frame: the call the child has + * open now (a foreground child's traffic reaches the parent's stream; a backgrounded child's does + * not), 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 [] + } + const decided = session.backgroundTasks.drainChildWorkEvidence(observedAt) + return [ + ...withClaudeChildWorkOwners(decided, session.translator?.childToolOwner), + ...(message ? claudeChildOperation(message, session.translator?.childActivity, observedAt) : []) + ] +} diff --git a/src/main/claude/claude-structured-child-work-producer.test.ts b/src/main/claude/claude-structured-child-work-producer.test.ts index e391ce77da05..abf4fe435f86 100644 --- a/src/main/claude/claude-structured-child-work-producer.test.ts +++ b/src/main/claude/claude-structured-child-work-producer.test.ts @@ -171,7 +171,14 @@ describe('Claude structured child-work producer', () => { { command: 'npm test', run_in_background: true }, 'toolu_fg' ), - lead: 'working' + lead: 'working', + // 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', { @@ -193,6 +200,11 @@ describe('Claude structured child-work producer', () => { byDescription('Find flaky tests')?.childWorkId ) }, + { + message: toolResult('toolu_bash', 'Command running in background', 'toolu_fg'), + lead: 'working', + check: () => expect(byDescription('Find flaky tests')?.operation).toBeUndefined() + }, { message: system('task_progress', { task_id: 'agent-fg', diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 1fcd961da810..1fdf824b1f3d 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -60,6 +60,8 @@ export type ClaudeJournalTranslator = { /** 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 } retryPendingTaskRows?: () => StructuredAgentSessionSinkAdmission /** Streamed blocks still awaiting a final frame. A settled turn leaves none. */ readonly pendingStreamedBlocks: number @@ -327,6 +329,16 @@ export function createClaudeJournalTranslator( ? null : (subagents.linkage.settledLinkageFor(ownerRef).linkage.agentId ?? null) }, + childActivity: (parentToolUseId) => { + let openTool: ClaudeToolUse | null = null + for (const tool of tools.values()) { + if (toolOrigins.childOwnerRef(tool.id) === parentToolUseId) { + openTool = tool + } + } + const { agentId } = subagents.linkage.settledLinkageFor(parentToolUseId).linkage + return { agentId: agentId ?? parentToolUseId, openTool } + }, retryPendingTaskRows: () => backgroundTasks.retryPendingWrites(), get pendingStreamedBlocks() { return streamedText.pending diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 5136366d9c9b..9e8628f51791 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -29,7 +29,7 @@ import { } from './claude-structured-session-exit-lifecycle' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { resolveClaudeProviderHistoryWindow } from './claude-structured-history-window' -import { withClaudeChildWorkOwners } from './claude-child-work-evidence' +import { drainClaudeChildWork } from './claude-child-work-evidence' import { admitClaudePromptCancellation, answerClaudeStructuredPrompt, @@ -146,14 +146,17 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda session ? backgroundTaskState(session) : null ) } - this.publishChildWork(event.sessionId, session) + 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): void { - const drained = session?.backgroundTasks.drainChildWorkEvidence(this.deps.now?.() ?? Date.now()) - const evidence = withClaudeChildWorkOwners(drained ?? [], session?.translator?.childToolOwner) + 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) } diff --git a/src/shared/agent-status-child-work-evidence-resolution.ts b/src/shared/agent-status-child-work-evidence-resolution.ts index d524cb1789bd..50c80e5bdbd7 100644 --- a/src/shared/agent-status-child-work-evidence-resolution.ts +++ b/src/shared/agent-status-child-work-evidence-resolution.ts @@ -20,7 +20,6 @@ import { agentStatusSubjectsEqual, type AgentStatusSubject } from './agent-statu * 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 STABLE_ALIAS_KINDS: ReadonlySet = new Set(['task_id', 'thread_id']) const RUN_ALIAS_KIND: AgentChildWorkAliasKind = 'tool_use_id' export const STRUCTURED_CHILD_WORK_PROVENANCE: AgentChildWorkProvenance = { @@ -137,16 +136,34 @@ export function resolveAgentChildWorkOwner( 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 -): { stableId?: string; runId?: string; aliases: AgentChildWorkObservationAlias[] } { +): { + 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 { - stableId: current.find((alias) => STABLE_ALIAS_KINDS.has(alias.aliasKind))?.alias, + ...(stable ? { stable } : {}), + stableId: stable?.id, runId: current.find((alias) => alias.aliasKind === RUN_ALIAS_KIND)?.alias, aliases: current.map((alias) => ({ segmentId: alias.segmentId, diff --git a/src/shared/agent-status-child-work-evidence.ts b/src/shared/agent-status-child-work-evidence.ts index 21e3a126f7b6..97188330f590 100644 --- a/src/shared/agent-status-child-work-evidence.ts +++ b/src/shared/agent-status-child-work-evidence.ts @@ -48,6 +48,17 @@ export type AgentChildWorkLiveEvidence = { child: AgentChildWorkLiveObservation } +/** 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' @@ -75,6 +86,7 @@ export type AgentChildWorkSessionEndedEvidence = { type: 'session-ended'; observ export type AgentChildWorkEvidence = | AgentChildWorkLiveEvidence + | AgentChildWorkOperationEvidence | AgentChildWorkEndedEvidence | AgentChildWorkInventoryEvidence | AgentChildWorkTurnEndedEvidence diff --git a/src/shared/agent-status-child-work-reconciliation.test.ts b/src/shared/agent-status-child-work-reconciliation.test.ts index 0a0379c9ad5d..5fa649bf75a5 100644 --- a/src/shared/agent-status-child-work-reconciliation.test.ts +++ b/src/shared/agent-status-child-work-reconciliation.test.ts @@ -263,6 +263,38 @@ describe('structured child-work reconciliation', () => { 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('folds raw provider labels to one line before admission', () => { const { store, apply } = harness() const result = apply( diff --git a/src/shared/agent-status-child-work-reconciliation.ts b/src/shared/agent-status-child-work-reconciliation.ts index 4fe85791e096..3438ee43ab86 100644 --- a/src/shared/agent-status-child-work-reconciliation.ts +++ b/src/shared/agent-status-child-work-reconciliation.ts @@ -9,7 +9,8 @@ import type { AgentChildWorkAdmission } from './agent-status-child-work-admissio import type { AgentChildWorkEndedEvidence, AgentChildWorkEvidence, - AgentChildWorkInventoryEvidence + AgentChildWorkInventoryEvidence, + AgentChildWorkOperationEvidence } from './agent-status-child-work-evidence' import { applyAgentChildWorkLive, @@ -62,6 +63,36 @@ function applyEnded(ctx: ReconcileContext, edge: AgentChildWorkEndedEvidence): v }) } +/** 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 + ) +} + function applyInventory(ctx: ReconcileContext, edge: AgentChildWorkInventoryEvidence): void { const listed = new Set() for (const child of edge.children.slice(0, STRUCTURED_CHILD_WORK_MAX_LIVE)) { @@ -131,6 +162,8 @@ export function reconcileAgentChildWorkEvidence( } if (edge.type === 'live') { applyAgentChildWorkLive(ctx, edge.child, edge.observedAt, false) + } else if (edge.type === 'operation') { + applyOperation(ctx, edge) } else if (edge.type === 'ended') { applyEnded(ctx, edge) } else if (edge.type === 'inventory') { From 5821a2e6bb69c93af0bcc481c9d7ee996ecbc108 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:17:29 -0700 Subject: [PATCH 09/21] 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. --- ...claude-background-task-aggregate-roster.ts | 7 +- .../claude/claude-background-task-restarts.ts | 69 ++++++++++++++++ .../claude/claude-background-task-tracker.ts | 25 +++--- .../claude/claude-child-work-evidence.test.ts | 45 +++++++++++ src/main/claude/claude-child-work-evidence.ts | 55 +++---------- ...ude-structured-child-work-producer.test.ts | 79 +++++++++++++++++-- 6 files changed, 215 insertions(+), 65 deletions(-) create mode 100644 src/main/claude/claude-background-task-restarts.ts diff --git a/src/main/claude/claude-background-task-aggregate-roster.ts b/src/main/claude/claude-background-task-aggregate-roster.ts index 5742f0397f65..82dce4644796 100644 --- a/src/main/claude/claude-background-task-aggregate-roster.ts +++ b/src/main/claude/claude-background-task-aggregate-roster.ts @@ -9,6 +9,7 @@ import { taskId, taskName } from './claude-background-task-frames' +import type { ClaudeTaskRestarts } from './claude-background-task-restarts' import type { ClaudeSettledBackgroundTasks, TrackedClaudeBackgroundTask @@ -21,6 +22,8 @@ export type ClaudeAggregateRosterInput = { retention: ClaudeSettledBackgroundTasks /** Terminal edges already seen; a listed id is live again. */ terminalTaskIds: Map + /** Runs started again after they ended; a listing hands one back to the live map. */ + restarts: Pick /** Read only for a task the roster lists for the first time. */ now: () => number maxTasks: number @@ -49,7 +52,9 @@ export function replaceClaudeAggregateRoster(input: ClaudeAggregateRosterInput): // FOREGROUND id undefended, since the start guard now convicts only // backgrounded starts. terminalTaskIds.delete(id) + const restarted = input.restarts.take(id) const existing = prior.get(id) ?? retention.resume(id) + const toolUseId = restarted?.toolUseId ?? existing?.toolUseId const kind = classifyClaudeBackgroundTaskKind(task.task_type) roster.set(id, { backgrounded: true, @@ -60,7 +65,7 @@ export function replaceClaudeAggregateRoster(input: ClaudeAggregateRosterInput): state: liveClaudeTaskRunState(task.status) ?? existing?.state, startedAt: existing?.startedAt ?? input.now(), totalTokens: existing?.totalTokens, - ...(existing?.toolUseId !== undefined ? { toolUseId: existing.toolUseId } : {}) + ...(toolUseId !== undefined ? { toolUseId } : {}) }) } // Live foreground work is not in a BACKGROUND roster and is not superseded diff --git a/src/main/claude/claude-background-task-restarts.ts b/src/main/claude/claude-background-task-restarts.ts new file mode 100644 index 000000000000..797b9d886af9 --- /dev/null +++ b/src/main/claude/claude-background-task-restarts.ts @@ -0,0 +1,69 @@ +// Tasks Claude started again, under a new spawn call, after they had ended. +// +// The legacy row keeps such a task hidden until a roster lists it again, so it stays out of the +// tracker's live map. Its record hears the new run at once, and holding the run here is what lets +// that run's own progress and spawn result reach the record in the meantime. + +import { + classifyClaudeBackgroundTaskKind, + taskAliasId, + taskDescription, + taskName +} from './claude-background-task-frames' +import { pendingClaudeTaskLive, type ClaudePendingChildWork } from './claude-child-work-evidence' +import type { TrackedClaudeBackgroundTask } from './claude-settled-background-tasks' + +const MAX_RESTARTED_TASKS = 256 + +export class ClaudeTaskRestarts { + readonly tasks = new Map() + + /** A start for a task that already ended. The spawn call it ended under, or none at all, is a + * late frame for the run that ended; a new one is the provider running the child again. */ + observe( + id: string, + message: Record, + endedUnder: string | undefined + ): ClaudePendingChildWork | null { + const toolUseId = taskAliasId(message.tool_use_id) + if ( + toolUseId === undefined || + toolUseId === endedUnder || + message.ambient === true || + message.skip_transcript === true + ) { + return null + } + const kind = classifyClaudeBackgroundTaskKind(message.task_type) + const task: TrackedClaudeBackgroundTask = { + backgrounded: message.is_backgrounded === true || kind === 'workflow' || kind === 'monitor', + liveInTurn: true, + kind, + description: taskDescription(message.description), + name: taskName(message), + startedAt: 0, + toolUseId + } + this.tasks.delete(id) + this.tasks.set(id, task) + if (this.tasks.size > MAX_RESTARTED_TASKS) { + this.tasks.delete(this.tasks.keys().next().value ?? id) + } + return pendingClaudeTaskLive(id, task) + } + + get(id: string): TrackedClaudeBackgroundTask | undefined { + return this.tasks.get(id) + } + + /** The run ended again, or a roster listed it and the tracker holds it from now on. */ + take(id: string): TrackedClaudeBackgroundTask | undefined { + const task = this.tasks.get(id) + this.tasks.delete(id) + return task + } + + clear(): void { + this.tasks.clear() + } +} diff --git a/src/main/claude/claude-background-task-tracker.ts b/src/main/claude/claude-background-task-tracker.ts index 1d440e541919..4f1e7934109f 100644 --- a/src/main/claude/claude-background-task-tracker.ts +++ b/src/main/claude/claude-background-task-tracker.ts @@ -21,13 +21,13 @@ import { claudeTaskProgressFacts, pendingClaudeInventory, pendingClaudeNotification, - pendingClaudeRestart, pendingClaudeSessionEnded, pendingClaudeTaskLive, pendingClaudeTerminalUpdate, pendingClaudeTurnEnded, type ClaudePendingChildWork } from './claude-child-work-evidence' +import { ClaudeTaskRestarts } from './claude-background-task-restarts' import { ClaudeSettledBackgroundTasks, claudeBackgroundTaskDetail, @@ -51,6 +51,7 @@ export class ClaudeBackgroundTaskTracker { private readonly retention = new ClaudeSettledBackgroundTasks() /** Terminal edges seen, with the spawn call each ended under. */ private readonly terminalTaskIds = new Map() + private readonly restarts = new ClaudeTaskRestarts() /** Child-work evidence decided since the last drain; see `claude-child-work-evidence`. */ private readonly childWork: ClaudePendingChildWork[] = [] private aggregateRosterObserved = false @@ -71,13 +72,7 @@ export class ClaudeBackgroundTaskTracker { } get stoppableTaskIds(): string[] { - const ids: string[] = [] - for (const [id, task] of this.tasks) { - if (task.backgrounded) { - ids.push(id) - } - } - return ids + return [...this.tasks].flatMap(([id, task]) => (task.backgrounded ? [id] : [])) } /** The evidence queued since the last drain, stamped with the host clock of the caller. */ @@ -101,7 +96,7 @@ export class ClaudeBackgroundTaskTracker { this.childWork.push(pendingClaudeTurnEnded) } // The legacy row keeps a foreground child until `result`; only the record learns its ending. - this.childWork.push(...claudeSpawnResults(message, this.tasks)) + this.childWork.push(...claudeSpawnResults(message, [this.tasks, this.restarts.tasks])) if (message.type === 'system') { if (!this.observeSystemFrame(message) && !startsTurn) { return false @@ -116,6 +111,7 @@ export class ClaudeBackgroundTaskTracker { this.tasks.clear() this.retention.clear() this.terminalTaskIds.clear() + this.restarts.clear() this.aggregateRosterObserved = false this.childWork.push(pendingClaudeSessionEnded) return this.refreshMonitoring() @@ -163,9 +159,10 @@ export class ClaudeBackgroundTaskTracker { // Progress `description` is the current activity ("Running "), not // the task's name — only usage (and a missing identity) may update. const existing = this.tasks.get(id) - if (existing) { + const run = existing ?? this.restarts.get(id) + if (run) { // Every child's progress reaches its record; the legacy row takes a background one's usage. - const named = { ...existing, name: existing.name ?? taskName(message) } + const named = { ...run, name: run.name ?? taskName(message) } this.childWork.push(pendingClaudeTaskLive(id, named, claudeTaskProgressFacts(message))) } const totalTokens = taskUsageTotalTokens(message) @@ -182,7 +179,7 @@ export class ClaudeBackgroundTaskTracker { return false } if (this.terminalTaskIds.has(id)) { - const restart = pendingClaudeRestart(id, message, this.terminalTaskIds.get(id)) + const restart = this.restarts.observe(id, message, this.terminalTaskIds.get(id)) if (restart) { this.childWork.push(restart) } @@ -260,6 +257,7 @@ export class ClaudeBackgroundTaskTracker { tasks: this.tasks, retention: this.retention, terminalTaskIds: this.terminalTaskIds, + restarts: this.restarts, now: this.now, maxTasks: MAX_TRACKED_TASKS }) @@ -308,7 +306,8 @@ export class ClaudeBackgroundTaskTracker { } private finish(id: string): void { - const toolUseId = this.tasks.get(id)?.toolUseId + const restarted = this.restarts.take(id) + const toolUseId = (this.tasks.get(id) ?? restarted)?.toolUseId this.tasks.delete(id) this.terminalTaskIds.delete(id) this.terminalTaskIds.set(id, toolUseId) diff --git a/src/main/claude/claude-child-work-evidence.test.ts b/src/main/claude/claude-child-work-evidence.test.ts index 325135da045a..0a913f04f766 100644 --- a/src/main/claude/claude-child-work-evidence.test.ts +++ b/src/main/claude/claude-child-work-evidence.test.ts @@ -218,6 +218,51 @@ describe('Claude child-work evidence from the task tracker', () => { }) }) ]) + // Until a roster lists it, the new run's progress still reaches its record. + tracker.observe(system('task_progress', { task_id: 'agent-bg', last_tool_name: 'Read' })) + expect(tracker.state).toEqual(before) + expect(drained(tracker)).toEqual([ + expect.objectContaining({ + child: expect.objectContaining({ + handle: { idKind: 'task_id', id: 'agent-bg', runId: 'toolu_resume' }, + operation: expect.objectContaining({ toolName: 'Read' }) + }) + }) + ]) + // A roster listing hands the run back to the live map under its new spawn call. + tracker.observe( + system('background_tasks_changed', { + tasks: [{ task_id: 'agent-bg', task_type: 'local_agent' }] + }) + ) + expect(drained(tracker)).toEqual([ + expect.objectContaining({ + type: 'inventory', + children: [ + expect.objectContaining({ + handle: { idKind: 'task_id', id: 'agent-bg', runId: 'toolu_resume' } + }) + ] + }) + ]) + }) + + it("ends a restarted foreground run on its own spawn call's result", () => { + const tracker = new ClaudeBackgroundTaskTracker(() => 100) + tracker.observe(foregroundAgent) + tracker.observe(system('task_notification', { task_id: 'agent-fg', status: 'completed' })) + tracker.observe({ ...foregroundAgent, tool_use_id: 'toolu_fg_2' }) + drained(tracker) + tracker.observe(spawnResult('toolu_fg')) + expect(drained(tracker)).toEqual([]) + tracker.observe(spawnResult('toolu_fg_2', true)) + expect(drained(tracker)).toEqual([ + expect.objectContaining({ + type: 'ended', + handle: { idKind: 'task_id', id: 'agent-fg', runId: 'toolu_fg_2' }, + outcome: 'failed' + }) + ]) }) it('marks a turn boundary and the end of the provider session', () => { diff --git a/src/main/claude/claude-child-work-evidence.ts b/src/main/claude/claude-child-work-evidence.ts index a07833826b47..417fae8cc135 100644 --- a/src/main/claude/claude-child-work-evidence.ts +++ b/src/main/claude/claude-child-work-evidence.ts @@ -16,15 +16,7 @@ import type { AgentChildWorkEvidenceHandle, AgentChildWorkLiveObservation } from '../../shared/agent-status-child-work-evidence' -import { - classifyClaudeBackgroundTaskKind, - record, - taskAliasId, - taskDescription, - taskName, - taskText, - taskUsageTotalTokens -} from './claude-background-task-frames' +import { record, taskText, taskUsageTotalTokens } from './claude-background-task-frames' import { isAgentChildWorkKind } from '../../shared/agent-status-child-work-liveness' import type { TrackedClaudeBackgroundTask } from './claude-settled-background-tasks' import type { ClaudeSession } from './claude-structured-session-state' @@ -180,44 +172,15 @@ export function pendingClaudeInventory( }) } -/** - * A start for a task that already ended. The legacy row waits for the roster to list it again; - * a start under a NEW spawn call is the provider running the child again, and the record hears - * it now. The spawn call it ended under, or none at all, is a late frame for the run that ended. - */ -export function pendingClaudeRestart( - id: string, - message: Record, - endedUnder: string | undefined -): ClaudePendingChildWork | null { - const toolUseId = taskAliasId(message.tool_use_id) - if ( - toolUseId === undefined || - toolUseId === endedUnder || - message.ambient === true || - message.skip_transcript === true - ) { - return null - } - const kind = classifyClaudeBackgroundTaskKind(message.task_type) - return pendingClaudeTaskLive(id, { - backgrounded: message.is_backgrounded === true || kind === 'workflow' || kind === 'monitor', - liveInTurn: true, - kind, - description: taskDescription(message.description), - name: taskName(message), - startedAt: 0, - toolUseId - }) -} - function foregroundAgentFor( - tasks: ReadonlyMap, + runs: readonly ReadonlyMap[], toolUseId: string ): string | null { - for (const [id, task] of tasks) { - if (task.toolUseId === toolUseId && !task.backgrounded && isAgentChildWorkKind(task.kind)) { - return id + for (const tasks of runs) { + for (const [id, task] of tasks) { + if (task.toolUseId === toolUseId && !task.backgrounded && isAgentChildWorkKind(task.kind)) { + return id + } } } return null @@ -230,14 +193,14 @@ function foregroundAgentFor( */ export function claudeSpawnResults( message: Record, - tasks: ReadonlyMap + runs: readonly ReadonlyMap[] ): ClaudePendingChildWork[] { const envelope = message.type === 'user' ? readClaudeMessageEnvelope(message) : null if (!envelope) { return [] } return claudeToolResults(envelope).flatMap((result) => { - const id = foregroundAgentFor(tasks, result.toolUseId) + const id = foregroundAgentFor(runs, result.toolUseId) if (id === null) { return [] } diff --git a/src/main/claude/claude-structured-child-work-producer.test.ts b/src/main/claude/claude-structured-child-work-producer.test.ts index abf4fe435f86..fb6008270f5f 100644 --- a/src/main/claude/claude-structured-child-work-producer.test.ts +++ b/src/main/claude/claude-structured-child-work-producer.test.ts @@ -46,13 +46,18 @@ function toolUse(id: string, name: string, input: unknown, parentToolUseId: stri } }) } -function toolResult(toolUseId: string, text: string, parentToolUseId: string | null = null) { +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 }] + content: [{ type: 'tool_result', tool_use_id: toolUseId, content: text, is_error: isError }] } }) } @@ -70,6 +75,8 @@ async function producer() { })() }) const deliveries: Delivery[] = [] + /** The producer linkage the journal stamped on each child row. */ + const stamps: { providerParentRef?: string; attempt?: number }[] = [] const evidenceLog: AgentChildWorkEvidence[][] = [] const adapter = new ClaudeStructuredSessionAdapter({ resolveLaunch: async () => ({ @@ -96,8 +103,12 @@ async function producer() { } }) const journal: StructuredAgentSessionEventSink = { - appendItem: (identity) => - deliveries.push({ kind: 'journal', detail: JSON.stringify(identity) }), + appendItem: (identity, _body, options) => { + deliveries.push({ kind: 'journal', detail: JSON.stringify(identity) }) + if (options?.agentId !== undefined) { + stamps.push(options) + } + }, appendTombstone: () => {}, publish: () => {} } @@ -115,7 +126,7 @@ async function producer() { const records = (): AgentChildWorkRecord[] => store.getChildren(parent) const byDescription = (description: string) => records().find((record) => record.description === description) - return { adapter, store, send, records, byDescription, evidenceLog } + return { adapter, store, send, records, byDescription, evidenceLog, stamps } } describe('Claude structured child-work producer', () => { @@ -314,4 +325,62 @@ describe('Claude structured child-work producer', () => { expect(records()).toEqual([]) expect(adapter.backgroundTaskState('session-1')).toBeUndefined() }) + + it("counts a child's runs the way the journal does, and hears a restarted run before any roster", 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 + }) + send(toolResult('toolu_2', 'Could not reproduce', null, true)) + expect(byDescription('Find flaky tests')).toMatchObject({ + membership: 'settled', + outcome: 'failed', + lastMessage: 'Could not reproduce', + invocation: { invocationId: 'toolu_2', generation: 2 }, + previousInvocations: [expect.objectContaining({ outcome: 'succeeded' })] + }) + }) }) From 0ded9770db83098e85e5861ffc752893a61b50d0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:57:53 -0700 Subject: [PATCH 10/21] 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. --- src/main/claude/claude-child-tool-queries.ts | 40 +++++++++++ .../claude-journal-translator-contract.ts | 41 +++++++++++ .../claude-structured-journal-translation.ts | 72 +++---------------- 3 files changed, 90 insertions(+), 63 deletions(-) create mode 100644 src/main/claude/claude-child-tool-queries.ts create mode 100644 src/main/claude/claude-journal-translator-contract.ts 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 000000000000..7a1e28b94d65 --- /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-journal-translator-contract.ts b/src/main/claude/claude-journal-translator-contract.ts new file mode 100644 index 000000000000..7a7fc4291c7c --- /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-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 1fdf824b1f3d..c18392e1aa94 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,42 +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 - /** 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 } - 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, @@ -129,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) => { @@ -323,22 +283,8 @@ export function createClaudeJournalTranslator( return turn.id }, flush: streamedText.flush, - childToolOwner: (toolUseId) => { - const ownerRef = toolOrigins.childOwnerRef(toolUseId) - return ownerRef === null - ? null - : (subagents.linkage.settledLinkageFor(ownerRef).linkage.agentId ?? null) - }, - childActivity: (parentToolUseId) => { - let openTool: ClaudeToolUse | null = null - for (const tool of tools.values()) { - if (toolOrigins.childOwnerRef(tool.id) === parentToolUseId) { - openTool = tool - } - } - const { agentId } = subagents.linkage.settledLinkageFor(parentToolUseId).linkage - return { agentId: agentId ?? parentToolUseId, openTool } - }, + childToolOwner: childQueries.childToolOwner, + childActivity: childQueries.childActivity, retryPendingTaskRows: () => backgroundTasks.retryPendingWrites(), get pendingStreamedBlocks() { return streamedText.pending From ac8e60a820dbb549bfc094265f7ba17aacce1d3d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:57:31 -0700 Subject: [PATCH 11/21] 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. --- src/main/claude/claude-child-work-evidence.ts | 3 +- ...nt-status-child-work-evidence-admission.ts | 35 +++++++------------ 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/src/main/claude/claude-child-work-evidence.ts b/src/main/claude/claude-child-work-evidence.ts index 417fae8cc135..574a9cc5d4df 100644 --- a/src/main/claude/claude-child-work-evidence.ts +++ b/src/main/claude/claude-child-work-evidence.ts @@ -65,7 +65,6 @@ export function claudeTaskObservation( facts: ClaudeTaskFacts = {}, observedAt = 0 ): AgentChildWorkLiveObservation { - const totalTokens = facts.totalTokens ?? task.totalTokens const operation: AgentChildWorkOperation | undefined = facts.toolName ? { toolName: facts.toolName, basis: 'reported', observedAt } : undefined @@ -78,7 +77,7 @@ export function claudeTaskObservation( // 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 } : {}), - ...(totalTokens !== undefined ? { totalTokens } : {}), + ...(facts.totalTokens !== undefined ? { totalTokens: facts.totalTokens } : {}), ...(operation ? { operation } : {}), ...(facts.lastMessage ? { lastMessage: facts.lastMessage } : {}), // Only a backgrounded task has a stop the host can target. diff --git a/src/shared/agent-status-child-work-evidence-admission.ts b/src/shared/agent-status-child-work-evidence-admission.ts index b0623abeff60..c2507a8b401c 100644 --- a/src/shared/agent-status-child-work-evidence-admission.ts +++ b/src/shared/agent-status-child-work-evidence-admission.ts @@ -102,7 +102,9 @@ export function agentChildWorkRunVerdict( return current === undefined ? 'current' : 'new' } -/** `prior` is the record when this evidence continues its current run; a new run starts bare. */ +/** `prior` is the record when this evidence continues its current run; a new run starts bare. + * Labels, tokens, owner and the last message are admission's to retain, so only this edge's + * own facts go in. */ function liveFields( ctx: AgentChildWorkEvidenceContext, child: AgentChildWorkLiveObservation, @@ -114,12 +116,9 @@ function liveFields( const at = existing ? Math.max(observedAt, existing.observedAt) : observedAt const owner = child.ownerId === undefined ? undefined : resolveAgentChildWorkOwner(ctx, child.ownerId) - const parentChildWorkId = owner ?? existing?.parentChildWorkId - const name = childWorkLabel(child.name) ?? existing?.name - const description = childWorkLabel(child.description) ?? existing?.description - const agentType = childWorkLabel(child.agentType) ?? existing?.agentType - const totalTokens = child.totalTokens ?? existing?.totalTokens - const lastMessage = child.lastMessage ?? prior?.lastMessage + const name = childWorkLabel(child.name) + const description = childWorkLabel(child.description) + const agentType = childWorkLabel(child.agentType) const operation = nextOperation(child.operation, prior?.operation) return { kind: child.kind, @@ -128,12 +127,11 @@ function liveFields( ...(name !== undefined ? { name } : {}), ...(description !== undefined ? { description } : {}), ...(agentType !== undefined ? { agentType } : {}), - ...(existing?.model !== undefined ? { model: existing.model } : {}), - ...(totalTokens !== undefined ? { totalTokens } : {}), - ...(parentChildWorkId !== undefined ? { parentChildWorkId } : {}), + ...(child.totalTokens !== undefined ? { totalTokens: child.totalTokens } : {}), + ...(owner !== undefined ? { parentChildWorkId: owner } : {}), residency: child.residency, ...(operation ? { operation } : {}), - ...(lastMessage !== undefined ? { lastMessage } : {}), + ...(child.lastMessage !== undefined ? { lastMessage: child.lastMessage } : {}), observedAt: at, stoppable: child.stoppable, provenance: STRUCTURED_CHILD_WORK_PROVENANCE @@ -229,23 +227,14 @@ export function settleAgentChildWork( ctx.outcome.rejected.push({ handleId, reason: 'unbound-child' }) return } - const totalTokens = reported.totalTokens ?? existing.totalTokens - const lastMessage = reported.lastMessage ?? existing.lastMessage + // 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, - ...(existing.name !== undefined ? { name: existing.name } : {}), - ...(existing.description !== undefined ? { description: existing.description } : {}), - ...(existing.agentType !== undefined ? { agentType: existing.agentType } : {}), - ...(existing.model !== undefined ? { model: existing.model } : {}), - ...(totalTokens !== undefined ? { totalTokens } : {}), - ...(existing.parentChildWorkId !== undefined - ? { parentChildWorkId: existing.parentChildWorkId } - : {}), - ...(existing.residency !== undefined ? { residency: existing.residency } : {}), - ...(lastMessage !== undefined ? { lastMessage } : {}), + ...(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, From fa71175a68a5f5834fe2f8819944c79b234c33a5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:47:42 -0700 Subject: [PATCH 12/21] 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. --- ...nt-status-child-work-evidence-admission.ts | 31 +++---------------- ...t-status-child-work-reconciliation.test.ts | 2 +- 2 files changed, 6 insertions(+), 27 deletions(-) diff --git a/src/shared/agent-status-child-work-evidence-admission.ts b/src/shared/agent-status-child-work-evidence-admission.ts index c2507a8b401c..e52f0d8eba38 100644 --- a/src/shared/agent-status-child-work-evidence-admission.ts +++ b/src/shared/agent-status-child-work-evidence-admission.ts @@ -21,15 +21,11 @@ import { STRUCTURED_CHILD_WORK_PROVENANCE, type AgentChildWorkEvidenceScope } from './agent-status-child-work-evidence-resolution' -import { normalizeOptionalField } from './agent-status-field-normalization' /** 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 -/** The record codec's label bound. */ -const CHILD_WORK_LABEL_MAX_LENGTH = 512 -const LABEL_SCAN_MAX_LENGTH = CHILD_WORK_LABEL_MAX_LENGTH * 4 export type AgentChildWorkReconcileOutcome = { admitted: number @@ -57,20 +53,6 @@ function counted( } } -/** Labels are one-line text: the record codec refuses raw provider text, and a refused label - * would cost the child its whole update. */ -function childWorkLabel(value: string | undefined): string | undefined { - if (value === undefined) { - return undefined - } - let text = '' - for (const char of value.slice(0, LABEL_SCAN_MAX_LENGTH)) { - const code = char.charCodeAt(0) - text += code <= 0x1f || code === 0x7f ? ' ' : char - } - return normalizeOptionalField(text, CHILD_WORK_LABEL_MAX_LENGTH) -} - /** An `open` operation ends on its own edge; a `reported` one lasts until the next report. */ function nextOperation( reported: AgentChildWorkOperation | null | undefined, @@ -103,8 +85,8 @@ export function agentChildWorkRunVerdict( } /** `prior` is the record when this evidence continues its current run; a new run starts bare. - * Labels, tokens, owner and the last message are admission's to retain, so only this edge's - * own facts go in. */ + * 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, @@ -116,17 +98,14 @@ function liveFields( const at = existing ? Math.max(observedAt, existing.observedAt) : observedAt const owner = child.ownerId === undefined ? undefined : resolveAgentChildWorkOwner(ctx, child.ownerId) - const name = childWorkLabel(child.name) - const description = childWorkLabel(child.description) - const agentType = childWorkLabel(child.agentType) const operation = nextOperation(child.operation, prior?.operation) return { kind: child.kind, state: child.state, membership: 'live', - ...(name !== undefined ? { name } : {}), - ...(description !== undefined ? { description } : {}), - ...(agentType !== undefined ? { agentType } : {}), + ...(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, diff --git a/src/shared/agent-status-child-work-reconciliation.test.ts b/src/shared/agent-status-child-work-reconciliation.test.ts index 5fa649bf75a5..ed3ab0a74843 100644 --- a/src/shared/agent-status-child-work-reconciliation.test.ts +++ b/src/shared/agent-status-child-work-reconciliation.test.ts @@ -295,7 +295,7 @@ describe('structured child-work reconciliation', () => { expect(only(store).operation).toBeUndefined() }) - it('folds raw provider labels to one line before admission', () => { + 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' })) From bf82728df66ed17e1b0ebab9ae3f43de42ef7a80 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:48:16 -0700 Subject: [PATCH 13/21] 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. --- ...ude-structured-child-work-producer.test.ts | 106 +++++++++++++++++- .../agent-status-child-work-reconciliation.ts | 7 +- 2 files changed, 102 insertions(+), 11 deletions(-) diff --git a/src/main/claude/claude-structured-child-work-producer.test.ts b/src/main/claude/claude-structured-child-work-producer.test.ts index fb6008270f5f..2e1d323d36c9 100644 --- a/src/main/claude/claude-structured-child-work-producer.test.ts +++ b/src/main/claude/claude-structured-child-work-producer.test.ts @@ -1,15 +1,19 @@ // A Claude session's frames, through the real adapter, into the host's child records: the order // the host receives them in, and whether the parent row the records imply is today's row. -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { foldAgentLeadStatus } from '../../shared/agent-lead-status-fold' 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 { agentChildWorkLiveness } from '../../shared/agent-status-child-work-liveness' -import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child-work-reconciliation' +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 { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' import { @@ -18,6 +22,10 @@ import { PROVIDER_SESSION_ID } from './claude-structured-session-test-support' +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) +afterEach(() => vi.restoreAllMocks()) + const parent = makeStructuredAgentStatusSubject( { executionHostId: 'local', @@ -64,7 +72,8 @@ function toolResult( type Delivery = { kind: 'journal' | 'legacy' | 'evidence'; detail: string } -async function producer() { +/** With `host`, evidence goes through the host's own ingest instead of straight to reconciliation. */ +async function producer(host?: AgentHookServer) { const claude = fakeClaude() const store = createAgentStatusStore({ epoch: 'epoch-1', mode: 'authority' }) expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull() @@ -78,6 +87,7 @@ async function producer() { /** 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', @@ -99,7 +109,11 @@ async function producer() { expect(sessionId).toBe('session-1') deliveries.push({ kind: 'evidence', detail: evidence.map((edge) => edge.type).join(',') }) evidenceLog.push(evidence) - reconcileAgentChildWorkEvidence({ store, admission, parent, provider: 'claude', evidence }) + if (host) { + ingested.push(host.ingestStructuredChildWork(parent, evidence, 'claude')) + } else { + reconcileAgentChildWorkEvidence({ store, admission, parent, provider: 'claude', evidence }) + } } }) const journal: StructuredAgentSessionEventSink = { @@ -123,10 +137,11 @@ async function producer() { claude.connections[0]!.handlers.onMessage?.(message) return deliveries.slice(from) } - const records = (): AgentChildWorkRecord[] => store.getChildren(parent) + const records = (): AgentChildWorkRecord[] => + host ? host.getStructuredChildWork(parent) : store.getChildren(parent) const byDescription = (description: string) => records().find((record) => record.description === description) - return { adapter, store, send, records, byDescription, evidenceLog, stamps } + return { adapter, store, send, records, byDescription, evidenceLog, stamps, ingested } } describe('Claude structured child-work producer', () => { @@ -383,4 +398,83 @@ describe('Claude structured child-work producer', () => { previousInvocations: [expect.objectContaining({ outcome: 'succeeded' })] }) }) + + describe('a second ending for a settled child', () => { + async function settledForegroundChild() { + 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 + ) + 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(toolResult('toolu_fg', '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/shared/agent-status-child-work-reconciliation.ts b/src/shared/agent-status-child-work-reconciliation.ts index 3438ee43ab86..55fc317a55c5 100644 --- a/src/shared/agent-status-child-work-reconciliation.ts +++ b/src/shared/agent-status-child-work-reconciliation.ts @@ -52,11 +52,8 @@ function applyEnded(ctx: ReconcileContext, edge: AgentChildWorkEndedEvidence): v if (agentChildWorkRunVerdict(ctx, existing, edge.handle.runId) !== 'current') { return } - // A reported ending latches. An `unknown` one (an inventory omitted the child before its own - // terminal frame arrived) learns the outcome that frame reports. - if (existing.membership === 'settled' && existing.outcome !== 'unknown') { - 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 } : {}) From 4f4885a0d86c6b7a068b90f5c5e3454250c3d663 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:12:10 -0700 Subject: [PATCH 14/21] 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. --- .../server/server-ingest-structured.ts | 2 +- .../agent-hooks/server/server-listeners.ts | 2 +- .../agent-status-child-work-reconciliation.ts | 5 +- .../agent-status-store-child-queries.ts | 12 ++-- src/shared/agent-status-store-record-keys.ts | 68 +++++++++++++++++++ src/shared/agent-status-store-state.ts | 18 ++--- src/shared/agent-status-store.ts | 9 +++ src/shared/agent-status-subject.ts | 13 +++- 8 files changed, 107 insertions(+), 22 deletions(-) create mode 100644 src/shared/agent-status-store-record-keys.ts diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts index b936d28b0e28..5a5e2ce5ecfd 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 1a1fe52e41f1..961d3a3e9c87 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/shared/agent-status-child-work-reconciliation.ts b/src/shared/agent-status-child-work-reconciliation.ts index 55fc317a55c5..fc36d3a37628 100644 --- a/src/shared/agent-status-child-work-reconciliation.ts +++ b/src/shared/agent-status-child-work-reconciliation.ts @@ -174,6 +174,9 @@ export function reconcileAgentChildWorkEvidence( ) } } - trimSettled(ctx) + // 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-child-queries.ts b/src/shared/agent-status-store-child-queries.ts index 4850c57e82d2..bd2a5d3c027d 100644 --- a/src/shared/agent-status-store-child-queries.ts +++ b/src/shared/agent-status-store-child-queries.ts @@ -3,7 +3,7 @@ import { type AgentChildWorkAliasInput, type AgentChildWorkAliasRecord } from './agent-status-child-work-alias' -import { deserializeAgentChildWorkBindingKey } from './agent-status-child-work-binding' +import { storedAliasKey, storedRetiredAlias } from './agent-status-store-record-keys' import { deepFreezeAgentStatusStoreValue, type AgentStatusStoreState @@ -17,7 +17,7 @@ export function resolveAgentStatusChildBindings( const keys = new Set(aliases.map(serializeAgentChildWorkAliasKey)) const matches: AgentChildWorkAliasRecord[] = [] for (const alias of state.aliases.values()) { - if (keys.has(serializeAgentChildWorkAliasKey(alias))) { + if (keys.has(storedAliasKey(alias))) { matches.push(alias) } } @@ -25,9 +25,11 @@ export function resolveAgentStatusChildBindings( 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 })) + const retired = storedRetiredAlias(tombstone) + if (retired && keys.has(retired.key)) { + matches.push( + deepFreezeAgentStatusStoreValue({ ...retired.alias, revision: tombstone.revision }) + ) } } return matches 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 000000000000..6e84970e2aea --- /dev/null +++ b/src/shared/agent-status-store-record-keys.ts @@ -0,0 +1,68 @@ +// Keys the store derives from its own records. Stored records are frozen, so a record's key is +// computed once instead of on every mutation's validation and every alias lookup. + +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 aliasKeys = new WeakMap() +const bindingKeys = new WeakMap() +type RetiredAlias = { alias: AgentChildWorkAliasInput; key: string } +const retiredAliases = new WeakMap() +const tombstoneKeysValid = new WeakMap() + +export function storedAliasKey(alias: AgentChildWorkAliasRecord): string { + return memoized(aliasKeys, alias, serializeAgentChildWorkAliasKey) +} + +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 storedTombstoneKeyIsValid(tombstone: AgentStatusTombstoneRecord): boolean { + return memoized(tombstoneKeysValid, tombstone, (record) => { + switch (record.entity) { + case 'parent': + return deserializeAgentStatusSubject(record.key) !== null + case 'alias': + return deserializeAgentChildWorkBindingKey(record.key) !== null + case 'fact': + return deserializeAgentStatusFactKey(record.key) !== null + case 'child': + return true + } + }) +} diff --git a/src/shared/agent-status-store-state.ts b/src/shared/agent-status-store-state.ts index 249830a5aa5a..b55e3132e393 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, storedTombstoneKeyIsValid } from './agent-status-store-record-keys' +import { serializeAgentStatusSubject } from './agent-status-subject' export type AgentStatusStoreState = { epoch: string @@ -151,7 +148,7 @@ export function validateAgentStatusStoreState(state: AgentStatusStoreState): boo const child = state.children.get(alias.childWorkId) const tombstone = state.tombstones.get(agentStatusTombstoneMapKey('alias', key)) if ( - key !== serializeAgentChildWorkBindingKey(alias) || + key !== storedBindingKey(alias) || alias.revision > state.revision || !child || !agentChildWorkBelongsTo(child, alias.parent) || @@ -175,12 +172,7 @@ export function validateAgentStatusStoreState(state: AgentStatusStoreState): boo } } 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 (item.revision > state.revision || !storedTombstoneKeyIsValid(item)) { return false } } diff --git a/src/shared/agent-status-store.ts b/src/shared/agent-status-store.ts index e59919096abe..389845eba7ec 100644 --- a/src/shared/agent-status-store.ts +++ b/src/shared/agent-status-store.ts @@ -40,6 +40,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 @@ -76,6 +79,12 @@ 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) { diff --git a/src/shared/agent-status-subject.ts b/src/shared/agent-status-subject.ts index 760fa3dfe322..794ecc4452f5 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 { From a366eccad5551a7b07d87d921541dd9f54891b1a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:23:43 -0700 Subject: [PATCH 15/21] 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. --- .../claude/claude-child-work-evidence.test.ts | 5 +- src/main/claude/claude-child-work-evidence.ts | 5 +- ...ude-structured-child-work-producer.test.ts | 51 ++++++++++++++++++- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/main/claude/claude-child-work-evidence.test.ts b/src/main/claude/claude-child-work-evidence.test.ts index 0a913f04f766..ba1e13aad51a 100644 --- a/src/main/claude/claude-child-work-evidence.test.ts +++ b/src/main/claude/claude-child-work-evidence.test.ts @@ -159,7 +159,8 @@ describe('Claude child-work evidence from the task tracker', () => { type: 'ended', observedAt: 500, handle: { idKind: 'task_id', id: 'agent-fg', runId: 'toolu_fg' }, - outcome: 'failed', + // An errored result does not say why the child ended; its own terminal frame does. + outcome: 'unknown', lastMessage: 'Found 3 flaky tests' } ]) @@ -260,7 +261,7 @@ describe('Claude child-work evidence from the task tracker', () => { expect.objectContaining({ type: 'ended', handle: { idKind: 'task_id', id: 'agent-fg', runId: 'toolu_fg_2' }, - outcome: 'failed' + outcome: 'unknown' }) ]) }) diff --git a/src/main/claude/claude-child-work-evidence.ts b/src/main/claude/claude-child-work-evidence.ts index 574a9cc5d4df..ddc629195e56 100644 --- a/src/main/claude/claude-child-work-evidence.ts +++ b/src/main/claude/claude-child-work-evidence.ts @@ -188,7 +188,8 @@ function foregroundAgentFor( /** * A spawn call's result, for each FOREGROUND agent task this frame answers. The call blocked on * the child, so its result is the child's ending; a backgrounded spawn returns at launch and - * proves nothing. + * proves nothing. An errored result does not say why: an interrupt can deliver it before the + * child's own stop, so it ends the child `unknown` for that frame to refine. */ export function claudeSpawnResults( message: Record, @@ -204,7 +205,7 @@ export function claudeSpawnResults( return [] } return [ - pendingClaudeTaskEnded(id, result.failed ? 'failed' : 'succeeded', { + pendingClaudeTaskEnded(id, result.failed ? 'unknown' : 'succeeded', { lastMessage: taskText(result.output), toolUseId: result.toolUseId }) diff --git a/src/main/claude/claude-structured-child-work-producer.test.ts b/src/main/claude/claude-structured-child-work-producer.test.ts index 2e1d323d36c9..5ee4849204cf 100644 --- a/src/main/claude/claude-structured-child-work-producer.test.ts +++ b/src/main/claude/claude-structured-child-work-producer.test.ts @@ -392,11 +392,16 @@ describe('Claude structured child-work producer', () => { send(toolResult('toolu_2', 'Could not reproduce', null, true)) expect(byDescription('Find flaky tests')).toMatchObject({ membership: 'settled', - outcome: 'failed', + outcome: 'unknown', lastMessage: 'Could not reproduce', invocation: { invocationId: 'toolu_2', generation: 2 }, previousInvocations: [expect.objectContaining({ outcome: 'succeeded' })] }) + send(system('task_notification', { task_id: 'agent-fg', status: 'failed' })) + expect(byDescription('Find flaky tests')).toMatchObject({ + outcome: 'failed', + invocation: { invocationId: 'toolu_2', generation: 2 } + }) }) describe('a second ending for a settled child', () => { @@ -477,4 +482,48 @@ describe('Claude structured child-work producer', () => { expect(error).not.toHaveBeenCalled() }) }) + + describe('an interrupted foreground child', () => { + const rejected = + "The user doesn't want to proceed with this tool use. The tool use was rejected." + const killed = system('task_updated', { task_id: 'agent-fg', patch: { status: 'killed' } }) + const stopped = system('task_notification', { + task_id: 'agent-fg', + tool_use_id: 'toolu_fg', + status: 'stopped', + summary: 'Run sleep command and report' + }) + // Frame orders captured from the real CLI interrupting a foreground agent. + it.each([ + [ + 'its own tool running, the spawn result first', + [toolResult('toolu_fg', rejected, null, true), killed, stopped] + ], + [ + 'between tools, its own stop first', + [killed, stopped, toolResult('toolu_fg', rejected, null, true)] + ] + ])('ends cancelled with %s', async (_case, ending) => { + const { send, byDescription } = await producer() + send(toolUse('toolu_fg', 'Agent', { description: 'Run sleep command and report' })) + send( + system('task_started', { + task_id: 'agent-fg', + tool_use_id: 'toolu_fg', + task_type: 'local_agent', + subagent_type: 'general-purpose', + description: 'Run sleep command and report', + is_backgrounded: false + }) + ) + for (const message of ending) { + send(message) + } + send(frame({ type: 'result', subtype: 'error_during_execution', is_error: true })) + expect(byDescription('Run sleep command and report')).toMatchObject({ + membership: 'settled', + outcome: 'cancelled' + }) + }) + }) }) From 7efdd97fb370d63dd75abd941010f21cf1278502 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:00:16 -0700 Subject: [PATCH 16/21] 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. --- ...ude-structured-child-work-producer.test.ts | 68 +++++++++++++++---- 1 file changed, 55 insertions(+), 13 deletions(-) diff --git a/src/main/claude/claude-structured-child-work-producer.test.ts b/src/main/claude/claude-structured-child-work-producer.test.ts index 5ee4849204cf..331b1ab68fe1 100644 --- a/src/main/claude/claude-structured-child-work-producer.test.ts +++ b/src/main/claude/claude-structured-child-work-producer.test.ts @@ -72,6 +72,24 @@ function toolResult( type Delivery = { kind: 'journal' | 'legacy' | 'evidence'; detail: string } +/** A real hook server already holding the session's parent row. */ +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. */ async function producer(host?: AgentHookServer) { const claude = fakeClaude() @@ -406,19 +424,7 @@ describe('Claude structured child-work producer', () => { describe('a second ending for a settled child', () => { async function settledForegroundChild() { - 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 - ) + const host = hostWithParent() const warn = vi.spyOn(console, 'warn') const error = vi.spyOn(console, 'error') const run = await producer(host) @@ -526,4 +532,40 @@ describe('Claude structured child-work producer', () => { }) }) }) + + it("keeps a finished foreground child's final summary and usage, in the order the CLI sends them", async () => { + const { send, byDescription, ingested } = await producer(hostWithParent()) + send(toolUse('toolu_fg', 'Agent', { description: 'Run echo hi command' })) + send( + system('task_started', { + task_id: 'agent-fg', + tool_use_id: 'toolu_fg', + task_type: 'local_agent', + subagent_type: 'general-purpose', + description: 'Run echo hi command', + is_backgrounded: false + }) + ) + // Captured from the real CLI: the child's own ending, then its summary, then the spawn result. + send(system('task_updated', { task_id: 'agent-fg', patch: { status: 'completed' } })) + send( + system('task_notification', { + task_id: 'agent-fg', + tool_use_id: 'toolu_fg', + status: 'completed', + summary: 'The command ran. Output: hi', + usage: { total_tokens: 16_908, tool_uses: 1, duration_ms: 3_393 } + }) + ) + send(toolResult('toolu_fg', 'hi')) + send(frame({ type: 'result', subtype: 'success', is_error: false })) + expect(byDescription('Run echo hi command')).toMatchObject({ + membership: 'settled', + outcome: 'succeeded', + lastMessage: 'The command ran. Output: hi', + totalTokens: 16_908, + invocation: { invocationId: 'toolu_fg', generation: 1 } + }) + expect(ingested.flatMap((outcome) => outcome?.rejected ?? [])).toEqual([]) + }) }) From c8a1f37e046c99e2a28d9e2fab9759415aaf6adc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:51:33 -0700 Subject: [PATCH 17/21] 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. --- ...t-status-child-work-codec-boundary.test.ts | 3 +- src/shared/agent-status-store-bounds.test.ts | 60 +++-- src/shared/agent-status-store-byte-budget.ts | 22 +- .../agent-status-store-child-queries.ts | 65 +++++- src/shared/agent-status-store-commit.ts | 199 ++++++++++++++++ ...tatus-store-copying-oracle.test-fixture.ts | 105 +++++++++ .../agent-status-store-in-place.test.ts | 170 ++++++++++++++ src/shared/agent-status-store-indexes.ts | 221 ++++++++++++++++++ src/shared/agent-status-store-mutation.ts | 125 +++++----- src/shared/agent-status-store-record-keys.ts | 35 ++- src/shared/agent-status-store-scaling.test.ts | 120 ++++++++++ src/shared/agent-status-store-state.ts | 140 +++++++---- src/shared/agent-status-store-table.ts | 116 +++++++++ src/shared/agent-status-store.ts | 49 ++-- 14 files changed, 1251 insertions(+), 179 deletions(-) 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-scaling.test.ts create mode 100644 src/shared/agent-status-store-table.ts 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 8affca519f66..aa4ac50c7ad5 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-store-bounds.test.ts b/src/shared/agent-status-store-bounds.test.ts index 45ba1de57d75..454d5ff843d5 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 aed2d6602f69..1a64866c3e46 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 bd2a5d3c027d..d2d7924bd522 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 { storedAliasKey, storedRetiredAlias } from './agent-status-store-record-keys' +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,25 +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(storedAliasKey(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 retired = storedRetiredAlias(tombstone) - if (retired && keys.has(retired.key)) { + 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({ ...retired.alias, revision: tombstone.revision }) + 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 000000000000..2ea07e07f5df --- /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 000000000000..cec2a2556e0b --- /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 000000000000..4ec13f11fc6d --- /dev/null +++ b/src/shared/agent-status-store-in-place.test.ts @@ -0,0 +1,170 @@ +// 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 type { AgentStatusStoreMutation } from './agent-status-store-contract' +import { createCopyingAgentStatusStoreOracle } from './agent-status-store-copying-oracle.test-fixture' +import { 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)() +} + +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('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 000000000000..c423ab989b41 --- /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 eb1adf604a66..d22f4e17c9f8 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 index 6e84970e2aea..7495053eac05 100644 --- a/src/shared/agent-status-store-record-keys.ts +++ b/src/shared/agent-status-store-record-keys.ts @@ -1,5 +1,6 @@ -// Keys the store derives from its own records. Stored records are frozen, so a record's key is -// computed once instead of on every mutation's validation and every alias lookup. +// 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, @@ -30,15 +31,9 @@ function memoized( return value } -const aliasKeys = new WeakMap() const bindingKeys = new WeakMap() type RetiredAlias = { alias: AgentChildWorkAliasInput; key: string } const retiredAliases = new WeakMap() -const tombstoneKeysValid = new WeakMap() - -export function storedAliasKey(alias: AgentChildWorkAliasRecord): string { - return memoized(aliasKeys, alias, serializeAgentChildWorkAliasKey) -} export function storedBindingKey(alias: AgentChildWorkAliasRecord): string { return memoized(bindingKeys, alias, serializeAgentChildWorkBindingKey) @@ -52,17 +47,15 @@ export function storedRetiredAlias(tombstone: AgentStatusTombstoneRecord): Retir }) } -export function storedTombstoneKeyIsValid(tombstone: AgentStatusTombstoneRecord): boolean { - return memoized(tombstoneKeysValid, tombstone, (record) => { - switch (record.entity) { - case 'parent': - return deserializeAgentStatusSubject(record.key) !== null - case 'alias': - return deserializeAgentChildWorkBindingKey(record.key) !== null - case 'fact': - return deserializeAgentStatusFactKey(record.key) !== null - case 'child': - return true - } - }) +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 000000000000..737fe1fbeb90 --- /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 b55e3132e393..ca05745ad3ba 100644 --- a/src/shared/agent-status-store-state.ts +++ b/src/shared/agent-status-store-state.ts @@ -32,7 +32,7 @@ import { parseAgentStatusParentRecord, type AgentStatusParentRecord } from './agent-status-store-parent' -import { storedBindingKey, storedTombstoneKeyIsValid } from './agent-status-store-record-keys' +import { storedBindingKey, tombstoneKeyIsValid } from './agent-status-store-record-keys' import { serializeAgentStatusSubject } from './agent-status-subject' export type AgentStatusStoreState = { @@ -114,65 +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 !== storedBindingKey(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 || !storedTombstoneKeyIsValid(item)) { + 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 000000000000..798ee0c10318 --- /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 389845eba7ec..a5b416c205cc 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, @@ -65,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) @@ -90,11 +100,9 @@ export function createAgentStatusStore(options: CreateAgentStatusStoreOptions): 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 @@ -103,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()) @@ -122,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, @@ -148,8 +152,7 @@ export function createAgentStatusStore(options: CreateAgentStatusStoreOptions): if (!restored) { return false } - state = restored - snapshotApplied = true + restore(restored) return true } if ( @@ -163,8 +166,7 @@ export function createAgentStatusStore(options: CreateAgentStatusStoreOptions): if (!mirrored) { return false } - state = mirrored - snapshotApplied = true + restore(mirrored) return true }, applyTransportEnvelope(value) { @@ -186,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 From 368ea4db3c5c3f1557b90acb01ef4938ecb4191b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:06:13 -0700 Subject: [PATCH 18/21] 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. --- ...claude-background-task-aggregate-roster.ts | 99 ----- .../claude/claude-background-task-restarts.ts | 69 ---- .../claude/claude-background-task-tracker.ts | 171 ++++----- ...de-captured-frame-builders.test-fixture.ts | 61 +++ .../claude/claude-child-work-decoder.test.ts | 204 ++++++++++ src/main/claude/claude-child-work-decoder.ts | 258 +++++++++++++ .../claude/claude-child-work-evidence.test.ts | 280 -------------- src/main/claude/claude-child-work-evidence.ts | 195 +--------- ...hild-work-producer-harness.test-fixture.ts | 174 +++++++++ .../claude/claude-settled-background-tasks.ts | 2 - ...ude-structured-child-work-producer.test.ts | 356 +++--------------- ...claude-structured-dispatch-test-support.ts | 2 + .../claude/claude-structured-options.test.ts | 2 + .../claude-structured-session-adapter.ts | 5 + .../claude/claude-structured-session-close.ts | 1 + .../claude-structured-session-publication.ts | 2 + .../claude/claude-structured-session-state.ts | 3 + ...nt-status-child-work-evidence-admission.ts | 8 +- .../agent-status-child-work-evidence.ts | 2 + .../agent-status-child-work-reconciliation.ts | 6 +- 20 files changed, 883 insertions(+), 1017 deletions(-) delete mode 100644 src/main/claude/claude-background-task-aggregate-roster.ts delete mode 100644 src/main/claude/claude-background-task-restarts.ts create mode 100644 src/main/claude/claude-captured-frame-builders.test-fixture.ts create mode 100644 src/main/claude/claude-child-work-decoder.test.ts create mode 100644 src/main/claude/claude-child-work-decoder.ts delete mode 100644 src/main/claude/claude-child-work-evidence.test.ts create mode 100644 src/main/claude/claude-child-work-producer-harness.test-fixture.ts diff --git a/src/main/claude/claude-background-task-aggregate-roster.ts b/src/main/claude/claude-background-task-aggregate-roster.ts deleted file mode 100644 index 82dce4644796..000000000000 --- a/src/main/claude/claude-background-task-aggregate-roster.ts +++ /dev/null @@ -1,99 +0,0 @@ -// Claude's `background_tasks_changed`: a full replacement of the live BACKGROUND roster. -// Moved out of the tracker unchanged; the tracker owns when it runs and what it publishes. - -import { - classifyClaudeBackgroundTaskKind, - liveClaudeTaskRunState, - record, - taskDescription, - taskId, - taskName -} from './claude-background-task-frames' -import type { ClaudeTaskRestarts } from './claude-background-task-restarts' -import type { - ClaudeSettledBackgroundTasks, - TrackedClaudeBackgroundTask -} from './claude-settled-background-tasks' - -export type ClaudeAggregateRosterInput = { - value: unknown[] - /** The tracker's live tasks; replaced in place. */ - tasks: Map - retention: ClaudeSettledBackgroundTasks - /** Terminal edges already seen; a listed id is live again. */ - terminalTaskIds: Map - /** Runs started again after they ended; a listing hands one back to the live map. */ - restarts: Pick - /** Read only for a task the roster lists for the first time. */ - now: () => number - maxTasks: number -} - -/** Apply one roster; returns the ids it listed, in the provider's order. */ -export function replaceClaudeAggregateRoster(input: ClaudeAggregateRosterInput): string[] { - const { tasks, retention, terminalTaskIds, maxTasks } = input - const prior = new Map(tasks) - tasks.clear() - const roster = new Map() - for (const valueTask of input.value) { - if (roster.size >= maxTasks) { - break - } - const task = record(valueTask) - if (!task || task.ambient === true) { - continue - } - const id = taskId(task) - if (!id) { - continue - } - // An authoritative live roster supersedes an earlier terminal edge — for - // the ids it actually lists. Wiping the whole set left a finished - // FOREGROUND id undefended, since the start guard now convicts only - // backgrounded starts. - terminalTaskIds.delete(id) - const restarted = input.restarts.take(id) - const existing = prior.get(id) ?? retention.resume(id) - const toolUseId = restarted?.toolUseId ?? existing?.toolUseId - const kind = classifyClaudeBackgroundTaskKind(task.task_type) - roster.set(id, { - backgrounded: true, - liveInTurn: true, - kind: kind !== 'unknown' ? kind : (existing?.kind ?? 'unknown'), - description: taskDescription(task.description) ?? existing?.description, - name: taskName(task) ?? existing?.name, - state: liveClaudeTaskRunState(task.status) ?? existing?.state, - startedAt: existing?.startedAt ?? input.now(), - totalTokens: existing?.totalTokens, - ...(toolUseId !== undefined ? { toolUseId } : {}) - }) - } - // Live foreground work is not in a BACKGROUND roster and is not superseded - // by one. Budget counted up front so eviction drops the STALEST retained - // rows rather than the newest, and roster entries are never starved. - const retainable = [...prior].filter( - ([id, task]) => !task.backgrounded && task.liveInTurn && !roster.has(id) - ) - let evict = Math.max(0, roster.size + retainable.length - maxTasks) - // Retained rows keep their own relative order and stay ahead of the roster, - // so a live row the user is reading does not drop below it when a roster - // frame lands. Within the roster the PROVIDER's order wins — including for - // a task it reports live again, which belongs where the provider lists it - // rather than appended after the rows that outlived it. - for (const [id, task] of retainable) { - if (evict > 0) { - evict -= 1 - continue - } - tasks.set(id, task) - } - for (const [id, task] of roster) { - tasks.set(id, task) - } - for (const [id, task] of prior) { - if (task.backgrounded && !tasks.has(id)) { - retention.rememberRemoved(id, task) - } - } - return [...roster.keys()] -} diff --git a/src/main/claude/claude-background-task-restarts.ts b/src/main/claude/claude-background-task-restarts.ts deleted file mode 100644 index 797b9d886af9..000000000000 --- a/src/main/claude/claude-background-task-restarts.ts +++ /dev/null @@ -1,69 +0,0 @@ -// Tasks Claude started again, under a new spawn call, after they had ended. -// -// The legacy row keeps such a task hidden until a roster lists it again, so it stays out of the -// tracker's live map. Its record hears the new run at once, and holding the run here is what lets -// that run's own progress and spawn result reach the record in the meantime. - -import { - classifyClaudeBackgroundTaskKind, - taskAliasId, - taskDescription, - taskName -} from './claude-background-task-frames' -import { pendingClaudeTaskLive, type ClaudePendingChildWork } from './claude-child-work-evidence' -import type { TrackedClaudeBackgroundTask } from './claude-settled-background-tasks' - -const MAX_RESTARTED_TASKS = 256 - -export class ClaudeTaskRestarts { - readonly tasks = new Map() - - /** A start for a task that already ended. The spawn call it ended under, or none at all, is a - * late frame for the run that ended; a new one is the provider running the child again. */ - observe( - id: string, - message: Record, - endedUnder: string | undefined - ): ClaudePendingChildWork | null { - const toolUseId = taskAliasId(message.tool_use_id) - if ( - toolUseId === undefined || - toolUseId === endedUnder || - message.ambient === true || - message.skip_transcript === true - ) { - return null - } - const kind = classifyClaudeBackgroundTaskKind(message.task_type) - const task: TrackedClaudeBackgroundTask = { - backgrounded: message.is_backgrounded === true || kind === 'workflow' || kind === 'monitor', - liveInTurn: true, - kind, - description: taskDescription(message.description), - name: taskName(message), - startedAt: 0, - toolUseId - } - this.tasks.delete(id) - this.tasks.set(id, task) - if (this.tasks.size > MAX_RESTARTED_TASKS) { - this.tasks.delete(this.tasks.keys().next().value ?? id) - } - return pendingClaudeTaskLive(id, task) - } - - get(id: string): TrackedClaudeBackgroundTask | undefined { - return this.tasks.get(id) - } - - /** The run ended again, or a roster listed it and the tracker holds it from now on. */ - take(id: string): TrackedClaudeBackgroundTask | undefined { - const task = this.tasks.get(id) - this.tasks.delete(id) - return task - } - - clear(): void { - this.tasks.clear() - } -} diff --git a/src/main/claude/claude-background-task-tracker.ts b/src/main/claude/claude-background-task-tracker.ts index 4f1e7934109f..14f4271b7d8a 100644 --- a/src/main/claude/claude-background-task-tracker.ts +++ b/src/main/claude/claude-background-task-tracker.ts @@ -3,31 +3,16 @@ import type { AgentSessionBackgroundTaskRunState, AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' -import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' -import { replaceClaudeAggregateRoster } from './claude-background-task-aggregate-roster' import { classifyClaudeBackgroundTaskKind, liveClaudeTaskRunState, record, - taskAliasId, taskDescription, taskId, taskName, taskUsageTotalTokens, terminalClaudeTaskRunState } from './claude-background-task-frames' -import { - claudeSpawnResults, - claudeTaskProgressFacts, - pendingClaudeInventory, - pendingClaudeNotification, - pendingClaudeSessionEnded, - pendingClaudeTaskLive, - pendingClaudeTerminalUpdate, - pendingClaudeTurnEnded, - type ClaudePendingChildWork -} from './claude-child-work-evidence' -import { ClaudeTaskRestarts } from './claude-background-task-restarts' import { ClaudeSettledBackgroundTasks, claudeBackgroundTaskDetail, @@ -49,11 +34,7 @@ const MAX_TRACKED_TASKS = 256 export class ClaudeBackgroundTaskTracker { private readonly tasks = new Map() private readonly retention = new ClaudeSettledBackgroundTasks() - /** Terminal edges seen, with the spawn call each ended under. */ - private readonly terminalTaskIds = new Map() - private readonly restarts = new ClaudeTaskRestarts() - /** Child-work evidence decided since the last drain; see `claude-child-work-evidence`. */ - private readonly childWork: ClaudePendingChildWork[] = [] + private readonly terminalTaskIds = new Set() private aggregateRosterObserved = false private monitoring = false private publishedTasksFingerprint = '' @@ -72,12 +53,13 @@ export class ClaudeBackgroundTaskTracker { } get stoppableTaskIds(): string[] { - return [...this.tasks].flatMap(([id, task]) => (task.backgrounded ? [id] : [])) - } - - /** The evidence queued since the last drain, stamped with the host clock of the caller. */ - drainChildWorkEvidence(observedAt: number): AgentChildWorkEvidence[] { - return this.childWork.splice(0).map((edge) => edge(observedAt)) + const ids: string[] = [] + for (const [id, task] of this.tasks) { + if (task.backgrounded) { + ids.push(id) + } + } + return ids } observe(message: Record, startsTurn = false): boolean { @@ -93,10 +75,7 @@ export class ClaudeBackgroundTaskTracker { // live work. if (startsTurn || message.type === 'result') { this.settleForegroundTasks() - this.childWork.push(pendingClaudeTurnEnded) } - // The legacy row keeps a foreground child until `result`; only the record learns its ending. - this.childWork.push(...claudeSpawnResults(message, [this.tasks, this.restarts.tasks])) if (message.type === 'system') { if (!this.observeSystemFrame(message) && !startsTurn) { return false @@ -111,9 +90,7 @@ export class ClaudeBackgroundTaskTracker { this.tasks.clear() this.retention.clear() this.terminalTaskIds.clear() - this.restarts.clear() this.aggregateRosterObserved = false - this.childWork.push(pendingClaudeSessionEnded) return this.refreshMonitoring() } @@ -152,19 +129,12 @@ export class ClaudeBackgroundTaskTracker { this.settle(id, terminalClaudeTaskRunState(message.status) ?? 'done', { totalTokens: taskUsageTotalTokens(message) }) - this.childWork.push(pendingClaudeNotification(id, message)) return true } if (message.subtype === 'task_progress') { // Progress `description` is the current activity ("Running "), not // the task's name — only usage (and a missing identity) may update. const existing = this.tasks.get(id) - const run = existing ?? this.restarts.get(id) - if (run) { - // Every child's progress reaches its record; the legacy row takes a background one's usage. - const named = { ...run, name: run.name ?? taskName(message) } - this.childWork.push(pendingClaudeTaskLive(id, named, claudeTaskProgressFacts(message))) - } const totalTokens = taskUsageTotalTokens(message) if (!existing?.backgrounded || totalTokens === undefined) { return false @@ -175,14 +145,7 @@ export class ClaudeBackgroundTaskTracker { if (message.subtype === 'task_updated') { return this.observeTaskUpdated(id, message) } - if (message.subtype !== 'task_started') { - return false - } - if (this.terminalTaskIds.has(id)) { - const restart = this.restarts.observe(id, message, this.terminalTaskIds.get(id)) - if (restart) { - this.childWork.push(restart) - } + if (message.subtype !== 'task_started' || this.terminalTaskIds.has(id)) { return false } if (message.ambient === true || message.skip_transcript === true) { @@ -204,8 +167,7 @@ export class ClaudeBackgroundTaskTracker { description: taskDescription(message.description), name: taskName(message), state: liveClaudeTaskRunState(message.status) ?? undefined, - startedAt: this.now(), - toolUseId: taskAliasId(message.tool_use_id) + startedAt: this.now() }) return true } @@ -218,7 +180,6 @@ export class ClaudeBackgroundTaskTracker { const settledState = terminalClaudeTaskRunState(patch.status) if (settledState) { this.settle(id, settledState) - this.childWork.push(pendingClaudeTerminalUpdate(id, message)) return true } const existing = this.tasks.get(id) @@ -251,24 +212,67 @@ export class ClaudeBackgroundTaskTracker { if (!Array.isArray(value)) { return } + const prior = new Map(this.tasks) this.aggregateRosterObserved = true - const listed = replaceClaudeAggregateRoster({ - value, - tasks: this.tasks, - retention: this.retention, - terminalTaskIds: this.terminalTaskIds, - restarts: this.restarts, - now: this.now, - maxTasks: MAX_TRACKED_TASKS - }) - this.childWork.push( - pendingClaudeInventory( - listed.flatMap((id) => { - const task = this.tasks.get(id) - return task ? [[id, task] as const] : [] - }) - ) + this.tasks.clear() + const roster = new Map() + for (const valueTask of value) { + if (roster.size >= MAX_TRACKED_TASKS) { + break + } + const task = record(valueTask) + if (!task || task.ambient === true) { + continue + } + const id = taskId(task) + if (!id) { + continue + } + // An authoritative live roster supersedes an earlier terminal edge — for + // the ids it actually lists. Wiping the whole set left a finished + // FOREGROUND id undefended, since the start guard now convicts only + // backgrounded starts. + this.terminalTaskIds.delete(id) + const existing = prior.get(id) ?? this.retention.resume(id) + const kind = classifyClaudeBackgroundTaskKind(task.task_type) + roster.set(id, { + backgrounded: true, + liveInTurn: true, + kind: kind !== 'unknown' ? kind : (existing?.kind ?? 'unknown'), + description: taskDescription(task.description) ?? existing?.description, + name: taskName(task) ?? existing?.name, + state: liveClaudeTaskRunState(task.status) ?? existing?.state, + startedAt: existing?.startedAt ?? this.now(), + totalTokens: existing?.totalTokens + }) + } + // Live foreground work is not in a BACKGROUND roster and is not superseded + // by one. Budget counted up front so eviction drops the STALEST retained + // rows rather than the newest, and roster entries are never starved. + const retainable = [...prior].filter( + ([id, task]) => !task.backgrounded && task.liveInTurn && !roster.has(id) ) + let evict = Math.max(0, roster.size + retainable.length - MAX_TRACKED_TASKS) + // Retained rows keep their own relative order and stay ahead of the roster, + // so a live row the user is reading does not drop below it when a roster + // frame lands. Within the roster the PROVIDER's order wins — including for + // a task it reports live again, which belongs where the provider lists it + // rather than appended after the rows that outlived it. + for (const [id, task] of retainable) { + if (evict > 0) { + evict -= 1 + continue + } + this.tasks.set(id, task) + } + for (const [id, task] of roster) { + this.tasks.set(id, task) + } + for (const [id, task] of prior) { + if (task.backgrounded && !this.tasks.has(id)) { + this.retention.rememberRemoved(id, task) + } + } } private upsert(id: string, task: Omit): void { @@ -287,32 +291,29 @@ export class ClaudeBackgroundTaskTracker { } const existing = this.tasks.get(id) ?? this.retention.resume(id) this.terminalTaskIds.delete(id) - const next: TrackedClaudeBackgroundTask = existing - ? { - backgrounded: existing.backgrounded || task.backgrounded, - // A settled foreground task is not revived by a late edge frame. - liveInTurn: existing.liveInTurn, - kind: task.kind !== 'unknown' ? task.kind : existing.kind, - description: task.description ?? existing.description, - name: task.name ?? existing.name, - state: task.state ?? existing.state, - startedAt: existing.startedAt, - totalTokens: existing.totalTokens, - toolUseId: task.toolUseId ?? existing.toolUseId - } - : { ...task, liveInTurn: true } - this.tasks.set(id, next) - this.childWork.push(pendingClaudeTaskLive(id, next)) + if (existing) { + this.tasks.set(id, { + backgrounded: existing.backgrounded || task.backgrounded, + // A settled foreground task is not revived by a late edge frame. + liveInTurn: existing.liveInTurn, + kind: task.kind !== 'unknown' ? task.kind : existing.kind, + description: task.description ?? existing.description, + name: task.name ?? existing.name, + state: task.state ?? existing.state, + startedAt: existing.startedAt, + totalTokens: existing.totalTokens + }) + return + } + this.tasks.set(id, { ...task, liveInTurn: true }) } private finish(id: string): void { - const restarted = this.restarts.take(id) - const toolUseId = (this.tasks.get(id) ?? restarted)?.toolUseId this.tasks.delete(id) this.terminalTaskIds.delete(id) - this.terminalTaskIds.set(id, toolUseId) + this.terminalTaskIds.add(id) if (this.terminalTaskIds.size > MAX_TRACKED_TASKS) { - const oldest = this.terminalTaskIds.keys().next() + const oldest = this.terminalTaskIds.values().next() if (!oldest.done) { this.terminalTaskIds.delete(oldest.value) } 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 000000000000..7fd6f8124e0b --- /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-child-work-decoder.test.ts b/src/main/claude/claude-child-work-decoder.test.ts new file mode 100644 index 000000000000..9891f6c3f84d --- /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 000000000000..6de305194b0a --- /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.test.ts b/src/main/claude/claude-child-work-evidence.test.ts deleted file mode 100644 index ba1e13aad51a..000000000000 --- a/src/main/claude/claude-child-work-evidence.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' -import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' - -function system(subtype: string, fields: Record): Record { - return { type: 'system', subtype, session_id: 'provider-1', uuid: crypto.randomUUID(), ...fields } -} - -function spawnResult(toolUseId: string, isError = false): Record { - return { - type: 'user', - session_id: 'provider-1', - uuid: crypto.randomUUID(), - parent_tool_use_id: null, - message: { - role: 'user', - content: [ - { - type: 'tool_result', - tool_use_id: toolUseId, - content: 'Found 3 flaky tests', - is_error: isError - } - ] - } - } -} - -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 drained(tracker: ClaudeBackgroundTaskTracker, at = 500): AgentChildWorkEvidence[] { - return tracker.drainChildWorkEvidence(at) -} - -describe('Claude child-work evidence from the task tracker', () => { - it('names a started child by its task id and the spawn call of this run', () => { - const tracker = new ClaudeBackgroundTaskTracker(() => 100) - tracker.observe(foregroundAgent) - expect(drained(tracker)).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(drained(tracker)).toEqual([]) - }) - - it("carries a FOREGROUND child's progress to its record while its legacy row stays as it was", () => { - const tracker = new ClaudeBackgroundTaskTracker(() => 100) - tracker.observe(foregroundAgent) - drained(tracker) - const before = tracker.state - expect( - tracker.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 } - }) - ) - ).toBe(false) - expect(tracker.state).toEqual(before) - expect(drained(tracker, 600)).toEqual([ - expect.objectContaining({ - type: 'live', - observedAt: 600, - child: expect.objectContaining({ - handle: { idKind: 'task_id', id: 'agent-fg', runId: 'toolu_fg' }, - residency: 'foreground', - // 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, not the legacy run state', () => { - const tracker = new ClaudeBackgroundTaskTracker(() => 100) - tracker.observe(backgroundAgent) - drained(tracker) - tracker.observe( - system('task_notification', { - task_id: 'agent-bg', - status: 'failed', - summary: 'Build broke', - usage: { total_tokens: 900 } - }) - ) - // The legacy row keeps calling a failure `blocked`; the record hears `failed`. - expect(tracker.state?.settledTasks).toBeUndefined() - expect(drained(tracker)).toEqual([ - { - type: 'ended', - observedAt: 500, - handle: { idKind: 'task_id', id: 'agent-bg' }, - outcome: 'failed', - lastMessage: 'Build broke', - totalTokens: 900 - } - ]) - for (const [status, outcome] of [ - ['completed', 'succeeded'], - ['killed', 'cancelled'], - ['stopped', 'cancelled'], - ['whatever', 'unknown'] - ]) { - tracker.observe(system('task_notification', { task_id: 'agent-bg', status })) - expect(drained(tracker)).toEqual([expect.objectContaining({ type: 'ended', outcome })]) - } - tracker.observe( - system('task_updated', { task_id: 'agent-bg', patch: { status: 'failed', error: 'OOM' } }) - ) - expect(drained(tracker)).toEqual([ - expect.objectContaining({ type: 'ended', outcome: 'failed', lastMessage: 'OOM' }) - ]) - }) - - it("ends a foreground child on its spawn call's result, and ignores a backgrounded spawn's", () => { - const tracker = new ClaudeBackgroundTaskTracker(() => 100) - tracker.observe(foregroundAgent) - tracker.observe(backgroundAgent) - drained(tracker) - const rows = tracker.state - expect(tracker.observe(spawnResult('toolu_bg'))).toBe(false) - expect(drained(tracker)).toEqual([]) - expect(tracker.observe(spawnResult('toolu_fg', true))).toBe(false) - expect(tracker.state).toEqual(rows) - expect(drained(tracker)).toEqual([ - { - type: 'ended', - observedAt: 500, - handle: { idKind: 'task_id', id: 'agent-fg', runId: 'toolu_fg' }, - // An errored result does not say why the child ended; its own terminal frame does. - outcome: 'unknown', - lastMessage: 'Found 3 flaky tests' - } - ]) - }) - - it('lists the whole background roster as one inventory, after the tracker applied it', () => { - const tracker = new ClaudeBackgroundTaskTracker(() => 100) - tracker.observe(backgroundAgent) - drained(tracker) - tracker.observe( - system('background_tasks_changed', { - tasks: [{ task_id: 'shell-1', task_type: 'local_bash', description: 'sleep 30' }] - }) - ) - expect(drained(tracker)).toEqual([ - { - type: 'inventory', - observedAt: 500, - residency: 'background', - children: [ - expect.objectContaining({ - handle: { idKind: 'task_id', id: 'shell-1' }, - kind: 'command', - residency: 'background', - description: 'sleep 30', - stoppable: true - }) - ] - } - ]) - }) - - it('tells a restart under a new spawn call from a late start of the run that ended', () => { - const tracker = new ClaudeBackgroundTaskTracker(() => 100) - tracker.observe(backgroundAgent) - tracker.observe(system('task_notification', { task_id: 'agent-bg', status: 'completed' })) - drained(tracker) - tracker.observe(backgroundAgent) - expect(drained(tracker)).toEqual([]) - const before = tracker.state - tracker.observe( - system('task_started', { - task_id: 'agent-bg', - tool_use_id: 'toolu_resume', - task_type: 'local_agent', - is_backgrounded: true - }) - ) - expect(tracker.state).toEqual(before) - expect(drained(tracker)).toEqual([ - expect.objectContaining({ - type: 'live', - child: expect.objectContaining({ - handle: { idKind: 'task_id', id: 'agent-bg', runId: 'toolu_resume' }, - residency: 'background' - }) - }) - ]) - // Until a roster lists it, the new run's progress still reaches its record. - tracker.observe(system('task_progress', { task_id: 'agent-bg', last_tool_name: 'Read' })) - expect(tracker.state).toEqual(before) - expect(drained(tracker)).toEqual([ - expect.objectContaining({ - child: expect.objectContaining({ - handle: { idKind: 'task_id', id: 'agent-bg', runId: 'toolu_resume' }, - operation: expect.objectContaining({ toolName: 'Read' }) - }) - }) - ]) - // A roster listing hands the run back to the live map under its new spawn call. - tracker.observe( - system('background_tasks_changed', { - tasks: [{ task_id: 'agent-bg', task_type: 'local_agent' }] - }) - ) - expect(drained(tracker)).toEqual([ - expect.objectContaining({ - type: 'inventory', - children: [ - expect.objectContaining({ - handle: { idKind: 'task_id', id: 'agent-bg', runId: 'toolu_resume' } - }) - ] - }) - ]) - }) - - it("ends a restarted foreground run on its own spawn call's result", () => { - const tracker = new ClaudeBackgroundTaskTracker(() => 100) - tracker.observe(foregroundAgent) - tracker.observe(system('task_notification', { task_id: 'agent-fg', status: 'completed' })) - tracker.observe({ ...foregroundAgent, tool_use_id: 'toolu_fg_2' }) - drained(tracker) - tracker.observe(spawnResult('toolu_fg')) - expect(drained(tracker)).toEqual([]) - tracker.observe(spawnResult('toolu_fg_2', true)) - expect(drained(tracker)).toEqual([ - expect.objectContaining({ - type: 'ended', - handle: { idKind: 'task_id', id: 'agent-fg', runId: 'toolu_fg_2' }, - outcome: 'unknown' - }) - ]) - }) - - it('marks a turn boundary and the end of the provider session', () => { - const tracker = new ClaudeBackgroundTaskTracker(() => 100) - tracker.observe({ type: 'result', subtype: 'success' }) - tracker.observe({ type: 'user' }, true) - tracker.clear() - expect(drained(tracker)).toEqual([ - { type: 'turn-ended', observedAt: 500 }, - { type: 'turn-ended', observedAt: 500 }, - { type: 'session-ended', observedAt: 500 } - ]) - }) -}) diff --git a/src/main/claude/claude-child-work-evidence.ts b/src/main/claude/claude-child-work-evidence.ts index ddc629195e56..32270e35d6fc 100644 --- a/src/main/claude/claude-child-work-evidence.ts +++ b/src/main/claude/claude-child-work-evidence.ts @@ -1,24 +1,14 @@ -// Claude task frames, decoded into child-work evidence for the host's records. -// -// The background-task tracker already reads every task frame and decides when a task is live, -// settled, or dropped by a roster; it queues an edge at each of those decisions. This module -// holds what those edges say, the frames only this path reads (a spawn call's result, the -// progress the tracker's legacy row ignores), and the owner lookup through the journal's -// linkage. Edges are stamped with the host clock when drained, after the journal has handled the -// frame, so the host never admits evidence ahead of the frame's own rows. +// 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 { - AgentChildWorkOperation, - AgentChildWorkOutcome -} from '../../shared/agent-status-child-work' +import type { AgentChildWorkOutcome } from '../../shared/agent-status-child-work' import type { AgentChildWorkEvidence, - AgentChildWorkEvidenceHandle, AgentChildWorkLiveObservation } from '../../shared/agent-status-child-work-evidence' -import { record, taskText, taskUsageTotalTokens } from './claude-background-task-frames' -import { isAgentChildWorkKind } from '../../shared/agent-status-child-work-liveness' -import type { TrackedClaudeBackgroundTask } from './claude-settled-background-tasks' +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 { @@ -28,10 +18,7 @@ import { type ClaudeToolUse } from './claude-structured-item-translation' -/** An edge the tracker decided on, stamped with the host clock once the frame is journaled. */ -export type ClaudePendingChildWork = (observedAt: number) => AgentChildWorkEvidence - -type ClaudeTaskFacts = { +export type ClaudeTaskFacts = { /** The tool the provider last reported; stamped at drain. */ toolName?: string lastMessage?: string @@ -54,50 +41,6 @@ export function claudeChildWorkOutcome(status: unknown): AgentChildWorkOutcome { } } -function claudeTaskHandle(id: string, toolUseId?: string): AgentChildWorkEvidenceHandle { - return { idKind: 'task_id', id, ...(toolUseId !== undefined ? { runId: toolUseId } : {}) } -} - -/** One tracked task as the host reads it: the tracker's own live row, plus what this frame said. */ -export function claudeTaskObservation( - id: string, - task: TrackedClaudeBackgroundTask, - facts: ClaudeTaskFacts = {}, - observedAt = 0 -): AgentChildWorkLiveObservation { - const operation: AgentChildWorkOperation | undefined = facts.toolName - ? { toolName: facts.toolName, basis: 'reported', observedAt } - : undefined - return { - handle: claudeTaskHandle(id, task.toolUseId), - kind: task.kind, - residency: task.backgrounded ? 'background' : 'foreground', - // The legacy row's own state rule, so both read the same child the same way. - state: task.state === 'working' || 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 function pendingClaudeTaskLive( - id: string, - task: TrackedClaudeBackgroundTask, - facts: ClaudeTaskFacts = {} -): ClaudePendingChildWork { - const snapshot = { ...task } - return (observedAt) => ({ - type: 'live', - observedAt, - child: claudeTaskObservation(id, snapshot, facts, observedAt) - }) -} - /** 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 { @@ -111,111 +54,8 @@ export function claudeTaskProgressFacts(message: Record): Claud } } -function pendingClaudeTaskEnded( - id: string, - outcome: AgentChildWorkOutcome, - reported: { lastMessage?: string; totalTokens?: number; toolUseId?: string } -): ClaudePendingChildWork { - return (observedAt) => ({ - type: 'ended', - observedAt, - handle: claudeTaskHandle(id, reported.toolUseId), - outcome, - ...(reported.lastMessage ? { lastMessage: reported.lastMessage } : {}), - ...(reported.totalTokens !== undefined ? { totalTokens: reported.totalTokens } : {}) - }) -} - -export const pendingClaudeTurnEnded: ClaudePendingChildWork = (observedAt) => ({ - type: 'turn-ended', - observedAt -}) - -export const pendingClaudeSessionEnded: ClaudePendingChildWork = (observedAt) => ({ - type: 'session-ended', - observedAt -}) - -/** A `task_notification`: the child's own ending, with its final summary and usage. */ -export function pendingClaudeNotification( - id: string, - message: Record -): ClaudePendingChildWork { - return pendingClaudeTaskEnded(id, claudeChildWorkOutcome(message.status), { - lastMessage: taskText(message.summary), - totalTokens: taskUsageTotalTokens(message) - }) -} - -/** A terminal `task_updated`: its error, when it has one, is the child's last word. */ -export function pendingClaudeTerminalUpdate( - id: string, - message: Record -): ClaudePendingChildWork { - const patch = record(message.patch) - return pendingClaudeTaskEnded(id, claudeChildWorkOutcome(patch?.status), { - lastMessage: taskText(patch?.error) - }) -} - -/** The roster's complete live BACKGROUND inventory: a background child it omits has ended. */ -export function pendingClaudeInventory( - listed: readonly (readonly [string, TrackedClaudeBackgroundTask])[] -): ClaudePendingChildWork { - const children = listed.map(([id, task]) => [id, { ...task }] as const) - return (observedAt) => ({ - type: 'inventory', - observedAt, - residency: 'background', - children: children.map(([id, task]) => claudeTaskObservation(id, task)) - }) -} - -function foregroundAgentFor( - runs: readonly ReadonlyMap[], - toolUseId: string -): string | null { - for (const tasks of runs) { - for (const [id, task] of tasks) { - if (task.toolUseId === toolUseId && !task.backgrounded && isAgentChildWorkKind(task.kind)) { - return id - } - } - } - return null -} - -/** - * A spawn call's result, for each FOREGROUND agent task this frame answers. The call blocked on - * the child, so its result is the child's ending; a backgrounded spawn returns at launch and - * proves nothing. An errored result does not say why: an interrupt can deliver it before the - * child's own stop, so it ends the child `unknown` for that frame to refine. - */ -export function claudeSpawnResults( - message: Record, - runs: readonly ReadonlyMap[] -): ClaudePendingChildWork[] { - const envelope = message.type === 'user' ? readClaudeMessageEnvelope(message) : null - if (!envelope) { - return [] - } - return claudeToolResults(envelope).flatMap((result) => { - const id = foregroundAgentFor(runs, result.toolUseId) - if (id === null) { - return [] - } - return [ - pendingClaudeTaskEnded(id, result.failed ? 'unknown' : 'succeeded', { - lastMessage: taskText(result.output), - toolUseId: result.toolUseId - }) - ] - }) -} - /** 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, and neither does work a - * backgrounded child launched: such a child sends no traffic to attribute. */ + * shell) call. A call the session's own agent made has no owner. */ export function withClaudeChildWorkOwners( evidence: AgentChildWorkEvidence[], ownerOf: ((toolUseId: string) => string | null) | undefined @@ -228,19 +68,14 @@ export function withClaudeChildWorkOwners( return ownerId !== null && ownerId !== child.handle.id ? { ...child, ownerId } : child } return evidence.map((edge) => - edge.type === 'live' - ? { ...edge, child: owned(edge.child) } - : edge.type === 'inventory' - ? { ...edge, children: edge.children.map(owned) } - : 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 (a foreground child's traffic reaches the parent's stream; a backgrounded child's does - * not), 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. + * 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, @@ -276,16 +111,18 @@ export function claudeChildOperation( /** Everything one frame (or a close) said about the session's child work, owners named. */ export function drainClaudeChildWork( - session: Pick | null | undefined, + session: Pick | null | undefined, message: Record | null, observedAt: number ): AgentChildWorkEvidence[] { if (!session) { return [] } - const decided = session.backgroundTasks.drainChildWorkEvidence(observedAt) return [ - ...withClaudeChildWorkOwners(decided, session.translator?.childToolOwner), + ...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 000000000000..8b1a8b0ee917 --- /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-settled-background-tasks.ts b/src/main/claude/claude-settled-background-tasks.ts index d5f96dc96a1e..73064becec4b 100644 --- a/src/main/claude/claude-settled-background-tasks.ts +++ b/src/main/claude/claude-settled-background-tasks.ts @@ -29,8 +29,6 @@ export type TrackedClaudeBackgroundTask = { * so clients can render elapsed and keep a stable first-seen sort. */ startedAt: number totalTokens?: number - /** The spawn call of the current run: the spawn's result and the child's own traffic name it. */ - toolUseId?: string } export function claudeBackgroundTaskDetail( diff --git a/src/main/claude/claude-structured-child-work-producer.test.ts b/src/main/claude/claude-structured-child-work-producer.test.ts index 331b1ab68fe1..df8f27d10521 100644 --- a/src/main/claude/claude-structured-child-work-producer.test.ts +++ b/src/main/claude/claude-structured-child-work-producer.test.ts @@ -1,167 +1,20 @@ // A Claude session's frames, through the real adapter, into the host's child records: the order -// the host receives them in, and whether the parent row the records imply is today's row. +// the host receives them in, and the record each child ends up with. import { afterEach, describe, expect, it, vi } from 'vitest' -import { foldAgentLeadStatus } from '../../shared/agent-lead-status-fold' -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 { agentChildWorkLiveness } from '../../shared/agent-status-child-work-liveness' 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 { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' -import { - fakeClaude, - identityFor, - PROVIDER_SESSION_ID -} from './claude-structured-session-test-support' + 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()) -const parent = makeStructuredAgentStatusSubject( - { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - 'session-1' -) - -let uuid = 0 -function frame(fields: Record): Record { - return { session_id: PROVIDER_SESSION_ID, uuid: `frame-${++uuid}`, ...fields } -} -function system(subtype: string, fields: Record) { - return frame({ type: 'system', subtype, ...fields }) -} -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 }] - } - }) -} -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 } - -/** A real hook server already holding the session's parent row. */ -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. */ -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}` - })() - }) - 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: () => 1_700_000_000_500, - 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) - } - const records = (): AgentChildWorkRecord[] => - host ? host.getStructuredChildWork(parent) : store.getChildren(parent) - const byDescription = (description: string) => - records().find((record) => record.description === description) - return { adapter, store, send, records, byDescription, evidenceLog, stamps, ingested } -} - 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() @@ -185,17 +38,10 @@ describe('Claude structured child-work producer', () => { ]) }) - it('records the parent state today reads, frame by frame, while adding outcome and activity', async () => { + 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 - lead: 'working' | 'done' - check?: () => void - }[] = [ - { - message: toolUse('toolu_fg', 'Agent', { description: 'Find flaky tests' }), - lead: 'working' - }, + const steps: { message: Record; check?: () => void }[] = [ + { message: toolUse('toolu_fg', 'Agent', { description: 'Find flaky tests' }) }, { message: system('task_started', { task_id: 'agent-fg', @@ -204,8 +50,7 @@ describe('Claude structured child-work producer', () => { subagent_type: 'Explore', description: 'Find flaky tests', is_backgrounded: false - }), - lead: 'working' + }) }, // The foreground child starts a background shell of its own. { @@ -215,7 +60,6 @@ describe('Claude structured child-work producer', () => { { command: 'npm test', run_in_background: true }, 'toolu_fg' ), - lead: 'working', // Its own call is what it is doing, previewed as a CLI row previews Bash. check: () => expect(byDescription('Find flaky tests')?.operation).toMatchObject({ @@ -232,21 +76,18 @@ describe('Claude structured child-work producer', () => { description: 'npm test', is_backgrounded: true }), - lead: 'working' - }, - { - message: system('background_tasks_changed', { - tasks: [{ task_id: 'shell-1', task_type: 'local_bash', description: 'npm test' }] - }), - lead: 'working', 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'), - lead: 'working', check: () => expect(byDescription('Find flaky tests')?.operation).toBeUndefined() }, { @@ -256,16 +97,24 @@ describe('Claude structured child-work producer', () => { last_tool_name: 'Bash', usage: { total_tokens: 1_200, tool_uses: 2, duration_ms: 800 } }), - lead: 'working', 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'), - lead: 'working', + 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', @@ -273,10 +122,7 @@ describe('Claude structured child-work producer', () => { lastMessage: 'Two tests flake on CI' }) }, - { - message: toolUse('toolu_bg', 'Agent', { description: 'Audit the build' }), - lead: 'working' - }, + { message: toolUse('toolu_bg', 'Agent', { description: 'Audit the build' }) }, { message: system('task_started', { task_id: 'agent-bg', @@ -284,8 +130,7 @@ describe('Claude structured child-work producer', () => { task_type: 'local_agent', description: 'Audit the build', is_backgrounded: true - }), - lead: 'working' + }) }, { message: system('background_tasks_changed', { @@ -293,17 +138,15 @@ describe('Claude structured child-work producer', () => { { task_id: 'shell-1', task_type: 'local_bash', description: 'npm test' }, { task_id: 'agent-bg', task_type: 'local_agent', description: 'Audit the build' } ] - }), - lead: 'working' + }) }, - { message: frame({ type: 'result', subtype: 'success', is_error: false }), lead: 'done' }, + { 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' }] }), - lead: 'done', - check: () => expect(byDescription('Audit the build')).toMatchObject({ outcome: 'unknown' }) + check: () => expect(byDescription('Audit the build')?.membership).toBe('live') }, { message: system('task_notification', { @@ -312,7 +155,6 @@ describe('Claude structured child-work producer', () => { summary: 'Build broke', usage: { total_tokens: 900 } }), - lead: 'done', check: () => expect(byDescription('Audit the build')).toMatchObject({ membership: 'settled', @@ -320,38 +162,34 @@ describe('Claude structured child-work producer', () => { lastMessage: 'Build broke' }) }, - { message: system('background_tasks_changed', { tasks: [] }), lead: 'done' }, + { + 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' } }), - lead: 'done', check: () => expect(byDescription('npm test')).toMatchObject({ outcome: 'cancelled' }) }, - // Claude resumes a finished background agent by listing it again. + // Messaging a finished background agent starts it again under the message call. + { message: toolUse('toolu_msg', 'SendMessage', { to: 'agent-bg', message: 'Again' }) }, { - message: system('background_tasks_changed', { - tasks: [{ task_id: 'agent-bg', task_type: 'local_agent', description: 'Audit the build' }] + message: system('task_started', { + task_id: 'agent-bg', + tool_use_id: 'toolu_msg', + task_type: 'local_agent', + description: 'Audit the build', + is_backgrounded: true }), - lead: 'done', check: () => expect(byDescription('Audit the build')).toMatchObject({ membership: 'live', - invocation: { generation: 2 }, + invocation: { invocationId: 'toolu_msg', generation: 2 }, previousInvocations: [expect.objectContaining({ outcome: 'failed' })] }) } ] - for (const [index, step] of steps.entries()) { + for (const step of steps) { send(step.message) - const legacy = agentChildWorkLiveness(adapter.backgroundTaskState('session-1')?.tasks) - const recorded = agentChildWorkLiveness( - records().filter((record) => record.membership === 'live') - ) - const fold = (childWorkLiveness: typeof legacy) => - foldAgentLeadStatus({ leadState: step.lead, childWorkLiveness }) - expect({ index, parent: fold(recorded) }).toEqual({ index, parent: fold(legacy) }) - if (step.lead === 'done') { - expect({ index, liveness: recorded }).toEqual({ index, liveness: legacy }) - } step.check?.() } await adapter.closeSession('session-1') @@ -359,7 +197,7 @@ describe('Claude structured child-work producer', () => { expect(adapter.backgroundTaskState('session-1')).toBeUndefined() }) - it("counts a child's runs the way the journal does, and hears a restarted run before any roster", async () => { + 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', { @@ -407,19 +245,16 @@ describe('Claude structured child-work producer', () => { 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: 'unknown', - lastMessage: 'Could not reproduce', + outcome: 'failed', invocation: { invocationId: 'toolu_2', generation: 2 }, previousInvocations: [expect.objectContaining({ outcome: 'succeeded' })] }) - send(system('task_notification', { task_id: 'agent-fg', status: 'failed' })) - expect(byDescription('Find flaky tests')).toMatchObject({ - outcome: 'failed', - invocation: { invocationId: 'toolu_2', generation: 2 } - }) }) describe('a second ending for a settled child', () => { @@ -438,7 +273,14 @@ describe('Claude structured child-work producer', () => { is_backgrounded: false }) ) - run.send(toolResult('toolu_fg', 'Two tests flake on CI')) + 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', @@ -488,84 +330,4 @@ describe('Claude structured child-work producer', () => { expect(error).not.toHaveBeenCalled() }) }) - - describe('an interrupted foreground child', () => { - const rejected = - "The user doesn't want to proceed with this tool use. The tool use was rejected." - const killed = system('task_updated', { task_id: 'agent-fg', patch: { status: 'killed' } }) - const stopped = system('task_notification', { - task_id: 'agent-fg', - tool_use_id: 'toolu_fg', - status: 'stopped', - summary: 'Run sleep command and report' - }) - // Frame orders captured from the real CLI interrupting a foreground agent. - it.each([ - [ - 'its own tool running, the spawn result first', - [toolResult('toolu_fg', rejected, null, true), killed, stopped] - ], - [ - 'between tools, its own stop first', - [killed, stopped, toolResult('toolu_fg', rejected, null, true)] - ] - ])('ends cancelled with %s', async (_case, ending) => { - const { send, byDescription } = await producer() - send(toolUse('toolu_fg', 'Agent', { description: 'Run sleep command and report' })) - send( - system('task_started', { - task_id: 'agent-fg', - tool_use_id: 'toolu_fg', - task_type: 'local_agent', - subagent_type: 'general-purpose', - description: 'Run sleep command and report', - is_backgrounded: false - }) - ) - for (const message of ending) { - send(message) - } - send(frame({ type: 'result', subtype: 'error_during_execution', is_error: true })) - expect(byDescription('Run sleep command and report')).toMatchObject({ - membership: 'settled', - outcome: 'cancelled' - }) - }) - }) - - it("keeps a finished foreground child's final summary and usage, in the order the CLI sends them", async () => { - const { send, byDescription, ingested } = await producer(hostWithParent()) - send(toolUse('toolu_fg', 'Agent', { description: 'Run echo hi command' })) - send( - system('task_started', { - task_id: 'agent-fg', - tool_use_id: 'toolu_fg', - task_type: 'local_agent', - subagent_type: 'general-purpose', - description: 'Run echo hi command', - is_backgrounded: false - }) - ) - // Captured from the real CLI: the child's own ending, then its summary, then the spawn result. - send(system('task_updated', { task_id: 'agent-fg', patch: { status: 'completed' } })) - send( - system('task_notification', { - task_id: 'agent-fg', - tool_use_id: 'toolu_fg', - status: 'completed', - summary: 'The command ran. Output: hi', - usage: { total_tokens: 16_908, tool_uses: 1, duration_ms: 3_393 } - }) - ) - send(toolResult('toolu_fg', 'hi')) - send(frame({ type: 'result', subtype: 'success', is_error: false })) - expect(byDescription('Run echo hi command')).toMatchObject({ - membership: 'settled', - outcome: 'succeeded', - lastMessage: 'The command ran. Output: hi', - totalTokens: 16_908, - invocation: { invocationId: 'toolu_fg', generation: 1 } - }) - expect(ingested.flatMap((outcome) => outcome?.rejected ?? [])).toEqual([]) - }) }) diff --git a/src/main/claude/claude-structured-dispatch-test-support.ts b/src/main/claude/claude-structured-dispatch-test-support.ts index c9e16084a7da..a309bd9d0b98 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-options.test.ts b/src/main/claude/claude-structured-options.test.ts index ec5e5477f45a..e02647f36253 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 9e8628f51791..c21f876ba86a 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -129,6 +129,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) diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 2d1b1f255d85..6569a581c5bb 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -113,6 +113,7 @@ async function finalizeClaudePublishedSession( } return false } + 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 1d4228ed837e..d64273f9b8b7 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 48d1dc1824a5..4669b7fabd7d 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -19,6 +19,7 @@ import type { } 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' @@ -161,6 +162,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/shared/agent-status-child-work-evidence-admission.ts b/src/shared/agent-status-child-work-evidence-admission.ts index e52f0d8eba38..b78859ff8406 100644 --- a/src/shared/agent-status-child-work-evidence-admission.ts +++ b/src/shared/agent-status-child-work-evidence-admission.ts @@ -121,7 +121,7 @@ export function applyAgentChildWorkLive( ctx: AgentChildWorkEvidenceContext, child: AgentChildWorkLiveObservation, observedAt: number, - listed: boolean + restart: boolean ): void { const { handle } = child const resolution = resolveAgentChildWorkHandle(ctx, [handle.idKind], handle.id) @@ -159,9 +159,9 @@ export function applyAgentChildWorkLive( if (run === 'previous') { return } - // A different spawn call for the same child is the provider starting it again; an - // authoritative inventory listing a settled child is the provider saying it runs again. - if (run === 'new' || (existing.membership === 'settled' && listed)) { + // 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), diff --git a/src/shared/agent-status-child-work-evidence.ts b/src/shared/agent-status-child-work-evidence.ts index 97188330f590..40ea45e2e643 100644 --- a/src/shared/agent-status-child-work-evidence.ts +++ b/src/shared/agent-status-child-work-evidence.ts @@ -46,6 +46,8 @@ 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 diff --git a/src/shared/agent-status-child-work-reconciliation.ts b/src/shared/agent-status-child-work-reconciliation.ts index fc36d3a37628..8c63dd25250c 100644 --- a/src/shared/agent-status-child-work-reconciliation.ts +++ b/src/shared/agent-status-child-work-reconciliation.ts @@ -49,7 +49,9 @@ function applyEnded(ctx: ReconcileContext, edge: AgentChildWorkEndedEvidence): v if (!existing) { return } - if (agentChildWorkRunVerdict(ctx, existing, edge.handle.runId) !== 'current') { + // 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 @@ -158,7 +160,7 @@ export function reconcileAgentChildWorkEvidence( continue } if (edge.type === 'live') { - applyAgentChildWorkLive(ctx, edge.child, edge.observedAt, false) + applyAgentChildWorkLive(ctx, edge.child, edge.observedAt, edge.restart === true) } else if (edge.type === 'operation') { applyOperation(ctx, edge) } else if (edge.type === 'ended') { From 35f97191b50f8a36f43039398aaf84ce702838bf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:07:10 -0700 Subject: [PATCH 19/21] 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. --- .../server-ingest-structured-children.test.ts | 4 +- ...ude-structured-child-work-producer.test.ts | 18 ++- ...ent-session-status-feed-child-work.test.ts | 2 +- .../agent-status-child-work-evidence.ts | 22 +-- ...t-status-child-work-reconciliation.test.ts | 125 ++++++++++-------- .../agent-status-child-work-reconciliation.ts | 41 +----- 6 files changed, 100 insertions(+), 112 deletions(-) diff --git a/src/main/agent-hooks/server-ingest-structured-children.test.ts b/src/main/agent-hooks/server-ingest-structured-children.test.ts index 4fe2e01e1ff5..92efc8ee553a 100644 --- a/src/main/agent-hooks/server-ingest-structured-children.test.ts +++ b/src/main/agent-hooks/server-ingest-structured-children.test.ts @@ -110,7 +110,9 @@ describe('structured child-work ingest', () => { 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(server.getStructuredChildWork(other)).toEqual([ + expect.objectContaining({ childWorkId: second.childWorkId, outcome: 'unknown' }) + ]) expect(server.getStructuredChildWork(SUBJECT)).toEqual([first]) }) diff --git a/src/main/claude/claude-structured-child-work-producer.test.ts b/src/main/claude/claude-structured-child-work-producer.test.ts index df8f27d10521..d47d07b43355 100644 --- a/src/main/claude/claude-structured-child-work-producer.test.ts +++ b/src/main/claude/claude-structured-child-work-producer.test.ts @@ -193,7 +193,23 @@ describe('Claude structured child-work producer', () => { step.check?.() } await adapter.closeSession('session-1') - expect(records()).toEqual([]) + // 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() }) 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 index c5e101ffebbc..1731674baad3 100644 --- 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 @@ -12,7 +12,7 @@ import { import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' const SESSION = 'status-session' -const EVIDENCE: AgentChildWorkEvidence[] = [{ type: 'turn-ended', observedAt: 5 }] +const EVIDENCE: AgentChildWorkEvidence[] = [{ type: 'session-ended', observedAt: 5 }] let root: string const journals = createTrackedJournalOpener() diff --git a/src/shared/agent-status-child-work-evidence.ts b/src/shared/agent-status-child-work-evidence.ts index 40ea45e2e643..06fe05d38a8c 100644 --- a/src/shared/agent-status-child-work-evidence.ts +++ b/src/shared/agent-status-child-work-evidence.ts @@ -1,9 +1,8 @@ // 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, how one ended, -// which children an authoritative inventory lists. The settlement rules that turn an inventory -// or a turn boundary into endings live with the records, where residency is stored. +// 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 { @@ -71,25 +70,12 @@ export type AgentChildWorkEndedEvidence = { totalTokens?: number } -/** A complete list of the live children of one residency class. A child of that class the host - * holds live and the list omits has ended, with an outcome nobody reported. */ -export type AgentChildWorkInventoryEvidence = { - type: 'inventory' - observedAt: number - residency: AgentChildWorkResidency - children: AgentChildWorkLiveObservation[] -} - -/** The session's own turn is over: work bound to it can no longer be running. */ -export type AgentChildWorkTurnEndedEvidence = { type: 'turn-ended'; observedAt: number } - -/** The provider session is gone; its children go with it. */ +/** 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 - | AgentChildWorkInventoryEvidence - | AgentChildWorkTurnEndedEvidence | AgentChildWorkSessionEndedEvidence diff --git a/src/shared/agent-status-child-work-reconciliation.test.ts b/src/shared/agent-status-child-work-reconciliation.test.ts index ed3ab0a74843..377c580fc41c 100644 --- a/src/shared/agent-status-child-work-reconciliation.test.ts +++ b/src/shared/agent-status-child-work-reconciliation.test.ts @@ -85,10 +85,9 @@ describe('structured child-work reconciliation', () => { ).toEqual(['task_id:task-a', 'tool_use_id:toolu_task-a']) }) - it('keeps one child when a roster names it before its spawn call is known', () => { + it('keeps one child when it is first named before its spawn call is known', () => { const { store, apply } = harness() - const unnamed = child('task-a', { handle: { idKind: 'task_id', id: 'task-a' } }) - apply({ type: 'inventory', observedAt: 100, residency: 'background', children: [unnamed] }) + 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({ @@ -145,14 +144,14 @@ describe('structured child-work reconciliation', () => { }) }) - it('keeps an outcome that arrives after a roster already dropped the child', () => { + 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: 'inventory', + type: 'ended', observedAt: 200, - residency: 'background', - children: [child('task-b')] + handle: { idKind: 'task_id', id: 'task-a' }, + outcome: 'unknown' }) expect(records(store)[0]).toMatchObject({ membership: 'settled', @@ -187,53 +186,38 @@ describe('structured child-work reconciliation', () => { expect(records(store)[1]).toMatchObject({ membership: 'live' }) }) - it('settles only background children an inventory omits, and revives one it lists again', () => { + it('opens a new run when the producer reports a restart, even under the same run handle', () => { const { store, apply } = harness() - apply(live(child('bg')), live(child('fg', { residency: 'foreground', stoppable: false })), { - type: 'inventory', + apply(live(child('task-a')), { + type: 'ended', observedAt: 200, - residency: 'background', - children: [] + handle: { idKind: 'task_id', id: 'task-a' }, + outcome: 'succeeded' }) - expect(records(store).map(({ membership, outcome }) => ({ membership, outcome }))).toEqual([ - { membership: 'settled', outcome: 'unknown' }, - { membership: 'live', outcome: undefined } - ]) - apply({ type: 'inventory', observedAt: 300, residency: 'background', children: [child('bg')] }) - expect(records(store)[0]).toMatchObject({ + // 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_bg', generation: 2 } + invocation: { invocationId: 'toolu_task-a', generation: 2 }, + previousInvocations: [expect.objectContaining({ outcome: 'succeeded' })] }) }) - it('reads an empty inventory as no live background work, and no inventory as no evidence', () => { - const { store, apply } = harness() - apply(live(child('bg'))) - apply(live(child('bg'), 150)) - expect(only(store).membership).toBe('live') - apply({ type: 'inventory', observedAt: 200, residency: 'background', children: [] }) - expect(only(store)).toMatchObject({ membership: 'settled', outcome: 'unknown' }) - }) - - it('ends foreground children still open when the turn ends, and leaves the rest', () => { + it('ends the current run on an ending whose run handle it never saw', () => { const { store, apply } = harness() - apply( - live(child('open-fg', { residency: 'foreground' })), - live(child('done-fg', { residency: 'foreground' })), - live(child('bg')), - { - type: 'ended', - observedAt: 150, - handle: { idKind: 'task_id', id: 'done-fg', runId: 'toolu_done-fg' }, - outcome: 'succeeded' - }, - { type: 'turn-ended', observedAt: 200 } - ) - expect(records(store).map(({ membership, outcome }) => ({ membership, outcome }))).toEqual([ - { membership: 'settled', outcome: 'unknown' }, - { membership: 'settled', outcome: 'succeeded' }, - { membership: 'live', outcome: undefined } - ]) + 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', () => { @@ -304,12 +288,43 @@ describe('structured child-work reconciliation', () => { expect(only(store)).toMatchObject({ description: 'Audit the tests', name: 'reviewer' }) }) - it('drops the session children when the provider session ends', () => { + 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'))) - expect(apply({ type: 'session-ended', observedAt: 200 })).toMatchObject({ removed: 2 }) + 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([]) - // Its old bindings fence the finished lifetime; the id is free again for a new one. apply(live(child('task-a'), 300)) expect(only(store)).toMatchObject({ membership: 'live', @@ -335,14 +350,10 @@ describe('structured child-work reconciliation', () => { // Same source, same parent and provider: only the producer differs. provenance: { source: 'structured-session', producerId: 'another-producer' } }) - apply( - live(child('task-a')), - { type: 'turn-ended', observedAt: 150 }, - { type: 'inventory', observedAt: 160, residency: 'background', children: [] }, - { type: 'session-ended', observedAt: 170 } - ) + apply(live(child('task-a')), { type: 'session-ended', observedAt: 170 }) expect(records(store)).toEqual([ - expect.objectContaining({ childWorkId: 'foreign', membership: 'live' }) + expect.objectContaining({ childWorkId: 'foreign', membership: 'live' }), + expect.objectContaining({ membership: 'settled', outcome: 'unknown' }) ]) }) diff --git a/src/shared/agent-status-child-work-reconciliation.ts b/src/shared/agent-status-child-work-reconciliation.ts index 8c63dd25250c..08d057afc96b 100644 --- a/src/shared/agent-status-child-work-reconciliation.ts +++ b/src/shared/agent-status-child-work-reconciliation.ts @@ -1,22 +1,19 @@ // 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. Settlement from -// evidence that names no single child (an inventory, a turn boundary) is decided here from the -// residency the record stores, so every producer shares one rule. It owns only the records its -// own producer admitted, and never claims an outcome the evidence did not report. +// 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, - AgentChildWorkInventoryEvidence, AgentChildWorkOperationEvidence } from './agent-status-child-work-evidence' import { applyAgentChildWorkLive, settleAgentChildWork, agentChildWorkRunVerdict, - STRUCTURED_CHILD_WORK_MAX_LIVE, type AgentChildWorkEvidenceContext, type AgentChildWorkReconcileOutcome } from './agent-status-child-work-evidence-admission' @@ -92,27 +89,10 @@ function applyOperation(ctx: ReconcileContext, edge: AgentChildWorkOperationEvid ) } -function applyInventory(ctx: ReconcileContext, edge: AgentChildWorkInventoryEvidence): void { - const listed = new Set() - for (const child of edge.children.slice(0, STRUCTURED_CHILD_WORK_MAX_LIVE)) { - listed.add(child.handle.id) - applyAgentChildWorkLive(ctx, child, edge.observedAt, true) - } - for (const record of ownedStructuredChildWork(ctx)) { - const stableId = currentAgentChildWorkAliases(ctx, record).stableId - if ( - record.membership === 'live' && - record.residency === edge.residency && - (stableId === undefined || !listed.has(stableId)) - ) { - settleAgentChildWork(ctx, record, 'unknown', edge.observedAt) - } - } -} - -function settleResidents(ctx: ReconcileContext, residency: 'foreground', observedAt: number): void { +/** 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' && record.residency === residency) { + if (record.membership === 'live') { settleAgentChildWork(ctx, record, 'unknown', observedAt) } } @@ -165,15 +145,8 @@ export function reconcileAgentChildWorkEvidence( applyOperation(ctx, edge) } else if (edge.type === 'ended') { applyEnded(ctx, edge) - } else if (edge.type === 'inventory') { - applyInventory(ctx, edge) - } else if (edge.type === 'turn-ended') { - settleResidents(ctx, 'foreground', edge.observedAt) } else { - removeChildren( - ctx, - ownedStructuredChildWork(ctx).map((record) => record.childWorkId) - ) + settleLive(ctx, edge.observedAt) } } // Only a settle adds settled history; skipping the scan otherwise keeps progress edges cheap. From ed592e8a8384286bc99bb886e5c15ad1887c1289 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:07:37 -0700 Subject: [PATCH 20/21] 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. --- ...captured-foreground-frames.test-fixture.ts | 147 +++++++++++ ...laude-captured-task-frames.test-fixture.ts | 228 ++++++++++++++++++ ...ude-structured-child-work-captures.test.ts | 180 ++++++++++++++ 3 files changed, 555 insertions(+) create mode 100644 src/main/claude/claude-captured-foreground-frames.test-fixture.ts create mode 100644 src/main/claude/claude-captured-task-frames.test-fixture.ts create mode 100644 src/main/claude/claude-structured-child-work-captures.test.ts 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 000000000000..485c29ae7aee --- /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-task-frames.test-fixture.ts b/src/main/claude/claude-captured-task-frames.test-fixture.ts new file mode 100644 index 000000000000..304cf6c2bcff --- /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-structured-child-work-captures.test.ts b/src/main/claude/claude-structured-child-work-captures.test.ts new file mode 100644 index 000000000000..d69ba1208466 --- /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([]) + }) +}) From 94e5159f525c9b3454804f07dd6e111febf84923 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:37:33 -0700 Subject: [PATCH 21/21] 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. --- .../agent-status-store-in-place.test.ts | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/shared/agent-status-store-in-place.test.ts b/src/shared/agent-status-store-in-place.test.ts index 4ec13f11fc6d..e67da48dfdfd 100644 --- a/src/shared/agent-status-store-in-place.test.ts +++ b/src/shared/agent-status-store-in-place.test.ts @@ -6,9 +6,18 @@ 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 { validateAgentStatusStoreState } from './agent-status-store-state' +import { + indexAgentStatusStoreState, + type AgentStatusStoreIndexes +} from './agent-status-store-indexes' +import { + createEmptyAgentStatusStoreState, + validateAgentStatusStoreState +} from './agent-status-store-state' import { makeStructuredAgentStatusSubject, serializeAgentStatusSubject, @@ -109,6 +118,28 @@ function mutations(random: () => number) { 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)', @@ -153,6 +184,27 @@ describe('AgentStatusStore applied in place', () => { 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')