diff --git a/src/main/claude/claude-background-task-frames.ts b/src/main/claude/claude-background-task-frames.ts index d954128d91b..c34d8224cef 100644 --- a/src/main/claude/claude-background-task-frames.ts +++ b/src/main/claude/claude-background-task-frames.ts @@ -7,6 +7,7 @@ import type { AgentSessionBackgroundTask, AgentSessionBackgroundTaskRunState } from '../../shared/agent-session-wire' +import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row' const MAX_TASK_ID_LENGTH = 512 const MAX_TASK_TEXT_LENGTH = 512 @@ -29,6 +30,10 @@ export function taskId(message: Record): string | null { return typeof value === 'string' && isBoundedClaudeTaskId(value) ? value : null } +export function taskAliasId(value: unknown): string | undefined { + return typeof value === 'string' && isBoundedClaudeTaskId(value) ? value : undefined +} + function boundedTaskText(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined @@ -41,6 +46,66 @@ export function taskDescription(value: unknown): string | undefined { return boundedTaskText(value) } +/** Every other provider string a durable task row carries — its summary, its + * error, its output path — takes the description bound: the row is replayed on + * every reconnect, and each reader clips it again anyway. */ +export function taskText(value: unknown): string | undefined { + return boundedTaskText(value) +} + +/** + * The sentence a task frame wrote about itself. + * + * Only for a frame the row owner could not claim — malformed or capacity-refused. + * It reaches the generic fallback, which has no key for `summary` and would + * otherwise print the bare opcode. Passed to that fallback as Claude's own + * display text rather than taught to its shared key list, because that list is + * read for every provider and already resolves `summary` by hand for two Codex + * methods; widening it globally to reach one malformed Claude frame would + * re-rank the row text of every unmodelled frame on both providers. + */ +export function taskFrameSentence(frame: Record): string | undefined { + const patch = record(frame.patch) + const sentence = + taskText(frame.summary) ?? + taskText(frame.error) ?? + taskText(patch?.summary) ?? + taskText(patch?.error) + if (sentence) { + return sentence + } + + // A terminal task update often carries only its status in the nested patch. + // Reuse the durable row's frozen sentence so capacity fallbacks never expose + // the provider opcode when no human-facing text was supplied. + if ( + frame.subtype !== 'task_started' && + frame.subtype !== 'task_updated' && + frame.subtype !== 'task_progress' && + frame.subtype !== 'task_notification' + ) { + return undefined + } + const status = patch?.status ?? frame.status + const state = terminalClaudeTaskRunState(status) + if (state === null) { + return undefined + } + const kind = classifyClaudeBackgroundTaskKind(patch?.task_type ?? frame.task_type) + return backgroundTaskFallbackText({ + type: 'background-task', + taskId: taskId(frame) ?? '', + kind, + label: + taskDescription(patch?.description) ?? + taskDescription(frame.description) ?? + (patch ? taskName(patch) : undefined) ?? + taskName(frame) ?? + '', + state + }) +} + /** The provider-reported identity for a task. Subagent frames have carried the * type under both `agent_type` and `subagent_type` across SDK versions. */ export function taskName(frame: Record): string | undefined { @@ -54,6 +119,7 @@ export function taskName(frame: Record): string | undefined { export function classifyClaudeBackgroundTaskKind(taskType: unknown): ClaudeBackgroundTaskKind { switch (taskType) { case 'local_agent': + case 'local_subagent': return 'agent' case 'local_workflow': return 'workflow' diff --git a/src/main/claude/claude-background-task-memory.ts b/src/main/claude/claude-background-task-memory.ts new file mode 100644 index 00000000000..dd127e5b893 --- /dev/null +++ b/src/main/claude/claude-background-task-memory.ts @@ -0,0 +1,184 @@ +import { isSettledBackgroundTaskState } from '../../shared/native-chat-background-task-row' +import type { ClaudeBackgroundTaskRow } from './claude-background-task-row-lifecycle' + +const MAX_GENERATION_ENTRIES = 512 +const MAX_FOREIGN_TASK_ROWS = 512 +const MAX_TERMINAL_TASK_IDS = 512 +const MAX_FALLBACK_TASK_IDS = 512 + +/** Bounded run identity ledger. Once old ids fall out, a monotonic sequence + * keeps a reused id from colliding with a durable row already in the journal. */ +class ClaudeBackgroundTaskGenerationLedger { + private readonly entries = new Map() + private nextUniqueGeneration = 1 + private evicted = false + + next(id: string): number { + const previous = this.entries.get(id) + const generation = + previous === undefined ? (this.evicted ? this.nextUniqueGeneration++ : 1) : previous + 1 + this.entries.delete(id) + this.entries.set(id, generation) + this.nextUniqueGeneration = Math.max(this.nextUniqueGeneration, generation + 1) + while (this.entries.size > MAX_GENERATION_ENTRIES) { + const oldest = this.entries.keys().next() + if (oldest.done || oldest.value === id) { + break + } + this.entries.delete(oldest.value) + this.evicted = true + } + return generation + } + + get size(): number { + return this.entries.size + } + + clear(): void { + this.entries.clear() + this.nextUniqueGeneration = 1 + this.evicted = false + } +} + +function rememberBoundedClaudeTaskSet(ids: Set, id: string, maxSize: number): void { + ids.delete(id) + ids.add(id) + while (ids.size > maxSize) { + const oldest = ids.values().next() + if (oldest.done || oldest.value === id) { + break + } + ids.delete(oldest.value) + } +} + +function rememberBoundedClaudeTaskMap( + entries: Map, + id: string, + value: T, + maxSize: number +): void { + entries.delete(id) + entries.set(id, value) + while (entries.size > maxSize) { + const oldest = entries.keys().next() + if (oldest.done || oldest.value === id) { + break + } + entries.delete(oldest.value) + } +} + +export function ensureClaudeBackgroundTaskRowSlot( + rows: Map, + maxSize: number +): boolean { + if (rows.size < maxSize) { + return true + } + for (const [id, row] of rows) { + if (isSettledBackgroundTaskState(row.block.state)) { + rows.delete(id) + return true + } + } + return false +} + +function rememberClaudeBackgroundTaskTerminal( + terminalIds: Set, + terminalToolUseIds: Map, + rows: Map, + id: string, + toolUseId: string | undefined, + maxSize: number +): void { + terminalIds.delete(id) + terminalIds.add(id) + terminalToolUseIds.set(id, toolUseId ?? rows.get(id)?.toolUseId ?? terminalToolUseIds.get(id)) + while (terminalIds.size > maxSize) { + const oldest = terminalIds.values().next() + if (oldest.done || oldest.value === id) { + break + } + terminalToolUseIds.delete(oldest.value) + terminalIds.delete(oldest.value) + } +} + +/** Who renders a task this owner deliberately declined. + * + * `sidechain` is a Task spawned inside a subagent's own run: its spawning tool + * never reached the top-level transcript, so no top-level row may claim it. + * `terminal` is a capacity-refused task whose settled typed row was already + * written without taking a live slot; its redeliveries must not print again. */ +export type ForeignOwner = 'roster' | 'ambient' | 'foreground' | 'sidechain' | 'terminal' + +/** How much each ledger is holding. Named and readonly so a caller can prove + * eviction still bounds them without reaching into the collections. */ +export type ClaudeBackgroundTaskLedgerSizes = { + readonly generations: number + readonly foreign: number + readonly fallbackTaskIds: number + readonly terminalTaskIds: number +} + +/** Every bounded ledger a session keeps beside its rows, with the caps that + * bound them. One owner, so a sweep clears them together and no cap is + * applied at only some of the call sites that write to a ledger. */ +export class ClaudeBackgroundTaskLedgers { + /** Runs seen per task id, so a reused id opens a new row instead of + * overwriting the finished one. Survives the row being evicted. */ + readonly generations = new ClaudeBackgroundTaskGenerationLedger() + readonly foreign = new Map() + /** Tasks that were declined because every typed row slot was live. Their + * later frames must remain visible through the generic fallback. */ + readonly fallbackTaskIds = new Set() + readonly terminalTaskIds = new Set() + /** The parent alias for the terminal run, when one was reported. Keeping it + * lets an evicted row distinguish a late duplicate start from a genuine + * restart under a fresh tool invocation. */ + readonly terminalToolUseIds = new Map() + + rememberForeign(id: string, owner: ForeignOwner): void { + rememberBoundedClaudeTaskMap(this.foreign, id, owner, MAX_FOREIGN_TASK_ROWS) + } + + rememberFallback(id: string): void { + rememberBoundedClaudeTaskSet(this.fallbackTaskIds, id, MAX_FALLBACK_TASK_IDS) + } + + rememberTerminal( + rows: Map, + id: string, + toolUseId: string | undefined + ): void { + rememberClaudeBackgroundTaskTerminal( + this.terminalTaskIds, + this.terminalToolUseIds, + rows, + id, + toolUseId, + MAX_TERMINAL_TASK_IDS + ) + } + + get sizes(): ClaudeBackgroundTaskLedgerSizes { + return { + generations: this.generations.size, + foreign: this.foreign.size, + fallbackTaskIds: this.fallbackTaskIds.size, + terminalTaskIds: this.terminalTaskIds.size + } + } + + clear(): void { + this.generations.clear() + this.foreign.clear() + this.fallbackTaskIds.clear() + this.terminalTaskIds.clear() + this.terminalToolUseIds.clear() + } +} diff --git a/src/main/claude/claude-background-task-roster.ts b/src/main/claude/claude-background-task-roster.ts new file mode 100644 index 00000000000..2375bedc6eb --- /dev/null +++ b/src/main/claude/claude-background-task-roster.ts @@ -0,0 +1,41 @@ +import { isSettledBackgroundTaskState } from '../../shared/native-chat-background-task-row' +import { + classifyClaudeBackgroundTaskKind, + record, + taskDescription, + taskId, + taskName +} from './claude-background-task-frames' +import type { + ClaudeBackgroundTaskChange, + ClaudeBackgroundTaskRow +} from './claude-background-task-row-lifecycle' + +/** A roster carries membership and identity, not a task's terminal outcome. */ +export function observeClaudeBackgroundTaskRoster( + tasks: unknown[], + rows: Map, + revise: (id: string, change: ClaudeBackgroundTaskChange) => void +): void { + for (const entry of tasks) { + const task = record(entry) + const id = task === null ? null : taskId(task) + const row = id === null ? undefined : rows.get(id) + if ( + task === null || + id === null || + task.ambient === true || + !row || + isSettledBackgroundTaskState(row.block.state) + ) { + continue + } + // Presence must not revive a settled row; the task's own frames alone + // settle or restart it, while the roster enriches its identity fields. + revise(id, { + label: taskDescription(task.description) ?? taskName(task), + kind: + task.task_type === undefined ? undefined : classifyClaudeBackgroundTaskKind(task.task_type) + }) + } +} diff --git a/src/main/claude/claude-background-task-row-journal.test.ts b/src/main/claude/claude-background-task-row-journal.test.ts new file mode 100644 index 00000000000..ecd23480605 --- /dev/null +++ b/src/main/claude/claude-background-task-row-journal.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionLifecycleJournal +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudeBackgroundTaskRow } from './claude-background-task-row-lifecycle' +import { + ClaudeBackgroundTaskIdentityResolver, + writeClaudeBackgroundTaskRow +} from './claude-background-task-row-journal' +import { ClaudeBackgroundTaskRows } from './claude-background-task-rows' +import { START_BASH } from './claude-background-task-row-test-support' + +function journal( + epoch: () => string, + visitItems = vi.fn() +): StructuredAgentSessionLifecycleJournal { + return { + get epoch() { + return epoch() + }, + visitItems + } +} + +function row(): ClaudeBackgroundTaskRow { + return { + block: { + type: 'background-task', + taskId: 'task-1', + kind: 'command', + label: 'Build', + state: 'blocked', + error: 'exit 1' + }, + lastSerialized: null, + toolUseId: 'tool-1', + terminalNotificationReceived: true, + generation: 1 + } +} + +describe('Claude background task row journal', () => { + it('resolves one immutable run once per journal epoch', () => { + let epoch = 'epoch-1' + const visits = vi.fn() + const firstJournal = journal(() => epoch, visits) + const resolver = new ClaudeBackgroundTaskIdentityResolver() + + for (let revision = 0; revision < 100; revision += 1) { + resolver.resolve(firstJournal, 'task-1', 'tool-1') + } + expect(visits).toHaveBeenCalledOnce() + + resolver.resolve(firstJournal, 'task-1', 'tool-2') + expect(visits).toHaveBeenCalledTimes(2) + + epoch = 'epoch-2' + resolver.resolve(firstJournal, 'task-1', 'tool-1') + expect(visits).toHaveBeenCalledTimes(3) + + resolver.resolve( + journal(() => 'epoch-2', visits), + 'task-1', + 'tool-1' + ) + expect(visits).toHaveBeenCalledTimes(4) + }) + + it('bounds resolved run identities with LRU eviction', () => { + const visits = vi.fn() + const boundJournal = journal(() => 'epoch-1', visits) + const resolver = new ClaudeBackgroundTaskIdentityResolver() + + for (let index = 0; index < 513; index += 1) { + resolver.resolve(boundJournal, `task-${index}`, `tool-${index}`) + } + resolver.resolve(boundJournal, 'task-0', 'tool-0') + + expect(visits).toHaveBeenCalledTimes(514) + }) + + it('records a revision only after its append and publication are admitted', () => { + const task = row() + const appendAndPublish = vi + .fn() + .mockReturnValueOnce({ accepted: false, reason: 'backpressure' }) + .mockReturnValueOnce({ accepted: true }) + const sink = { + appendItem: vi.fn(), + appendTombstone: vi.fn(), + publish: vi.fn(), + tryAppendResolvedItemAndPublish: appendAndPublish + } + const resolver = new ClaudeBackgroundTaskIdentityResolver() + + expect(writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task)).toEqual({ + accepted: false, + reason: 'backpressure' + }) + expect(task.lastSerialized).toBeNull() + expect(writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task)).toEqual({ + accepted: true + }) + expect(task.lastSerialized).not.toBeNull() + expect(appendAndPublish).toHaveBeenCalledTimes(2) + }) + + it('keeps fallback append coalescing separate from its ordered publication', () => { + const calls: { operation: 'append' | 'publish'; coalescingKey?: string }[] = [] + const task = row() + const sink: StructuredAgentSessionEventSink = { + appendItem: vi.fn(), + appendTombstone: vi.fn(), + publish: vi.fn(), + tryAppendResolvedItem: vi.fn((_identity, _body, _resolve, options) => { + calls.push({ + operation: 'append', + ...(options?.coalescingKey ? { coalescingKey: options.coalescingKey } : {}) + }) + return { accepted: true as const } + }), + tryPublish: vi.fn((options) => { + calls.push({ + operation: 'publish', + ...(options?.coalescingKey ? { coalescingKey: options.coalescingKey } : {}) + }) + return { accepted: true as const } + }) + } + + expect( + writeClaudeBackgroundTaskRow(sink, new ClaudeBackgroundTaskIdentityResolver(), 'task-1', task) + ).toEqual({ accepted: true }) + expect(calls).toEqual([ + { + operation: 'append', + coalescingKey: JSON.stringify(['claude-background-task', 'task-1', 'tool-1']) + }, + { operation: 'publish' } + ]) + expect(task.lastSerialized).not.toBeNull() + }) + + it('uses reserved lifecycle capacity when provider exit settles a live row', () => { + const appendAndPublish = vi.fn< + NonNullable + >(() => ({ accepted: true as const })) + const rows = new ClaudeBackgroundTaskRows({ + sink: { + appendItem: vi.fn(), + appendTombstone: vi.fn(), + publish: vi.fn(), + tryAppendResolvedItemAndPublish: appendAndPublish + }, + isForwardedParentTool: () => true + }) + + rows.observe(START_BASH) + rows.settleSession() + + expect(appendAndPublish).toHaveBeenCalledTimes(2) + expect(appendAndPublish.mock.calls[1]?.[3]).toMatchObject({ lifecycle: true }) + }) + + it('promotes a backpressured terminal row to lifecycle capacity on provider exit', () => { + const appendAndPublish = vi + .fn>() + .mockReturnValueOnce({ accepted: false, reason: 'backpressure' }) + .mockReturnValueOnce({ accepted: true }) + const rows = new ClaudeBackgroundTaskRows({ + sink: { + appendItem: vi.fn(), + appendTombstone: vi.fn(), + publish: vi.fn(), + tryAppendResolvedItemAndPublish: appendAndPublish + }, + isForwardedParentTool: () => true + }) + + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'terminal-at-exit', + status: 'failed', + summary: 'failed' + }) + rows.settleSession() + + expect(appendAndPublish).toHaveBeenCalledTimes(2) + expect(appendAndPublish.mock.calls[1]?.[3]).toMatchObject({ lifecycle: true }) + }) + + it('retries a refused row before later provider messages are read', () => { + const appendAndPublish = vi + .fn() + .mockReturnValueOnce({ accepted: false, reason: 'backpressure' }) + .mockReturnValueOnce({ accepted: true }) + const rows = new ClaudeBackgroundTaskRows({ + sink: { + appendItem: vi.fn(), + appendTombstone: vi.fn(), + publish: vi.fn(), + tryAppendResolvedItemAndPublish: appendAndPublish + }, + isForwardedParentTool: () => true + }) + + expect(rows.observe(START_BASH)).toBe(true) + expect(rows.retryPendingWrites()).toEqual({ accepted: true }) + expect(appendAndPublish).toHaveBeenCalledTimes(2) + expect(rows.retryPendingWrites()).toEqual({ accepted: true }) + expect(appendAndPublish).toHaveBeenCalledTimes(2) + }) + + it('abandons a permanently failed retry and reports recovery instead of latching', () => { + const onPersistenceFailure = vi.fn() + const appendAndPublish = vi + .fn() + .mockReturnValueOnce({ accepted: false, reason: 'backpressure' }) + .mockReturnValueOnce({ accepted: false, reason: 'failed' }) + const rows = new ClaudeBackgroundTaskRows({ + sink: { + appendItem: vi.fn(), + appendTombstone: vi.fn(), + publish: vi.fn(), + tryAppendResolvedItemAndPublish: appendAndPublish + }, + isForwardedParentTool: () => true, + onPersistenceFailure + }) + + rows.observe(START_BASH) + expect(rows.retryPendingWrites()).toEqual({ accepted: false, reason: 'failed' }) + expect(onPersistenceFailure).toHaveBeenCalledOnce() + expect(rows.retryPendingWrites()).toEqual({ accepted: true }) + }) + + it('hands retry-capacity exhaustion to session recovery without throwing', () => { + const onPersistenceFailure = vi.fn() + const rows = new ClaudeBackgroundTaskRows({ + sink: { + appendItem: vi.fn(), + appendTombstone: vi.fn(), + publish: vi.fn(), + tryAppendResolvedItemAndPublish: vi.fn(() => ({ + accepted: false as const, + reason: 'backpressure' as const + })) + }, + isForwardedParentTool: () => true, + onPersistenceFailure + }) + + for (let index = 0; index < 512; index += 1) { + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: `task-${index}`, + status: 'failed', + summary: 'failed' + }) + } + expect(() => { + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'task-overflow', + status: 'failed', + summary: 'failed' + }) + }).not.toThrow() + expect(onPersistenceFailure).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'claude background task journal retry capacity exhausted' + }) + ) + }) + + it.each(['closed', 'failed'] as const)('surfaces a %s sink refusal', (reason) => { + const task = row() + const sink = { + appendItem: vi.fn(), + appendTombstone: vi.fn(), + publish: vi.fn(), + tryAppendResolvedItemAndPublish: vi.fn(() => ({ accepted: false as const, reason })) + } + + expect( + writeClaudeBackgroundTaskRow(sink, new ClaudeBackgroundTaskIdentityResolver(), 'task-1', task) + ).toEqual({ accepted: false, reason }) + expect(task.lastSerialized).toBeNull() + }) +}) diff --git a/src/main/claude/claude-background-task-row-journal.ts b/src/main/claude/claude-background-task-row-journal.ts new file mode 100644 index 00000000000..a4779898204 --- /dev/null +++ b/src/main/claude/claude-background-task-row-journal.ts @@ -0,0 +1,195 @@ +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row' +import { + isBackgroundTaskBlock, + type NativeChatBackgroundTaskBlock +} from '../../shared/native-chat-types' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionLifecycleJournal, + StructuredAgentSessionSinkAdmission +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudeBackgroundTaskRow } from './claude-background-task-row-lifecycle' +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' + +const MAX_RESOLVED_TASK_IDENTITIES = 512 +const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +/** Durable identity for one RUN of a task. + * + * A provider may reuse a task id for a distinct later invocation, and a row + * keyed by the id alone would overwrite the first run's transcript history + * instead of leaving it standing beside the restart. The generation suffix + * separates them. Generation 1 carries no suffix, so every row written before + * generations existed keeps the key it already has. */ +export function claudeBackgroundTaskIdentity( + taskId: string, + generation = 1 +): AgentJournalItemIdentity { + const key = + generation > 1 + ? `claude-background-task:${taskId}#${generation}` + : `claude-background-task:${taskId}` + return { provider: 'orca', clientMessageId: key } +} + +export function claudeBackgroundTaskBody( + block: NativeChatBackgroundTaskBlock +): AgentJournalItemBody { + return { + kind: 'message', + role: 'system', + blocks: [{ type: 'text', text: backgroundTaskFallbackText(block) }, { ...block }] + } +} + +/** Reconcile one queued row against the durable run identity after a rebind. */ +export function resolveClaudeBackgroundTaskIdentity( + journal: StructuredAgentSessionLifecycleJournal, + id: string, + toolUseId: string | undefined +): AgentJournalItemIdentity { + let maxGeneration = 0 + let matchingGeneration: number | undefined + let parentlessGeneration: number | undefined + journal.visitItems((itemId, _sequence, body) => { + const identity = parseAgentJournalItemKey(itemId) + if (!identity || identity.provider !== 'orca') { + return + } + const taskBlock = body.kind === 'message' ? body.blocks.find(isBackgroundTaskBlock) : undefined + if (!taskBlock || taskBlock.taskId !== id) { + return + } + const generation = persistedTaskGeneration(identity.clientMessageId, id) + if (generation === null) { + return + } + maxGeneration = Math.max(maxGeneration, generation) + if (taskBlock.parentToolUseId === toolUseId) { + matchingGeneration = Math.max(matchingGeneration ?? 0, generation) + } else if (taskBlock.parentToolUseId === undefined) { + parentlessGeneration = Math.max(parentlessGeneration ?? 0, generation) + } + }) + // A prior parentless row cannot be proved distinct from the later aliased outcome. + return claudeBackgroundTaskIdentity( + id, + matchingGeneration ?? parentlessGeneration ?? (maxGeneration === 0 ? 1 : maxGeneration + 1) + ) +} + +/** Resolves each immutable provider run once per bound journal epoch. */ +export class ClaudeBackgroundTaskIdentityResolver { + private journal: StructuredAgentSessionLifecycleJournal | null = null + private epoch: string | null = null + private readonly identities = new Map() + + resolve = ( + journal: StructuredAgentSessionLifecycleJournal, + id: string, + toolUseId: string | undefined + ): AgentJournalItemIdentity => { + if (this.journal !== journal || this.epoch !== journal.epoch) { + this.journal = journal + this.epoch = journal.epoch + this.identities.clear() + } + const key = JSON.stringify([id, toolUseId ?? null]) + const cached = this.identities.get(key) + if (cached) { + this.identities.delete(key) + this.identities.set(key, cached) + return cached + } + const identity = resolveClaudeBackgroundTaskIdentity(journal, id, toolUseId) + this.identities.set(key, identity) + if (this.identities.size > MAX_RESOLVED_TASK_IDENTITIES) { + const oldest = this.identities.keys().next() + if (!oldest.done) { + this.identities.delete(oldest.value) + } + } + return identity + } + + clear(): void { + this.journal = null + this.epoch = null + this.identities.clear() + } +} + +function persistedTaskGeneration(clientMessageId: string, taskId: string): number | null { + const base = `claude-background-task:${taskId}` + if (clientMessageId === base) { + return 1 + } + const prefix = `${base}#` + if (!clientMessageId.startsWith(prefix)) { + return null + } + const generation = Number(clientMessageId.slice(prefix.length)) + return Number.isSafeInteger(generation) && generation > 1 ? generation : null +} + +export function writeClaudeBackgroundTaskRow( + sink: StructuredAgentSessionEventSink, + identities: ClaudeBackgroundTaskIdentityResolver, + id: string, + row: ClaudeBackgroundTaskRow, + /** Runs before admission to preserve turn-before-row ordering; duplicate + * delivery skips it, and a retry reuses the turn the first attempt opened. */ + beforeAppend?: () => void, + lifecycle = false +): StructuredAgentSessionSinkAdmission { + const body = claudeBackgroundTaskBody(row.block) + const serialized = JSON.stringify(body) + if (serialized === row.lastSerialized) { + return ADMITTED + } + beforeAppend?.() + const identity = claudeBackgroundTaskIdentity(id, row.generation) + // Generation is translator-local and resets when a provider stream is + // recreated. Keep unresolved writes from distinct provider runs queued side + // by using the provider's parent tool identity as the coalescing discriminator. + const coalescingKey = JSON.stringify(['claude-background-task', id, row.toolUseId ?? null]) + const appendOptions = { coalescingKey, ...(lifecycle ? { lifecycle: true } : {}) } + const publishOptions = lifecycle ? { lifecycle: true } : {} + const resolveIdentity = (journal: StructuredAgentSessionLifecycleJournal) => + identities.resolve(journal, id, row.toolUseId) + const appendAndPublish = sink.tryAppendResolvedItemAndPublish + let admission: StructuredAgentSessionSinkAdmission + if (appendAndPublish) { + // Reserve enough space for any safe generation suffix; the actual identity + // is selected once the deferred sink is bound to the durable journal. + const identitySizeBound = claudeBackgroundTaskIdentity(id, Number.MAX_SAFE_INTEGER) + admission = appendAndPublish(identitySizeBound, body, resolveIdentity, appendOptions) + } else if (sink.tryAppendResolvedItem) { + const identitySizeBound = claudeBackgroundTaskIdentity(id, Number.MAX_SAFE_INTEGER) + admission = sink.tryAppendResolvedItem(identitySizeBound, body, resolveIdentity, appendOptions) + if (admission.accepted) { + admission = sink.tryPublish + ? sink.tryPublish(publishOptions) + : (sink.publish(publishOptions), ADMITTED) + } + } else if (sink.tryAppendItem) { + admission = sink.tryAppendItem(identity, body, appendOptions) + if (admission.accepted) { + admission = sink.tryPublish + ? sink.tryPublish(publishOptions) + : (sink.publish(publishOptions), ADMITTED) + } + } else { + sink.appendItem(identity, body, appendOptions) + sink.publish(publishOptions) + admission = ADMITTED + } + if (admission.accepted) { + row.lastSerialized = serialized + } + return admission +} diff --git a/src/main/claude/claude-background-task-row-lifecycle.ts b/src/main/claude/claude-background-task-row-lifecycle.ts new file mode 100644 index 00000000000..0707a7c16a6 --- /dev/null +++ b/src/main/claude/claude-background-task-row-lifecycle.ts @@ -0,0 +1,229 @@ +import { + canReplaceBackgroundTaskState, + isSettledBackgroundTaskState +} from '../../shared/native-chat-background-task-row' +import type { NativeChatBackgroundTaskBlock } from '../../shared/native-chat-types' +import type { ClaudeSubagentIds } from './claude-subagent-id-aliases' +import { + classifyClaudeBackgroundTaskKind, + liveClaudeTaskRunState, + record, + taskAliasId, + taskDescription, + taskId, + taskName, + taskText, + taskUsageTotalTokens, + terminalClaudeTaskRunState +} from './claude-background-task-frames' + +export type ClaudeBackgroundTaskRow = { + block: NativeChatBackgroundTaskBlock + lastSerialized: string | null + /** Immutable alias for persistence/coalescing; the final frame may enrich the block's parent. */ + toolUseId?: string + /** Whether the provider's terminal notification finalized this run. */ + terminalNotificationReceived: boolean + /** Which RUN of this task id the row records. 1 for the first. */ + generation: number +} + +export type ClaudeBackgroundTaskChange = { + state?: NativeChatBackgroundTaskBlock['state'] | null + label?: string | undefined + kind?: NativeChatBackgroundTaskBlock['kind'] | undefined + summary?: string | undefined + error?: string | undefined + outputFile?: string | undefined + tokens?: number | undefined +} + +export function canonicalClaudeBackgroundTaskId( + message: Record, + ids: ClaudeSubagentIds +): string | null { + const declared = taskId(message) + const toolUseId = claudeBackgroundTaskToolUseId(message) + if (declared === null) { + const aliased = toolUseId === undefined ? null : ids.canonical(toolUseId) + return aliased !== null && aliased !== toolUseId ? aliased : null + } + if (toolUseId !== undefined) { + ids.alias(toolUseId, declared) + } + return declared +} + +export function claudeBackgroundTaskToolUseId( + message: Record +): string | undefined { + const patch = record(message.patch) + return taskAliasId(message.tool_use_id) ?? taskAliasId(patch?.tool_use_id) +} + +export function claudeBackgroundTaskNotificationChange( + message: Record +): ClaudeBackgroundTaskChange { + return { + state: terminalClaudeTaskRunState(message.status) ?? 'done', + summary: taskText(message.summary), + error: taskText(message.error), + outputFile: taskText(message.output_file), + tokens: taskUsageTotalTokens(message) + } +} + +export function claudeBackgroundTaskPatchChange( + message: Record +): ClaudeBackgroundTaskChange { + const patch = record(message.patch) ?? message + const status = patch.status ?? message.status + const terminal = terminalClaudeTaskRunState(status) + return { + state: terminal ?? liveClaudeTaskRunState(status), + label: + message.subtype === 'task_progress' + ? undefined + : (taskDescription(patch.description) ?? taskName(patch)), + kind: 'task_type' in patch ? classifyClaudeBackgroundTaskKind(patch.task_type) : undefined, + error: taskText(patch.error), + tokens: taskUsageTotalTokens(message) + } +} + +export function newClaudeBackgroundTaskRow( + id: string, + message: Record, + now: number, + generation: number +): ClaudeBackgroundTaskRow { + const totalTokens = taskUsageTotalTokens(message) + const toolUseId = claudeBackgroundTaskToolUseId(message) + return { + lastSerialized: null, + terminalNotificationReceived: false, + generation, + ...(toolUseId === undefined ? {} : { toolUseId }), + block: { + type: 'background-task', + taskId: id, + kind: classifyClaudeBackgroundTaskKind(message.task_type), + label: taskDescription(message.description) ?? taskName(message) ?? '', + ...(toolUseId === undefined ? {} : { parentToolUseId: toolUseId }), + state: + terminalClaudeTaskRunState(message.status) ?? + liveClaudeTaskRunState(message.status) ?? + 'working', + startedAt: now, + ...(totalTokens === undefined ? {} : { tokens: totalTokens }) + } + } +} + +/** The row a terminal notification opens on its own. + * + * A terminal frame is self-sufficient: it states an outcome the transcript owes + * the user whether or not an announcement ever admitted the task, so the row is + * built from the frame's own fields — its summary as the sentence, its status as + * the state, its error, output path and usage. */ +export function newClaudeBackgroundTaskRowFromNotification( + id: string, + message: Record, + now: number, + generation: number +): ClaudeBackgroundTaskRow { + const row = newClaudeBackgroundTaskRow(id, message, now, generation) + finalizeClaudeBackgroundTaskRow(row, message, now) + return row +} + +/** A status patch is provisional; the notification supplies the final verdict. */ +export function finalizeClaudeBackgroundTaskRow( + row: ClaudeBackgroundTaskRow, + message: Record, + now: number +): void { + reviseClaudeBackgroundTaskRow(row, claudeBackgroundTaskNotificationChange(message), now) + const notificationToolUseId = claudeBackgroundTaskToolUseId(message) + row.block = { + ...row.block, + ...(row.block.parentToolUseId === undefined && notificationToolUseId !== undefined + ? { parentToolUseId: notificationToolUseId } + : {}), + state: terminalClaudeTaskRunState(message.status) ?? 'done', + settledAt: now + } + row.terminalNotificationReceived = true +} + +export function shouldRestartClaudeBackgroundTaskRow( + row: ClaudeBackgroundTaskRow, + message: Record +): boolean { + if (!isSettledBackgroundTaskState(row.block.state) || row.block.startedAt === undefined) { + return false + } + const toolUseId = claudeBackgroundTaskToolUseId(message) + // One rule for a restart, the same one the terminal ledger applies to a row + // that has already been evicted: only when BOTH runs name their parent is a + // different alias the provider's restart signal. A finished run that named no + // parent cannot be proved distinct from this announcement, so it stands. + const parentToolUseId = row.block.parentToolUseId + return parentToolUseId !== undefined && toolUseId !== undefined && toolUseId !== parentToolUseId +} + +/** Task types the transcript materializes as a row. + * + * Type is the whole gate. A MONITOR is never admitted: it is Claude's own + * ambient housekeeping, runs for the life of the session, and has no outcome a + * transcript row could report. A type this build does not recognise is not + * evidence of anything a row could truthfully say either. Agents are + * materialized too, by the subagent roster, which claims them upstream of this + * owner — so the set left here is the backgrounded shell command and the + * workflow. */ +const MATERIALIZED_TASK_KINDS: ReadonlySet = new Set([ + 'command', + 'workflow' +]) + +export function isClaudeBackgroundTranscriptTask( + message: Record, + kind: NativeChatBackgroundTaskBlock['kind'] +): boolean { + // A task the provider explicitly calls foreground is the turn's own work and + // already has the tool row that invoked it. + return MATERIALIZED_TASK_KINDS.has(kind) && message.is_backgrounded !== false +} + +export function reviseClaudeBackgroundTaskRow( + row: ClaudeBackgroundTaskRow, + change: ClaudeBackgroundTaskChange, + now: number +): void { + const next: NativeChatBackgroundTaskBlock = { ...row.block } + if (change.label && !next.label) { + next.label = change.label + } + if (change.kind !== undefined && change.kind !== 'unknown') { + next.kind = change.kind + } + if (change.summary !== undefined) { + next.summary = change.summary + } + if (change.error !== undefined) { + next.error = change.error + } + if (change.outputFile !== undefined) { + next.outputFile = change.outputFile + } + if (change.tokens !== undefined) { + next.tokens = change.tokens + } + if (change.state && canReplaceBackgroundTaskState(next.state, change.state)) { + next.state = change.state + if (isSettledBackgroundTaskState(change.state)) { + next.settledAt = now + } + } + row.block = next +} diff --git a/src/main/claude/claude-background-task-row-test-support.ts b/src/main/claude/claude-background-task-row-test-support.ts new file mode 100644 index 00000000000..450f4ebe292 --- /dev/null +++ b/src/main/claude/claude-background-task-row-test-support.ts @@ -0,0 +1,113 @@ +// Shared fixtures and the sink harness the background-task row suites drive. +// +// Moved out of claude-background-task-rows.test.ts verbatim when that suite +// reached its line budget, so the terminal-frame suite reads the same captured +// payloads and the same forwarded-tool admission set rather than a second copy. + +import { vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { NativeChatBackgroundTaskBlock } from '../../shared/native-chat-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { ClaudeBackgroundTaskRows } from './claude-background-task-rows' + +/** The exact payloads the user's journal carried for the reported failure. */ +export const FAILED_UPDATE = { + type: 'system', + subtype: 'task_updated', + task_id: 'byjnee2no', + patch: { status: 'failed', end_time: 1_789_332_035_695 } +} +export const FAILED_NOTIFICATION = { + type: 'system', + subtype: 'task_notification', + task_id: 'byjnee2no', + tool_use_id: 'toolu_01CqPd7y', + status: 'failed', + output_file: '/private/tmp/claude-501/tasks/byjnee2no.output', + summary: 'Background command "Wait for the verification verdict" failed with exit code 1' +} + +/** A terminal frame captured from a user session whose task was never admitted. + * Nothing rendered for it: the typed path declined the row and told the generic + * fallback the frame was covered. */ +export const ORPHAN_FAILED_NOTIFICATION = { + type: 'system', + subtype: 'task_notification', + task_id: 'bjzenpq13', + tool_use_id: 'toolu_01ASNfnDBEzt4w3ejLE12bGu', + status: 'failed', + output_file: '', + summary: 'Locate the exact screenshot session', + uuid: '1d748563-5741-4aa8-9c21-7023b90bc737', + session_id: 'f4579b9c-b4bb-4551-81b2-2acca35e4a7b' +} + +export function blockOf( + body: AgentJournalItemBody | undefined +): NativeChatBackgroundTaskBlock | null { + if (!body || body.kind !== 'message') { + return null + } + const block = body.blocks.find( + (candidate): candidate is NativeChatBackgroundTaskBlock => candidate.type === 'background-task' + ) + return block ?? null +} + +export function twinOf(body: AgentJournalItemBody | undefined): string | null { + if (!body || body.kind !== 'message') { + return null + } + const block = body.blocks.find((candidate) => candidate.type === 'text') + return block?.type === 'text' ? block.text : null +} + +/** The spawn call the harness treats as forwarded to the top-level transcript. + * Admission consults this, so a test that wants a row must name it. */ +export const FORWARDED_TOOL = 'toolu_01CqPd7y' + +export function harness( + forwarded: readonly string[] = [FORWARDED_TOOL, 'toolu_first', 'toolu_second'] +) { + const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const turnOpens: number[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => items.push({ identity, body }), + appendTombstone: vi.fn(), + publish: vi.fn() + } + let clock = 1_000 + const forwardedTools = new Set(forwarded) + const rows = new ClaudeBackgroundTaskRows({ + sink, + isForwardedParentTool: (toolUseId) => forwardedTools.has(toolUseId), + openOutputTurn: () => turnOpens.push(1), + now: () => (clock += 10) + }) + const keys = (): string[] => + items.map((item) => + item.identity.provider === 'orca' ? item.identity.clientMessageId : item.identity.provider + ) + return { + rows, + items, + keys, + forwardedTools, + latest: () => blockOf(items.at(-1)?.body), + latestTwin: () => twinOf(items.at(-1)?.body), + turnOpens + } +} + +export const START_BASH = { + type: 'system', + subtype: 'task_started', + task_id: 'byjnee2no', + tool_use_id: 'toolu_01CqPd7y', + task_type: 'local_bash', + description: 'Wait for the verification verdict', + is_backgrounded: true +} diff --git a/src/main/claude/claude-background-task-row-writer.ts b/src/main/claude/claude-background-task-row-writer.ts new file mode 100644 index 00000000000..bb973a1ae70 --- /dev/null +++ b/src/main/claude/claude-background-task-row-writer.ts @@ -0,0 +1,92 @@ +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionSinkAdmission +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudeBackgroundTaskRow } from './claude-background-task-row-lifecycle' +import { + ClaudeBackgroundTaskIdentityResolver, + writeClaudeBackgroundTaskRow +} from './claude-background-task-row-journal' + +const MAX_PENDING_TASK_WRITES = 512 + +export class ClaudeBackgroundTaskRowWriter { + private readonly pending = new Map< + string, + { id: string; row: ClaudeBackgroundTaskRow; lifecycle: boolean } + >() + private readonly identities = new ClaudeBackgroundTaskIdentityResolver() + + constructor( + private readonly sink: StructuredAgentSessionEventSink, + private readonly onPersistenceFailure?: (error: Error) => void + ) {} + + write( + id: string, + row: ClaudeBackgroundTaskRow, + beforeAppend?: () => void, + lifecycle = false + ): void { + const admission = writeClaudeBackgroundTaskRow( + this.sink, + this.identities, + id, + row, + beforeAppend, + lifecycle + ) + const key = JSON.stringify([id, row.toolUseId ?? null, row.generation]) + if (admission.accepted) { + this.pending.delete(key) + } else if (admission.reason === 'backpressure') { + if (!this.pending.has(key) && this.pending.size >= MAX_PENDING_TASK_WRITES) { + this.pending.clear() + this.onPersistenceFailure?.( + new Error('claude background task journal retry capacity exhausted') + ) + return + } + this.pending.set(key, { id, row, lifecycle }) + } else if (admission.reason === 'failed') { + this.onPersistenceFailure?.(new Error('claude background task journal sink failed')) + } + } + + /** Replays bounded row obligations before provider reading resumes. */ + retryPendingWrites(): StructuredAgentSessionSinkAdmission { + for (const [key, pending] of this.pending) { + const admission = writeClaudeBackgroundTaskRow( + this.sink, + this.identities, + pending.id, + pending.row, + undefined, + pending.lifecycle + ) + if (!admission.accepted) { + if (admission.reason !== 'backpressure') { + this.pending.clear() + if (admission.reason === 'failed') { + this.onPersistenceFailure?.(new Error('claude background task journal sink failed')) + } + } + return admission + } + this.pending.delete(key) + } + return { accepted: true } + } + + settlePendingWrites(): void { + for (const pending of this.pending.values()) { + pending.lifecycle = true + } + this.retryPendingWrites() + } + + dispose(): void { + this.pending.clear() + this.identities.clear() + } +} diff --git a/src/main/claude/claude-background-task-rows.test.ts b/src/main/claude/claude-background-task-rows.test.ts new file mode 100644 index 00000000000..ab1c22a96d6 --- /dev/null +++ b/src/main/claude/claude-background-task-rows.test.ts @@ -0,0 +1,705 @@ +import { describe, expect, it } from 'vitest' +import { + FAILED_NOTIFICATION, + FAILED_UPDATE, + FORWARDED_TOOL, + harness, + START_BASH +} from './claude-background-task-row-test-support' + +describe('claude background task rows', () => { + it('reports one failed backgrounded command as ONE row carrying the provider sentence', () => { + const { rows, items, latest, latestTwin } = harness() + rows.observe(START_BASH) + rows.observe(FAILED_UPDATE) + rows.observe(FAILED_NOTIFICATION) + + // One durable identity, not one row per frame: the same failure arrived on + // two frames and printed twice before this owner existed. + const identities = new Set( + items.map((item) => + item.identity.provider === 'orca' ? item.identity.clientMessageId : item.identity.provider + ) + ) + expect([...identities]).toEqual(['claude-background-task:byjnee2no']) + expect(latest()).toMatchObject({ + type: 'background-task', + taskId: 'byjnee2no', + kind: 'command', + label: 'Wait for the verification verdict', + state: 'blocked', + summary: FAILED_NOTIFICATION.summary, + outputFile: FAILED_NOTIFICATION.output_file + }) + // The visible text is the provider's own sentence, never the wire opcode. + expect(latestTwin()).toBe(FAILED_NOTIFICATION.summary) + expect(latestTwin()).not.toContain('task_notification') + }) + + it('lands the captured failure on its row when the announcement carried no tool id', () => { + // An announcement naming NO tool is admitted — absence of the field is not + // evidence of an unforwarded parent — so by the time this REAL captured + // frame arrives its row already exists and simply takes the sentence. + const { rows, latestTwin } = harness() + rows.observe({ + type: 'system', + subtype: 'task_started', + task_id: 'bo2vuy8qb', + task_type: 'local_bash', + description: 'verify', + is_backgrounded: true + }) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'bo2vuy8qb', + status: 'failed', + output_file: '', + summary: "Check the verifier's state" + }) + expect(latestTwin()).toBe("Check the verifier's state") + }) + + it('keeps terminal ownership after a tracked task reports foregrounded', () => { + const { rows, latest } = harness() + rows.observe(START_BASH) + rows.observe({ + type: 'system', + subtype: 'task_updated', + task_id: START_BASH.task_id, + patch: { is_backgrounded: false, status: 'running' } + }) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: START_BASH.task_id, + status: 'failed', + summary: 'foreground transition failed' + }) + expect(latest()).toMatchObject({ state: 'blocked', summary: 'foreground transition failed' }) + }) + + it('revises in place rather than opening a row from a patch', () => { + const { rows, items, latest } = harness() + rows.observe({ + type: 'system', + subtype: 'task_updated', + task_id: 'never-announced', + patch: { status: 'running' } + }) + expect(items).toEqual([]) + rows.observe(START_BASH) + rows.observe({ + type: 'system', + subtype: 'task_progress', + task_id: 'byjnee2no', + description: 'Running Bash', + usage: { total_tokens: 1_200 } + }) + // Progress `description` is the CURRENT ACTIVITY, not the task's name. + expect(latest()).toMatchObject({ label: 'Wait for the verification verdict', tokens: 1_200 }) + }) + + it('takes no row from a terminal patch for an untracked task', () => { + const { rows, items } = harness() + expect( + rows.observe({ + type: 'system', + subtype: 'task_updated', + task_id: 'pre-journal', + patch: { status: 'failed', description: 'Check logs', error: 'boom' } + }) + ).toBe(true) + expect(items).toEqual([]) + }) + + it('latches a reported outcome against a later live tick', () => { + const { rows, latest } = harness() + rows.observe(START_BASH) + rows.observe(FAILED_NOTIFICATION) + rows.observe({ + type: 'system', + subtype: 'background_tasks_changed', + tasks: [{ task_id: 'byjnee2no', task_type: 'local_bash', description: 'still listed' }] + }) + expect(latest()).toMatchObject({ state: 'blocked' }) + }) + + it('ignores late revisions after a task has reported its outcome', () => { + const { rows, items, latest, turnOpens } = harness() + rows.observe(START_BASH) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: START_BASH.task_id, + status: 'failed', + summary: 'first run failed' + }) + const writes = items.length + const opens = turnOpens.length + + rows.observe({ + type: 'system', + subtype: 'task_progress', + task_id: START_BASH.task_id, + usage: { total_tokens: 99 } + }) + rows.observe({ + type: 'system', + subtype: 'task_updated', + task_id: START_BASH.task_id, + patch: { error: 'late update' } + }) + rows.observe({ + type: 'system', + subtype: 'background_tasks_changed', + tasks: [{ task_id: START_BASH.task_id, task_type: 'local_bash', description: 'late roster' }] + }) + rows.observe({ ...START_BASH, description: 'duplicate start' }) + + expect(items).toHaveLength(writes) + expect(turnOpens).toHaveLength(opens) + expect(latest()).toMatchObject({ state: 'blocked', summary: 'first run failed' }) + }) + + it('does not reopen a turn when a settled session receives a late outcome', () => { + const { rows, latest, turnOpens } = harness() + rows.observe(START_BASH) + rows.settleSession() + const opens = turnOpens.length + + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: START_BASH.task_id, + status: 'failed', + summary: 'late outcome' + }) + + expect(turnOpens).toHaveLength(opens) + expect(latest()).toMatchObject({ state: 'blocked', summary: 'late outcome' }) + }) + + it('reopens a settled row when Claude re-announces the same task id with a new tool id', () => { + const { rows, latest } = harness() + rows.observe({ ...START_BASH, task_id: 'resume-1', tool_use_id: 'toolu_first' }) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'resume-1', + tool_use_id: 'toolu_first', + status: 'completed', + summary: 'first run finished' + }) + expect(latest()).toMatchObject({ state: 'done', summary: 'first run finished' }) + + rows.observe({ + ...START_BASH, + task_id: 'resume-1', + tool_use_id: 'toolu_second', + status: 'running', + description: 'Second run' + }) + expect(latest()).toMatchObject({ state: 'working', label: 'Second run' }) + expect(latest()).not.toHaveProperty('summary') + + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'resume-1', + tool_use_id: 'toolu_second', + status: 'failed', + summary: 'second run failed' + }) + expect(latest()).toMatchObject({ state: 'blocked', summary: 'second run failed' }) + }) + + // Entries carry `{task_id, task_type, description, ambient?}` and NOTHING + // else — no per-entry status — so these use the real payload shape. + it('takes identity from the aggregate roster', () => { + const { rows, latest } = harness() + rows.observe({ ...START_BASH, task_id: 'aggregate-1', description: undefined }) + expect(latest()).toMatchObject({ label: '', state: 'working' }) + + rows.observe({ + type: 'system', + subtype: 'background_tasks_changed', + tasks: [{ task_id: 'aggregate-1', task_type: 'local_bash', description: 'Named by roster' }] + }) + + expect(latest()).toMatchObject({ label: 'Named by roster', state: 'working' }) + }) + + // NOT an ablation of the status-read removal: that removal is behaviour-neutral + // on every real payload, which is exactly why the branch it fed was dead. This + // pins the standing latch rule instead — presence is a level signal whose + // ordering against the start/stop edges is unspecified and which carries no + // evidence of a new run, so a row that reported its own outcome keeps it. + it('does not let mere presence in the live set revive a settled row', () => { + const { rows, latest } = harness() + rows.observe({ ...START_BASH, task_id: 'aggregate-2' }) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'aggregate-2', + status: 'completed' + }) + expect(latest()).toMatchObject({ state: 'done' }) + + rows.observe({ + type: 'system', + subtype: 'background_tasks_changed', + tasks: [{ task_id: 'aggregate-2', task_type: 'local_bash', description: 'still listed' }] + }) + + expect(latest()).toMatchObject({ state: 'done' }) + }) + + it('excludes ambient housekeeping from the aggregate roster', () => { + const { rows, latest } = harness() + rows.observe({ ...START_BASH, task_id: 'aggregate-3', description: undefined }) + rows.observe({ + type: 'system', + subtype: 'background_tasks_changed', + tasks: [ + { + task_id: 'aggregate-3', + task_type: 'local_bash', + description: 'housekeeping name', + ambient: true + } + ] + }) + expect(latest()).toMatchObject({ label: '' }) + }) + + it('never burns a revision on a duplicate delivery', () => { + const { rows, items } = harness() + rows.observe(START_BASH) + const afterStart = items.length + rows.observe(START_BASH) + expect(items.length).toBe(afterStart) + }) + + it('claims a resumed task announced under a new tool id', () => { + const { rows, items, latest } = harness() + rows.observe(START_BASH) + rows.observe({ + type: 'system', + subtype: 'task_notification', + tool_use_id: 'toolu_01CqPd7y', + status: 'failed', + summary: 'it failed' + }) + expect(items.at(-1)?.identity).toMatchObject({ + clientMessageId: 'claude-background-task:byjnee2no' + }) + expect(latest()).toMatchObject({ taskId: 'byjnee2no', state: 'blocked' }) + }) + + it('rejects overlong tool-use aliases instead of clipping them into collisions', () => { + // The row is opened under a usable alias; a later frame carrying an + // oversized one must resolve to NO task rather than being clipped into this + // one and attaching another task's failure to it. + const { rows, items, latest } = harness() + rows.observe({ ...START_BASH, task_id: 'task-a' }) + const afterStart = items.length + + expect( + rows.observe({ + type: 'system', + subtype: 'task_notification', + tool_use_id: `${'x'.repeat(512)}A`, + status: 'failed', + summary: 'misattributed failure' + }) + ).toBe(false) + expect(items).toHaveLength(afterStart) + expect(latest()).toMatchObject({ taskId: 'task-a', state: 'working' }) + }) + + it('evicts settled rows so the lifetime cap cannot drop a later failure', () => { + const { rows, latest } = harness() + for (let index = 0; index < 64; index += 1) { + rows.observe({ ...START_BASH, task_id: `settled-${index}` }) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: `settled-${index}`, + status: 'completed' + }) + } + + rows.observe({ ...START_BASH, task_id: 'overflow' }) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'overflow', + status: 'failed', + summary: 'overflow failed' + }) + + expect(latest()).toMatchObject({ + taskId: 'overflow', + state: 'blocked', + summary: 'overflow failed' + }) + }) + + it('reopens a settled task after its row was evicted when the parent alias changes', () => { + const { rows, keys, latest } = harness([FORWARDED_TOOL, 'toolu_second']) + rows.observe({ ...START_BASH, task_id: 'evicted-restart' }) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'evicted-restart', + tool_use_id: FORWARDED_TOOL, + status: 'completed' + }) + for (let index = 0; index < 63; index += 1) { + rows.observe({ ...START_BASH, task_id: `settled-${index}` }) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: `settled-${index}`, + status: 'completed' + }) + } + // The first settled row is evicted to make room for this one. + rows.observe({ ...START_BASH, task_id: 'evictor' }) + rows.observe({ + ...START_BASH, + task_id: 'evicted-restart', + tool_use_id: 'toolu_second', + description: 'second invocation' + }) + + expect([...new Set(keys())]).toContain('claude-background-task:evicted-restart#2') + expect(latest()).toMatchObject({ + taskId: 'evicted-restart', + state: 'working', + label: 'second invocation' + }) + }) + + it('bounds generation history without reusing an evicted durable identity', () => { + const { rows, keys, latest } = harness([FORWARDED_TOOL, 'toolu_second']) + rows.observe({ ...START_BASH, task_id: 'generation-reused' }) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'generation-reused', + status: 'completed' + }) + rows.observe({ ...START_BASH, task_id: 'generation-reused', tool_use_id: 'toolu_second' }) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'generation-reused', + tool_use_id: 'toolu_second', + status: 'completed' + }) + for (let index = 0; index < 513; index += 1) { + const taskId = `generation-${index}` + rows.observe({ ...START_BASH, task_id: taskId }) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: taskId, + status: 'completed' + }) + } + + rows.observe({ ...START_BASH, task_id: 'generation-reused' }) + rows.observe({ + ...START_BASH, + task_id: 'generation-0', + tool_use_id: 'toolu_second', + description: 'reused after ledger eviction' + }) + + const identities = new Set(keys()) + expect(identities).toContain('claude-background-task:generation-reused#2') + expect(identities).toContain('claude-background-task:generation-reused#4') + expect(identities).toContain('claude-background-task:generation-0') + expect(identities).toContain('claude-background-task:generation-0#5') + expect(latest()).toMatchObject({ + taskId: 'generation-0', + state: 'working', + label: 'reused after ledger eviction' + }) + + expect(rows.ledgerSizes.generations).toBeLessThanOrEqual(512) + }) + + it('declines coverage so the fallback still reports when every row slot is live', () => { + // The row map is bounded. A task that cannot be admitted for lack of a slot + // is not silently swallowed: coverage is declined so the generic fallback + // reports it instead. + const { rows } = harness() + for (let index = 0; index < 64; index += 1) { + rows.observe({ ...START_BASH, task_id: `live-${index}` }) + } + expect(rows.observe({ ...START_BASH, task_id: 'overflow-live' })).toBe(false) + }) + + it('keeps one overflow terminal row across its update and notification', () => { + const { rows, keys, latest } = harness() + for (let index = 0; index < 64; index += 1) { + rows.observe({ ...START_BASH, task_id: `live-${index}` }) + } + + expect(rows.observe({ ...START_BASH, task_id: 'overflow-fallback' })).toBe(false) + expect( + rows.observe({ + type: 'system', + subtype: 'task_updated', + task_id: 'overflow-fallback', + patch: { status: 'failed' } + }) + ).toBe(true) + expect(keys().filter((id) => id === 'claude-background-task:overflow-fallback')).toHaveLength(1) + expect(latest()).toMatchObject({ state: 'blocked' }) + expect( + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'overflow-fallback', + status: 'failed', + summary: 'overflow failed' + }) + ).toBe(true) + expect( + rows.observe({ + type: 'system', + subtype: 'task_progress', + task_id: 'overflow-fallback', + usage: { total_tokens: 3 } + }) + ).toBe(true) + expect( + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'overflow-fallback', + status: 'failed', + summary: 'duplicate overflow failed' + }) + ).toBe(true) + expect(keys().filter((id) => id === 'claude-background-task:overflow-fallback')).toHaveLength(2) + expect(latest()).toMatchObject({ state: 'blocked', summary: 'overflow failed' }) + }) + + it('lets the final notification correct a provisional failed update', () => { + const { rows, latest, latestTwin } = harness() + rows.observe(START_BASH) + rows.observe({ ...FAILED_UPDATE, task_id: START_BASH.task_id }) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: START_BASH.task_id, + status: 'stopped', + summary: 'No completion record was found' + }) + + expect(latest()).toMatchObject({ state: 'idle', summary: 'No completion record was found' }) + expect(latestTwin()).toBe('No completion record was found') + }) + + it('corrects a capacity-refused failed update with the final stopped verdict', () => { + const { rows, latest, keys } = harness() + for (let index = 0; index < 64; index += 1) { + rows.observe({ ...START_BASH, task_id: `live-${index}` }) + } + rows.observe({ ...START_BASH, task_id: 'overflow-stopped' }) + rows.observe({ + type: 'system', + subtype: 'task_updated', + task_id: 'overflow-stopped', + patch: { status: 'failed' } + }) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'overflow-stopped', + status: 'stopped', + summary: 'No completion record was found' + }) + + expect(latest()).toMatchObject({ state: 'idle', summary: 'No completion record was found' }) + expect(keys().filter((id) => id === 'claude-background-task:overflow-stopped')).toHaveLength(2) + }) + + it('preserves a fallback run alias across a duplicate terminal without one', () => { + const { rows, latest } = harness([FORWARDED_TOOL, 'toolu_second']) + for (let index = 0; index < 64; index += 1) { + rows.observe({ ...START_BASH, task_id: `live-${index}` }) + } + + expect(rows.observe({ ...START_BASH, task_id: 'overflow-restart' })).toBe(false) + expect( + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'overflow-restart', + tool_use_id: FORWARDED_TOOL, + status: 'completed' + }) + ).toBe(true) + expect( + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'overflow-restart', + status: 'completed' + }) + ).toBe(true) + + // Make a typed slot available for the new invocation. The alias from the + // first terminal edge is still needed to distinguish this restart from a + // redelivery of the completed fallback run. + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'live-0', + status: 'completed' + }) + expect( + rows.observe({ + ...START_BASH, + task_id: 'overflow-restart', + tool_use_id: 'toolu_second', + description: 'second overflow run' + }) + ).toBe(true) + expect(latest()).toMatchObject({ + taskId: 'overflow-restart', + state: 'working', + label: 'second overflow run' + }) + }) + + it('bounds foreign-owner memory for tasks rendered elsewhere', () => { + const { rows } = harness() + for (let index = 0; index < 600; index += 1) { + rows.observe({ + type: 'system', + subtype: 'task_started', + task_id: `agent-${index}`, + task_type: 'local_agent', + subagent_type: 'explorer' + }) + } + + expect(rows.ledgerSizes.foreign).toBeLessThanOrEqual(512) + }) + + it('bounds fallback ownership memory for capacity-refused tasks', () => { + const { rows } = harness() + for (let index = 0; index < 64; index += 1) { + rows.observe({ ...START_BASH, task_id: `live-${index}` }) + } + for (let index = 0; index < 600; index += 1) { + rows.observe({ ...START_BASH, task_id: `overflow-${index}` }) + } + + expect(rows.ledgerSizes.fallbackTaskIds).toBeLessThanOrEqual(512) + }) + + it('bounds settled overflow rows while preserving the evicted outcome', () => { + const { rows, keys } = harness() + for (let index = 0; index < 64; index += 1) { + rows.observe({ ...START_BASH, task_id: `live-${index}` }) + } + for (let index = 0; index < 513; index += 1) { + const id = `overflow-${index}` + rows.observe({ ...START_BASH, task_id: id }) + rows.observe({ + type: 'system', + subtype: 'task_updated', + task_id: id, + patch: { status: 'failed' } + }) + } + const beforeRedelivery = keys().length + expect(rows.ledgerSizes.overflowTerminalRows).toBeLessThanOrEqual(512) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'overflow-0', + status: 'failed', + summary: 'late outcome' + }) + expect(keys()).toHaveLength(beforeRedelivery) + expect(keys()).toContain('claude-background-task:overflow-0') + }) + + it('loses contact rather than claiming an outcome when the provider goes away', () => { + const { rows, latest, latestTwin } = harness() + rows.observe(START_BASH) + rows.dispose() + expect(latest()).toMatchObject({ state: 'unverifiable' }) + // The frozen sentence a client without the block type reads must not assert + // a liveness only the dead process could have observed. + expect(latestTwin()).toBe( + 'Background command "Wait for the verification verdict" stopped reporting' + ) + }) + + it('states only that a live task was started, never that it is still running', () => { + const { rows, latestTwin } = harness() + rows.observe(START_BASH) + expect(latestTwin()).toBe('Started background command "Wait for the verification verdict"') + }) + + it('declines frames it does not own', () => { + const { rows } = harness() + expect(rows.observe({ type: 'system', subtype: 'init' })).toBe(false) + expect(rows.observe({ type: 'assistant' })).toBe(false) + expect(rows.observe({ type: 'system', subtype: 'task_started', task_id: 'x' })).toBe(true) + }) + it('admits a task type it does not recognise as no task at all', () => { + const { rows, items } = harness() + rows.observe({ ...START_BASH, task_id: 'weird-1', task_type: 'local_teleport' }) + expect(items).toEqual([]) + }) + + it('gives a reused task id a fresh row instead of overwriting the finished run', () => { + const { rows, keys, latest } = harness([FORWARDED_TOOL, 'toolu_second_run']) + rows.observe(START_BASH) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'byjnee2no', + status: 'failed', + summary: 'first run failed' + }) + rows.observe({ ...START_BASH, tool_use_id: 'toolu_second_run' }) + const written = [...new Set(keys())] + expect(written).toEqual([ + 'claude-background-task:byjnee2no', + 'claude-background-task:byjnee2no#2' + ]) + expect(latest()).toMatchObject({ state: 'working', parentToolUseId: 'toolu_second_run' }) + }) + + it('carries the spawning tool call on the row', () => { + const { rows, latest } = harness() + rows.observe(START_BASH) + expect(latest()).toMatchObject({ parentToolUseId: FORWARDED_TOOL }) + }) + + it('does not re-open a task that is already running', () => { + // A redelivered announcement is not a second run. The row it would revise + // is one the user is already reading, so it yields no deltas at all — even + // when the redelivery carries metadata the first announcement lacked. + const { rows, items, latest } = harness() + rows.observe({ ...START_BASH, description: undefined }) + const afterStart = items.length + expect(latest()).toMatchObject({ label: '', state: 'working' }) + + rows.observe({ ...START_BASH, description: 'Named on redelivery' }) + expect(items.length).toBe(afterStart) + expect(latest()).toMatchObject({ label: '' }) + }) +}) diff --git a/src/main/claude/claude-background-task-rows.ts b/src/main/claude/claude-background-task-rows.ts new file mode 100644 index 00000000000..64829d0493a --- /dev/null +++ b/src/main/claude/claude-background-task-rows.ts @@ -0,0 +1,279 @@ +// One durable row per Claude background `task_id`, revised in place from the +// lifecycle frames so a failed command prints once with the provider sentence. + +import { isSettledBackgroundTaskState } from '../../shared/native-chat-background-task-row' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { record, taskAliasId } from './claude-background-task-frames' +import { + claudeBackgroundTaskPatchChange, + claudeBackgroundTaskToolUseId, + canonicalClaudeBackgroundTaskId, + finalizeClaudeBackgroundTaskRow, + newClaudeBackgroundTaskRow, + newClaudeBackgroundTaskRowFromNotification, + reviseClaudeBackgroundTaskRow, + type ClaudeBackgroundTaskChange, + type ClaudeBackgroundTaskRow +} from './claude-background-task-row-lifecycle' +import { + ClaudeBackgroundTaskLedgers, + ensureClaudeBackgroundTaskRowSlot, + type ClaudeBackgroundTaskLedgerSizes +} from './claude-background-task-memory' +import { ClaudeBackgroundTaskRowWriter } from './claude-background-task-row-writer' +import { observeClaudeBackgroundTaskStart } from './claude-background-task-start' +import { observeClaudeBackgroundTaskRoster } from './claude-background-task-roster' +import { ClaudeSubagentIds } from './claude-subagent-id-aliases' +import { ClaudeOverflowTerminalRows } from './claude-overflow-terminal-rows' + +const MAX_TASK_ROWS = 64 + +const TASK_SUBTYPES: ReadonlySet = new Set([ + 'task_started', + 'task_updated', + 'task_progress', + 'task_notification' +]) + +export type ClaudeBackgroundTaskRowsDeps = { + sink: StructuredAgentSessionEventSink + /** Whether a tool id names a tool call this session forwarded at the TOP + * level. Consulted on first admission only: a task whose spawning tool never + * reached the transcript is a nested child, and a top-level row minted for it + * would claim an invocation the user never saw. */ + isForwardedParentTool: (toolUseId: string) => boolean + /** Opens a turn for the frame being journaled. A typed row is provider + * output, so writing one must reopen a turn the provider resumed itself — + * otherwise the session renders the row while reporting idle. */ + openOutputTurn?: (frame: Record, observedAt: number) => void + onPersistenceFailure?: (error: Error) => void + now?: () => number +} + +export class ClaudeBackgroundTaskRows { + private readonly rows = new Map() + private readonly writer: ClaudeBackgroundTaskRowWriter + private readonly overflowTerminalRows: ClaudeOverflowTerminalRows + private readonly ledgers = new ClaudeBackgroundTaskLedgers() + private readonly ids = new ClaudeSubagentIds() + private readonly now: () => number + + constructor(private readonly deps: ClaudeBackgroundTaskRowsDeps) { + this.now = deps.now ?? (() => Date.now()) + this.writer = new ClaudeBackgroundTaskRowWriter(deps.sink, deps.onPersistenceFailure) + this.overflowTerminalRows = new ClaudeOverflowTerminalRows( + this.ledgers, + this.now, + (id, row, openOutputTurn) => this.writeRow(id, row, openOutputTurn) + ) + } + + /** @internal - exposed for tests only: what the bounded ledgers are holding, + * so eviction can be proved without reaching into the collections. */ + get ledgerSizes(): ClaudeBackgroundTaskLedgerSizes & { readonly overflowTerminalRows: number } { + return { ...this.ledgers.sizes, overflowTerminalRows: this.overflowTerminalRows.size } + } + + /** The frame being journaled right now, so a write can open its turn. Null + * outside `observe`: a teardown sweep must never open one. */ + private journaling: { frame: Record; observedAt: number } | null = null + + observe(message: Record, observedAt: number = this.now()): boolean { + this.journaling = { frame: message, observedAt } + try { + return this.observeFrame(message) + } finally { + this.journaling = null + } + } + + /** A Monitor result can identify its task before any lifecycle announcement. + * Its later notification belongs to that tool call, not a transcript row. */ + observeMonitorToolResult(taskId: unknown): void { + const id = taskAliasId(taskId) + if (id !== undefined) { + this.ledgers.rememberForeign(id, 'ambient') + } + } + + private observeFrame(message: Record): boolean { + if (message.type !== 'system') { + return false + } + if (message.subtype === 'background_tasks_changed') { + if (!Array.isArray(message.tasks)) { + return false + } + observeClaudeBackgroundTaskRoster(message.tasks, this.rows, (id, change) => + this.revise(id, change) + ) + return true + } + if (typeof message.subtype !== 'string' || !TASK_SUBTYPES.has(message.subtype)) { + return false + } + const id = canonicalClaudeBackgroundTaskId(message, this.ids) + if (id === null) { + return false + } + if (message.subtype === 'task_started') { + return observeClaudeBackgroundTaskStart({ + id, + message, + rows: this.rows, + ledgers: this.ledgers, + isForwardedParentTool: this.deps.isForwardedParentTool, + openRow: (taskId, frame) => this.openRow(taskId, frame), + maxRows: MAX_TASK_ROWS + }) + } + if (this.ledgers.foreign.has(id)) { + return true + } + if (message.subtype === 'task_notification') { + return this.observeNotification(id, message) + } + return this.observePatch(id, message) + } + + settleSession(): void { + for (const [id, row] of this.rows) { + if (!isSettledBackgroundTaskState(row.block.state)) { + this.revise(id, { state: 'unverifiable' }, true) + } + } + this.writer.settlePendingWrites() + } + + dispose(): void { + this.settleSession() + this.rows.clear() + this.writer.dispose() + this.overflowTerminalRows.clear() + this.ledgers.clear() + this.ids.clear() + } + + retryPendingWrites() { + return this.writer.retryPendingWrites() + } + + private openRow(id: string, message: Record): void { + const generation = this.ledgers.generations.next(id) + this.rows.set(id, newClaudeBackgroundTaskRow(id, message, this.now(), generation)) + this.write(id) + } + + private observeNotification(id: string, message: Record): boolean { + if (this.ledgers.fallbackTaskIds.has(id)) { + this.ledgers.rememberTerminal(this.rows, id, claudeBackgroundTaskToolUseId(message)) + this.overflowTerminalRows.observeNotification(id, message) + return true + } + const row = this.rows.get(id) + if (row && row.terminalNotificationReceived) { + return true + } + // Remembered even for a task never admitted. It scopes the anti-resurrection + // guard to ANNOUNCEMENTS: a late `task_started` cannot reopen work already + // reported finished, which is a different thing from this frame stating the + // outcome itself. + this.ledgers.rememberTerminal(this.rows, id, claudeBackgroundTaskToolUseId(message)) + if (!row) { + return this.openTerminalRow(id, message) + } + const wasLive = !isSettledBackgroundTaskState(row.block.state) + finalizeClaudeBackgroundTaskRow(row, message, this.now()) + this.write(id, wasLive) + return true + } + + /** The row a terminal frame opens for itself. A task the transcript never + * admitted still owes the user its outcome, and the frame carries everything + * that outcome needs, so the row map only ever ENRICHES one — a missing entry + * is not a reason to render nothing. */ + private openTerminalRow(id: string, message: Record): boolean { + if (!ensureClaudeBackgroundTaskRowSlot(this.rows, MAX_TASK_ROWS)) { + this.overflowTerminalRows.observeNotificationWithoutSlot(id, message) + return true + } + const generation = this.ledgers.generations.next(id) + this.rows.set( + id, + newClaudeBackgroundTaskRowFromNotification(id, message, this.now(), generation) + ) + this.write(id) + return true + } + + private observePatch(id: string, message: Record): boolean { + if (this.ledgers.fallbackTaskIds.has(id)) { + const change = claudeBackgroundTaskPatchChange(message) + if (change.state && isSettledBackgroundTaskState(change.state)) { + this.ledgers.rememberTerminal(this.rows, id, claudeBackgroundTaskToolUseId(message)) + this.overflowTerminalRows.observePatch(id, message, change) + return true + } + return false + } + const row = this.rows.get(id) + if (row && isSettledBackgroundTaskState(row.block.state)) { + return true + } + const patch = record(message.patch) + // A tracked row remains this owner's responsibility even if a later patch + // reports foreground execution; its terminal notification still revises + // the durable row. Only an untracked task belongs to the foreground owner. + if (patch?.is_backgrounded === false && !this.rows.has(id)) { + this.ledgers.rememberForeign(id, 'foreground') + return true + } + const change = claudeBackgroundTaskPatchChange(message) + if (change.state && isSettledBackgroundTaskState(change.state)) { + this.ledgers.rememberTerminal(this.rows, id, claudeBackgroundTaskToolUseId(message)) + } + // A patch is folded into the row it names and is never a row of its own, so + // an untracked task takes no row from it. + if (row) { + this.revise(id, change) + } + return true + } + + private revise(id: string, change: ClaudeBackgroundTaskChange, lifecycle = false): void { + const row = this.rows.get(id) + if (!row) { + return + } + const wasLive = !isSettledBackgroundTaskState(row.block.state) + reviseClaudeBackgroundTaskRow(row, change, this.now()) + this.write(id, wasLive, lifecycle) + } + + private write(id: string, openOutputTurn = true, lifecycle = false): void { + const row = this.rows.get(id) + if (!row) { + return + } + this.writeRow(id, row, openOutputTurn, lifecycle) + } + + private writeRow( + id: string, + row: ClaudeBackgroundTaskRow, + openOutputTurn = true, + lifecycle = false + ): void { + const journaling = this.journaling + this.writer.write( + id, + row, + () => { + if (journaling && openOutputTurn) { + this.deps.openOutputTurn?.(journaling.frame, journaling.observedAt) + } + }, + lifecycle + ) + } +} diff --git a/src/main/claude/claude-background-task-start.ts b/src/main/claude/claude-background-task-start.ts new file mode 100644 index 00000000000..33bcc3a4a60 --- /dev/null +++ b/src/main/claude/claude-background-task-start.ts @@ -0,0 +1,98 @@ +import { isSettledBackgroundTaskState } from '../../shared/native-chat-background-task-row' +import { classifyClaudeBackgroundTaskKind } from './claude-background-task-frames' +import { + claudeBackgroundTaskToolUseId, + isClaudeBackgroundTranscriptTask, + shouldRestartClaudeBackgroundTaskRow, + type ClaudeBackgroundTaskRow +} from './claude-background-task-row-lifecycle' +import { + ensureClaudeBackgroundTaskRowSlot, + type ClaudeBackgroundTaskLedgers +} from './claude-background-task-memory' +import { isClaudeSubagentTask } from './claude-subagent-task-frames' + +export function observeClaudeBackgroundTaskStart(input: { + id: string + message: Record + rows: Map + ledgers: ClaudeBackgroundTaskLedgers + isForwardedParentTool: (toolUseId: string) => boolean + openRow: (id: string, message: Record) => void + maxRows: number +}): boolean { + const { id, message, rows, ledgers } = input + if (ledgers.fallbackTaskIds.has(id)) { + if (ledgers.terminalTaskIds.has(id)) { + const previousToolUseId = ledgers.terminalToolUseIds.get(id) + const currentToolUseId = claudeBackgroundTaskToolUseId(message) + if ( + previousToolUseId !== undefined && + currentToolUseId !== undefined && + previousToolUseId !== currentToolUseId + ) { + ledgers.fallbackTaskIds.delete(id) + } else { + return false + } + } else { + return false + } + } + if (message.ambient === true || message.skip_transcript === true) { + ledgers.rememberForeign(id, 'ambient') + return true + } + if (isClaudeSubagentTask(message)) { + ledgers.rememberForeign(id, 'roster') + return true + } + const kind = classifyClaudeBackgroundTaskKind(message.task_type) + if (!isClaudeBackgroundTranscriptTask(message, kind)) { + ledgers.rememberForeign(id, 'foreground') + return true + } + const existing = rows.get(id) + if (existing) { + ledgers.foreign.delete(id) + // A live task's duplicate announcement is redelivery, not a new run. + if (!isSettledBackgroundTaskState(existing.block.state)) { + return true + } + if (shouldRestartClaudeBackgroundTaskRow(existing, message)) { + input.openRow(id, message) + } + return true + } + let restartedTerminal = false + if (ledgers.terminalTaskIds.has(id)) { + const previousToolUseId = ledgers.terminalToolUseIds.get(id) + const currentToolUseId = claudeBackgroundTaskToolUseId(message) + // A terminal edge without a usable parent cannot prove a later start is a new run. + if ( + previousToolUseId === undefined || + currentToolUseId === undefined || + previousToolUseId === currentToolUseId + ) { + return true + } + restartedTerminal = true + } + ledgers.foreign.delete(id) + const toolUseId = claudeBackgroundTaskToolUseId(message) + // Absence of a parent is not evidence of an unforwarded parent. + if (toolUseId !== undefined && !input.isForwardedParentTool(toolUseId)) { + ledgers.rememberForeign(id, 'sidechain') + return true + } + if (!ensureClaudeBackgroundTaskRowSlot(rows, input.maxRows)) { + ledgers.rememberFallback(id) + return false + } + if (restartedTerminal) { + ledgers.terminalTaskIds.delete(id) + ledgers.terminalToolUseIds.delete(id) + } + input.openRow(id, message) + return true +} diff --git a/src/main/claude/claude-background-task-terminal-frames.test.ts b/src/main/claude/claude-background-task-terminal-frames.test.ts new file mode 100644 index 00000000000..86a2dd2f409 --- /dev/null +++ b/src/main/claude/claude-background-task-terminal-frames.test.ts @@ -0,0 +1,238 @@ +// What a TERMINAL background-task frame renders on its own, and what it must +// not. A terminal frame states an outcome, so it is self-sufficient: the row map +// enriches one, it never gates one. The deliberate hand-offs still win, and a +// late announcement still cannot reopen work already reported finished. + +import { describe, expect, it } from 'vitest' +import { + FORWARDED_TOOL, + harness, + ORPHAN_FAILED_NOTIFICATION, + START_BASH +} from './claude-background-task-row-test-support' + +describe('claude background task terminal frames', () => { + it('reports a failure for a task it never saw admitted', () => { + // The row map ENRICHES a terminal frame; it never gates one. This exact + // frame reached a user session whose task was never admitted, and both the + // typed path and the generic fallback stayed silent, so the failure was + // dropped on the floor. + const { rows, items, latest, latestTwin, turnOpens } = harness() + expect(rows.observe(ORPHAN_FAILED_NOTIFICATION)).toBe(true) + expect(items).toHaveLength(1) + // The row is provider output like any other, so writing it reopens the turn + // the provider resumed itself rather than printing beside an idle session. + expect(turnOpens).toHaveLength(1) + expect(latest()).toMatchObject({ + type: 'background-task', + taskId: 'bjzenpq13', + kind: 'unknown', + state: 'blocked', + summary: 'Locate the exact screenshot session', + parentToolUseId: 'toolu_01ASNfnDBEzt4w3ejLE12bGu' + }) + // The sentence carries the provider's words; the header falls back to the + // kind label, so one field is never drawn in two slots of the same row. + expect(latest()?.label).toBe('') + expect(latestTwin()).toBe('Locate the exact screenshot session') + }) + + it('carries the error, output path and usage a terminal frame supplies itself', () => { + const { rows, latest } = harness() + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'orphan-detailed', + status: 'failed', + error: 'exit code 2', + output_file: '/tmp/orphan-detailed.output', + usage: { total_tokens: 41 } + }) + expect(latest()).toMatchObject({ + state: 'blocked', + error: 'exit code 2', + outputFile: '/tmp/orphan-detailed.output', + tokens: 41 + }) + }) + + it('reports every terminal outcome it never saw start, not failures alone', () => { + const { rows, latest } = harness() + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'quiet-1', + status: 'completed' + }) + expect(latest()).toMatchObject({ taskId: 'quiet-1', kind: 'unknown', state: 'done' }) + + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'quiet-2', + status: 'stopped', + summary: 'the user stopped it' + }) + expect(latest()).toMatchObject({ taskId: 'quiet-2', state: 'idle' }) + }) + + it('leaves agent tasks to the subagent roster', () => { + const { rows, items } = harness() + rows.observe({ + type: 'system', + subtype: 'task_started', + task_id: 'task-agent', + task_type: 'local_agent', + subagent_type: 'explorer', + description: 'Map the lane' + }) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'task-agent', + status: 'failed', + summary: 'the child failed' + }) + expect(items).toEqual([]) + }) + + it('leaves legacy local_subagent tasks to the subagent roster', () => { + const { rows, items } = harness() + rows.observe({ + type: 'system', + subtype: 'task_started', + task_id: 'task-legacy-agent', + task_type: 'local_subagent', + subagent_type: 'explorer' + }) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'task-legacy-agent', + status: 'failed', + summary: 'the child failed' + }) + expect(items).toEqual([]) + }) + + it('writes nothing for ambient housekeeping the user never asked for', () => { + const { rows, items } = harness() + rows.observe({ ...START_BASH, task_id: 'ambient-1', ambient: true }) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'ambient-1', + status: 'failed' + }) + expect(items).toEqual([]) + }) + + it('leaves foreground commands to the ordinary transcript path', () => { + const { rows, items } = harness() + expect( + rows.observe({ + ...START_BASH, + task_id: 'foreground-1', + is_backgrounded: false + }) + ).toBe(true) + expect( + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'foreground-1', + status: 'failed', + summary: 'foreground command failed' + }) + ).toBe(true) + expect(items).toEqual([]) + }) + + it('refuses a nested child whose spawning tool was never forwarded', () => { + // A Task spawned inside a subagent's sidechain names a tool id that only + // exists in that sidechain. A top-level row for it would claim an + // invocation the user never saw. + const { rows, items } = harness([]) + rows.observe({ ...START_BASH, task_id: 'nested-1', tool_use_id: 'toolu_sidechain' }) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'nested-1', + tool_use_id: 'toolu_sidechain', + status: 'failed', + summary: 'the nested child failed' + }) + expect(items).toEqual([]) + }) + + it('never lets a monitor reach the timeline', () => { + // A monitor is Claude's own housekeeping: it runs for the life of the + // session and has no outcome a transcript row could report. + const { rows, items } = harness() + rows.observe({ + type: 'system', + subtype: 'task_started', + task_id: 'monitor-1', + tool_use_id: FORWARDED_TOOL, + task_type: 'monitor', + description: 'Watch the build', + is_backgrounded: true + }) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'monitor-1', + status: 'failed', + summary: 'monitor stopped' + }) + expect(items).toEqual([]) + }) + + it('does not resurrect a task whose terminal edge arrived before its start', () => { + // The guard is scoped to ANNOUNCEMENTS: the terminal frame states an + // outcome and gets its row, and the late `task_started` that follows may + // not reopen work already reported finished. + const { rows, items, keys, latest } = harness() + expect( + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: 'done-before-start', + status: 'completed' + }) + ).toBe(true) + expect(items).toHaveLength(1) + + expect(rows.observe({ ...START_BASH, task_id: 'done-before-start' })).toBe(true) + expect(items).toHaveLength(1) + expect([...new Set(keys())]).toEqual(['claude-background-task:done-before-start']) + expect(latest()).toMatchObject({ state: 'done' }) + }) + + it('does not resurrect an orphan outcome that named its own parent tool', () => { + const { rows, items, latest } = harness([ + FORWARDED_TOOL, + ORPHAN_FAILED_NOTIFICATION.tool_use_id + ]) + rows.observe(ORPHAN_FAILED_NOTIFICATION) + rows.observe({ + ...START_BASH, + task_id: ORPHAN_FAILED_NOTIFICATION.task_id, + tool_use_id: ORPHAN_FAILED_NOTIFICATION.tool_use_id + }) + expect(items).toHaveLength(1) + expect(latest()).toMatchObject({ state: 'blocked' }) + }) + + it('does not resurrect after a terminal update that arrived before start', () => { + const { rows, items } = harness() + rows.observe({ + type: 'system', + subtype: 'task_updated', + task_id: 'updated-before-start', + patch: { status: 'completed' } + }) + rows.observe({ ...START_BASH, task_id: 'updated-before-start' }) + expect(items).toEqual([]) + }) +}) diff --git a/src/main/claude/claude-background-task-turn-resumption.test.ts b/src/main/claude/claude-background-task-turn-resumption.test.ts new file mode 100644 index 00000000000..519496df413 --- /dev/null +++ b/src/main/claude/claude-background-task-turn-resumption.test.ts @@ -0,0 +1,214 @@ +// A typed background-task row is provider output. Journaling one must reopen a +// turn the provider resumed on its own, or the session renders the row while +// the projector — and so the sidebar row and the chat indicator — read idle. +// +// This is the same failure shape as the reported incident: a `result` settles +// the turn, then a background task reports in and wakes the agent. + +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalRenderItem +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + hasUnansweredStructuredAgentSessionDispatch, + projectStructuredAgentSessionStatus +} from '../../shared/structured-agent-session-projection' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +const SESSION = 'claude-session' +const TOOL = 'toolu_fwd' + +function harness() { + const appended: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => appended.push({ identity, body }), + appendTombstone: () => {}, + publish: vi.fn() + } + const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' }) + const items = (): AgentJournalRenderItem[] => { + const byKey = new Map() + appended.forEach(({ identity, body }, index) => { + const key = agentJournalItemKey(identity) + const existing = byKey.get(key) + byKey.set(key, { + itemId: key, + revision: (existing?.revision ?? 0) + 1, + body, + sequence: existing?.sequence ?? index, + observedAt: index + }) + }) + return [...byKey.values()].sort((a, b) => a.sequence - b.sequence) + } + return { translator, items, appended } +} + +function projected(items: readonly AgentJournalRenderItem[]): string { + expect(hasUnansweredStructuredAgentSessionDispatch([], null)).toBe(false) + return projectStructuredAgentSessionStatus(items, [], null) +} + +function systemFrame(uuid: string, fields: Record) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { type: 'system', uuid, session_id: SESSION, ...fields } + } +} + +/** The assistant turn that invokes the spawn tool, which admission requires. */ +function spawnToolCall(translator: ReturnType['translator']) { + translator.handle({ + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'assistant', + uuid: 'a1', + session_id: SESSION, + parent_tool_use_id: null, + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: TOOL, name: 'Bash', input: { command: 'wait' } }] + } + } + }) +} + +function settleTurn(translator: ReturnType['translator']) { + translator.handle({ + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'result', + subtype: 'success', + uuid: 'r1', + session_id: SESSION, + parent_tool_use_id: null, + duration_ms: 1000 + } + }) +} + +describe('a typed background-task row opens the turn it resumes', () => { + it('reports working, not idle, when a task notification wakes the agent', () => { + const { translator, items } = harness() + spawnToolCall(translator) + translator.handle( + systemFrame('s1', { + subtype: 'task_started', + task_id: 'byjnee2no', + tool_use_id: TOOL, + task_type: 'local_bash', + description: 'Wait for the verification verdict', + is_backgrounded: true + }) + ) + settleTurn(translator) + // The turn is settled: this is the state the reported session was in. + expect(projected(items())).toBe('idle') + + translator.handle( + systemFrame('s2', { + subtype: 'task_notification', + task_id: 'byjnee2no', + tool_use_id: TOOL, + status: 'failed', + summary: 'Background command "Wait" failed with exit code 1' + }) + ) + + // The typed row is journaled... + // `agentJournalItemKey` percent-encodes the ':', so the durable row reads + // `orca:claude-background-task%3Abyjnee2no`. + const taskRow = items().find((item) => item.itemId.includes('byjnee2no')) + expect(taskRow).toBeDefined() + // ...and it opened a turn, so the session does not read idle beside it. + const running = items().filter((item) => readAgentJournalTurn(item.body)?.state === 'running') + expect(running).toHaveLength(1) + expect(projected(items())).toBe('working') + }) + + it('does not reopen a completed turn for a late task revision', () => { + const { translator, items } = harness() + spawnToolCall(translator) + translator.handle( + systemFrame('s1', { + subtype: 'task_started', + task_id: 'late-revision', + tool_use_id: TOOL, + task_type: 'local_bash', + description: 'Wait for the verification verdict', + is_backgrounded: true + }) + ) + translator.handle( + systemFrame('s2', { + subtype: 'task_notification', + task_id: 'late-revision', + tool_use_id: TOOL, + status: 'completed', + summary: 'first run finished' + }) + ) + settleTurn(translator) + expect(projected(items())).toBe('idle') + + translator.handle( + systemFrame('s3', { + subtype: 'task_progress', + task_id: 'late-revision', + usage: { total_tokens: 99 } + }) + ) + + expect(projected(items())).toBe('idle') + }) + + it('does not reopen a completed turn when an overflow terminal row is enriched', () => { + const { translator, items } = harness() + for (let index = 0; index < 64; index += 1) { + translator.handle( + systemFrame(`start-${index}`, { + subtype: 'task_started', + task_id: `live-${index}`, + task_type: 'local_bash', + is_backgrounded: true + }) + ) + } + translator.handle( + systemFrame('overflow-start', { + subtype: 'task_started', + task_id: 'overflow-turn', + task_type: 'local_bash', + is_backgrounded: true + }) + ) + translator.handle( + systemFrame('overflow-update', { + subtype: 'task_updated', + task_id: 'overflow-turn', + patch: { status: 'failed' } + }) + ) + settleTurn(translator) + expect(projected(items())).toBe('idle') + + translator.handle( + systemFrame('overflow-notification', { + subtype: 'task_notification', + task_id: 'overflow-turn', + status: 'stopped', + summary: 'No completion record was found' + }) + ) + + expect(projected(items())).toBe('idle') + }) +}) diff --git a/src/main/claude/claude-forwarded-tool-registry.ts b/src/main/claude/claude-forwarded-tool-registry.ts new file mode 100644 index 00000000000..6e376b13bdf --- /dev/null +++ b/src/main/claude/claude-forwarded-tool-registry.ts @@ -0,0 +1,43 @@ +// Which Claude tool calls this session actually forwarded to the top-level +// transcript. +// +// A task announces the tool call that spawned it. That tool call is only +// evidence the user can act on when it was forwarded at the TOP level: a nested +// Task spawned from inside a subagent's sidechain names a tool id that exists +// only in that sidechain, and a row minted for it would claim a top-level +// invocation that never appeared. So admission asks this registry, and a task +// whose parent was never forwarded yields no row at all. + +/** Event-accumulated and pruned by nothing, so bounded. Eviction is oldest + * first: a tool id old enough to fall out can no longer be the parent of a + * task announcement still in flight. */ +const MAX_FORWARDED_TOOL_IDS = 512 + +export class ClaudeForwardedToolRegistry { + private readonly ids = new Set() + + /** Record a tool call forwarded at the top level. Nested traffic must not + * reach here — its caller checks `parent_tool_use_id` first. */ + record(toolUseId: string): void { + if (toolUseId.length === 0) { + return + } + this.ids.delete(toolUseId) + this.ids.add(toolUseId) + while (this.ids.size > MAX_FORWARDED_TOOL_IDS) { + const oldest = this.ids.values().next() + if (oldest.done || oldest.value === toolUseId) { + break + } + this.ids.delete(oldest.value) + } + } + + has(toolUseId: string): boolean { + return this.ids.has(toolUseId) + } + + clear(): void { + this.ids.clear() + } +} diff --git a/src/main/claude/claude-message-journaling.ts b/src/main/claude/claude-message-journaling.ts new file mode 100644 index 00000000000..5a278f55210 --- /dev/null +++ b/src/main/claude/claude-message-journaling.ts @@ -0,0 +1,156 @@ +// Journaling ONE Claude message envelope: its body, tool calls, tool results, +// reasoning, and the unmodeled content that falls back to a generic row. +// +// Split out of the translator when that file reached its line budget. The body +// moved unchanged; the only edit is that what were closure variables are now +// read off an explicit context, so the open turn and the collaborators it +// writes through stay owned by the translator. + +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudeBackgroundTaskRows } from './claude-background-task-rows' +import type { ClaudeForwardedToolRegistry } from './claude-forwarded-tool-registry' +import { + claudeRecord, + claudeMessageBody, + claudeMessageIdentity, + claudeOutputEnvelope, + claudeThinkingIdentity, + claudeThinkingText, + claudeToolBody, + claudeToolIdentity, + claudeToolResults, + claudeToolUses, + readClaudeMessageEnvelope, + type ClaudeToolUse +} from './claude-structured-item-translation' +import { + appendUnmodeledContent, + type ClaudeProviderFrameFallback +} from './claude-structured-provider-fallback' +import type { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity' +import type { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints' +import type { ClaudeSubagentRoster } from './claude-subagent-roster' +import { claudeTurnOpenedBySendEcho, type ClaudeTurnSource } from './claude-turn-opening' +import type { ClaudeOpenTurn } from './claude-open-turn' + +export type ClaudeMessageJournalContext = { + sink: StructuredAgentSessionEventSink + tools: Map + streamedBlocks: ReturnType + streamedText: ReturnType + subagents: ClaudeSubagentRoster + forwardedTools: ClaudeForwardedToolRegistry + backgroundTasks: ClaudeBackgroundTaskRows + providerFallback: ClaudeProviderFrameFallback + /** The session's open turn. Sole owner of turn identity and of the reopen + * latch; this module asks it rather than tracking a copy. */ + turn: ClaudeOpenTurn +} + +export function journalClaudeMessage( + ctx: ClaudeMessageJournalContext, + message: Record, + startsTurn: boolean, + observedAt: number, + /** Host clock on the submission that produced this send, when known. */ + requestedAt?: number +): boolean { + const envelope = readClaudeMessageEnvelope(message) + if (!envelope) { + return false + } + let changed = false + if (envelope.parentToolUseId) { + ctx.subagents.observeChildActivity(envelope.parentToolUseId) + } + const outputEnvelope = claudeOutputEnvelope(envelope) + const body = claudeMessageBody(outputEnvelope) + const identity = + (body && envelope.role === 'assistant' ? ctx.streamedBlocks.reconcile(envelope) : null) ?? + claudeMessageIdentity(envelope) + ctx.streamedText.forget(agentJournalItemKey(identity)) + const thinking = claudeThinkingText(outputEnvelope) + const source: ClaudeTurnSource = { + sessionId: envelope.sessionId, + uuid: envelope.uuid, + assistant: envelope.role === 'assistant' + } + const openOutputTurn = (): void => ctx.turn.ensureOpen(message, source, observedAt) + if (body) { + // Opening before the append is what brackets a turn around its own first + // output; a reader that scans back to the turn record and stops would + // otherwise look straight past the row that opened it. + ctx.turn.ensureOpen(message, source, observedAt) + ctx.sink.appendItem(identity, body) + changed = true + } + for (const tool of claudeToolUses(outputEnvelope)) { + ctx.turn.ensureOpen(message, source, observedAt) + ctx.tools.set(tool.id, tool) + // Only a TOP-LEVEL call can be the parent of a top-level task row; a + // sidechain's own tool ids never reach the transcript. + if (!envelope.parentToolUseId) { + ctx.forwardedTools.record(tool.id) + } + ctx.sink.appendItem(claudeToolIdentity(envelope.sessionId, tool.id), claudeToolBody({ tool })) + changed = true + } + const results = claudeToolResults(envelope) + for (const result of results) { + const tool = ctx.tools.get(result.toolUseId) ?? { + id: result.toolUseId, + name: 'tool', + input: null + } + ctx.sink.appendItem( + claudeToolIdentity(envelope.sessionId, result.toolUseId), + claudeToolBody({ tool, result }) + ) + ctx.subagents.observeToolResult(result.toolUseId, result.failed) + if ( + results.length === 1 && + envelope.parentToolUseId === null && + tool.name === 'Monitor' && + ctx.forwardedTools.has(result.toolUseId) + ) { + ctx.backgroundTasks.observeMonitorToolResult(claudeRecord(message.tool_use_result)?.taskId) + } + ctx.tools.delete(result.toolUseId) + changed = true + } + if (thinking) { + ctx.turn.ensureOpen(message, source, observedAt) + ctx.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), { + kind: 'message', + role: 'reasoning', + blocks: [ + { type: 'text', text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } + ] + }) + changed = true + } + changed = + appendUnmodeledContent(ctx.providerFallback, outputEnvelope, message, openOutputTurn) || changed + // The send's turn is anchored to the user row journaled just above it. + const sendEchoTurn = claudeTurnOpenedBySendEcho({ + envelope, + frame: message, + startsTurn, + observedAt, + ...(requestedAt === undefined ? {} : { requestedAt }), + userItemId: agentJournalItemKey(identity) + }) + if (sendEchoTurn) { + ctx.turn.allowReopen() + ctx.turn.open(sendEchoTurn, observedAt) + } + if (changed) { + ctx.sink.publish() + } + return true +} diff --git a/src/main/claude/claude-overflow-terminal-rows.ts b/src/main/claude/claude-overflow-terminal-rows.ts new file mode 100644 index 00000000000..68fdf831534 --- /dev/null +++ b/src/main/claude/claude-overflow-terminal-rows.ts @@ -0,0 +1,92 @@ +import { + finalizeClaudeBackgroundTaskRow, + newClaudeBackgroundTaskRow, + newClaudeBackgroundTaskRowFromNotification, + reviseClaudeBackgroundTaskRow, + type ClaudeBackgroundTaskChange, + type ClaudeBackgroundTaskRow +} from './claude-background-task-row-lifecycle' +import type { ClaudeBackgroundTaskLedgers } from './claude-background-task-memory' + +const MAX_OVERFLOW_TERMINAL_ROWS = 512 + +/** Settled rows waiting for their final notification never consume a live task slot. */ +export class ClaudeOverflowTerminalRows { + private readonly rows = new Map() + + constructor( + private readonly ledgers: ClaudeBackgroundTaskLedgers, + private readonly now: () => number, + private readonly write: ( + id: string, + row: ClaudeBackgroundTaskRow, + openOutputTurn?: boolean + ) => void + ) {} + + get size(): number { + return this.rows.size + } + + observePatch( + id: string, + message: Record, + change: ClaudeBackgroundTaskChange + ): void { + if (this.rows.has(id)) { + return + } + const row = newClaudeBackgroundTaskRow( + id, + message, + this.now(), + this.ledgers.generations.next(id) + ) + reviseClaudeBackgroundTaskRow(row, change, this.now()) + this.rows.set(id, row) + this.write(id, row) + while (this.rows.size > MAX_OVERFLOW_TERMINAL_ROWS) { + const oldest = this.rows.keys().next() + if (oldest.done) { + break + } + this.rows.delete(oldest.value) + this.ledgers.fallbackTaskIds.delete(oldest.value) + this.ledgers.rememberForeign(oldest.value, 'terminal') + } + } + + observeNotification(id: string, message: Record): void { + const row = this.rows.get(id) + this.rows.delete(id) + this.ledgers.fallbackTaskIds.delete(id) + if (row) { + finalizeClaudeBackgroundTaskRow(row, message, this.now()) + this.write(id, row, false) + } else { + this.writeNotification(id, message) + } + this.ledgers.rememberForeign(id, 'terminal') + } + + observeNotificationWithoutSlot(id: string, message: Record): void { + this.writeNotification(id, message) + this.ledgers.rememberForeign(id, 'terminal') + } + + clear(): void { + this.rows.clear() + } + + private writeNotification(id: string, message: Record): void { + this.write( + id, + newClaudeBackgroundTaskRowFromNotification( + id, + message, + this.now(), + this.ledgers.generations.next(id) + ) + ) + } +} diff --git a/src/main/claude/claude-stream-json-connection-close.test.ts b/src/main/claude/claude-stream-json-connection-close.test.ts index e824139a776..22076f3600e 100644 --- a/src/main/claude/claude-stream-json-connection-close.test.ts +++ b/src/main/claude/claude-stream-json-connection-close.test.ts @@ -37,6 +37,131 @@ function fakeChild(): ChildProcessWithoutNullStreams { } describe('Claude stream-json close ordering', () => { + it('stops pulling SDK messages until reading resumes', async () => { + mocks.refresh.mockReset() + mocks.proveClaudeChildExit.mockReset() + mocks.refresh.mockResolvedValue(undefined) + mocks.proveClaudeChildExit.mockResolvedValue(true) + const child = fakeChild() + const first = Promise.withResolvers>() + const next = vi + .fn<() => Promise>>>() + .mockImplementationOnce(async () => ({ value: await first.promise, done: false })) + .mockResolvedValueOnce({ value: { type: 'second' }, done: false }) + .mockResolvedValue({ value: undefined, done: true }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection. + const queryImpl = ((params: Parameters[0]) => { + params.options?.spawnClaudeCodeProcess?.({ + command: 'claude', + args: [], + env: {}, + signal: new AbortController().signal + }) + return { + [Symbol.asyncIterator]: () => ({ next }) + } + }) as unknown as typeof query + const seen: string[] = [] + let connection: Awaited> + connection = await openClaudeStreamJsonConnection( + { pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' }, + { + onMessage: (message) => { + seen.push(String(message.type)) + if (message.type === 'first') { + connection.pauseReading?.() + } + } + }, + () => child, + queryImpl + ) + + first.resolve({ type: 'first' }) + await vi.waitFor(() => expect(seen).toEqual(['first'])) + await new Promise((resolve) => setImmediate(resolve)) + expect(next).toHaveBeenCalledOnce() + + connection.resumeReading?.() + await vi.waitFor(() => expect(seen).toEqual(['first', 'second'])) + expect(next).toHaveBeenCalledTimes(3) + await expect(connection.close()).resolves.toBe(true) + }) + + it('releases a pulled frame when provider exit is reported', async () => { + mocks.refresh.mockReset() + mocks.proveClaudeChildExit.mockReset() + mocks.refresh.mockResolvedValue(undefined) + mocks.proveClaudeChildExit.mockResolvedValue(true) + const child = fakeChild() + const first = Promise.withResolvers>() + const next = vi + .fn<() => Promise>>>() + .mockImplementationOnce(async () => ({ value: await first.promise, done: false })) + .mockResolvedValue({ value: undefined, done: true }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection. + const queryImpl = ((params: Parameters[0]) => { + params.options?.spawnClaudeCodeProcess?.({ + command: 'claude', + args: [], + env: {}, + signal: new AbortController().signal + }) + return { + [Symbol.asyncIterator]: () => ({ next }) + } + }) as unknown as typeof query + const events: string[] = [] + const connection = await openClaudeStreamJsonConnection( + { pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' }, + { + onMessage: (message) => events.push(`message:${String(message.type)}`), + onExit: () => events.push('exit') + }, + () => child, + queryImpl + ) + + connection.pauseReading?.() + first.resolve({ type: 'task_notification' }) + await vi.waitFor(() => expect(next).toHaveBeenCalledOnce()) + expect(events).toEqual([]) + + child.emit('exit', 1, null) + await vi.waitFor(() => expect(events).toEqual(['exit', 'message:task_notification'])) + await expect(connection.close()).resolves.toBe(true) + }) + + it('returns an unproven close without waiting on a live output reader', async () => { + mocks.refresh.mockReset() + mocks.proveClaudeChildExit.mockReset() + mocks.refresh.mockResolvedValue(undefined) + mocks.proveClaudeChildExit.mockResolvedValue(false) + const child = fakeChild() + const next = vi.fn(() => new Promise>>(() => {})) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection. + const queryImpl = ((params: Parameters[0]) => { + params.options?.spawnClaudeCodeProcess?.({ + command: 'claude', + args: [], + env: {}, + signal: new AbortController().signal + }) + return { + [Symbol.asyncIterator]: () => ({ next }) + } + }) as unknown as typeof query + const connection = await openClaudeStreamJsonConnection( + { pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' }, + {}, + () => child, + queryImpl + ) + + await expect(connection.close()).resolves.toBe(false) + expect(next).toHaveBeenCalledOnce() + }) + it('waits for the live tree refresh before ending stdin', async () => { const refreshDone = Promise.withResolvers() mocks.refresh.mockReturnValueOnce(refreshDone.promise) diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index 1a99bb92064..89255094e59 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -38,6 +38,10 @@ function loadClaudeAgentSdk(): Promise { return claudeAgentSdk } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + export type ClaudeStreamJsonLaunch = { /** Orca's resolved user CLI; the SDK falls back to a bundled binary that is not installed. */ pathToClaudeCodeExecutable: string @@ -78,6 +82,8 @@ export type ClaudeStreamJsonConnection = ClaudeControlSurface & { readonly closed: boolean /** What the ladder has observed so far; read after a `close()` that returned false. */ readonly exitVerdict: ClaudeChildExitVerdict + pauseReading?: () => void + resumeReading?: () => void send: (message: Record) => Promise /** Resolves true after processless settlement, or root exit plus observed tree exit. */ close: () => Promise @@ -141,6 +147,23 @@ export async function openClaudeStreamJsonConnection( let faultReported = false let exitReported = false let closePromise: Promise | null = null + let readingBarrier: Promise | null = null + let releaseReadingBarrier: (() => void) | null = null + const pauseReading = (): void => { + if (closing || exited || terminalError || readingBarrier) { + return + } + readingBarrier = new Promise((resolve) => { + releaseReadingBarrier = resolve + }) + } + const resumeReading = (): void => { + const release = releaseReadingBarrier + readingBarrier = null + releaseReadingBarrier = null + release?.() + } + const waitUntilReadable = (): Promise => readingBarrier ?? Promise.resolve() // One reaper per child: every close attempt and error-path reap shares its proof. const rootSettled = (): boolean => exited || processless const tree = createClaudeChildTreeReaper(child, { exited: rootSettled }) @@ -180,6 +203,7 @@ export async function openClaudeStreamJsonConnection( }) const handleUnexpectedEnd = (cause?: Error): void => { + resumeReading() terminalError ??= exitError(spawner.stderrTail, exitStatus, cause) inbox.fail(terminalError) if (!closing && !faultReported) { @@ -192,18 +216,38 @@ export async function openClaudeStreamJsonConnection( } } - void (async () => { - for await (const message of session) { - handlers.onMessage?.(message as unknown as Record) + const readerDone = (async () => { + try { + const iterator = session[Symbol.asyncIterator]() + let completed = false + try { + for (;;) { + await waitUntilReadable() + const next = await iterator.next() + if (next.done) { + completed = true + break + } + await waitUntilReadable() + if (!isRecord(next.value)) { + throw new Error('claude stream-json yielded a non-object message') + } + handlers.onMessage?.(next.value) + } + } finally { + if (!completed) { + await iterator.return?.() + } + } + } catch (error: unknown) { + // The SDK ends its generator in error when the child dies or the transport + // fails; a transport failure with a live child still has to reap the tree. + if (!closing && !exited) { + void tree.reap() + } + handleUnexpectedEnd(error instanceof Error ? error : new Error(String(error))) } - })().catch((error: unknown) => { - // The SDK ends its generator in error when the child dies or the transport - // fails; a transport failure with a live child still has to reap the tree. - if (!closing && !exited) { - void tree.reap() - } - handleUnexpectedEnd(error instanceof Error ? error : new Error(String(error))) - }) + })() child.on('error', (error) => { if (spawner.pid === undefined) { @@ -251,6 +295,7 @@ export async function openClaudeStreamJsonConnection( const close = (): Promise => { closePromise ??= (async () => { closing = true + resumeReading() // Arm the descendant proof before ending stdin. The SDK may exit the root // immediately; a post-exit walk cannot recover descendants that reparented. await (tree.refresh?.() ?? tree.capture()) @@ -264,8 +309,10 @@ export async function openClaudeStreamJsonConnection( inbox.fail(new Error('claude stream-json connection closed')) if (!proven) { closePromise = null + return false } - return proven + await readerDone + return true })() return closePromise } @@ -284,6 +331,8 @@ export async function openClaudeStreamJsonConnection( tree: tree.treeVerdict } as const }, + pauseReading, + resumeReading, send, close } diff --git a/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts new file mode 100644 index 00000000000..aef4c3ece0a --- /dev/null +++ b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts @@ -0,0 +1,867 @@ +import { describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { + createDeferredStructuredAgentSessionEventSink, + type StructuredAgentSessionEventSink, + type StructuredAgentSessionEventTarget, + type StructuredAgentSessionLifecycleJournal +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import { blockOf } from './claude-background-task-row-test-support' + +// The frames below are the ones the reported session actually carried: two real +// failures that printed THREE red rows whose visible text was the wire opcode, +// two of them for the same task. +const TASK_ID = 'byjnee2no' +const SUMMARY = 'Background command "Wait for the verification verdict" failed with exit code 1' + +function orcaClientMessageId(identity: AgentJournalItemIdentity): string | null { + return identity.provider === 'orca' ? identity.clientMessageId : null +} + +function harness() { + const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => items.push({ identity, body }), + appendTombstone: vi.fn(), + publish: vi.fn() + } + const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' }) + const rowsWithPrefix = (prefix: string): AgentJournalItemBody[] => + items + .filter((item) => (orcaClientMessageId(item.identity) ?? '').startsWith(prefix)) + .map((item) => item.body) + const textOf = (body: AgentJournalItemBody): string => { + if (body.kind === 'status') { + return body.text + } + return body.kind === 'message' + ? body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join(' ') + : '' + } + return { + translator, + items, + /** Generic unknown-frame rows — the ones that printed `claude · `. */ + fallbackRows: () => rowsWithPrefix('provider-frame:').map(textOf), + taskRowIds: () => + items + .map((item) => orcaClientMessageId(item.identity) ?? '') + .filter((id) => id.startsWith('claude-background-task:')), + taskRowTexts: () => rowsWithPrefix('claude-background-task:').map(textOf) + } +} + +function systemFrame(fields: Record) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { type: 'system', session_id: 'claude-session', ...fields } + } +} + +/** The assistant turn that invokes the spawn tool. Admission consults it: a task + * whose spawning tool never reached the transcript is a nested child, so every + * realistic sequence forwards this first. */ +function spawnToolCall( + translator: ReturnType['translator'], + toolUseId = 'toolu_01CqPd7y' +): void { + translator.handle({ + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'assistant', + uuid: `assistant-${toolUseId}`, + session_id: 'claude-session', + parent_tool_use_id: null, + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: toolUseId, name: 'Bash', input: { command: 'wait' } }] + } + } + }) +} + +function playFailedBackgroundCommand(translator: ReturnType['translator']): void { + spawnToolCall(translator) + translator.handle( + systemFrame({ + subtype: 'task_started', + task_id: TASK_ID, + tool_use_id: 'toolu_01CqPd7y', + task_type: 'local_bash', + description: 'Wait for the verification verdict', + is_backgrounded: true + }) + ) + translator.handle( + systemFrame({ + subtype: 'task_updated', + task_id: TASK_ID, + patch: { status: 'failed', end_time: 1_789_332_035_695 } + }) + ) + translator.handle( + systemFrame({ + subtype: 'task_notification', + task_id: TASK_ID, + tool_use_id: 'toolu_01CqPd7y', + status: 'failed', + output_file: '/private/tmp/claude-501/tasks/byjnee2no.output', + summary: SUMMARY + }) + ) +} + +function fillLiveTaskRows(translator: ReturnType['translator']): void { + for (let index = 0; index < 64; index += 1) { + const toolUseId = `toolu-live-${index}` + spawnToolCall(translator, toolUseId) + translator.handle( + systemFrame({ + subtype: 'task_started', + task_id: `live-${index}`, + tool_use_id: toolUseId, + task_type: 'local_bash', + description: `live ${index}`, + is_backgrounded: true + }) + ) + } +} + +function persistedTarget( + persisted: Map +): StructuredAgentSessionEventTarget { + const journal = + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this test double implements the journal methods exercised by the deferred sink. + { + appendItem: async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { + persisted.set(agentJournalItemKey(identity), body) + return { cursor: { epoch: 'test', sequence: persisted.size }, itemId: '', revision: 1 } + }, + appendTombstone: vi.fn(), + visitItems: ( + visit: (itemId: string, sequence: number, body: AgentJournalItemBody) => void + ) => { + for (const [itemId, body] of persisted) { + visit(itemId, 0, body) + } + }, + epoch: 'test' + } as unknown as AgentSessionJournal + return { journal, fence: 1, publish: vi.fn() } +} + +describe('claude journal translation — background task rows', () => { + it('persists one terminal row at the hard watermark without an opcode fallback', async () => { + const persisted = new Map() + const appendEntered = Promise.withResolvers() + const appendGate = Promise.withResolvers() + const target = persistedTarget(persisted) + const appendItem = target.journal.appendItem.bind(target.journal) + vi.spyOn(target.journal, 'appendItem').mockImplementationOnce(async (...args) => { + appendEntered.resolve() + await appendGate.promise + return appendItem(...args) + }) + const deferred = createDeferredStructuredAgentSessionEventSink({ + watermarks: { + pauseQueuedOperations: 1, + maxQueuedOperations: 4, + lowQueuedOperations: 0, + maxQueuedBytes: 1_000_000 + } + }) + const translator = createClaudeJournalTranslator({ + sink: deferred.sink, + fallbackIdPrefix: 'hard-watermark' + }) + const providerResume = vi.fn() + let sinkPaused = false + deferred.sink.bindReadingControl?.({ + pauseReading: () => { + sinkPaused = true + }, + resumeReading: () => { + sinkPaused = false + const admission = translator.retryPendingTaskRows?.() ?? { accepted: true } + if (!sinkPaused && (admission.accepted || admission.reason !== 'backpressure')) { + providerResume() + } + } + }) + deferred.bind(target) + deferred.sink.appendItem( + { provider: 'orca', clientMessageId: 'blocked-prefill' }, + { kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] } + ) + await appendEntered.promise + const notification = systemFrame({ + subtype: 'task_notification', + task_id: 'hard-watermark-task', + tool_use_id: 'toolu-hard-watermark', + status: 'failed', + summary: 'The real provider task failed', + uuid: 'hard-watermark-notification' + }) + + translator.handle(notification) + translator.handle(notification) + expect(deferred.state().queuedOperations).toBe(4) + expect(translator.retryPendingTaskRows?.()).toEqual({ + accepted: false, + reason: 'backpressure' + }) + expect( + [...persisted.values()].filter( + (body) => body.kind === 'message' && blockOf(body)?.taskId === 'hard-watermark-task' + ) + ).toEqual([]) + + appendGate.resolve() + await vi.waitFor(() => expect(providerResume).toHaveBeenCalledOnce()) + await expect(deferred.drained()).resolves.toEqual({ ok: true }) + const taskRows = [...persisted.values()].filter( + (body) => body.kind === 'message' && blockOf(body)?.taskId === 'hard-watermark-task' + ) + expect(taskRows).toHaveLength(1) + expect(blockOf(taskRows[0])?.error).toBeUndefined() + expect(blockOf(taskRows[0])?.summary).toBe('The real provider task failed') + expect( + [...persisted.values()].some( + (body) => + body.kind === 'status' && body.providerFrame?.kind.includes('task_notification') === true + ) + ).toBe(false) + }) + + it('coalesces an unbound overflow patch and aliased final notification', async () => { + const persisted = new Map() + const deferred = createDeferredStructuredAgentSessionEventSink() + const translator = createClaudeJournalTranslator({ + sink: deferred.sink, + fallbackIdPrefix: 'test' + }) + fillLiveTaskRows(translator) + translator.handle( + systemFrame({ subtype: 'task_started', task_id: 'queued-overflow', task_type: 'local_bash' }) + ) + translator.handle( + systemFrame({ + subtype: 'task_updated', + task_id: 'queued-overflow', + patch: { status: 'failed' } + }) + ) + translator.handle( + systemFrame({ + subtype: 'task_notification', + task_id: 'queued-overflow', + tool_use_id: 'toolu-final', + status: 'stopped', + summary: 'No completion record was found' + }) + ) + deferred.bind(persistedTarget(persisted)) + await deferred.drained() + + expect([...persisted.keys()].filter((key) => key.includes('queued-overflow'))).toEqual([ + 'orca:claude-background-task%3Aqueued-overflow' + ]) + expect(blockOf(persisted.get('orca:claude-background-task%3Aqueued-overflow'))).toMatchObject({ + state: 'idle', + parentToolUseId: 'toolu-final', + summary: 'No completion record was found' + }) + }) + + it('reconciles an aliased notification after a parentless overflow patch was persisted', async () => { + const persisted = new Map() + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind(persistedTarget(persisted)) + const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'first' }) + fillLiveTaskRows(first) + first.handle( + systemFrame({ subtype: 'task_started', task_id: 'bound-overflow', task_type: 'local_bash' }) + ) + first.handle( + systemFrame({ + subtype: 'task_updated', + task_id: 'bound-overflow', + patch: { status: 'failed' } + }) + ) + await deferred.drained() + + const resumed = createClaudeJournalTranslator({ + sink: deferred.sink, + fallbackIdPrefix: 'resumed' + }) + resumed.handle( + systemFrame({ + subtype: 'task_notification', + task_id: 'bound-overflow', + tool_use_id: 'toolu-first', + status: 'stopped', + summary: 'No completion record was found' + }) + ) + await deferred.drained() + expect([...persisted.keys()].filter((key) => key.includes('bound-overflow'))).toEqual([ + 'orca:claude-background-task%3Abound-overflow' + ]) + expect(blockOf(persisted.get('orca:claude-background-task%3Abound-overflow'))).toMatchObject({ + state: 'idle', + parentToolUseId: 'toolu-first' + }) + + const restarted = createClaudeJournalTranslator({ + sink: deferred.sink, + fallbackIdPrefix: 'next' + }) + spawnToolCall(restarted, 'toolu-second') + restarted.handle( + systemFrame({ + subtype: 'task_started', + task_id: 'bound-overflow', + tool_use_id: 'toolu-second', + task_type: 'local_bash', + is_backgrounded: true + }) + ) + await deferred.drained() + expect([...persisted.keys()].filter((key) => key.includes('bound-overflow'))).toEqual([ + 'orca:claude-background-task%3Abound-overflow', + 'orca:claude-background-task%3Abound-overflow%232' + ]) + }) + + it('resolves a queued restart identity after the sink rebinds', async () => { + const persisted = new Map() + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind(persistedTarget(persisted)) + + const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'first' }) + spawnToolCall(first, 'toolu-first') + first.handle( + systemFrame({ + subtype: 'task_started', + task_id: 'queued-restart', + tool_use_id: 'toolu-first', + task_type: 'local_bash', + is_backgrounded: true + }) + ) + await deferred.drained() + first.dispose() + await deferred.drained() + + const restarted = createDeferredStructuredAgentSessionEventSink() + const second = createClaudeJournalTranslator({ + sink: restarted.sink, + fallbackIdPrefix: 'second' + }) + spawnToolCall(second, 'toolu-second') + second.handle( + systemFrame({ + subtype: 'task_started', + task_id: 'queued-restart', + tool_use_id: 'toolu-second', + task_type: 'local_bash', + is_backgrounded: true + }) + ) + restarted.bind(persistedTarget(persisted)) + await restarted.drained() + + expect([...persisted.keys()].filter((key) => key.includes('queued-restart'))).toEqual([ + 'orca:claude-background-task%3Aqueued-restart', + 'orca:claude-background-task%3Aqueued-restart%232' + ]) + }) + + it('keeps pending writes from distinct runs when a translator is recreated', async () => { + const persisted = new Map() + const deferred = createDeferredStructuredAgentSessionEventSink() + + const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'first' }) + spawnToolCall(first, 'toolu-first') + first.handle( + systemFrame({ + subtype: 'task_started', + task_id: 'queued-overlap', + tool_use_id: 'toolu-first', + task_type: 'local_bash', + is_backgrounded: true + }) + ) + first.dispose() + + const second = createClaudeJournalTranslator({ + sink: deferred.sink, + fallbackIdPrefix: 'second' + }) + spawnToolCall(second, 'toolu-second') + second.handle( + systemFrame({ + subtype: 'task_started', + task_id: 'queued-overlap', + tool_use_id: 'toolu-second', + task_type: 'local_bash', + is_backgrounded: true + }) + ) + + deferred.bind(persistedTarget(persisted)) + await deferred.drained() + + expect([...persisted.keys()].filter((key) => key.includes('queued-overlap'))).toEqual([ + 'orca:claude-background-task%3Aqueued-overlap', + 'orca:claude-background-task%3Aqueued-overlap%232' + ]) + }) + + it('does not overwrite a prior run when a new translator sees a reused task id', () => { + const persisted = new Map() + const journal: StructuredAgentSessionLifecycleJournal = { + epoch: '', + visitItems: (visit) => { + for (const [itemId, body] of persisted) { + visit(itemId, 0, body) + } + } + } + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => persisted.set(agentJournalItemKey(identity), body), + appendTombstone: vi.fn(), + publish: vi.fn(), + tryAppendResolvedItem: (_identitySizeBound, body, resolveIdentity) => { + const identity = resolveIdentity(journal) + if (identity) { + persisted.set(agentJournalItemKey(identity), body) + } + return { accepted: true } + } + } + const first = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'first' }) + spawnToolCall(first, 'toolu-first') + first.handle( + systemFrame({ + subtype: 'task_started', + task_id: 'reused-after-reconnect', + tool_use_id: 'toolu-first', + task_type: 'local_bash', + is_backgrounded: true + }) + ) + first.handle( + systemFrame({ + subtype: 'task_notification', + task_id: 'reused-after-reconnect', + tool_use_id: 'toolu-first', + status: 'failed', + summary: 'first run failed' + }) + ) + first.dispose() + + const resumed = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'resumed' }) + spawnToolCall(resumed, 'toolu-first') + resumed.handle( + systemFrame({ + subtype: 'task_started', + task_id: 'reused-after-reconnect', + tool_use_id: 'toolu-first', + task_type: 'local_bash', + is_backgrounded: true + }) + ) + expect([...persisted.keys()].filter((key) => key.includes('reused-after-reconnect'))).toEqual([ + 'orca:claude-background-task%3Areused-after-reconnect' + ]) + resumed.dispose() + + const second = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'second' }) + spawnToolCall(second, 'toolu-second') + second.handle( + systemFrame({ + subtype: 'task_started', + task_id: 'reused-after-reconnect', + tool_use_id: 'toolu-second', + task_type: 'local_bash', + is_backgrounded: true + }) + ) + + const rows = [...persisted.entries()].filter(([key]) => key.includes('reused-after-reconnect')) + expect(rows.map(([key]) => key)).toEqual([ + 'orca:claude-background-task%3Areused-after-reconnect', + 'orca:claude-background-task%3Areused-after-reconnect%232' + ]) + }) + + it('prints the provider sentence once instead of the opcode twice', () => { + const { translator, fallbackRows, taskRowIds, taskRowTexts } = harness() + playFailedBackgroundCommand(translator) + + // ABLATION: drop `message:system:task_*` from CLAUDE_TYPED_TRANSLATOR_KINDS + // and this is `['claude · message:system:task_updated', 'claude · + // message:system:task_notification']` — the reported bug exactly. + expect(fallbackRows()).toEqual([]) + // One durable row for one task, however many frames reported it. + expect(new Set(taskRowIds())).toEqual(new Set([`claude-background-task:${TASK_ID}`])) + expect(taskRowTexts().at(-1)).toBe(SUMMARY) + }) + + it('reports a failure for a task nothing in this transcript ever admitted', () => { + // The captured frame from a session where NOTHING rendered: the typed path + // declined the row and told the fallback the frame was covered, so the + // failure reached neither surface. + const { translator, fallbackRows, taskRowIds, taskRowTexts } = harness() + translator.handle( + systemFrame({ + subtype: 'task_notification', + task_id: 'bjzenpq13', + tool_use_id: 'toolu_01ASNfnDBEzt4w3ejLE12bGu', + status: 'failed', + output_file: '', + summary: 'Locate the exact screenshot session', + uuid: '1d748563-5741-4aa8-9c21-7023b90bc737' + }) + ) + + expect(taskRowIds()).toEqual(['claude-background-task:bjzenpq13']) + expect(taskRowTexts().at(-1)).toBe('Locate the exact screenshot session') + // One row, not two: the typed row is real coverage, so the generic fallback + // stays quiet beside it rather than printing the opcode. + expect(fallbackRows()).toEqual([]) + }) + + it('keeps the aggregate roster frame off the transcript even when it carries a failure', () => { + const { translator, fallbackRows, taskRowIds } = harness() + translator.handle( + systemFrame({ + subtype: 'background_tasks_changed', + tasks: [{ task_id: TASK_ID, status: 'failed', task_type: 'local_bash' }] + }) + ) + // It promotes through the payload sniffer exactly as the per-task frames do, + // and it creates no row of its own: the task's own frames own that. + expect(fallbackRows()).toEqual([]) + expect(taskRowIds()).toEqual([]) + }) + + it('still surfaces an unmodelled failed frame through the generic fallback', () => { + const { translator, fallbackRows } = harness() + translator.handle(systemFrame({ subtype: 'future_event', status: 'failed' })) + // The coverage contract is per-kind, so nothing about it weakens the payload + // sniffer for the kinds nobody has modelled. + expect(fallbackRows()).toEqual(['claude · message:system:future_event']) + }) + + it('falls back visibly when a malformed task frame reports a failure', () => { + const { translator, fallbackRows, taskRowIds } = harness() + translator.handle( + systemFrame({ + subtype: 'task_notification', + status: 'failed', + summary: 'Background command "Wait" failed with exit code 1' + }) + ) + + expect(taskRowIds()).toEqual([]) + expect(fallbackRows()).toEqual(['Background command "Wait" failed with exit code 1']) + }) + + it('keeps a refused live task failure on one typed terminal row', () => { + const { translator, fallbackRows, taskRowIds, taskRowTexts } = harness() + fillLiveTaskRows(translator) + + spawnToolCall(translator, 'toolu-overflow') + translator.handle( + systemFrame({ + subtype: 'task_started', + task_id: 'overflow-fallback', + tool_use_id: 'toolu-overflow', + task_type: 'local_bash', + description: 'overflow task', + is_backgrounded: true + }) + ) + translator.handle( + systemFrame({ + subtype: 'task_updated', + task_id: 'overflow-fallback', + patch: { status: 'failed' } + }) + ) + translator.handle( + systemFrame({ + subtype: 'task_notification', + task_id: 'overflow-fallback', + tool_use_id: 'toolu-overflow', + status: 'failed', + summary: 'overflow failed' + }) + ) + translator.handle( + systemFrame({ + subtype: 'task_progress', + task_id: 'overflow-fallback', + usage: { total_tokens: 3 } + }) + ) + translator.handle( + systemFrame({ + subtype: 'task_notification', + task_id: 'overflow-fallback', + status: 'failed', + summary: 'duplicate overflow failed' + }) + ) + + expect(fallbackRows()).toEqual([]) + expect( + taskRowIds().filter((id) => id === 'claude-background-task:overflow-fallback') + ).toHaveLength(2) + expect(taskRowTexts().at(-1)).toBe('overflow failed') + }) + + it('prints a notification-first failure only once when every typed slot is live', () => { + const { translator, fallbackRows, taskRowIds } = harness() + fillLiveTaskRows(translator) + const notification = systemFrame({ + subtype: 'task_notification', + task_id: 'orphan-overflow', + status: 'failed', + summary: 'orphan overflow failed' + }) + + translator.handle(notification) + translator.handle(notification) + + expect(fallbackRows()).toEqual([]) + expect( + taskRowIds().filter((id) => id === 'claude-background-task:orphan-overflow') + ).toHaveLength(1) + }) + + it('keeps fallback ownership when a finished overflow task redelivers its start', () => { + const { translator, fallbackRows, taskRowIds } = harness() + fillLiveTaskRows(translator) + spawnToolCall(translator, 'toolu-overflow') + const start = systemFrame({ + subtype: 'task_started', + task_id: 'overflow-redelivery', + tool_use_id: 'toolu-overflow', + task_type: 'local_bash', + is_backgrounded: true + }) + const notification = systemFrame({ + subtype: 'task_notification', + task_id: 'overflow-redelivery', + tool_use_id: 'toolu-overflow', + status: 'failed', + summary: 'overflow redelivery failed' + }) + translator.handle(start) + translator.handle(notification) + translator.handle(start) + translator.handle( + systemFrame({ subtype: 'task_notification', task_id: 'live-0', status: 'completed' }) + ) + translator.handle(notification) + + expect(fallbackRows()).toEqual([]) + expect( + taskRowIds().filter((id) => id === 'claude-background-task:overflow-redelivery') + ).toHaveLength(1) + }) + + it.each(['completed', 'stopped'])( + 'reports a capacity-refused %s task without a generic frame row', + (status) => { + const { translator, fallbackRows, taskRowIds, taskRowTexts } = harness() + fillLiveTaskRows(translator) + translator.handle( + systemFrame({ + subtype: 'task_started', + task_id: 'overflow-success', + task_type: 'local_bash', + is_backgrounded: true + }) + ) + translator.handle( + systemFrame({ + subtype: 'task_updated', + task_id: 'overflow-success', + patch: { status } + }) + ) + translator.handle( + systemFrame({ + subtype: 'task_notification', + task_id: 'overflow-success', + status, + summary: `task ${status}` + }) + ) + + expect(fallbackRows()).toEqual([]) + expect( + taskRowIds().filter((id) => id === 'claude-background-task:overflow-success') + ).toHaveLength(2) + expect(taskRowTexts().at(-1)).toBe(`task ${status}`) + } + ) + + it('settles live background rows when the provider ends before disposal', () => { + const { translator, taskRowTexts } = harness() + spawnToolCall(translator) + translator.handle( + systemFrame({ + subtype: 'task_started', + task_id: TASK_ID, + tool_use_id: 'toolu_01CqPd7y', + task_type: 'local_bash', + description: 'Wait for the verification verdict', + is_backgrounded: true + }) + ) + + translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) + + expect(taskRowTexts().at(-1)).toBe( + 'Background command "Wait for the verification verdict" stopped reporting' + ) + }) + it('keeps a nested child spawned inside a sidechain off the top-level transcript', () => { + const { translator, taskRowIds, fallbackRows } = harness() + // The spawn tool call is emitted by a SUBAGENT, so it carries a parent tool + // id and is never a top-level invocation. + translator.handle({ + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'assistant', + uuid: 'nested-assistant', + session_id: 'claude-session', + parent_tool_use_id: 'toolu_parent_agent', + message: { + role: 'assistant', + content: [ + { type: 'tool_use', id: 'toolu_sidechain', name: 'Bash', input: { command: 'wait' } } + ] + } + } + }) + translator.handle( + systemFrame({ + subtype: 'task_started', + task_id: 'nested-1', + tool_use_id: 'toolu_sidechain', + task_type: 'local_bash', + description: 'nested work', + is_backgrounded: true + }) + ) + translator.handle( + systemFrame({ + subtype: 'task_notification', + task_id: 'nested-1', + tool_use_id: 'toolu_sidechain', + status: 'failed', + summary: 'nested child failed' + }) + ) + + expect(taskRowIds()).toEqual([]) + expect(fallbackRows()).toEqual([]) + }) + + it('keeps a monitor off the timeline entirely', () => { + const { translator, taskRowIds, fallbackRows } = harness() + spawnToolCall(translator) + translator.handle( + systemFrame({ + subtype: 'task_started', + task_id: 'monitor-1', + tool_use_id: 'toolu_01CqPd7y', + task_type: 'monitor', + description: 'Watch the build', + is_backgrounded: true + }) + ) + translator.handle( + systemFrame({ + subtype: 'task_notification', + task_id: 'monitor-1', + tool_use_id: 'toolu_01CqPd7y', + status: 'failed', + summary: 'monitor stopped' + }) + ) + + expect(taskRowIds()).toEqual([]) + expect(fallbackRows()).toEqual([]) + }) + + it('keeps a monitor without a start frame owned by its top-level tool result', () => { + const { translator, taskRowIds, fallbackRows } = harness() + const toolUseId = 'toolu-monitor' + const taskId = 'bm5w1s2mv' + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { + type: 'assistant', + uuid: 'monitor-call', + session_id: 'claude-session', + parent_tool_use_id: null, + message: { + role: 'assistant', + content: [ + { type: 'tool_use', id: toolUseId, name: 'Monitor', input: { persistent: true } } + ] + } + } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { + type: 'user', + uuid: 'monitor-result', + session_id: 'claude-session', + parent_tool_use_id: null, + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'Monitor started' }] + }, + tool_use_result: { taskId, timeoutMs: 0, persistent: true } + } + }) + translator.handle( + systemFrame({ subtype: 'task_updated', task_id: taskId, patch: { status: 'completed' } }) + ) + translator.handle( + systemFrame({ + subtype: 'task_notification', + task_id: taskId, + tool_use_id: '', + status: 'completed', + summary: 'Monitor event: workflow journal results' + }) + ) + + expect(taskRowIds()).toEqual([]) + expect(fallbackRows()).toEqual([]) + }) +}) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 5bb32abf621..112f4319b17 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -1,47 +1,36 @@ -import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { - boundInlineText, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionSinkAdmission +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' import { - claudeMessageBody, - claudeMessageIdentity, - claudeOutputEnvelope, claudeStreamingMessageBody, - claudeThinkingIdentity, - claudeThinkingText, - claudeToolBody, - claudeToolIdentity, - claudeToolResults, - claudeToolUses, - readClaudeMessageEnvelope, type ClaudeToolUse } from './claude-structured-item-translation' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { claudeProviderFrameActivity } from '../native-chat/agent-session-wire/provider-frame-activity' import { - appendUnmodeledContent, claudeProviderFrameKind, claudeResultFailure, createClaudeProviderFrameFallback, isSettledClaudeResultKind } from './claude-structured-provider-fallback' +import { taskFrameSentence } from './claude-background-task-frames' +import { ClaudeBackgroundTaskRows } from './claude-background-task-rows' +import { ClaudeForwardedToolRegistry } from './claude-forwarded-tool-registry' import { ClaudeSubagentRoster } from './claude-subagent-roster' import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity' import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints' import { claudeStreamTurnStartSource, claudeStreamTurnSource, - claudeTurnOpenedBySendEcho, - isRootClaudeFrame, - type ClaudeTurnSource + isRootClaudeFrame } from './claude-turn-opening' import { claudeTurnEndForResult } from './claude-turn-lifecycle-item' import { ClaudeOpenTurn } from './claude-open-turn' import { ClaudeJournalPrompts } from './claude-structured-journal-prompts' +import { journalClaudeMessage, type ClaudeMessageJournalContext } from './claude-message-journaling' export type ClaudeJournalTranslatorDeps = { sink: StructuredAgentSessionEventSink @@ -49,6 +38,7 @@ export type ClaudeJournalTranslatorDeps = { coalesceMs?: number schedule?: AgentSessionDeltaCoalescerDeps['schedule'] fallbackIdPrefix?: string + onBackgroundTaskJournalFailure?: (error: Error) => void } export type ClaudeJournalTranslator = { @@ -58,6 +48,7 @@ export type ClaudeJournalTranslator = { * a client's Stop names. Sole owner: no reader keeps a copy to disagree with. */ readonly currentTurnId: string | null flush: () => void + retryPendingTaskRows?: () => StructuredAgentSessionSinkAdmission /** Streamed blocks still awaiting a final frame. A settled turn leaves none. */ readonly pendingStreamedBlocks: number dispose: () => void @@ -66,12 +57,14 @@ export type ClaudeJournalTranslator = { export function createClaudeSessionJournalTranslator( sink: StructuredAgentSessionEventSink | undefined, prompts: ClaudePromptRegistry, - fallbackIdPrefix: string + fallbackIdPrefix: string, + onBackgroundTaskJournalFailure?: (error: Error) => void ): ClaudeJournalTranslator | null { return sink ? createClaudeJournalTranslator({ sink, fallbackIdPrefix, + ...(onBackgroundTaskJournalFailure ? { onBackgroundTaskJournalFailure } : {}), bindPromptItemId: (itemId, promptKey, questionId) => prompts.bindJournalItemId(itemId, promptKey, questionId) }) @@ -96,6 +89,18 @@ export function createClaudeJournalTranslator( sink: deps.sink, currentGroupKey: () => turn.groupKey }) + const forwardedTools = new ClaudeForwardedToolRegistry() + const backgroundTasks = new ClaudeBackgroundTaskRows({ + sink: deps.sink, + isForwardedParentTool: (toolUseId) => forwardedTools.has(toolUseId), + // A typed task row is provider output: journaling one must open a resumed + // turn, or the session shows the row while reading idle. + openOutputTurn: (frame, observedAt) => + turn.ensureOpen(frame, claudeStreamTurnSource(frame), observedAt), + ...(deps.onBackgroundTaskJournalFailure + ? { onPersistenceFailure: deps.onBackgroundTaskJournalFailure } + : {}) + }) const streamedText = createClaudeStreamedTextCheckpoints({ ...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }), ...(deps.schedule ? { schedule: deps.schedule } : {}), @@ -129,106 +134,32 @@ export function createClaudeJournalTranslator( return true } + const messageContext: ClaudeMessageJournalContext = { + sink: deps.sink, + tools, + streamedBlocks, + streamedText, + subagents, + forwardedTools, + backgroundTasks, + providerFallback, + turn + } + const handleMessage = ( message: Record, startsTurn: boolean, observedAt: number, requestedAt?: number - ): boolean => { - const envelope = readClaudeMessageEnvelope(message) - if (!envelope) { - return false - } - let changed = false - if (envelope.parentToolUseId) { - subagents.observeChildActivity(envelope.parentToolUseId) - } - const outputEnvelope = claudeOutputEnvelope(envelope) - const body = claudeMessageBody(outputEnvelope) - // The final frame of a streamed block lands on the block's identity, not its own uuid. - const identity = - (body && envelope.role === 'assistant' ? streamedBlocks.reconcile(envelope) : null) ?? - claudeMessageIdentity(envelope) - streamedText.forget(agentJournalItemKey(identity)) - const thinking = claudeThinkingText(outputEnvelope) - const source: ClaudeTurnSource = { - sessionId: envelope.sessionId, - uuid: envelope.uuid, - assistant: envelope.role === 'assistant' - } - const openOutputTurn = (): void => turn.ensureOpen(message, source, observedAt) - if (body) { - // Opening before the append is what brackets a turn around its own first - // output; a reader that scans back to the turn record and stops would - // otherwise look straight past the row that opened it. - turn.ensureOpen(message, source, observedAt) - deps.sink.appendItem(identity, body) - changed = true - } - for (const tool of claudeToolUses(outputEnvelope)) { - turn.ensureOpen(message, source, observedAt) - tools.set(tool.id, tool) - deps.sink.appendItem( - claudeToolIdentity(envelope.sessionId, tool.id), - claudeToolBody({ tool }) - ) - changed = true - } - for (const result of claudeToolResults(envelope)) { - const tool = tools.get(result.toolUseId) ?? { - id: result.toolUseId, - name: 'tool', - input: null - } - deps.sink.appendItem( - claudeToolIdentity(envelope.sessionId, result.toolUseId), - claudeToolBody({ tool, result }) - ) - // A spawn call's result is the parent turn's evidence its child finished. - subagents.observeToolResult(result.toolUseId, result.failed) - // Tool inputs are only needed until their matching result arrives. - tools.delete(result.toolUseId) - changed = true - } - if (thinking) { - turn.ensureOpen(message, source, observedAt) - deps.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), { - kind: 'message', - role: 'reasoning', - blocks: [ - { type: 'text', text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } - ] - }) - changed = true - } - changed = - appendUnmodeledContent(providerFallback, outputEnvelope, message, openOutputTurn) || changed - // The send's turn is anchored to the user row journaled just above it. - const sendEchoTurn = claudeTurnOpenedBySendEcho({ - envelope, - frame: message, - startsTurn, - observedAt, - ...(requestedAt === undefined ? {} : { requestedAt }), - userItemId: agentJournalItemKey(identity) - }) - if (sendEchoTurn) { - turn.allowReopen() - turn.open(sendEchoTurn, observedAt) - } - if (changed) { - deps.sink.publish() - } - return true - } + ): boolean => journalClaudeMessage(messageContext, message, startsTurn, observedAt, requestedAt) return { handle: (event) => { if (event.type === 'ended') { prompts.retryPendingCancellations() streamedText.flush() - // No event will ever settle a child once the provider is gone. subagents.settleSession() + backgroundTasks.settleSession() // The host saw the child end, so the turn's end is observed, not lost. turn.settle({ state: 'interrupted', completedAt: event.observedAt ?? Date.now() }) // A frame that arrives after the child is gone must not open a turn no @@ -265,15 +196,16 @@ export function createClaudeJournalTranslator( streamedText.settle() } const kind = claudeProviderFrameKind(event.message) - // Ordinary turn bookkeeping stays suppressed; a reported failure never does. const failure = claudeResultFailure(event.message) if (failure || !isSettledClaudeResultKind(kind)) { providerFallback.append(kind, event.message, failure?.text) } } else if (event.type === 'message') { - // These frames stay `status-chrome`: the roster reads them here, and the - // fallback below still drops the raw frame instead of printing an opcode. subagents.observeSystemFrame(event.message) + const backgroundTaskCovered = backgroundTasks.observe( + event.message, + event.observedAt ?? Date.now() + ) const kind = claudeProviderFrameKind(event.message) if ( !handleMessage( @@ -283,7 +215,13 @@ export function createClaudeJournalTranslator( event.requestedAt ) ) { - providerFallback.append(kind, event.message) + providerFallback.append( + kind, + event.message, + taskFrameSentence(event.message), + undefined, + { coveredByTypedTranslator: backgroundTaskCovered } + ) } publishActivity(kind, event.message) } else if (event.type === 'provider-frame') { @@ -296,6 +234,7 @@ export function createClaudeJournalTranslator( return turn.id }, flush: streamedText.flush, + retryPendingTaskRows: () => backgroundTasks.retryPendingWrites(), get pendingStreamedBlocks() { return streamedText.pending }, @@ -305,6 +244,8 @@ export function createClaudeJournalTranslator( prompts.clear() streamedBlocks.clear() subagents.dispose() + backgroundTasks.dispose() + forwardedTools.clear() } } } diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index 167ab37ce98..c1aa37e98b7 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -5,6 +5,7 @@ import { } from '../native-chat/agent-session-journal/journal-payload-bounds' import { CLAUDE_STREAM_JSON_FRAME_KINDS } from '../native-chat/agent-session-wire/claude-stream-json-frame-schema' import { + type UnhandledProviderFrameJournalItemOptions, readableProviderFrameText, unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' @@ -110,14 +111,23 @@ export function createClaudeProviderFrameFallback( kind: string, payload: unknown, displayText?: string | null, - beforeAppend?: () => void + /** Runs only when a row is actually going to be written, so a frame that + * translates to nothing never opens a turn. */ + beforeAppend?: () => void, + options?: UnhandledProviderFrameJournalItemOptions ) => boolean } { let sequence = 0 return { - append: (kind, payload, displayText, beforeAppend) => { + append: (kind, payload, displayText, beforeAppend, options) => { sequence += 1 - const translated = unhandledProviderFrameJournalItem('claude', kind, payload) + const translated = unhandledProviderFrameJournalItem( + 'claude', + kind, + payload, + DEFAULT_JOURNAL_PAYLOAD_LIMITS, + options + ) if (!translated) { return false } diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 622b7618926..a10a2dca671 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -47,6 +47,10 @@ import { resolveClaudeAcquisitionError } from './claude-structured-session-close import { readClaudeTranscriptEntryUuid } from './claude-tui-exit' import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' import { resolveClaudeAcquisitionLaunch } from './claude-structured-acquisition-launch' +import { + bindClaudeJournalReadingControl, + createClaudeJournalFailureHandler +} from './claude-structured-session-journal-control' export const CLAUDE_STRUCTURED_INIT_TIMEOUT_MS = 10_000 @@ -72,12 +76,8 @@ export async function acquireClaudeSession({ } const sessionId = input.identity.sessionId const prompts = new ClaudePromptRegistry() - const translator = createClaudeSessionJournalTranslator( - input.events, - prompts, - String(input.fence) - ) const { previous, attempt } = acquisitions.start(sessionId, prompts) + let unbindReadingControl: (() => void) | undefined let liveSession: ClaudeSession | null = null let observedLeafUuid: string | null = null, expectedProviderSessionId: string | null = null @@ -85,6 +85,12 @@ export async function acquireClaudeSession({ // this acquisition owns. Keep the check ahead of every stateful consumer. const initTimeoutMs = deps.initTimeoutMs ?? CLAUDE_STRUCTURED_INIT_TIMEOUT_MS const initDeadline = createClaudeInitDeadline(sessionId, initTimeoutMs) + const translator = createClaudeSessionJournalTranslator( + input.events, + prompts, + String(input.fence), + createClaudeJournalFailureHandler({ attempt, initDeadline, callbacks, sessionId }) + ) const rewind = new ClaudeRewindAttempt(input.rewind, input.rewind?.onProved) const onMessage = (message: Record): void => { @@ -198,6 +204,7 @@ export async function acquireClaudeSession({ ) ) attempt.connection = connection + unbindReadingControl = bindClaudeJournalReadingControl(input.events, connection, translator) acquisitions.assertCurrent(sessionId, attempt) initDeadline.start() const [initialization, init] = await withAgentSessionCreatePhase( @@ -259,6 +266,7 @@ export async function acquireClaudeSession({ prompts, translator, events: input.events, + ...(unbindReadingControl ? { unbindReadingControl } : {}), process, acquisitionGeneration: mintClaudeAcquisitionGeneration(deps), options: acquisitionOptions.options, @@ -284,6 +292,7 @@ export async function acquireClaudeSession({ return acquired } catch (error) { initDeadline.clear() + unbindReadingControl?.() const acquisitionError = await resolveClaudeAcquisitionError({ error, sessionId, diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 9357e9635f6..a719745aa50 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -26,7 +26,10 @@ import { closeClaudeSession, settleClaudeExitedSession } from './claude-structured-session-close' -import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof' +import { + drainClaudeObservedExits, + persistClaudeSessionHandle +} from './claude-structured-session-exit-lifecycle' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { resolveClaudeProviderHistoryWindow } from './claude-structured-history-window' import { @@ -80,7 +83,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda attempt.buffered.push(event) return } - if (this.sessions.get(sessionId)?.connection === attempt.connection) { + if ( + this.sessions.get(sessionId)?.connection === attempt.connection || + this.exits.get(sessionId)?.connection === attempt.connection + ) { event() } } @@ -103,7 +109,12 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } this.exits.set(sessionId, exit) exit.publication = closePromise - .then((proven) => (proven ? this.settleUnexpectedExit(sessionId, exit) : undefined)) + .then((proven) => { + if (!proven) { + return undefined + } + return this.settleUnexpectedExit(sessionId, exit) + }) .catch(() => undefined) } @@ -112,37 +123,19 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda * retry. Publication trails observation by the close ladder and the * transcript cursor write, so nothing outside can otherwise tell the two * apart without guessing at wall-clock. */ - drainObservedExits = async (): Promise => { - const awaited = new Set>() - for (;;) { - const pending = [...this.exits.values()] - .map((exit) => exit.publication) - .filter( - (publication): publication is Promise => - publication !== undefined && !awaited.has(publication) - ) - if (pending.length === 0) { - return - } - for (const publication of pending) { - awaited.add(publication) - } - // A publication can settle an exit that itself observes another; only the - // ones this pass has not already awaited keep the loop going. - await Promise.all(pending) - } - } + drainObservedExits = (): Promise => drainClaudeObservedExits(this.exits) /** Lifecycle recovery is published only after the child tree proof is true. */ private settleUnexpectedExit(sessionId: string, exit: ClaudeSessionExit): Promise { exit.settlementPromise ??= (async () => { + exit.session.unbindReadingControl?.() if (this.exits.get(sessionId) !== exit) { settleClaudeExitedSession(exit.session) return } // Persist the transcript-derived cursor before publishing the lifecycle // event that lets the host release and reacquire this exact child. - await this.persistSessionHandle(sessionId, exit.session).catch(() => undefined) + await persistClaudeSessionHandle(sessionId, exit.session, this.deps).catch(() => undefined) if (this.exits.get(sessionId) !== exit) { settleClaudeExitedSession(exit.session) return @@ -177,30 +170,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda this.sessions.has(input.identity.sessionId) || this.exits.has(input.identity.sessionId) }) - private async persistSessionHandle(sessionId: string, session: ClaudeSession): Promise { - try { - const transcriptLeaf = this.deps.readTranscriptLeaf - ? await readClaudeTranscriptLeafWithReproof({ - readTranscriptLeaf: this.deps.readTranscriptLeaf, - providerSessionId: session.providerSessionId, - previousLeafUuid: session.leafUuid, - claudeConfigDir: session.claudeConfigDir - }) - : null - if (transcriptLeaf) { - session.leafUuid = transcriptLeaf - } - } catch { - // A stale or unavailable tail must not overwrite the last observed leaf. - } - await this.deps.persistHandle?.({ - sessionId, - providerSessionId: session.providerSessionId, - leafUuid: session.leafUuid, - fence: session.fence - }) - } - private emit(session: ClaudeSession | null, event: ClaudeStructuredSessionEvent): void { const backgroundTasksChanged = event.type === 'ended' diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 431d6e38ab8..f6a58c36357 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -97,7 +97,9 @@ async function finalizeClaudePublishedSession( for (const prompt of session.prompts.clear()) { prompt.settle(null) } - if ((await session.connection.close()) !== true) { + const connectionClosed = await session.connection.close() + session.unbindReadingControl?.() + if (connectionClosed !== true) { const cleanupError = claudeAcquisitionCleanupError( session.connection, new Error('provider close unproven') diff --git a/src/main/claude/claude-structured-session-exit-lifecycle.ts b/src/main/claude/claude-structured-session-exit-lifecycle.ts new file mode 100644 index 00000000000..20cbc1f3125 --- /dev/null +++ b/src/main/claude/claude-structured-session-exit-lifecycle.ts @@ -0,0 +1,56 @@ +import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof' +import type { + ClaudeSession, + ClaudeSessionExit, + ClaudeStructuredSessionAdapterDeps +} from './claude-structured-session-state' + +/** Wait for each first-hand exit's publication, including exits observed while waiting. */ +export async function drainClaudeObservedExits( + exits: Map +): Promise { + const awaited = new Set>() + for (;;) { + const pending = [...exits.values()] + .map((exit) => exit.publication) + .filter( + (publication): publication is Promise => + publication !== undefined && !awaited.has(publication) + ) + if (pending.length === 0) { + return + } + for (const publication of pending) { + awaited.add(publication) + } + await Promise.all(pending) + } +} + +export async function persistClaudeSessionHandle( + sessionId: string, + session: ClaudeSession, + deps: Pick +): Promise { + try { + const transcriptLeaf = deps.readTranscriptLeaf + ? await readClaudeTranscriptLeafWithReproof({ + readTranscriptLeaf: deps.readTranscriptLeaf, + providerSessionId: session.providerSessionId, + previousLeafUuid: session.leafUuid, + claudeConfigDir: session.claudeConfigDir + }) + : null + if (transcriptLeaf) { + session.leafUuid = transcriptLeaf + } + } catch { + // An unavailable tail must not overwrite the last observed leaf. + } + await deps.persistHandle?.({ + sessionId, + providerSessionId: session.providerSessionId, + leafUuid: session.leafUuid, + fence: session.fence + }) +} diff --git a/src/main/claude/claude-structured-session-journal-control.ts b/src/main/claude/claude-structured-session-journal-control.ts new file mode 100644 index 00000000000..dc8a5c9d262 --- /dev/null +++ b/src/main/claude/claude-structured-session-journal-control.ts @@ -0,0 +1,53 @@ +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' +import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { createClaudeInitDeadline } from './claude-structured-init-deadline' +import type { + ClaudeAcquisitionAttempt, + ClaudeAcquireCallbacks +} from './claude-structured-session-state' + +export function createClaudeJournalFailureHandler(input: { + attempt: ClaudeAcquisitionAttempt + initDeadline: ReturnType + callbacks: ClaudeAcquireCallbacks + sessionId: string +}): (error: Error) => void { + return (error) => { + if (!input.attempt.published) { + input.initDeadline.reject(error) + return + } + const connection = input.attempt.connection + if (connection) { + void connection + .close() + .catch(() => false) + .finally(() => input.callbacks.handleExit(input.sessionId, input.attempt, error)) + } + } +} + +export function bindClaudeJournalReadingControl( + sink: StructuredAgentSessionEventSink | undefined, + connection: ClaudeStreamJsonConnection, + translator: ClaudeJournalTranslator | null +): (() => void) | undefined { + if (!connection.pauseReading || !connection.resumeReading) { + return undefined + } + let sinkPaused = false + return sink?.bindReadingControl?.({ + pauseReading: () => { + sinkPaused = true + connection.pauseReading?.() + }, + resumeReading: () => { + sinkPaused = false + const retried = translator?.retryPendingTaskRows?.() ?? { accepted: true } + if (!sinkPaused && (retried.accepted || retried.reason !== 'backpressure')) { + connection.resumeReading?.() + } + } + }) +} diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index 395335332e7..2434f522661 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -19,6 +19,7 @@ export function createClaudeSessionPublication(input: { prompts: ClaudePromptRegistry translator: ClaudeJournalTranslator | null events: ClaudeSession['events'] + unbindReadingControl?: () => void process: AgentSessionAcquisition['process'] linkId?: string observedAt: number @@ -83,7 +84,8 @@ export function createClaudeSessionPublication(input: { ]), restoreSkippedOptions: new Set(), translator: input.translator, - events: input.events + events: input.events, + ...(input.unbindReadingControl ? { unbindReadingControl: input.unbindReadingControl } : {}) } } } diff --git a/src/main/claude/claude-structured-session-reading-control.test.ts b/src/main/claude/claude-structured-session-reading-control.test.ts new file mode 100644 index 00000000000..7113abd5bf5 --- /dev/null +++ b/src/main/claude/claude-structured-session-reading-control.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createDeferredStructuredAgentSessionEventSink, + type StructuredAgentSessionEventTarget, + type StructuredAgentSessionEventSink, + type StructuredAgentSessionReadingControl +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' +import { blockOf } from './claude-background-task-row-test-support' +import { + adapterFor, + fakeClaude, + identityFor, + PROVIDER_SESSION_ID +} from './claude-structured-session-test-support' + +function controlledSink(): { + sink: StructuredAgentSessionEventSink + control: () => StructuredAgentSessionReadingControl | undefined + unbind: ReturnType +} { + let control: StructuredAgentSessionReadingControl | undefined + const unbind = vi.fn() + return { + sink: { + appendItem: vi.fn(), + appendTombstone: vi.fn(), + publish: vi.fn(), + bindReadingControl: (next) => { + control = next + return unbind + } + }, + control: () => control, + unbind + } +} + +function persistedTarget( + persisted: Map +): StructuredAgentSessionEventTarget { + const journal = + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test double implements the journal methods exercised by the deferred sink. + { + appendItem: async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { + persisted.set(agentJournalItemKey(identity), body) + return { cursor: { epoch: 'test', sequence: persisted.size }, itemId: '', revision: 1 } + }, + appendTombstone: vi.fn(), + visitItems: ( + visit: (itemId: string, sequence: number, body: AgentJournalItemBody) => void + ) => { + for (const [itemId, body] of persisted) { + visit(itemId, 0, body) + } + }, + epoch: 'test' + } as unknown as AgentSessionJournal + return { journal, fence: 1, publish: vi.fn() } +} + +describe('Claude structured reading control', () => { + it('binds sink pressure to SDK reading and unbinds on requested close', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude) + const events = controlledSink() + + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: events.sink + }) + + const pauseReading = vi.spyOn(claude.connections[0], 'pauseReading') + const resumeReading = vi.spyOn(claude.connections[0], 'resumeReading') + events.control()?.pauseReading() + expect(pauseReading).toHaveBeenCalledOnce() + events.control()?.resumeReading() + expect(resumeReading).toHaveBeenCalledOnce() + + await expect(adapter.closeSession('session-1')).resolves.toBe(true) + expect(events.unbind).toHaveBeenCalledOnce() + }) + + it('unbinds when acquisition fails after the connection opens', async () => { + const claude = fakeClaude({ initProof: 'none' }) + const adapter = adapterFor(claude, {}, [], [], 1) + const events = controlledSink() + + await expect( + adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: events.sink + }) + ).rejects.toThrow('did not finish starting') + expect(events.unbind).toHaveBeenCalledOnce() + }) + + it('unbinds when the published provider exits unexpectedly', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude) + const events = controlledSink() + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: events.sink + }) + + claude.connections[0].handlers.onExit?.(new Error('provider exited')) + await adapter.drainObservedExits() + + expect(events.unbind).toHaveBeenCalledOnce() + }) + + it('keeps delivery ownership until a reported provider exit finishes closing', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude) + const events = controlledSink() + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: events.sink + }) + const connection = claude.connections[0] + + connection.handlers.onExit?.(new Error('provider exited')) + connection.handlers.onMessage?.({ + type: 'system', + subtype: 'task_notification', + session_id: PROVIDER_SESSION_ID, + task_id: 'held-terminal-frame', + status: 'failed', + summary: 'The final task outcome' + }) + + expect(events.sink.appendItem).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + kind: 'message', + role: 'system', + blocks: expect.arrayContaining([ + expect.objectContaining({ + type: 'background-task', + taskId: 'held-terminal-frame', + summary: 'The final task outcome' + }) + ]) + }), + expect.anything() + ) + await adapter.drainObservedExits() + expect(events.unbind).toHaveBeenCalledOnce() + }) + + it('releases SDK reading when a pending row becomes permanently refused', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude) + const events = controlledSink() + events.sink.tryAppendResolvedItemAndPublish = vi + .fn() + .mockReturnValueOnce({ accepted: false, reason: 'backpressure' }) + .mockReturnValueOnce({ accepted: false, reason: 'failed' }) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: events.sink + }) + const resumeReading = vi.spyOn(claude.connections[0], 'resumeReading') + claude.connections[0].handlers.onMessage?.({ + type: 'system', + subtype: 'task_notification', + session_id: PROVIDER_SESSION_ID, + task_id: 'failed-journal-row', + status: 'failed', + summary: 'failed' + }) + + events.control()?.pauseReading() + events.control()?.resumeReading() + + expect(resumeReading).toHaveBeenCalledOnce() + await adapter.drainObservedExits() + }) + + it('automatically retries a hard-watermark row before resuming SDK reads', async () => { + const persisted = new Map() + const target = persistedTarget(persisted) + const deferred = createDeferredStructuredAgentSessionEventSink({ + watermarks: { + pauseQueuedOperations: 1, + maxQueuedOperations: 4, + lowQueuedOperations: 0, + maxQueuedBytes: 1_000_000 + } + }) + deferred.bind(target) + const claude = fakeClaude() + const adapter = adapterFor(claude) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: deferred.sink + }) + await deferred.drained() + persisted.clear() + + const appendEntered = Promise.withResolvers() + const appendGate = Promise.withResolvers() + const appendItem = target.journal.appendItem.bind(target.journal) + vi.spyOn(target.journal, 'appendItem').mockImplementationOnce(async (...args) => { + appendEntered.resolve() + await appendGate.promise + return appendItem(...args) + }) + const resumeReading = vi.spyOn(claude.connections[0], 'resumeReading') + deferred.sink.appendItem( + { provider: 'orca', clientMessageId: 'blocked-prefill' }, + { kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] } + ) + await appendEntered.promise + const notification = { + type: 'system', + subtype: 'task_notification', + session_id: PROVIDER_SESSION_ID, + task_id: 'hard-watermark-task', + tool_use_id: 'toolu-hard-watermark', + status: 'failed', + summary: 'The real provider task failed', + uuid: 'hard-watermark-notification' + } + claude.connections[0].handlers.onMessage?.(notification) + claude.connections[0].handlers.onMessage?.(notification) + expect(deferred.state().queuedOperations).toBe(4) + + appendGate.resolve() + await vi.waitFor(() => expect(resumeReading).toHaveBeenCalledOnce()) + await expect(deferred.drained()).resolves.toEqual({ ok: true }) + const taskRows = [...persisted.values()].filter( + (body) => body.kind === 'message' && blockOf(body)?.taskId === 'hard-watermark-task' + ) + expect(taskRows).toHaveLength(1) + expect(blockOf(taskRows[0])?.summary).toBe('The real provider task failed') + expect( + [...persisted.values()].some( + (body) => + body.kind === 'status' && body.providerFrame?.kind.includes('task_notification') === true + ) + ).toBe(false) + await expect(adapter.closeSession('session-1')).resolves.toBe(true) + }) +}) diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 055a477c74c..de697192074 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -170,6 +170,7 @@ export type ClaudeSession = { closeEnded?: boolean translator: ClaudeJournalTranslator | null events: StructuredAgentSessionEventSink | undefined + unbindReadingControl?: () => void } export function mintClaudeAcquisitionGeneration(deps: ClaudeStructuredSessionAdapterDeps): string { diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 352a7ed18ec..50b8e766ce4 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -74,6 +74,7 @@ export function fakeClaude( const route = routes[subtype] return route ? route(params) : undefined } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fake implements the complete connection contract below. const openConnection = (async (launch, handlers = {}) => { const connection: FakeConnection = { launch, @@ -83,6 +84,8 @@ export function fakeClaude( closeCount: 0, pid: 4321, closed: false, + pauseReading: () => {}, + resumeReading: () => {}, initializationResult: async () => { connection.calls.push({ subtype: 'initialize' }) if (options.exitBeforeInit) { diff --git a/src/main/claude/claude-subagent-task-frames.test.ts b/src/main/claude/claude-subagent-task-frames.test.ts index 230ef45e19c..58950307ba5 100644 --- a/src/main/claude/claude-subagent-task-frames.test.ts +++ b/src/main/claude/claude-subagent-task-frames.test.ts @@ -33,6 +33,20 @@ describe('readClaudeSubagentTaskFrame', () => { }) }) + it('announces the legacy local_subagent task type', () => { + expect( + readClaudeSubagentTaskFrame( + system('task_started', { + task_id: 'task-legacy-subagent', + tool_use_id: 'toolu_legacy', + task_type: 'local_subagent', + subagent_type: 'code-reviewer', + description: 'Review the diff' + }) + ) + ).toMatchObject({ announcesSubagent: true, excluded: false }) + }) + it('excludes a backgrounded shell command even though it carries a tool_use_id', () => { const frame = readClaudeSubagentTaskFrame( system('task_started', { diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index b3aab6b1e3b..716dcde0a1f 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -164,9 +164,11 @@ export class AgentSessionJournal { snapshot = (): AgentJournalSnapshot => renderJournalState(this.state) /** Visits reduced items without allocating and sorting a full snapshot. */ - visitItems = (visit: (itemId: string, sequence: number) => void): void => { + visitItems = ( + visit: (itemId: string, sequence: number, body: AgentJournalItemBody) => void + ): void => { for (const item of this.state.items.values()) { - visit(item.itemId, item.sequence) + visit(item.itemId, item.sequence, item.body) } } diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts index 9d6887de61e..b0dff559b1a 100644 --- a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts @@ -7,8 +7,12 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { isSubagentGroupBlock } from '../../../shared/native-chat-types' -import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types' +import { backgroundTaskFallbackText } from '../../../shared/native-chat-background-task-row' +import { isBackgroundTaskBlock, isSubagentGroupBlock } from '../../../shared/native-chat-types' +import type { + NativeChatBackgroundTaskBlock, + NativeChatSubagentEntry +} from '../../../shared/native-chat-types' import { codexSubagentGroupBody, codexSubagentGroupIdentity @@ -55,6 +59,34 @@ function rosterRow(agents: NativeChatSubagentEntry[]) { } } +function backgroundTaskBlock( + overrides: Partial = {} +): NativeChatBackgroundTaskBlock { + return { + type: 'background-task', + taskId: 'task-1', + kind: 'command', + label: 'sleep 20', + state: 'working', + startedAt: 10, + ...overrides + } +} + +function backgroundTaskRow(block = backgroundTaskBlock()) { + return { + identity: { + provider: 'orca' as const, + clientMessageId: `claude-background-task:${block.taskId}` + }, + body: { + kind: 'message' as const, + role: 'system' as const, + blocks: [{ type: 'text' as const, text: backgroundTaskFallbackText(block) }, block] + } + } +} + function renderItem(agents: NativeChatSubagentEntry[]): AgentJournalRenderItem { const row = rosterRow(agents) return { @@ -70,6 +102,10 @@ function rosterOf(body: AgentJournalRenderItem['body']): NativeChatSubagentEntry return body.kind === 'message' ? (body.blocks.find(isSubagentGroupBlock)?.agents ?? []) : [] } +function taskOf(body: AgentJournalRenderItem['body']): NativeChatBackgroundTaskBlock | undefined { + return body.kind === 'message' ? body.blocks.find(isBackgroundTaskBlock) : undefined +} + function twinOf(body: AgentJournalRenderItem['body']): string | undefined { return body.kind === 'message' ? body.blocks.find((block) => block.type === 'text')?.text @@ -104,6 +140,23 @@ describe('staleSubagentRosterRevisions', () => { expect(twinOf(revisions[0]!.body)).toBe('Ran 2 subagents (1 unverifiable)') }) + it('settles a background task the previous host left live, and moves the twin with it', () => { + const row = backgroundTaskRow() + const revisions = staleSubagentRosterRevisions([ + { + itemId: agentJournalItemKey(row.identity), + revision: 1, + body: row.body, + sequence: 2, + observedAt: 1 + } + ]) + + expect(revisions).toHaveLength(1) + expect(taskOf(revisions[0]!.body)).toMatchObject({ taskId: 'task-1', state: 'unverifiable' }) + expect(twinOf(revisions[0]!.body)).toBe('Background command "sleep 20" stopped reporting') + }) + // The child stopped being observable at an unknown moment. A stamp taken now // would report the time the app was down as how long the child ran. it('records no terminal timestamp for a child whose run length is unknown', () => { @@ -114,6 +167,21 @@ describe('staleSubagentRosterRevisions', () => { expect(rosterOf(revisions[0]!.body)[0]).not.toHaveProperty('settledAt') }) + it('records no terminal timestamp for a background task whose run length is unknown', () => { + const row = backgroundTaskRow(backgroundTaskBlock({ settledAt: 20 })) + const revisions = staleSubagentRosterRevisions([ + { + itemId: agentJournalItemKey(row.identity), + revision: 1, + body: row.body, + sequence: 2, + observedAt: 1 + } + ]) + + expect(taskOf(revisions[0]!.body)).not.toHaveProperty('settledAt') + }) + it('owes nothing for a roster whose children all settled', () => { expect( staleSubagentRosterRevisions([ @@ -186,6 +254,22 @@ describe('journal reopen after the writing host is gone', () => { expect(reopened.snapshot().items.at(-1)?.revision).toBe(2) }) + it('settles a persisted working background task to unverifiable', async () => { + const live = await open() + const row = backgroundTaskRow() + await live.appendItem(row.identity, row.body, { fence: 0 }) + const beforeRestart = live.snapshot().items.at(-1)! + expect(taskOf(beforeRestart.body)).toMatchObject({ state: 'working' }) + expect(twinOf(beforeRestart.body)).toBe('Started background command "sleep 20"') + await live.close() + + const reopened = await open() + const afterRestart = reopened.snapshot().items.at(-1)! + expect(afterRestart.itemId).toBe(beforeRestart.itemId) + expect(taskOf(afterRestart.body)).toMatchObject({ state: 'unverifiable' }) + expect(twinOf(afterRestart.body)).toBe('Background command "sleep 20" stopped reporting') + }) + it('writes nothing on a second reopen once every child is settled', async () => { const live = await open() const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }]) diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts index 9b2724e9d1d..f3868eedcf4 100644 --- a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts @@ -27,8 +27,15 @@ import { subagentGroupFallbackText } from '../../../shared/native-chat-subagent-summary' import { + backgroundTaskFallbackText, + isSettledBackgroundTaskState, + normalizeBackgroundTaskState +} from '../../../shared/native-chat-background-task-row' +import { + isBackgroundTaskBlock, isSubagentGroupBlock, type NativeChatBlock, + type NativeChatBackgroundTaskBlock, type NativeChatSubagentGroupBlock } from '../../../shared/native-chat-types' @@ -45,7 +52,7 @@ export function staleSubagentRosterRevisions( const revisions: JournalSubagentLivenessRevision[] = [] for (const item of items) { const body = item.body - if (body.kind !== 'message' || !body.blocks.some(hasWorkingChild)) { + if (body.kind !== 'message' || !body.blocks.some(hasStaleLiveWork)) { continue } // A key that will not parse cannot be re-addressed, and appending under a @@ -59,30 +66,58 @@ export function staleSubagentRosterRevisions( return revisions } -function hasWorkingChild(block: NativeChatBlock): boolean { +function hasStaleLiveWork(block: NativeChatBlock): boolean { + return hasWorkingChild(block) || hasLiveBackgroundTask(block) +} + +function hasWorkingChild(block: NativeChatBlock): block is NativeChatSubagentGroupBlock { return ( isSubagentGroupBlock(block) && block.agents.some((agent) => normalizeSubagentState(agent.state) === 'working') ) } +function hasLiveBackgroundTask(block: NativeChatBlock): block is NativeChatBackgroundTaskBlock { + return ( + isBackgroundTaskBlock(block) && + !isSettledBackgroundTaskState(normalizeBackgroundTaskState(block.state)) + ) +} + /** No `settledAt`: the child stopped being observable at an unknown moment, and * stamping the reopen would report the time the app was down as how long it * ran. Readers already draw an unverifiable child with no stamp as having no * known run length. */ function settleBlocks(blocks: readonly NativeChatBlock[]): NativeChatBlock[] { - const settled = blocks.map((block) => - hasWorkingChild(block) ? settleGroup(block as NativeChatSubagentGroupBlock) : block + const backgroundTaskTwinText = new Map() + const settled = blocks.map((block) => { + if (hasWorkingChild(block)) { + return settleGroup(block) + } + if (hasLiveBackgroundTask(block)) { + const next = settleBackgroundTask(block) + backgroundTaskTwinText.set( + backgroundTaskFallbackText(block), + backgroundTaskFallbackText(next) + ) + return next + } + return block + }) + const withBackgroundTaskTwins = settled.map((block) => + block.type === 'text' + ? { ...block, text: backgroundTaskTwinText.get(block.text) ?? block.text } + : block ) - const rosters = settled.filter(isSubagentGroupBlock) + const rosters = withBackgroundTaskTwins.filter(isSubagentGroupBlock) const only = rosters.length === 1 ? rosters[0] : undefined if (!only) { - return settled + return withBackgroundTaskTwins } // The plain-text twin is all a client without the block type ever shows, so it // has to move with the block or the two would disagree about the same row. const twin = subagentGroupFallbackText(only.agents) - return settled.map((block) => + return withBackgroundTaskTwins.map((block) => block.type === 'text' && isSubagentGroupFallbackText(block.text) ? { ...block, text: twin } : block @@ -99,3 +134,8 @@ function settleGroup(block: NativeChatSubagentGroupBlock): NativeChatSubagentGro ) } } + +function settleBackgroundTask(block: NativeChatBackgroundTaskBlock): NativeChatBackgroundTaskBlock { + const { settledAt: _settledAt, ...withoutSettledAt } = block + return { ...withoutSettledAt, state: 'unverifiable' } +} diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts index b1acb14eee3..cdee04cf2bf 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../agent-session-journal/journal-payload-bounds' import { CODEX_APP_SERVER_NOTIFICATION_METHODS } from '../../codex/codex-app-server-notification-schema' import { CLAUDE_STREAM_JSON_FRAME_KINDS } from './claude-stream-json-frame-schema' import { @@ -166,6 +167,68 @@ describe('provider frame classification catalog', () => { }) }) +describe('typed translator coverage', () => { + it('emits no generic row for a covered kind, whatever the payload reports', () => { + // The catalogue calls these `status-chrome`, but `hasProviderError` promotes + // any of them that reports a failure — which is how a failed background task + // reached users as `claude · message:system:task_notification`. Coverage is + // the contract that stops it: the typed translator writes the row instead. + for (const kind of [ + 'message:system:task_started', + 'message:system:task_updated', + 'message:system:task_progress', + 'message:system:task_notification', + 'message:system:background_tasks_changed' + ]) { + expect( + unhandledProviderFrameJournalItem( + 'claude', + kind, + { + task_id: 'byjnee2no', + status: 'failed', + summary: 'Background command "Wait" failed with exit code 1' + }, + DEFAULT_JOURNAL_PAYLOAD_LIMITS, + { coveredByTypedTranslator: true } + ), + kind + ).toBeNull() + } + }) + + it('keeps malformed covered-kind failures eligible for the generic fallback', () => { + // Eligibility is all this layer decides. A frame naming no task is not + // claimed by the row owner, which withholds the coverage flag so the + // failure still reaches the user. The SENTENCE it leads with is Claude's to + // supply, through the fallback's display-text seam — proven in + // `claude-structured-journal-translation-background-tasks.test.ts`. Teaching + // `summary` to the shared key list here would re-rank the row text of every + // unmodelled frame on both providers to reach this one case. + expect( + unhandledProviderFrameJournalItem('claude', 'message:system:task_notification', { + status: 'failed', + summary: 'Background command "Wait" failed with exit code 1' + }) + ).toMatchObject({ classification: 'error-surface' }) + }) + + it('covers Claude only — the same method name on another provider still falls back', () => { + expect( + unhandledProviderFrameJournalItem('codex', 'message:system:task_notification', { + status: 'failed' + }) + ).not.toBeNull() + }) + + it('leaves an unmodelled Claude failure on the visible fallback', () => { + const item = unhandledProviderFrameJournalItem('claude', 'message:system:future_task', { + status: 'failed' + }) + expect(item?.classification).toBe('error-surface') + }) +}) + describe('notice disposition boundaries', () => { it.each(['warning', 'guardianWarning', 'deprecationNotice', 'configWarning'])( 'retains the error-surface cap exemption for %s', diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index dea830b1315..22b3b26a453 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -149,6 +149,38 @@ export const PROVIDER_FRAME_CLASSIFICATIONS = { } } as const satisfies ProviderFrameClassificationTable +/** + * Frame kinds a DEDICATED typed translator owns end to end. + * + * Coverage is a contract, not a label. The catalogue classification alone is + * only a hint about where a frame belongs, and `hasProviderError` deliberately + * outranks it so an unmodelled failure still reaches the user — which is how a + * failed background task ended up rendered by the generic fallback, whose row + * text is the wire opcode when the payload carries no key the fallback knows. + * A kind listed here is guaranteed no fallback row instead, so its translator + * may legitimately emit zero rows for a frame and nothing appears beside it. + * + * Listing a kind before its translator exists deletes the only report of a + * failure, so nothing may be added here except together with the code that + * renders it. + * + * A frame of a listed kind that names no task writes nothing. The row it + * replaces named no task either — it printed the opcode and a raw payload — + * and every frame this protocol sends carries the id its own tracker and + * roster have always required. + */ +const CLAUDE_TYPED_TRANSLATOR_KINDS: ReadonlySet = new Set([ + 'message:system:task_started', + 'message:system:task_updated', + 'message:system:task_progress', + 'message:system:task_notification', + 'message:system:background_tasks_changed' +] satisfies ClaudeStreamJsonFrameKind[]) + +export function hasTypedProviderFrameTranslator(provider: string, kind: string): boolean { + return provider === 'claude' && CLAUDE_TYPED_TRANSLATOR_KINDS.has(kind) +} + const ERROR_VARIANT_KEYS = new Set(['type', 'status', 'state', 'subtype', 'outcome']) const ERROR_VALUE_KEYS = new Set(['error', 'failureReason', 'failure_reason']) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index 3d0a5e8a269..8a2485473f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -224,6 +224,35 @@ describe('deferred structured agent-session event sink', () => { expect(log).toHaveLength(2) }) + it('admits a resolved append and publication as one bounded operation', async () => { + const log: Recorded[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink({ + watermarks: { + pauseQueuedOperations: 1, + maxQueuedOperations: 2, + lowQueuedOperations: 0, + maxQueuedBytes: 1_000_000 + } + }) + + expect(deferred.sink.tryAppendItem?.(identity(0), BODY)).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendResolvedItemAndPublish?.(identity(1), BODY, () => identity(1)) + ).toEqual({ accepted: true }) + expect(deferred.sink.tryAppendItem?.(identity(2), BODY)).toEqual({ + accepted: false, + reason: 'backpressure' + }) + + deferred.bind(target(5, log)) + await deferred.drained() + expect(log).toEqual([ + { call: 'appendItem', fence: 5, ordinal: 0 }, + { call: 'appendItem', fence: 5, ordinal: 1 }, + { call: 'publish', fence: 5 } + ]) + }) + it('pauses provider reading at the soft byte watermark before rejecting writes', async () => { const log: Recorded[] = [] const changes: boolean[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 857aa118fe8..b27e466d94b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -8,6 +8,7 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' import { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' +import { createStructuredAgentSessionResolvedAppend } from './structured-agent-session-resolved-append' export type StructuredAgentSessionSinkAdmission = | { accepted: true } @@ -36,10 +37,13 @@ export type StructuredAgentSessionLifecycleJournal = Pick< 'epoch' | 'visitItems' > -export type StructuredAgentSessionLifecycleIdentityResolver = ( +export type StructuredAgentSessionIdentityResolver = ( journal: StructuredAgentSessionLifecycleJournal ) => AgentJournalItemIdentity | null +/** Compatibility alias for lifecycle callers that already use this resolver. */ +export type StructuredAgentSessionLifecycleIdentityResolver = StructuredAgentSessionIdentityResolver + export type StructuredAgentSessionEventSink = { appendItem( identity: AgentJournalItemIdentity, @@ -61,11 +65,25 @@ export type StructuredAgentSessionEventSink = { body: AgentJournalItemBody, options?: StructuredAgentSessionAppendOptions ): StructuredAgentSessionSinkAdmission + /** Queues an ordinary append whose identity is resolved after journal bind. */ + tryAppendResolvedItem?( + identitySizeBound: AgentJournalItemIdentity, + body: AgentJournalItemBody, + resolveIdentity: StructuredAgentSessionIdentityResolver, + options?: StructuredAgentSessionAppendOptions + ): StructuredAgentSessionSinkAdmission + /** Queues one resolved append and its publication as a single admitted operation. */ + tryAppendResolvedItemAndPublish?( + identitySizeBound: AgentJournalItemIdentity, + body: AgentJournalItemBody, + resolveIdentity: StructuredAgentSessionIdentityResolver, + options?: StructuredAgentSessionAppendOptions + ): StructuredAgentSessionSinkAdmission /** Queues one journal-derived lifecycle append; a null resolution is a no-op. */ tryAppendLifecycleTransition?( identitySizeBound: AgentJournalItemIdentity, body: AgentJournalItemBody, - resolveIdentity: StructuredAgentSessionLifecycleIdentityResolver + resolveIdentity: StructuredAgentSessionIdentityResolver ): StructuredAgentSessionSinkAdmission /** Current durable epoch, when this deferred sink is bound to its journal. */ journalEpoch?(): string | null @@ -142,6 +160,7 @@ export function createDeferredStructuredAgentSessionEventSink( ...(deps.readingControl ? { readingControl: deps.readingControl } : {}), ...(deps.onBackpressureChange ? { onBackpressureChange: deps.onBackpressureChange } : {}) }) + const resolvedAppend = createStructuredAgentSessionResolvedAppend(queue) const appendLifecycleBatch = ( settlementId: string, @@ -203,6 +222,7 @@ export function createDeferredStructuredAgentSessionEventSink( }, options ), + ...resolvedAppend, tryAppendLifecycleTransition: (identitySizeBound, body, resolveIdentity) => { const bytes = estimateStructuredAgentSessionItemBytes(identitySizeBound, body) return queue.submit( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts new file mode 100644 index 00000000000..b8fb2f1986c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts @@ -0,0 +1,61 @@ +import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' + +/** Resolve a queued item's run identity against the journal bound at execution. */ +export function createStructuredAgentSessionResolvedAppend( + queue: StructuredAgentSessionSinkQueue +): { + tryAppendResolvedItem: NonNullable + tryAppendResolvedItemAndPublish: NonNullable< + StructuredAgentSessionEventSink['tryAppendResolvedItemAndPublish'] + > +} { + return { + tryAppendResolvedItem: (identitySizeBound, body, resolveIdentity, options = {}) => { + const bytes = estimateStructuredAgentSessionItemBytes(identitySizeBound, body) + return queue.submit( + { + bytes, + run: async (bound) => { + const identity = resolveIdentity(bound.journal) + if (identity === null) { + return + } + if (estimateStructuredAgentSessionItemBytes(identity, body) > bytes) { + throw new Error('structured agent-session item identity exceeded its reserved size') + } + await bound.journal.appendItem(identity, body, { + fence: bound.fence, + ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }) + }) + } + }, + options + ) + }, + tryAppendResolvedItemAndPublish: (identitySizeBound, body, resolveIdentity, options = {}) => { + const bytes = estimateStructuredAgentSessionItemBytes(identitySizeBound, body) + 1 + return queue.submit( + { + bytes, + run: async (bound) => { + const identity = resolveIdentity(bound.journal) + if (identity === null) { + return + } + if (estimateStructuredAgentSessionItemBytes(identity, body) + 1 > bytes) { + throw new Error('structured agent-session item identity exceeded its reserved size') + } + await bound.journal.appendItem(identity, body, { + fence: bound.fence, + ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }) + }) + bound.publish() + } + }, + options + ) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts index e93aef352fe..ac13c75d480 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts @@ -16,6 +16,26 @@ describe('rewind recovery of newer durable records', () => { blocks: [{ type: 'text', text: '{"type":"future-block"}' }] }) }) + + it('preserves background-task blocks across rewind recovery', () => { + const body = { + kind: 'message' as const, + role: 'system', + blocks: [ + { type: 'text' as const, text: 'Started background command "sleep 20"' }, + { + type: 'background-task' as const, + taskId: 'task-1', + kind: 'command', + label: 'sleep 20', + state: 'working' + } + ] + } + + expect(restoreRewindJournalBody(body)).toEqual(body) + }) + it('preserves unknown state as evidence rather than inventing success or pending work', () => { const body = { kind: 'tool-call' as const, diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts index e23058a7d7b..db92d55e110 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts @@ -21,7 +21,12 @@ export function restoreRewindJournalBody(body: StoredBody): AgentJournalItemBody (block.type === 'text' && 'text' in block) || (block.type === 'tool-call' && 'name' in block && !('state' in block)) || (block.type === 'tool-result' && 'output' in block) || - block.type === 'image-ref' + block.type === 'image-ref' || + (block.type === 'background-task' && + 'taskId' in block && + 'kind' in block && + 'label' in block && + 'state' in block) ) { return block } diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts index 22ca3d89542..d1efdcbb3f6 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts @@ -6,7 +6,10 @@ import { type JournalPayloadLimits } from '../agent-session-journal/journal-payload-bounds' import { codexGoalRowText } from '../../codex/codex-goal-journal-rows' -import { classifyProviderFrame } from './provider-frame-disposition' +import { + classifyProviderFrame, + hasTypedProviderFrameTranslator +} from './provider-frame-disposition' export type UnhandledProviderFrameJournalItem = { body: AgentJournalStatusItem @@ -14,6 +17,11 @@ export type UnhandledProviderFrameJournalItem = { classification: 'timeline-substantive' | 'error-surface' } +export type UnhandledProviderFrameJournalItemOptions = { + /** A typed translator accepted this exact frame, not merely this frame kind. */ + coveredByTypedTranslator?: boolean +} + function serializeProviderPayload(payload: unknown): string { try { const serialized = JSON.stringify(payload) @@ -76,8 +84,20 @@ export function unhandledProviderFrameJournalItem( provider: string, kind: string, payload: unknown, - limits: JournalPayloadLimits = DEFAULT_JOURNAL_PAYLOAD_LIMITS + limits: JournalPayloadLimits = DEFAULT_JOURNAL_PAYLOAD_LIMITS, + options: UnhandledProviderFrameJournalItemOptions = {} ): UnhandledProviderFrameJournalItem | null { + // A kind a typed translator owns never degrades to its opcode here, in either + // direction: "no row" is that translator's decision, not a gap this fallback + // has to cover. Checked before classification, because the payload sniffer + // inside it promotes a covered frame that reports a failure and would + // otherwise print `${provider} · ${kind}` beside the typed row. + if ( + options.coveredByTypedTranslator === true && + hasTypedProviderFrameTranslator(provider, kind) + ) { + return null + } const classification = classifyProviderFrame(provider, kind, payload) if ( classification === 'stream-into-item' || diff --git a/src/main/runtime/orchestration/worker-transcript-activity-block-bounds.ts b/src/main/runtime/orchestration/worker-transcript-activity-block-bounds.ts new file mode 100644 index 00000000000..6e4d682da74 --- /dev/null +++ b/src/main/runtime/orchestration/worker-transcript-activity-block-bounds.ts @@ -0,0 +1,63 @@ +import { + normalizeBackgroundTaskKind, + normalizeBackgroundTaskState +} from '../../../shared/native-chat-background-task-row' +import { normalizeSubagentState } from '../../../shared/native-chat-subagent-summary' +import type { NativeChatBlock, NativeChatSubagentState } from '../../../shared/native-chat-types' + +type WorkerTranscriptActivityBlock = Extract< + NativeChatBlock, + { type: 'subagent-group' | 'background-task' } +> + +export type WorkerTranscriptActivityBlockBounders = { + clipMetadata: (value: string) => string + clipText: (value: string) => string + boundEntryId: (value: string) => string + markClipped: (warning: string) => void +} + +const MAX_WORKER_TRANSCRIPT_SUBAGENTS = 64 + +export function boundWorkerTranscriptActivityBlock( + block: WorkerTranscriptActivityBlock, + bounders: WorkerTranscriptActivityBlockBounders +): NativeChatBlock { + if (block.type === 'subagent-group') { + const agents = block.agents.slice(0, MAX_WORKER_TRANSCRIPT_SUBAGENTS) + if (agents.length < block.agents.length) { + bounders.markClipped('Some subagents were omitted from oversized spawn groups.') + } + return { + ...block, + groupId: bounders.clipMetadata(block.groupId), + agents: agents.map((agent) => ({ + ...agent, + id: bounders.boundEntryId(agent.id), + label: bounders.clipMetadata(agent.label), + state: clipSubagentState(agent.state, bounders) + })) + } + } + const { outputFile, ...carried } = block + if (outputFile) { + bounders.markClipped('Background task output paths were omitted from transcript output.') + } + return { + ...carried, + taskId: bounders.boundEntryId(block.taskId), + kind: normalizeBackgroundTaskKind(bounders.clipMetadata(block.kind)), + label: bounders.clipMetadata(block.label), + state: normalizeBackgroundTaskState(bounders.clipMetadata(block.state)), + ...(block.summary ? { summary: bounders.clipText(block.summary) } : {}), + ...(block.error ? { error: bounders.clipText(block.error) } : {}) + } +} + +function clipSubagentState( + value: NativeChatSubagentState, + bounders: WorkerTranscriptActivityBlockBounders +): NativeChatSubagentState { + const clipped = bounders.clipMetadata(value) + return clipped === value ? value : normalizeSubagentState(clipped) +} diff --git a/src/main/runtime/orchestration/worker-transcript-payload.test.ts b/src/main/runtime/orchestration/worker-transcript-payload.test.ts index aaa3fdcfcc1..c843fe1cfa7 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.test.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.test.ts @@ -132,6 +132,37 @@ describe('worker transcript wire bounds', () => { expect(result.limited).toBe(true) }) + it('bounds a background-task kind and state a newer build wrote as open strings', () => { + const result = boundWorkerTranscriptMessages([ + JSON.parse( + JSON.stringify({ + id: 'message-task-state', + role: 'system', + timestamp: null, + source: 'transcript', + blocks: [ + { + type: 'background-task', + taskId: 'task-1', + kind: 'k'.repeat(900), + label: 'l'.repeat(900), + state: 's'.repeat(900) + } + ] + }) + ) + ]) + + const block = result.messages[0]?.blocks[0] + if (block?.type !== 'background-task') { + throw new Error('expected a background-task block') + } + expect(block.kind).toBe('unknown') + expect(block.label).toHaveLength(512) + expect(block.state).toBe('unverifiable') + expect(result.limited).toBe(true) + }) + it('keeps complete bounded messages unlimited', () => { const result = boundWorkerTranscriptMessages([ { diff --git a/src/main/runtime/orchestration/worker-transcript-payload.ts b/src/main/runtime/orchestration/worker-transcript-payload.ts index 0f50aa8309c..b4365e259c2 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.ts @@ -1,11 +1,7 @@ import { createHash } from 'node:crypto' -import { normalizeSubagentState } from '../../../shared/native-chat-subagent-summary' -import type { - NativeChatBlock, - NativeChatMessage, - NativeChatSubagentState -} from '../../../shared/native-chat-types' +import type { NativeChatBlock, NativeChatMessage } from '../../../shared/native-chat-types' import { boundSubagentEntryId } from '../../native-chat/subagent-entry-id-bounds' +import { boundWorkerTranscriptActivityBlock } from './worker-transcript-activity-block-bounds' export const DEFAULT_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 40 export const MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 50 @@ -13,10 +9,6 @@ const MAX_WORKER_TRANSCRIPT_BLOCKS = 6 const MAX_WORKER_TRANSCRIPT_BLOCK_CHARS = 1_200 const MAX_WORKER_TRANSCRIPT_INPUT_ITEMS = 20 const MAX_WORKER_TRANSCRIPT_INPUT_NODES = 100 -// Matches the producer's per-group cap, so no group this build writes is clipped -// here. The bound stays because the journal schema declares no maximum and a -// remote host may run a build with a larger one. -const MAX_WORKER_TRANSCRIPT_SUBAGENTS = 64 // Message ids, turn ids, tool-call names and image urls, not only roster fields. // Equal to `MAX_SUBAGENT_FIELD_CHARS` today, kept a separate literal so a // roster-motivated change to that cap cannot silently move this one. @@ -142,23 +134,13 @@ function boundBlock(block: NativeChatBlock, state: TranscriptBoundState): Native input: boundToolInput(block.input, budget, 0, state) } } - if (block.type === 'subagent-group') { - const agents = block.agents.slice(0, MAX_WORKER_TRANSCRIPT_SUBAGENTS) - if (agents.length < block.agents.length) { - markClipped(state, 'Some subagents were omitted from oversized spawn groups.') - } - // Labels, ids and states come from provider-supplied strings, so they get the - // same redaction and clipping every other piece of transcript metadata gets. - return { - ...block, - groupId: clipMetadata(block.groupId, state), - agents: agents.map((agent) => ({ - ...agent, - id: boundEntryId(agent.id, state), - label: clipMetadata(agent.label, state), - state: clipSubagentState(agent.state, state) - })) - } + if (block.type === 'subagent-group' || block.type === 'background-task') { + return boundWorkerTranscriptActivityBlock(block, { + clipMetadata: (value) => clipMetadata(value, state), + clipText: (value) => clipText(value, state), + boundEntryId: (value) => boundEntryId(value, state), + markClipped: (warning) => markClipped(state, warning) + }) } if (block.path || (block.url && isLocalFileLocator(block.url))) { markClipped(state, 'Local image paths were omitted from transcript output.') @@ -216,17 +198,6 @@ function clipMetadata(value: string, state: TranscriptBoundState): string { return redacted.slice(0, MAX_WORKER_TRANSCRIPT_METADATA_CHARS) } -/** `state` is an open string on the wire, so it takes the same bound. A value - * that had to be redacted or clipped names no state any build knows, which is - * exactly what `unverifiable` records. */ -function clipSubagentState( - value: NativeChatSubagentState, - state: TranscriptBoundState -): NativeChatSubagentState { - const clipped = clipMetadata(value, state) - return clipped === value ? value : normalizeSubagentState(clipped) -} - function clipText(value: string, state: TranscriptBoundState): string { const redacted = redactSensitiveText(value, state.warnings) if (redacted.length <= MAX_WORKER_TRANSCRIPT_BLOCK_CHARS) { diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTaskRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTaskRun.test.tsx new file mode 100644 index 00000000000..13b2be287cf --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTaskRun.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import type { NativeChatBackgroundTaskBlock } from '../../../../shared/native-chat-types' +import { deriveNativeChatRowContent } from './native-chat-row-content' +import { NativeChatBackgroundTaskRun } from './NativeChatBackgroundTaskRun' + +afterEach(cleanup) + +function task( + overrides: Partial = {} +): NativeChatBackgroundTaskBlock { + return { + type: 'background-task', + taskId: 'byjnee2no', + kind: 'command', + label: 'Wait for the verification verdict', + state: 'working', + startedAt: 1_000, + ...overrides + } +} + +describe('NativeChatBackgroundTaskRun', () => { + it('draws the failure as a state, with the provider sentence beside it', () => { + render( + + ) + expect(screen.getByText('Wait for the verification verdict')).toBeInTheDocument() + // The outcome is a state word plus its reason — the same vocabulary the + // strip above the composer uses — not a red row of prose. + expect(screen.getByText(/^blocked · failed · 18\.2k · 1m 0s$/)).toBeInTheDocument() + expect( + screen.getByText('Background command "Wait" failed with exit code 1') + ).toBeInTheDocument() + }) + + it('never draws a wire opcode, whatever the task reported', () => { + const { container } = render() + expect(container.textContent).not.toContain('message:system') + expect(container.textContent).not.toContain('task_notification') + }) + + it('falls through to the kind when the provider named nothing usable', () => { + render() + expect(screen.getByText('Background workflow')).toBeInTheDocument() + }) + + it('reads a state this build has no word for as no contact, never as live', () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: models a row a newer build wrote, which the wire admits as an open string. + render() + expect(screen.getByText(/unverifiable/)).toBeInTheDocument() + }) +}) + +describe('background task rows in a transcript message', () => { + it('drops the frozen twin the block replaces, and keeps real prose', () => { + const block = task({ state: 'blocked', summary: 'it failed' }) + const content = deriveNativeChatRowContent([ + { type: 'text', text: 'here is what happened' }, + { type: 'text', text: 'it failed' }, + block + ]) + expect(content.markdown).toBe('here is what happened') + expect(content.backgroundTasks).toEqual([block]) + }) + + it('counts a task row as content, so the transcript reserves its slot', () => { + const content = deriveNativeChatRowContent([ + { type: 'text', text: 'it failed' }, + task({ state: 'blocked', summary: 'it failed' }) + ]) + expect(content.markdown).toBe('') + expect(content.backgroundTasks).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTaskRun.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTaskRun.tsx new file mode 100644 index 00000000000..e5d532d2033 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTaskRun.tsx @@ -0,0 +1,83 @@ +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { AgentStateDot } from '@/components/AgentStateDot' +import { + isSettledBackgroundTaskState, + normalizeBackgroundTaskKind, + normalizeBackgroundTaskState +} from '../../../../shared/native-chat-background-task-row' +import type { NativeChatBackgroundTaskBlock } from '../../../../shared/native-chat-types' +import { + backgroundTaskStateReason, + backgroundTaskStateWord, + formatBackgroundTaskTokens, + resolveBackgroundTaskName +} from './background-task-roster' +import { KIND_ICONS } from './NativeChatBackgroundTasksStatus' +import { formatNativeChatDuration } from './NativeChatWorkingStatus' + +/** + * One background task's durable row: what it was, how it ended, and the + * provider's own sentence about it. + * + * The state is drawn exactly as the journal recorded it. Turn state is NOT + * consulted — a backgrounded task is explicitly told to outlive the turn that + * started it, so a turn boundary is no evidence about the task. Only the host + * that watched it can say it stopped reporting, and one does. + */ +export function NativeChatBackgroundTaskRun({ + block +}: { + block: NativeChatBackgroundTaskBlock +}): React.JSX.Element { + const kind = normalizeBackgroundTaskKind(block.kind) + const Icon = KIND_ICONS[kind] + const state = normalizeBackgroundTaskState(block.state) + const settled = isSettledBackgroundTaskState(state) + // Same name resolution the strip above the composer uses, so one task does + // not read as two different things on the two surfaces. + const label = resolveBackgroundTaskName({ id: block.taskId, kind, description: block.label }) + // Every attention state states its reason on the row, the same word the strip + // uses; `unverifiable` ("no contact") must never be silently dropped. + const reason = backgroundTaskStateReason(state) + // The sentence the provider itself wrote. It is the row's whole reason for + // existing when a task fails, and it is dropped from the prose above as the + // block's twin, so it has to be drawn here. + const sentence = block.summary?.trim() || block.error?.trim() || null + // A settled row keeps a fixed duration; a live one shows none rather than a + // frozen clock, which the strip above the composer counts for real. + const duration = + settled && block.startedAt !== undefined && block.settledAt !== undefined + ? formatNativeChatDuration(Math.max(0, (block.settledAt - block.startedAt) / 1000)) + : null + const meta = [ + block.tokens === undefined ? null : formatBackgroundTaskTokens(block.tokens), + duration + ].filter((part): part is string => part !== null) + return ( +
+
+
+ {sentence === null ? null : ( +

+ {sentence} +

+ )} + {block.outputFile ? ( +

+ {translate('components.native-chat.backgroundTasks.outputFile', 'Output: {{value0}}', { + value0: block.outputFile + })} +

+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx index d9bef4e6099..1b5225efed8 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx @@ -45,7 +45,9 @@ function useNarrowStrip(ref: React.RefObject): boolean { return narrow } -const KIND_ICONS = { +/** Shared with the transcript row so a task reads as the same thing in the + * strip above the composer and in the row that outlives it. */ +export const KIND_ICONS = { agent: Bot, command: SquareTerminal, monitor: Activity, diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index fcad31aac13..9d90c1ff469 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -59,9 +59,8 @@ export const MessageRow = memo(function MessageRow({ const rowRef = useRef(null) // One pass per block set, shared with the list that decides whether this row // occupies a slot — so "draws nothing" means the same thing to both. - const { hasImages, markdown, prose, subagentGroups, tools } = deriveNativeChatRowContent( - message.blocks - ) + const { backgroundTasks, hasImages, markdown, prose, subagentGroups, tools } = + deriveNativeChatRowContent(message.blocks) const isUser = message.role === 'user' const isReasoning = message.role === 'reasoning' const isSystem = message.role === 'system' @@ -76,7 +75,13 @@ export const MessageRow = memo(function MessageRow({ // Skip rows with nothing renderable so the transcript shows no empty/ghost // bubble. // After all hooks, so hook order stays unconditional. - if (markdown.length === 0 && !hasImages && tools.length === 0 && subagentGroups.length === 0) { + if ( + markdown.length === 0 && + !hasImages && + tools.length === 0 && + subagentGroups.length === 0 && + backgroundTasks.length === 0 + ) { return null } @@ -183,7 +188,7 @@ export const MessageRow = memo(function MessageRow({ linkifyFilePaths={onLinkClick !== undefined} /> ) : null} - {tools.length > 0 || subagentGroups.length > 0 ? ( + {tools.length > 0 || subagentGroups.length > 0 || backgroundTasks.length > 0 ? ( void /** Spawn-group rosters that belong with this run's activity, one row each. */ subagentGroups?: NativeChatSubagentGroupBlock[] + /** Background tasks that belong with this run's activity, one row each. */ + backgroundTasks?: NativeChatBackgroundTaskBlock[] /** Legacy view-level default; production native-chat entry points pass false. */ expandSignal: boolean /** Per-turn disclosure state controlled by the completed turn status row. */ @@ -94,6 +100,13 @@ export function NativeChatToolRun({ const subagentRows = subagentGroups .filter(isRenderableSubagentGroup) .map((group) => ) + // Neither a roster nor a background task is tool activity, so both take every + // escape below that the tool header does not: a task row outlives the turn + // that started it and is the only durable report of how it ended. + const standaloneRows = [ + ...subagentRows, + ...backgroundTasks.map((task) => ) + ] const { asks, unansweredAsks, @@ -170,7 +183,7 @@ export function NativeChatToolRun({ // whole transcript — and left the caller, which counts a spawn group as // renderable, drawing the empty bubble it explicitly guards against. if (blocks.length === 0) { - return subagentRows.length > 0 ?
{subagentRows}
: null + return standaloneRows.length > 0 ?
{standaloneRows}
: null } // Completed turn activity belongs behind the turn-status disclosure. Keeping @@ -186,14 +199,14 @@ export function NativeChatToolRun({ // The roster is not tool activity, so it survives this guard exactly as it // survives the tool-less escape above — otherwise a group sharing a message // with tool calls is dropped from every settled turn. - return subagentRows.length > 0 ?
{subagentRows}
: null + return standaloneRows.length > 0 ?
{standaloneRows}
: null } return ( // Extra top margin sets the tool run apart from the assistant prose above it // so the turn's activity doesn't crowd the message text.
- {subagentRows} + {standaloneRows} {hasAskCall ? ( ) : null} diff --git a/src/renderer/src/components/native-chat/native-chat-row-content.ts b/src/renderer/src/components/native-chat/native-chat-row-content.ts index e410a2b2099..2dc95bfd609 100644 --- a/src/renderer/src/components/native-chat/native-chat-row-content.ts +++ b/src/renderer/src/components/native-chat/native-chat-row-content.ts @@ -6,11 +6,19 @@ // Cached on the block array itself, so a streaming turn re-deriving on every frame // pays once per revision rather than once per consumer. +import { + backgroundTaskBlocks, + claimBackgroundTaskTwins +} from '../../../../shared/native-chat-background-task-row' import { isSubagentGroupFallbackText, subagentGroupBlocks } from '../../../../shared/native-chat-subagent-summary' -import { isSubagentGroupBlock, type NativeChatBlock } from '../../../../shared/native-chat-types' +import { + isBackgroundTaskBlock, + isSubagentGroupBlock, + type NativeChatBlock +} from '../../../../shared/native-chat-types' import { splitNativeChatBlocks } from './native-chat-tool-fold' import { nativeChatProseToMarkdown } from './native-chat-prose' @@ -22,21 +30,28 @@ const derivations = new WeakMap() function derive(blocks: readonly NativeChatBlock[]) { const split = splitNativeChatBlocks(blocks) const groups = subagentGroupBlocks(split.prose) - // A spawn-group row carries a plain-text twin so a client without the block type - // still reads the roster. This draws the block, so only the twin is dropped — - // never real text beside it, which a lane folding a roster into a message keeps. + const tasks = backgroundTaskBlocks(split.prose) + // Both row kinds carry a plain-text twin so a client without the block type + // still reads them. This draws the blocks, so only the twins are dropped — + // never real text beside them, which a lane folding a roster into a message + // keeps. A task row's twin is claimed by exact text, because its sentence is + // often the provider's own and has no shape to match. + const taskTwins = claimBackgroundTaskTwins(split.prose) const prose = - groups.length === 0 + groups.length === 0 && tasks.length === 0 ? split.prose : split.prose.filter( - (block) => + (block, index) => !isSubagentGroupBlock(block) && - !(block.type === 'text' && isSubagentGroupFallbackText(block.text)) + !isBackgroundTaskBlock(block) && + !taskTwins.twinTextIndexes.has(index) && + !(groups.length > 0 && block.type === 'text' && isSubagentGroupFallbackText(block.text)) ) return { prose, tools: split.tools, subagentGroups: groups, + backgroundTasks: tasks, markdown: nativeChatProseToMarkdown(prose), hasImages: prose.some((block) => block.type === 'image-ref') } @@ -56,6 +71,13 @@ export function deriveNativeChatRowContent( /** Whether the row draws anything. An empty row takes no slot in the transcript. */ export function nativeChatRowRendersContent(blocks: readonly NativeChatBlock[]): boolean { - const { markdown, hasImages, tools, subagentGroups } = deriveNativeChatRowContent(blocks) - return markdown.length > 0 || hasImages || tools.length > 0 || subagentGroups.length > 0 + const { markdown, hasImages, tools, subagentGroups, backgroundTasks } = + deriveNativeChatRowContent(blocks) + return ( + markdown.length > 0 || + hasImages || + tools.length > 0 || + subagentGroups.length > 0 || + backgroundTasks.length > 0 + ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5b32729987a..a0ce84c3559 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17236,6 +17236,7 @@ "command": "Background command", "monitor": "Background monitor", "task": "Background task", + "outputFile": "Output: {{value0}}", "detailsUnavailable": "Task details are unavailable for this session.", "countAgentsOne": "1 agent", "countAgentsMany": "{{value0}} agents", diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 31934b9c1e5..f355e6c1bb0 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -46,7 +46,8 @@ const KNOWN_BLOCK_TYPES = new Set([ 'tool-call', 'tool-result', 'image-ref', - 'subagent-group' + 'subagent-group', + 'background-task' ]) /** Provider IDs are opaque; reject all-whitespace values without rewriting valid IDs. */ @@ -101,6 +102,23 @@ const Block = z.union([ type: z.literal('subagent-group'), groupId: z.string(), agents: z.array(SubagentEntry) + }), + // `kind` and `state` stay open strings for the same reason a child's + // lifecycle does: a vocabulary a newer build writes must not turn the row + // malformed. The renderer falls back on anything it cannot name. + z.object({ + type: z.literal('background-task'), + taskId: z.string().min(1), + kind: z.string().min(1), + label: z.string(), + state: z.string().min(1), + parentToolUseId: z.string().optional(), + summary: z.string().optional(), + error: z.string().optional(), + outputFile: z.string().optional(), + tokens: z.number().optional(), + startedAt: z.number().optional(), + settledAt: z.number().optional() }) ]), z.object({ type: z.string() }).refine((block) => !KNOWN_BLOCK_TYPES.has(block.type)) diff --git a/src/shared/native-chat-background-task-row.test.ts b/src/shared/native-chat-background-task-row.test.ts new file mode 100644 index 00000000000..cd1d9bb7ccf --- /dev/null +++ b/src/shared/native-chat-background-task-row.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { + backgroundTaskFallbackText, + canReplaceBackgroundTaskState, + claimBackgroundTaskTwins, + isSettledBackgroundTaskState, + normalizeBackgroundTaskState +} from './native-chat-background-task-row' +import type { NativeChatBackgroundTaskBlock, NativeChatBlock } from './native-chat-types' + +function task( + overrides: Partial = {} +): NativeChatBackgroundTaskBlock { + return { + type: 'background-task', + taskId: 'task-1', + kind: 'command', + label: 'sleep 20', + state: 'working', + ...overrides + } +} + +describe('background task row text', () => { + it('leads with the provider sentence — the only account of the failure it ever gave', () => { + expect( + backgroundTaskFallbackText( + task({ state: 'blocked', summary: 'Background command "X" failed with exit code 1' }) + ) + ).toBe('Background command "X" failed with exit code 1') + }) + + it('falls back to the provider error when no summary arrived', () => { + expect(backgroundTaskFallbackText(task({ state: 'blocked', error: 'ENOENT' }))).toBe('ENOENT') + }) + + it('claims only that a live task was started, never that it is still running', () => { + // The clients reading this instead of the block reconcile nothing and cannot + // re-check the task, so a frozen "running" would assert a liveness only the + // dead process could have observed. + expect(backgroundTaskFallbackText(task())).toBe('Started background command "sleep 20"') + expect(backgroundTaskFallbackText(task())).not.toContain('running') + }) + + it('names the outcome for a settled task with no provider sentence', () => { + expect(backgroundTaskFallbackText(task({ state: 'done' }))).toBe( + 'Background command "sleep 20" finished' + ) + expect(backgroundTaskFallbackText(task({ state: 'unverifiable' }))).toBe( + 'Background command "sleep 20" stopped reporting' + ) + }) + + it('falls through to the kind when the provider named nothing', () => { + expect(backgroundTaskFallbackText(task({ label: ' ', state: 'done' }))).toBe( + 'Background command finished' + ) + }) +}) + +describe('background task row state', () => { + it('reads a state this build has no word for as unverifiable, never as live', () => { + expect(normalizeBackgroundTaskState('teleported')).toBe('unverifiable') + expect(isSettledBackgroundTaskState('teleported')).toBe(true) + expect(isSettledBackgroundTaskState('waiting')).toBe(false) + }) + + it('latches a reported outcome but lets a real verdict correct lost contact', () => { + expect(canReplaceBackgroundTaskState('working', 'blocked')).toBe(true) + expect(canReplaceBackgroundTaskState('blocked', 'working')).toBe(false) + expect(canReplaceBackgroundTaskState('done', 'blocked')).toBe(false) + expect(canReplaceBackgroundTaskState('unverifiable', 'done')).toBe(true) + expect(canReplaceBackgroundTaskState('unverifiable', 'working')).toBe(false) + }) +}) + +describe('background task twins', () => { + it('pairs each row with the frozen sentence written beside it', () => { + const row = task({ state: 'blocked', summary: 'it failed' }) + const blocks: NativeChatBlock[] = [{ type: 'text', text: 'it failed' }, row] + const claims = claimBackgroundTaskTwins(blocks) + expect([...claims.twinTextIndexes]).toEqual([0]) + expect(claims.unpairedRows.size).toBe(0) + }) + + it('leaves real prose beside a row alone', () => { + const row = task({ state: 'blocked', summary: 'it failed' }) + const blocks: NativeChatBlock[] = [ + { type: 'text', text: 'here is what I found' }, + { type: 'text', text: 'it failed' }, + row + ] + expect([...claimBackgroundTaskTwins(blocks).twinTextIndexes]).toEqual([1]) + }) + + it('makes a row with no twin print its own sentence rather than nothing', () => { + const row = task({ state: 'blocked', summary: 'it failed' }) + const claims = claimBackgroundTaskTwins([row]) + expect(claims.unpairedRows.get(0)).toBe('it failed') + }) + + it('gives two rows sharing a sentence one twin each', () => { + const rows = [task({ taskId: 'a', state: 'done' }), task({ taskId: 'b', state: 'done' })] + const blocks: NativeChatBlock[] = [ + { type: 'text', text: 'Background command "sleep 20" finished' }, + { type: 'text', text: 'Background command "sleep 20" finished' }, + ...rows + ] + const claims = claimBackgroundTaskTwins(blocks) + expect([...claims.twinTextIndexes]).toEqual([0, 1]) + expect(claims.unpairedRows.size).toBe(0) + }) +}) diff --git a/src/shared/native-chat-background-task-row.ts b/src/shared/native-chat-background-task-row.ts new file mode 100644 index 00000000000..9be089b7486 --- /dev/null +++ b/src/shared/native-chat-background-task-row.ts @@ -0,0 +1,168 @@ +// One background task's durable transcript row: the vocabulary its producer, +// the desktop renderer and the plain-text surfaces all read it by. +// +// Shared because the row is written once and read by clients that cannot draw +// the block. The frozen sentence beside it is built here too, so the twin and +// the block can never describe the task differently. + +import { + isBackgroundTaskBlock, + type NativeChatBackgroundTaskBlock, + type NativeChatBlock +} from './native-chat-types' + +/** The only states a task can still leave. Everything else is an outcome, + * including `unverifiable`, which records that we stopped being able to see + * the task rather than what it did (docs/reference/ssh-execution-boundary.md). + * A state this build does not know reads as settled, never as in-flight: a row + * written by a newer build must not leave the transcript spinning forever. */ +const IN_FLIGHT_TASK_STATES: ReadonlySet = new Set(['working', 'monitoring', 'waiting']) + +/** The task's own verdict about itself, which a later frame may not overwrite. + * `unverifiable` is deliberately absent: contact can return, and latching the + * loss would report a task that finished as one we never saw finish. */ +const LATCHED_TASK_STATES: ReadonlySet = new Set(['done', 'blocked', 'idle']) + +const KNOWN_TASK_STATES = [ + 'working', + 'monitoring', + 'waiting', + 'blocked', + 'done', + 'idle', + 'unverifiable' +] as const satisfies NativeChatBackgroundTaskBlock['state'][] + +/** A state this build has no word for reads as `unverifiable` — we cannot say + * what the task did, only that we cannot name what it reported. */ +export function normalizeBackgroundTaskState( + state: string +): NativeChatBackgroundTaskBlock['state'] { + return KNOWN_TASK_STATES.find((known) => known === state) ?? 'unverifiable' +} + +export function isSettledBackgroundTaskState(state: string): boolean { + return !IN_FLIGHT_TASK_STATES.has(state) +} + +/** Whether `next` may replace `current`. Nothing returns to in-flight once we + * have given up on it, so a straggler progress tick cannot re-light a settled + * row. */ +export function canReplaceBackgroundTaskState(current: string, next: string): boolean { + if (!isSettledBackgroundTaskState(current)) { + return true + } + if (LATCHED_TASK_STATES.has(current)) { + return false + } + return LATCHED_TASK_STATES.has(next) +} + +const KNOWN_TASK_KINDS = [ + 'agent', + 'workflow', + 'command', + 'monitor', + 'unknown' +] as const satisfies NativeChatBackgroundTaskBlock['kind'][] + +/** A kind this build has no name for is simply an unnamed task, not a guess. */ +export function normalizeBackgroundTaskKind(kind: string): NativeChatBackgroundTaskBlock['kind'] { + return KNOWN_TASK_KINDS.find((known) => known === kind) ?? 'unknown' +} + +const KIND_NOUNS: Record = { + agent: 'background agent', + workflow: 'background workflow', + command: 'background command', + monitor: 'background monitor', + unknown: 'background task' +} + +const SETTLED_VERBS: Record = { + done: 'finished', + blocked: 'failed', + idle: 'was stopped' +} + +/** + * Plain-text stand-in for the row, frozen into the journal at write time for + * clients without the block type — mobile renders only this. + * + * It leads with the provider's OWN sentence whenever it sent one: that sentence + * is the point of the row, and paraphrasing it would discard the only account + * of the failure the provider ever gave. Everything else states what stays true + * once the writing process is gone. A live task claims only that it was + * started, never that it is still running: the clients reading this instead of + * the block reconcile nothing and cannot re-check the task, so a frozen + * "running" would assert a liveness only the dead process could have observed. + */ +export function backgroundTaskFallbackText(block: NativeChatBackgroundTaskBlock): string { + const sentence = block.summary?.trim() || block.error?.trim() + if (sentence) { + return sentence + } + const noun = KIND_NOUNS[normalizeBackgroundTaskKind(block.kind)] + // A task the provider never named falls through to its kind: quoting an empty + // label would print `background command ""`. + const subject = block.label.trim() ? `${noun} "${block.label}"` : noun + if (!isSettledBackgroundTaskState(block.state)) { + return `Started ${subject}` + } + const verb = SETTLED_VERBS[block.state] ?? 'stopped reporting' + return `${subject.charAt(0).toUpperCase()}${subject.slice(1)} ${verb}` +} + +/** The background-task rows in `blocks`. */ +export function backgroundTaskBlocks( + blocks: readonly NativeChatBlock[] +): NativeChatBackgroundTaskBlock[] { + return blocks.filter(isBackgroundTaskBlock) +} + +export type BackgroundTaskTwinClaims = { + /** Text blocks a row's frozen twin occupies, by position, so a surface + * drawing the block does not print the same sentence beside it. */ + twinTextIndexes: Set + /** Rows left with no twin, by position, and the sentence each must print + * itself on a surface that cannot draw the block. */ + unpairedRows: Map +} + +/** Pair every task row with the frozen twin written beside it. + * + * Matched on exact text, which the producer guarantees: it writes the twin + * from `backgroundTaskFallbackText` and nothing else into the row. A row + * written by a newer build that phrases a state differently matches nothing, + * and then prints its own sentence beside the unclaimed text — redundant, but + * never a lost report, which is the only degradation this row may have. */ +export function claimBackgroundTaskTwins( + blocks: readonly NativeChatBlock[] +): BackgroundTaskTwinClaims { + const twinTextIndexes = new Set() + const unpairedRows = new Map() + const wanted = new Map() + const rows: { index: number; sentence: string }[] = [] + blocks.forEach((block, index) => { + if (isBackgroundTaskBlock(block)) { + const sentence = backgroundTaskFallbackText(block) + rows.push({ index, sentence }) + wanted.set(sentence, (wanted.get(sentence) ?? 0) + 1) + } + }) + for (const [index, block] of blocks.entries()) { + const count = block.type === 'text' ? (wanted.get(block.text) ?? 0) : 0 + if (block.type === 'text' && count > 0) { + wanted.set(block.text, count - 1) + twinTextIndexes.add(index) + } + } + for (const row of rows) { + const count = wanted.get(row.sentence) ?? 0 + if (count > 0) { + wanted.set(row.sentence, count - 1) + unpairedRows.set(row.index, row.sentence) + } + } + return { twinTextIndexes, unpairedRows } +} diff --git a/src/shared/native-chat-tool-attribution-allocation.test.ts b/src/shared/native-chat-tool-attribution-allocation.test.ts index 48d28166c1f..f4de7e0d0a2 100644 --- a/src/shared/native-chat-tool-attribution-allocation.test.ts +++ b/src/shared/native-chat-tool-attribution-allocation.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import { foldToolMessages } from './native-chat-tool-fold' -import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' +import type { + NativeChatBackgroundTaskBlock, + NativeChatBlock, + NativeChatMessage +} from './native-chat-types' function message(id: string, blocks: NativeChatBlock[]): NativeChatMessage { return { @@ -12,6 +16,23 @@ function message(id: string, blocks: NativeChatBlock[]): NativeChatMessage { } } +function backgroundTaskMessage(id: string): NativeChatMessage { + const task: NativeChatBackgroundTaskBlock = { + type: 'background-task', + taskId: 'task-1', + kind: 'command', + label: 'wait', + state: 'working' + } + return { + id, + role: 'system', + blocks: [{ type: 'text', text: 'Started background command "wait"' }, task], + timestamp: null, + source: 'transcript' + } +} + describe('tool attribution allocation', () => { it('does not append valid prose blocks to discarded attribution arrays', () => { const prose: NativeChatBlock = { type: 'text', text: 'Ordinary prose' } @@ -47,4 +68,22 @@ describe('tool attribution allocation', () => { expect(input.blocks).toHaveLength(7) expect(foldToolMessages([message('two', [result])])).toEqual([]) }) + + it('keeps tool attribution across a background-task activity row', () => { + const call: NativeChatBlock = { + type: 'tool-call', + name: 'read', + input: {} + } + const result: NativeChatBlock = { type: 'tool-result', output: 'done' } + const folded = foldToolMessages([ + message('assistant', [call]), + backgroundTaskMessage('background-task'), + message('result', [result]) + ]) + + expect(folded).toHaveLength(2) + expect(folded[0]?.blocks).toEqual([call, result]) + expect(folded[1]).toMatchObject({ id: 'background-task' }) + }) }) diff --git a/src/shared/native-chat-tool-fold.ts b/src/shared/native-chat-tool-fold.ts index 32a2531b9ee..e08a722c242 100644 --- a/src/shared/native-chat-tool-fold.ts +++ b/src/shared/native-chat-tool-fold.ts @@ -1,4 +1,5 @@ import { + isBackgroundTaskBlock, isSubagentGroupBlock, isToolCallBlock, isToolResultBlock, @@ -36,13 +37,17 @@ function isHarnessSidecarToolMessage(message: NativeChatMessage): boolean { ) } -/** The spawn-group roster row lands mid-turn, between the assistant's tool - * calls. It is activity chrome, not a new turn, so it must not end the run the - * following tool messages fold into. */ +/** Activity rows land mid-turn, between the assistant's tool calls. They are + * chrome, not a new turn, so they must not end the run the following tool + * messages fold into. */ function isSubagentRosterMessage(message: NativeChatMessage): boolean { return message.blocks.some(isSubagentGroupBlock) } +function isBackgroundTaskMessage(message: NativeChatMessage): boolean { + return message.blocks.some(isBackgroundTaskBlock) +} + function isInterruptionBoundary(message: NativeChatMessage): boolean { return message.blocks.some( (block) => @@ -116,6 +121,7 @@ export function foldToolMessages(messages: readonly NativeChatMessage[]): Native clonedAssistantIndex = -1 } else if ( !isSubagentRosterMessage(message) && + !isBackgroundTaskMessage(message) && (!isNoiseMessage(message) || isInterruptionBoundary(message)) ) { mutableAssistantIndex = -1 diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index c6835301c88..1eee5e0e2a3 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -6,6 +6,10 @@ // here must be plain JSON: these values cross the IPC boundary, so no class // instances, Maps, or Dates. +import type { + AgentSessionBackgroundTask, + AgentSessionBackgroundTaskRunState +} from './agent-session-background-task-wire' import type { AgentType } from './agent-status-types' import type { NativeChatToolMetadata } from './native-chat-tool-identity' @@ -136,12 +140,48 @@ export type NativeChatSubagentGroupBlock = { agents: NativeChatSubagentEntry[] } +/** One provider background task — a shell command, workflow or monitor run + * beside the turn — as its own durable row, revised in place from the + * provider's lifecycle frames. Sibling of the roster block rather than a + * one-child roster: a backgrounded `sleep 20` is not a subagent, and a group + * holding it would read "Ran 1 subagent". + * + * Outcome is a STATUS FIELD, never a red row: a task that failed is a task + * with a terminal state, and the row that reports it is the same row that + * reported it starting. */ +export type NativeChatBackgroundTaskBlock = { + type: 'background-task' + /** Provider task id — the row key, stable across a resume. */ + taskId: string + kind: AgentSessionBackgroundTask['kind'] + /** Display name: the provider's description, else the identity it reported. */ + label: string + /** Run state, in the vocabulary the background-tasks strip already renders. */ + state: AgentSessionBackgroundTaskRunState + /** The tool call that spawned this task. The transcript has no structural + * parent link for a row, so the relationship is carried as a field here and + * consumers co-locate the row with that tool call. */ + parentToolUseId?: string + /** The provider's own sentence about the outcome, when it sent one. */ + summary?: string + /** The provider's error text, when it reported one apart from the summary. */ + error?: string + /** Where the provider wrote the task's output. */ + outputFile?: string + /** Latest total tokens the provider reported FOR THIS TASK. */ + tokens?: number + startedAt?: number + /** Epoch ms the row latched terminal. */ + settledAt?: number +} + export type NativeChatBlock = | NativeChatTextBlock | NativeChatToolCallBlock | NativeChatToolResultBlock | NativeChatImageRefBlock | NativeChatSubagentGroupBlock + | NativeChatBackgroundTaskBlock export type NativeChatMessage = { /** Stable across re-reads/appends so the assembler and the renderer list can @@ -231,3 +271,9 @@ export function isSubagentGroupBlock( ): block is NativeChatSubagentGroupBlock { return block.type === 'subagent-group' } + +export function isBackgroundTaskBlock( + block: NativeChatBlock +): block is NativeChatBackgroundTaskBlock { + return block.type === 'background-task' +} diff --git a/src/shared/worker-transcript-background-task-text.test.ts b/src/shared/worker-transcript-background-task-text.test.ts new file mode 100644 index 00000000000..1e9d727406b --- /dev/null +++ b/src/shared/worker-transcript-background-task-text.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from './native-chat-types' +import { formatWorkerTranscriptMessage } from './worker-transcript-text' + +function message(blocks: NativeChatMessage['blocks']): NativeChatMessage { + return { id: 'm-1', role: 'system', blocks, timestamp: null, source: 'hook' } +} + +describe('worker transcript text — background task rows', () => { + it('prints the twin once and never the raw block', () => { + const text = formatWorkerTranscriptMessage( + message([ + { type: 'text', text: 'Background command "X" failed with exit code 1' }, + { + type: 'background-task', + taskId: 'task-1', + kind: 'command', + label: 'X', + state: 'blocked', + summary: 'Background command "X" failed with exit code 1' + } + ]) + ) + expect(text).toBe('[system] Background command "X" failed with exit code 1') + expect(text).not.toContain('[unsupported block]') + }) + + it('prints a row that arrived with no twin rather than dropping its sentence', () => { + expect( + formatWorkerTranscriptMessage( + message([ + { + type: 'background-task', + taskId: 'task-1', + kind: 'command', + label: 'X', + state: 'blocked', + summary: 'it failed' + } + ]) + ) + ).toBe('[system] it failed') + }) +}) diff --git a/src/shared/worker-transcript-text.ts b/src/shared/worker-transcript-text.ts index 8beec7f5a82..45a98a87835 100644 --- a/src/shared/worker-transcript-text.ts +++ b/src/shared/worker-transcript-text.ts @@ -6,6 +6,7 @@ * copied: two renderings would let the two surfaces disagree about what a tool call looked like. */ +import { claimBackgroundTaskTwins } from './native-chat-background-task-row' import { isSubagentGroupFallbackText, subagentGroupFallbackText @@ -20,10 +21,17 @@ export function formatWorkerTranscriptMessage(message: NativeChatMessage): strin // every fallback-shaped text block as soon as any group is present and draws // each group, so it never has to decide which twin belongs to which group. const standIns = claimSubagentGroupTwins(message.blocks) + // A background task's row is the same shape: one block, one frozen twin. Its + // twin is matched on exact text rather than by shape, because the sentence is + // often the provider's own and has none. + const taskTwins = claimBackgroundTaskTwins(message.blocks) const blocks = message.blocks.map((block, index) => { if (block.type === 'text') { return block.text } + if (block.type === 'background-task') { + return taskTwins.unpairedRows.get(index) ?? null + } if (block.type === 'tool-call') { return `[tool ${block.name}] ${safeJson(block.input)}` }