diff --git a/src/main/agent-hooks/server-last-status-lead-boundary.test.ts b/src/main/agent-hooks/server-last-status-lead-boundary.test.ts index a78476f21bc..23ea0560e41 100644 --- a/src/main/agent-hooks/server-last-status-lead-boundary.test.ts +++ b/src/main/agent-hooks/server-last-status-lead-boundary.test.ts @@ -100,8 +100,8 @@ describe('Persisted Claude lead boundaries', () => { lastAssistantMessage: 'Lead finished.', subagents: [expect.objectContaining({ id: 'arestored-child', state: 'working' })] }) - const turnCompletedAt = firstServer.getStatusSnapshot()[0]?.turnCompletedAt - expect(turnCompletedAt).toEqual(expect.any(Number)) + // A subagent holds the lead's finish, so its Stop is not stamped for announcement. + expect(firstServer.getStatusSnapshot()[0]?.turnCompletedAt).toBeUndefined() expect(firstServer._getStateForTests().lastStatusByPaneKey.get(PANE)?.hookEventName).toBe( 'Stop' ) @@ -121,7 +121,7 @@ describe('Persisted Claude lead boundaries', () => { agentType: 'claude', prompt: 'finish after child', lastAssistantMessage: 'Lead finished.', - turnCompletedAt + mainAgent: expect.objectContaining({ state: 'done' }) }) expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined() expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() @@ -166,7 +166,7 @@ describe('Persisted Claude lead boundaries', () => { expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', agentType: 'claude', - turnCompletedAt: expect.any(Number) + mainAgent: expect.objectContaining({ state: 'done' }) }) expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined() } finally { diff --git a/src/main/claude/claude-agent-wait-calls.ts b/src/main/claude/claude-agent-wait-calls.ts new file mode 100644 index 00000000000..62ab8fb5184 --- /dev/null +++ b/src/main/claude/claude-agent-wait-calls.ts @@ -0,0 +1,30 @@ +import { BoundedMap } from '../../shared/bounded-map' +import { runsOrcaOrchestrationWait } from '../../shared/orca-cli-invocations' +import { + claudeRecord, + claudeToolUses, + readClaudeMessageEnvelope +} from './structured/journal/claude-structured-item-translation' + +/** Bash calls that block on `orca orchestration check --wait`, by tool_use_id, until their background task starts. */ +export class ClaudeAgentWaitCalls { + // A foreground wait never starts a task (or is moved to the background later), so bound what waits for one. + private readonly pending = new BoundedMap({ maxEntries: 256 }) + + observe(message: Record): void { + const envelope = readClaudeMessageEnvelope(message) + for (const tool of envelope ? claudeToolUses(envelope) : []) { + if (runsOrcaOrchestrationWait(claudeRecord(tool.input)?.command)) { + this.pending.set(tool.id, true) + } + } + } + + take(toolUseId: string | undefined): boolean { + return toolUseId !== undefined && this.pending.delete(toolUseId) + } + + clear(): void { + this.pending.clear() + } +} diff --git a/src/main/claude/claude-background-stop-records.test.ts b/src/main/claude/claude-background-stop-records.test.ts index 75f4c03e873..c9b3a4feb82 100644 --- a/src/main/claude/claude-background-stop-records.test.ts +++ b/src/main/claude/claude-background-stop-records.test.ts @@ -100,6 +100,15 @@ describe('a background Stop acts on the host child records', () => { task_id: 'agent-1', patch: { status: 'completed' } } + }, + { + at: 7_000, + frame: { + type: 'system', + subtype: 'task_notification', + task_id: 'agent-1', + status: 'completed' + } } ]) diff --git a/src/main/claude/claude-background-task-rows.ts b/src/main/claude/claude-background-task-rows.ts index 9ee358e6159..fddc499afb9 100644 --- a/src/main/claude/claude-background-task-rows.ts +++ b/src/main/claude/claude-background-task-rows.ts @@ -1,6 +1,7 @@ import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-turn-scope' import { isSettledBackgroundTaskState } from '../../shared/native-chat/input/native-chat-background-task-row' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { BoundedMap } from '../../shared/bounded-map' import { record, taskAliasId } from './claude-background-task-frames' import { claudeBackgroundTaskPatchChange, @@ -79,11 +80,29 @@ export class ClaudeBackgroundTaskRows { return { ...this.ledgers.sizes, overflowTerminalRows: this.overflowTerminalRows.size } } + /** Notification frames that already opened a wake turn; a redelivered one must not open another. */ + private readonly wakeFrames = new BoundedMap({ maxEntries: 64 }) + /** Null outside observe so teardown writes cannot reopen a turn. */ private journaling: { frame: Record; observedAt: number } | null = null observe(message: Record, observedAt: number = this.now()): boolean { this.journaling = { frame: message, observedAt } + // Claude wakes the lead for any task the lead launched, rowless subagents included; open that turn at the notification, once per frame. + const toolUseId = + message.subtype === 'task_notification' ? taskAliasId(message.tool_use_id) : undefined + const frameId = typeof message.uuid === 'string' ? message.uuid : undefined + if ( + message.type === 'system' && + toolUseId && + this.deps.isForwardedParentTool(toolUseId) && + !(frameId && this.wakeFrames.has(frameId)) + ) { + if (frameId) { + this.wakeFrames.set(frameId, true) + } + this.deps.openOutputTurn?.(message, observedAt) + } try { return this.observeFrame(message) } finally { diff --git a/src/main/claude/claude-child-work-decoder.test.ts b/src/main/claude/claude-child-work-decoder.test.ts index 7291540abcc..0aaf043146b 100644 --- a/src/main/claude/claude-child-work-decoder.test.ts +++ b/src/main/claude/claude-child-work-decoder.test.ts @@ -53,6 +53,48 @@ describe('Claude child-work decoder', () => { expect(decoder.drain(600)).toEqual([]) }) + it('marks a background orca orchestration check --wait as the worker wait it is', () => { + const decoder = new ClaudeChildWorkDecoder() + decoder.observe({ + type: 'assistant', + session_id: 'provider-1', + uuid: 'assistant-1', + parent_tool_use_id: null, + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'toolu_wait', + name: 'Bash', + input: { + command: `ORCA_RUN=${'x'.repeat(200)} orca orchestration check --wait --json`, + run_in_background: true + } + } + ] + } + }) + decoder.observe( + system('task_started', { + task_id: 'wait-1', + tool_use_id: 'toolu_wait', + task_type: 'local_bash', + description: 'Wait for next worker message', + is_backgrounded: true + }) + ) + expect(decoder.drain(500)).toMatchObject([ + { + type: 'live', + child: { + kind: 'command', + operation: { toolName: 'Bash', input: 'orca orchestration check --wait' } + } + } + ]) + }) + it("carries a child's progress: the tool it last ran, its summary and its usage", () => { const decoder = decoderWith(foregroundAgent) decoder.observe( @@ -117,6 +159,16 @@ describe('Claude child-work decoder', () => { ]) }) + it('ends a background agent at the notification that wakes its launcher, not the update before it', () => { + for (const status of ['completed', 'failed', 'killed']) { + const decoder = decoderWith(backgroundAgent) + decoder.observe(system('task_updated', { task_id: 'agent-bg', patch: { status } })) + expect(decoder.drain(500)).toEqual([]) + decoder.observe(system('task_notification', { task_id: 'agent-bg', status })) + expect(decoder.drain(600)).toEqual([expect.objectContaining({ type: 'ended' })]) + } + }) + it("reads nothing from a roster, a turn's end or a spawn call's result", () => { const decoder = decoderWith(foregroundAgent, backgroundAgent) decoder.observe(system('background_tasks_changed', { tasks: [] })) diff --git a/src/main/claude/claude-child-work-decoder.ts b/src/main/claude/claude-child-work-decoder.ts index 3dab5a42f81..2629493c625 100644 --- a/src/main/claude/claude-child-work-decoder.ts +++ b/src/main/claude/claude-child-work-decoder.ts @@ -22,6 +22,8 @@ import { terminalClaudeTaskRunState } from './claude-background-task-frames' import { isClaudeSubagentTask } from './claude-subagent-task-frames' +import { ClaudeAgentWaitCalls } from './claude-agent-wait-calls' +import { ORCA_ORCHESTRATION_WAIT_COMMAND } from '../../shared/orca-cli-invocations' import { claudeChildWorkOutcome, claudeTaskProgressFacts, @@ -36,8 +38,12 @@ type DecodedClaudeTask = { name?: string description?: string toolUseId?: string + /** Runs `orca orchestration check --wait`: waiting on worker agents, so agent work. */ + awaitsAgents?: true } +type ClaudeToolOwner = (toolUseId: string) => string | null + type PendingEdge = (observedAt: number) => AgentChildWorkEvidence function observation( @@ -49,7 +55,14 @@ function observation( ): AgentChildWorkLiveObservation { const operation: AgentChildWorkOperation | undefined = facts.toolName ? { toolName: facts.toolName, basis: 'reported', observedAt } - : undefined + : task.awaitsAgents + ? { + toolName: 'Bash', + input: ORCA_ORCHESTRATION_WAIT_COMMAND, + basis: 'reported', + observedAt + } + : undefined return { handle: { idKind: 'task_id', @@ -74,12 +87,21 @@ export class ClaudeChildWorkDecoder { /** Retain ended runs for the session to distinguish wakes from late starts. */ private readonly ended = new Map< string, - { run: string | undefined; outcome: AgentChildWorkOutcome } + { + run: string | undefined + outcome: AgentChildWorkOutcome + task?: DecodedClaudeTask + } >() private waiting: ReadonlySet = new Set() private pending: PendingEdge[] = [] - - observe(message: Record): void { + private readonly agentWaits = new ClaudeAgentWaitCalls() + /** `ownerOf` names the subagent that made a child tool call; an ending task wakes it. */ + observe(message: Record, ownerOf?: ClaudeToolOwner): void { + if (message.type === 'assistant') { + this.agentWaits.observe(message) + return + } if (message.type !== 'system') { return } @@ -95,14 +117,19 @@ export class ClaudeChildWorkDecoder { this.progressed(id, message) return case 'task_updated': - this.updated(id, record(message.patch)) + this.updated(id, record(message.patch), ownerOf) return case 'task_notification': - this.end(id, message.status, { - runId: taskAliasId(message.tool_use_id), - lastMessage: taskText(message.summary), - totalTokens: taskUsageTotalTokens(message) - }) + this.end( + id, + message.status, + { + runId: taskAliasId(message.tool_use_id), + lastMessage: taskText(message.summary), + totalTokens: taskUsageTotalTokens(message) + }, + ownerOf + ) } } @@ -120,6 +147,7 @@ export class ClaudeChildWorkDecoder { clear(): void { this.live.clear() this.ended.clear() + this.agentWaits.clear() this.waiting = new Set() this.pending.push((observedAt) => ({ type: 'session-ended', observedAt })) } @@ -131,10 +159,11 @@ export class ClaudeChildWorkDecoder { } private started(id: string, message: Record): void { + const toolUseId = taskAliasId(message.tool_use_id) + const awaitsAgents = this.agentWaits.take(toolUseId) if (message.ambient === true || message.skip_transcript === true) { return } - const toolUseId = taskAliasId(message.tool_use_id) const existing = this.live.get(id) const ended = existing ? undefined : this.ended.get(id) const restart = ended !== undefined @@ -155,13 +184,23 @@ export class ClaudeChildWorkDecoder { running: liveClaudeTaskRunState(message.status) !== null || existing?.running === true, name: taskName(message) ?? existing?.name, description: taskDescription(message.description) ?? existing?.description, - toolUseId: toolUseId ?? existing?.toolUseId + toolUseId: toolUseId ?? existing?.toolUseId, + ...(awaitsAgents || existing?.awaitsAgents ? { awaitsAgents: true as const } : {}) }, {}, restart ) } + /** Claude resumes a finished subagent when a task it launched notifies; show it working from that frame, not its restart a moment later. */ + private wakeOwner(toolUseId: string | undefined, ownerOf: ClaudeToolOwner | undefined): void { + const ownerId = toolUseId ? ownerOf?.(toolUseId) : null + const owner = ownerId ? this.ended.get(ownerId) : undefined + if (ownerId && owner?.outcome === 'succeeded' && owner.task?.kind === 'agent') { + this.upsert(ownerId, { ...owner.task, running: true }, {}, true) + } + } + private progressed(id: string, message: Record): void { const task = this.live.get(id) if (!task) { @@ -175,15 +214,22 @@ export class ClaudeChildWorkDecoder { ) } - private updated(id: string, patch: Record | null): void { + private updated( + id: string, + patch: Record | null, + ownerOf: ClaudeToolOwner | undefined + ): void { if (!patch) { return } + const existing = this.live.get(id) if (terminalClaudeTaskRunState(patch.status) !== null) { - this.end(id, patch.status, { lastMessage: taskText(patch.error) }) + // A background agent's ending always notifies its launcher at once; ending here would show the launcher finished before that wake. + if (existing?.backgrounded !== true || existing.kind !== 'agent') { + this.end(id, patch.status, { lastMessage: taskText(patch.error) }, ownerOf) + } return } - const existing = this.live.get(id) // Late updates cannot reopen an ended task. if (!existing && this.ended.has(id)) { return @@ -204,7 +250,8 @@ export class ClaudeChildWorkDecoder { running: running || existing?.running === true, name: name ?? existing?.name, description: description ?? existing?.description, - toolUseId: existing?.toolUseId + toolUseId: existing?.toolUseId, + ...(existing?.awaitsAgents ? { awaitsAgents: true as const } : {}) }, {}, false @@ -239,12 +286,22 @@ export class ClaudeChildWorkDecoder { runId?: string lastMessage?: string totalTokens?: number - } + }, + ownerOf?: ClaudeToolOwner ): void { const task = this.live.get(id) this.live.delete(id) const outcome = claudeChildWorkOutcome(status) - this.ended.set(id, { run: reported.runId ?? task?.toolUseId, outcome }) + this.ended.set(id, { + run: reported.runId ?? task?.toolUseId, + outcome, + // Kept only for an agent a task it launched can wake. + ...(task?.kind === 'agent' ? { task } : {}) + }) + // Every ending but a stop notifies, which wakes the subagent that launched the task. + if (outcome !== 'cancelled') { + this.wakeOwner(reported.runId ?? task?.toolUseId, ownerOf) + } this.pending.push((observedAt) => ({ type: 'ended', observedAt, diff --git a/src/main/claude/structured/claude-structured-event-delivery.ts b/src/main/claude/structured/claude-structured-event-delivery.ts index 2531adf01e6..5e8c92719d9 100644 --- a/src/main/claude/structured/claude-structured-event-delivery.ts +++ b/src/main/claude/structured/claude-structured-event-delivery.ts @@ -26,7 +26,7 @@ export function emitClaudeStructuredSessionEvent({ session?.childWork.clear() session?.backgroundTasks.clear() } else if (event.type === 'message') { - session?.childWork.observe(event.message) + session?.childWork.observe(event.message, session.translator?.childToolOwner) session?.backgroundTasks.observe(event.message, event.startsTurn === true) } else if (event.type === 'prompt-cancelled') { // Clear waiting before journaling cancellation so status cannot wait on a closed card. diff --git a/src/main/external-conversations/external-conversation-events.ts b/src/main/external-conversations/external-conversation-events.ts index 78b070910ed..d2313e03285 100644 --- a/src/main/external-conversations/external-conversation-events.ts +++ b/src/main/external-conversations/external-conversation-events.ts @@ -98,7 +98,8 @@ export class ConversationEvents { deliver: (item) => this.told(item), setTimer: (run, delayMs) => this.deps.setTimer(run, delayMs), started: (item) => this.started(item), - stopped: (item) => this.ended(item, agentMainAgentVerdict(item.row)) + stopped: (item) => this.ended(item, agentMainAgentVerdict(item.row)), + everyLeadTurn: true }) const unsubscribe = this.deps.onAgentPaneChange((change) => { this.settleRejected(change.paneKey) diff --git a/src/main/external-conversations/external-conversation-prompts.test.ts b/src/main/external-conversations/external-conversation-prompts.test.ts index 6d2374d1e93..83c375aa89b 100644 --- a/src/main/external-conversations/external-conversation-prompts.test.ts +++ b/src/main/external-conversations/external-conversation-prompts.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentPaneChange } from '../agent-hooks/agent-transition-detector' import type { AgentStatusIpcPayload } from '../../shared/agent-status-ipc-payload' -import type { AgentPhase, AgentTransition } from '../../shared/agent-transitions' +import { + AGENT_DONE_QUIET_MS, + type AgentPhase, + type AgentTransition +} from '../../shared/agent-transitions' import type { ConversationEvent, ConversationSnapshot } from './external-conversation-events' import type { ConversationKey } from './external-conversation-types' import { @@ -223,6 +227,56 @@ describe('ExternalConversationSeam watch', () => { }) }) + it('tells the reply of a turn its subagent holds open, then the wake-up turn that follows', async () => { + const { seam, host, hookRows, paneChange } = await linked() + const seen: ConversationEvent[] = [] + seam.watch(host, (event) => { + if (event.kind !== 'snapshot') { + seen.push(event) + } + }) + const observedWorking: AgentPaneChange = { + kind: 'observed', + paneKey: PANE, + workspace: { worktreeId: WORKTREE, sshHostId: null }, + phase: 'working', + at: 2 + } + hookRows.set(PANE, hookRow({ turnStartedAt: 100 })) + paneChange(moved('rest', 'working', 'prompt-submitted')) + paneChange(observedWorking) + // The lead's turn ended; its running subagent keeps the row working and holds the notification. + const launched = { state: 'done' as const, stateStartedAt: 300, outcome: 'success' as const } + hookRows.set( + PANE, + hookRow({ turnStartedAt: 100, lastAssistantMessage: 'Launched.', mainAgent: launched }) + ) + paneChange(observedWorking) + hookRows.set(PANE, hookRow({ turnStartedAt: 400 })) + paneChange(observedWorking) + hookRows.set( + PANE, + hookRow({ + state: 'done', + stateStartedAt: 500, + turnStartedAt: 400, + lastAssistantMessage: 'Subagent done.', + mainAgent: { state: 'done', stateStartedAt: 500, outcome: 'success' } + }) + ) + vi.useFakeTimers() + try { + paneChange(moved('working', 'done', 'turn-finished')) + vi.advanceTimersByTime(AGENT_DONE_QUIET_MS) + } finally { + vi.useRealTimers() + } + + expect( + seen.flatMap((event) => (event.kind === 'turn-ended' ? [event.lastAssistantMessage] : [])) + ).toEqual(['Launched.', 'Subagent done.']) + }) + it('names the turn a reply belongs to as turn events do, only while watched and linked', async () => { const { seam, host, hookRows, paneChange } = await linked() expect(seam.replyTarget(PANE)).toBeNull() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts index 63dd00f43b5..b2fb6e766f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts @@ -10,6 +10,11 @@ import { isSubagentBlock } from '../../../shared/native-chat/native-chat-types' import { activeStructuredAgentSessionTurnId } from '../../../shared/agent-session/view/structured-agent-session-live-turn' import { acpChildWorkStatusSink } from '../../acp/acp-structured-child-work.test-support' import { RESUMED_BY_MESSAGE } from '../../claude/claude-captured-task-frames.test-fixture' +import { + result, + says, + type CapturedFrame +} from '../../claude/claude-captured-frame-builders.test-fixture' import { WAKES_AFTER_ITS_SHELL } from '../../claude/claude-captured-wake-frames.test-fixture' import { ClaudeStructuredSessionAdapter } from '../../claude/structured/claude-structured-session-adapter' import { @@ -213,6 +218,42 @@ it("keeps an agent whose helper the store refused past idle cleanup, until the s await eventually(() => expect(strip()?.incomplete).toBeUndefined()) }) +it('announces a helper that outlives the turn once, after the lead answers its final result', async () => { + const connection = claude.connections[0]! + const completions: string[] = [] + host.subscribeTurnCompletions({ + id: 'completions', + emit: (event) => { + if (event.type === 'completion') { + completions.push(event.completion.turnId) + } + } + }) + await sendAndEcho(connection, 'Sleep in the background.') + const start = clock + // The capture omits the lead's replies: Claude wakes it after each of the helper's notifications. + const leadWake = (at: number, uuid: string): CapturedFrame[] => [ + { at, frame: says(uuid, 'Noted.', null) }, + { at: at + 1, frame: result('success') } + ] + const frames: CapturedFrame[] = WAKES_AFTER_ITS_SHELL.flatMap((captured, index) => + captured.frame.subtype === 'task_notification' && captured.frame.task_id === 'agent-1' + ? [captured, ...leadWake(captured.at + 1_000, `lead-wake-${index}`)] + : [captured] + ) + for (const [index, captured] of frames.entries()) { + clock = start + captured.at + frame(connection, { uuid: `frame-${index}`, ...captured.frame }) + await host.flushStreamedEvents(SESSION) + } + + // The first turn and the wake while the helper's own shell runs stay quiet; the wake its final notification opens announces. + const finalNotification = frames.findLastIndex( + ({ frame }) => frame.subtype === 'task_notification' + ) + await eventually(() => expect(completions).toEqual([`frame-${finalNotification}`])) +}) + it('keeps a helper working while its own shell runs and through its wake-up, ending on its answer', async () => { const connection = claude.connections[0]! await sendAndEcho(connection, 'Sleep in the background.') @@ -246,7 +287,8 @@ it('keeps a helper working while its own shell runs and through its wake-up, end state: 'completed', tokens: 18_181, startedAt: start + 15, - settledAt: start + 46_198 + // Settled by its notification, which wakes the lead, not the update a millisecond before. + settledAt: start + 46_199 }) ) expect(strip()).toEqual([]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index 6bda665c8cc..d2ff988b1ac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -78,7 +78,8 @@ export class StructuredAgentSessionClientDelivery { sessions, now, readStatusState: (sessionId, journal) => - this.statusFeed.journalProjection(sessionId, journal)?.state ?? null + this.statusFeed.journalProjection(sessionId, journal)?.state ?? null, + readChildWork: (sessionId) => this.statusFeed.readChildWork(sessionId) }) this.sendSettlement = new StructuredAgentSessionSendSettlement((sessionId) => this.settlementReading(sessionId, this.requireJournal(sessionId)) @@ -173,7 +174,11 @@ export class StructuredAgentSessionClientDelivery { sessionId: string, evidence: AgentChildWorkEvidence[], producer?: StructuredAgentSessionChildWorkProducer - ): void => this.statusFeed.publishChildWork(sessionId, evidence, producer) + ): void => { + this.statusFeed.publishChildWork(sessionId, evidence, producer) + // A drain can be the moment a held turn becomes finished. + this.turnCompletionFeed.observe(sessionId) + } readStopping = (sessionId: string): boolean => { const projection = this.statusFeed.journalProjection(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index 9f1fb34a69d..c3a4cf6206b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -92,7 +92,9 @@ async function sessionWithRunningTurn() { const completions = new StructuredAgentSessionTurnCompletionFeed({ sessions, now: () => clock, - readStatusState: (sessionId, source) => feed.journalProjection(sessionId, source)?.state ?? null + readStatusState: (sessionId, source) => + feed.journalProjection(sessionId, source)?.state ?? null, + readChildWork: (sessionId) => feed.readChildWork(sessionId) }) const completionEvents: AgentSessionTurnCompletionEvent[] = [] completions.subscribe({ id: 'dot-1', emit: (event) => completionEvents.push(event) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-child-work.test.ts new file mode 100644 index 00000000000..a83f2c06135 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-child-work.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnLifecycle +} from '../../../shared/agent-session/journal/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session/journal/agent-session-journal-item-key' +import type { AgentChildWorkLivenessCandidate } from '../../../shared/agent-status-child-work-liveness' +import { projectStructuredAgentSessionStatusState } from '../../../shared/agent-session/view/structured-agent-session-projection' +import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed' + +const LOCATION = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} as const +const SUBAGENT = { kind: 'agent', state: 'working' } as const +const DEV_SERVER = { kind: 'command', state: 'working' } as const + +function turnItem(lifecycle: AgentJournalTurnLifecycle, sequence = 2): AgentJournalRenderItem { + return { + itemId: `codex:turn:${lifecycle.turnId}`, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'turn', ...lifecycle } + } +} + +// A turn the agent opened itself keys its lifecycle row to itself. +function selfStarted(lifecycle: AgentJournalTurnLifecycle): AgentJournalTurnLifecycle { + return { ...lifecycle, userItemId: `codex:turn:${lifecycle.turnId}` } +} + +function harness(childWork: AgentChildWorkLivenessCandidate[]) { + let items: AgentJournalRenderItem[] = [] + let submissions: AgentJournalSubmission[] = [] + let sequence = 0 + const journal = { cursor: () => ({ epoch: 'epoch-1', sequence }) } + const feed = new StructuredAgentSessionTurnCompletionFeed({ + sessions: new Map([['session-1', { journal, params: { location: LOCATION } }]]), + now: () => 1_700, + readStatusState: () => projectStructuredAgentSessionStatusState(items, submissions), + readChildWork: () => childWork + }) + const outcomes: [string, string][] = [] + feed.subscribe({ + id: 'sub', + emit: (event) => { + if (event.type === 'completion') { + outcomes.push([event.completion.turnId, event.completion.outcome]) + } + } + }) + const commit = (next: AgentJournalRenderItem[], nextSubmissions: AgentJournalSubmission[]) => { + items = next + submissions = nextSubmissions + sequence += 1 + feed.observe('session-1') + } + return { + outcomes, + feed, + commit, + settle: (lifecycle: AgentJournalTurnLifecycle) => { + commit([turnItem({ ...lifecycle, state: 'running', outcome: undefined })], []) + commit([turnItem(lifecycle)], []) + } + } +} + +describe('a turn that settles while its child work is still live', () => { + it('holds the finish until the last subagent drains, then announces that turn once', () => { + const childWork: AgentChildWorkLivenessCandidate[] = [SUBAGENT] + const h = harness(childWork) + h.settle({ turnId: 'turn-1', state: 'completed', outcome: 'success' }) + expect(h.outcomes).toEqual([]) + + childWork[0] = { ...SUBAGENT, state: 'done' } + h.feed.observe('session-1') + h.feed.observe('session-1') + expect(h.outcomes).toEqual([['turn-1', 'success']]) + }) + + it('holds a coordinator waiting on workers through a background orca orchestration check --wait', () => { + const h = harness([{ ...DEV_SERVER, operation: { input: 'orca orchestration check --wait' } }]) + h.settle({ turnId: 'turn-1', state: 'completed', outcome: 'success' }) + expect(h.outcomes).toEqual([]) + }) + + it('does not hold a failed turn', () => { + const h = harness([SUBAGENT]) + h.settle({ turnId: 'turn-1', state: 'completed', outcome: 'failure' }) + expect(h.outcomes).toEqual([['turn-1', 'failure']]) + }) + + it('lets a dev server hold only a turn the agent started itself, with nothing steered into it', () => { + const user = harness([DEV_SERVER]) + user.settle({ turnId: 'turn-1', state: 'completed', outcome: 'success' }) + expect(user.outcomes).toEqual([['turn-1', 'success']]) + + const woken = harness([DEV_SERVER]) + woken.settle(selfStarted({ turnId: 'wake-1', state: 'completed', outcome: 'success' })) + expect(woken.outcomes).toEqual([]) + + const steered = harness([DEV_SERVER]) + const steer: AgentJournalSubmission = { + clientMessageId: 'm1', + fence: 1, + payloadFingerprint: 'm1', + providerItemId: null, + reason: null, + submittedAt: 10, + resolvedAt: 20, + handoverRecorded: true, + dispatchState: 'accepted', + answeredInTurn: { turnItemId: 'codex:turn:wake-1', via: 'steer' } + } + const message: AgentJournalRenderItem = { + itemId: agentJournalSubmissionKey('m1'), + revision: 0, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'm1' }] } + } + steered.commit( + [message, turnItem(selfStarted({ turnId: 'wake-1', state: 'running' }))], + [steer] + ) + steered.commit( + [ + message, + turnItem(selfStarted({ turnId: 'wake-1', state: 'completed', outcome: 'success' })) + ], + [steer] + ) + expect(steered.outcomes).toEqual([['wake-1', 'success']]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts index 37eef3ff846..7d7bec74fef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts @@ -100,7 +100,8 @@ function harness(): { const feed = new StructuredAgentSessionTurnCompletionFeed({ sessions, now: () => 1_700, - readStatusState: () => projectStructuredAgentSessionStatusState(items, submissions, fence) + readStatusState: () => projectStructuredAgentSessionStatusState(items, submissions, fence), + readChildWork: () => [] }) const events: AgentSessionTurnCompletionEvent[] = [] return { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts index 209ac349ff4..617e3c3d979 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts @@ -10,6 +10,14 @@ import type { AgentSessionAttentionEdge, StructuredAttentionState } from '../../../shared/agent-session/agent-session-attention' +import { + foldAgentLeadStatus, + heldTurnCountsAsFinished +} from '../../../shared/agent-lead-status-fold' +import { + agentChildWorkLiveness, + type AgentChildWorkLivenessCandidate +} from '../../../shared/agent-status-child-work-liveness' import type { StructuredAgentSessionLatestRequest } from '../../../shared/agent-session/requests/structured-agent-session-latest-request' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionStatusState } from './structured-agent-session-status-feed' @@ -38,6 +46,7 @@ export type StructuredAgentSessionTurnCompletionFeedDeps = { sessionId: string, journal?: AgentSessionJournal ) => StructuredAgentSessionStatusState | null + readChildWork: (sessionId: string) => readonly AgentChildWorkLivenessCandidate[] | undefined } type RequestMark = Pick @@ -173,7 +182,8 @@ export class StructuredAgentSessionTurnCompletionFeed { if ( state.owesWork || !request || - (baseline.settled?.kind === request.kind && baseline.settled.id === request.id) + (baseline.settled?.kind === request.kind && baseline.settled.id === request.id) || + this.heldByLiveWork(sessionId, state, request) ) { return null } @@ -193,6 +203,25 @@ export class StructuredAgentSessionTurnCompletionFeed { } } + /** A successful turn is finished only when the combined row would be done; a held request stays unaccounted so a later observe can announce it. */ + private heldByLiveWork( + sessionId: string, + state: StructuredAgentSessionStatusState, + request: StructuredAgentSessionLatestRequest + ): boolean { + if (request.outcome !== 'success' || state.summary.status === 'attention') { + return false + } + const held = foldAgentLeadStatus({ + leadState: 'done', + childWorkLiveness: agentChildWorkLiveness(this.deps.readChildWork(sessionId)) + }) + return ( + held.stateName !== 'done' && + !heldTurnCountsAsFinished(held, request.outcome, request.selfStarted === true) + ) + } + private broadcast( event: AgentSessionAttentionEdge, audience: 'all' | 'legacy' | 'prompt-aware' diff --git a/src/main/runtime/structured-agent-session-mobile-retirement.test.ts b/src/main/runtime/structured-agent-session-mobile-retirement.test.ts index 78084fcfbc8..467fa20c29d 100644 --- a/src/main/runtime/structured-agent-session-mobile-retirement.test.ts +++ b/src/main/runtime/structured-agent-session-mobile-retirement.test.ts @@ -114,6 +114,7 @@ describe('host prompt delivery and retirement', () => { ] ]), readStatusState: () => projectStructuredAgentSessionStatusState(items), + readChildWork: () => undefined, now: () => 42 }) feed.subscribe({ id: 'legacy', emit: (event) => legacy.push(event) }) diff --git a/src/main/runtime/terminal-agent-turn-news.ts b/src/main/runtime/terminal-agent-turn-news.ts index 6f1768261b4..632fca07f0d 100644 --- a/src/main/runtime/terminal-agent-turn-news.ts +++ b/src/main/runtime/terminal-agent-turn-news.ts @@ -1,5 +1,6 @@ // Share desktop completion/input timing and hook rows; phone/connector listeners choose wording and transport. import type { AgentPaneChange } from '../agent-hooks/agent-transition-detector' +import { mainAgentTurnInterrupted } from '../../shared/agent-lead-status-fold' import { agentTurnEndedOnPurpose } from '../../shared/agent-main-agent-verdict' import type { AgentStatusIpcPayload } from '../../shared/agent-status-ipc-payload' import { AGENT_DONE_QUIET_MS } from '../../shared/agent-transitions' @@ -23,6 +24,8 @@ export type TerminalAgentTurnNewsDeps = { stopped?: (news: TerminalAgentTurnNewsItem) => void /** Opt-in: a finish is news only once the pane's children settle too, not when its lead agent does. */ allClearOnly?: true + /** Opt-in: every turn the lead agent ends is news, even one the host holds back from notifications while children run. */ + everyLeadTurn?: true } type PendingFinish = { @@ -60,10 +63,11 @@ export class TerminalAgentTurnNews { const finished = transition === 'turn-finished' || transition === 'errored' const row = finished || transition === 'needs-input' ? this.deps.readHookRow(paneKey) : null // The all-clear of a turn announced when its lead agent finished: that finish still stands. + const leadTurnEndedAt = row ? this.leadTurnEndedAt(row) : undefined if ( finished && - row?.turnCompletedAt !== undefined && - this.finishedTurnByPane.get(paneKey) === row.turnCompletedAt + leadTurnEndedAt !== undefined && + this.finishedTurnByPane.get(paneKey) === leadTurnEndedAt ) { return } @@ -116,7 +120,7 @@ export class TerminalAgentTurnNews { if (!row || row.state !== 'working' || !worktreeId) { return } - const { turnCompletedAt } = row + const turnCompletedAt = this.leadTurnEndedAt(row) if (turnCompletedAt === undefined || this.deps.allClearOnly) { this.workingSeen.add(paneKey) return @@ -140,14 +144,26 @@ export class TerminalAgentTurnNews { private seed(paneKey: string): void { const row = this.deps.readHookRow(paneKey) // An all-clear listener never heard the lead's finish, so its stamp announces nothing. - if (row?.turnCompletedAt !== undefined && !this.deps.allClearOnly) { - this.finishedTurnByPane.set(paneKey, row.turnCompletedAt) + const leadTurnEndedAt = row ? this.leadTurnEndedAt(row) : undefined + if (leadTurnEndedAt !== undefined && !this.deps.allClearOnly) { + this.finishedTurnByPane.set(paneKey, leadTurnEndedAt) } if (row?.state === 'working') { this.workingSeen.add(paneKey) } } + /** The host's announce stamp; for every-lead-turn listeners, else the end of a lead turn its children still hold open. */ + private leadTurnEndedAt(row: AgentStatusIpcPayload): number | undefined { + if (row.turnCompletedAt !== undefined || !this.deps.everyLeadTurn) { + return row.turnCompletedAt + } + const lead = row.mainAgent + return lead?.state === 'done' && !mainAgentTurnInterrupted(lead) + ? lead.stateStartedAt + : undefined + } + private announce( paneKey: string, worktreeId: string, diff --git a/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts index b5fb688cf64..147b2d661bc 100644 --- a/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts @@ -155,41 +155,90 @@ describe('Claude background-turn completion notifications', () => { return { banners, rows } } - it('announces a subagent turn at Stop with that turn’s text, not when the child later drains', async () => { - const { banners, rows } = await play([ + // Real Claude 2.1.287 PTY hooks (see the fixture's scenarios): every background-subagent run announces once, after the wake-up turn that consumes the last result. + it.each([ + 'one-subagent', + 'two-subagents-together', + 'subagent-resumed', + 'subagent-with-own-shell', + 'subagent-idle-ctrl-c' + ])('announces the %s capture once, when everything has drained', async (scenario) => { + const records: { + scenario: string + t: number + kind: string + payload?: Record + }[] = ( + await readFile( + new URL( + '../../../shared/__fixtures__/claude-task-notification-hooks.jsonl', + import.meta.url + ), + 'utf8' + ) + ) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + const events = records.flatMap((record) => + record.scenario === scenario && record.kind === 'hook' && record.payload + ? [{ at: 1_700_000_000_000 + Math.round(record.t * 1000), payload: record.payload }] + : [] + ) + const lastStop = events.findLast((event) => event.payload.hook_event_name === 'Stop') + + const { banners } = await play(events) + + // A plain done waits out the quiet window before it is announced. + expect(banners.map((banner) => banner.at)).toEqual([(lastStop?.at ?? 0) + HOOK_DONE_QUIET_MS]) + }) + + it('announces a coordinator waiting on workers in the background once, at its final turn', async () => { + const wait = 'orca orchestration check --wait --types worker_done,escalation --json' + const waitFor = (id: string, at: number) => [ { - at: 1_700_000_000_000, - payload: { hook_event_name: 'UserPromptSubmit', prompt: 'review the PR' } - }, - { - at: 1_700_000_001_000, + at, payload: { - hook_event_name: 'SubagentStart', - agent_id: 'a1', - agent_type: 'general-purpose' + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: wait, run_in_background: true }, + tool_response: { backgroundTaskId: id } } }, { - at: 1_700_000_006_000, + at: at + 1_000, payload: { hook_event_name: 'Stop', - last_assistant_message: 'Which cells need hand-verification?', - background_tasks: [{ id: 'a1', type: 'subagent', status: 'running' }] + last_assistant_message: 'Waiting for workers.', + background_tasks: [{ id, type: 'shell', status: 'running', command: wait }] } - }, + } + ] + const wake = (id: string, at: number) => ({ + at, + payload: { + hook_event_name: 'UserPromptSubmit', + prompt: `\n${id}\ncompleted\n` + } + }) + + const { banners } = await play([ + { at: 1_700_000_000_000, payload: { hook_event_name: 'UserPromptSubmit', prompt: 'run it' } }, + ...waitFor('w1', 1_700_000_001_000), + wake('w1', 1_700_000_060_000), + ...waitFor('w2', 1_700_000_061_000), + wake('w2', 1_700_000_120_000), { - at: 1_700_000_055_000, - payload: { hook_event_name: 'SubagentStop', agent_id: 'a1' } + at: 1_700_000_121_000, + payload: { + hook_event_name: 'Stop', + last_assistant_message: 'All workers done.', + background_tasks: [] + } } ]) - const stop = rows.find((row) => row.lastAssistantMessage?.includes('hand-verification')) - expect(stop?.state).toBe('working') - expect(stop?.turnCompletedAt).toBe(1_700_000_006_000) - expect(banners).toHaveLength(1) - expect(banners[0]?.at).toBe(1_700_000_006_000) - expect(banners[0]?.body).toBe('Which cells need hand-verification?') - expect(banners[0]?.stateStartedAt).toBe(1_700_000_006_000) + expect(banners).toEqual([expect.objectContaining({ body: 'All workers done.' })]) }) it('notifies each background-shell turn with a distinct notification identity', async () => { @@ -234,7 +283,7 @@ describe('Claude background-turn completion notifications', () => { expect(banners[0]?.stateStartedAt).not.toBe(banners[1]?.stateStartedAt) }) - it('applies the provider retirement to visible rows and quit capture without announcing the parent twice', async () => { + it('applies the provider retirement to visible rows and quit capture, announcing the parent once it is done', async () => { const { observeAgentHookCompletionForNotification } = await import('./agent-hook-completion-notifications') actualStore = createTestStore() @@ -321,14 +370,14 @@ describe('Claude background-turn completion notifications', () => { } const before = store.getState().agentStatusByPaneKey[PANE_KEY]! const beforeEvent = current! - expect(beforeEvent.payload.turnCompletedAt).toBe(Date.parse(kill.timestamp) - 1_000) + expect(beforeEvent.payload.turnCompletedAt).toBeUndefined() expect(before.turnEndedAt).toBeUndefined() expect(before.turnEndDisposition).toBeUndefined() expect(before.subagents?.[0]?.id).toBe('a123') poller.schedule(current!) vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) await poller.poll(PANE_KEY) - expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() await appendFile(path, `${JSON.stringify(kill)}\n`) await poller.poll(PANE_KEY) vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) @@ -338,7 +387,6 @@ describe('Claude background-turn completion notifications', () => { expect(after.mainAgent).toEqual(before.mainAgent) expect(current?.hookEventName).toBe('SubagentStop') expect(current?.toolAgentId).toBe('a123') - expect(current?.payload.turnCompletedAt).toBe(beforeEvent.payload.turnCompletedAt) expect(after.turnEndedAt).toBeUndefined() expect(after.turnEndDisposition).toBeUndefined() expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) diff --git a/src/shared/agent-hook-listener-claude-subagents.test.ts b/src/shared/agent-hook-listener-claude-subagents.test.ts index c8911601c54..c7adc5f1e93 100644 --- a/src/shared/agent-hook-listener-claude-subagents.test.ts +++ b/src/shared/agent-hook-listener-claude-subagents.test.ts @@ -41,47 +41,6 @@ describe('shared agent-hook-listener', () => { expect(stop?.payload.turnCompletedAt).toBeUndefined() }) - it('stamps turnCompletedAt on a gated lead Stop and repeats it on the all-clear', () => { - vi.useFakeTimers() - try { - vi.setSystemTime(1_700_000_005_000) - claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'review the PR' }) - claudeEvent({ - hook_event_name: 'SubagentStart', - agent_id: 'a1', - agent_type: 'general-purpose' - }) - const stop = claudeEvent({ - hook_event_name: 'Stop', - last_assistant_message: 'Which cells need hand-verification?', - background_tasks: [ - { - id: 'a1', - type: 'subagent', - status: 'running', - description: 'Review loop', - agent_type: 'general-purpose' - } - ] - }) - expect(stop?.payload.state).toBe('working') - expect(stop?.payload.turnCompletedAt).toBe(1_700_000_005_000) - expect(stop?.payload.lastAssistantMessage).toBe('Which cells need hand-verification?') - - vi.setSystemTime(1_700_000_055_000) - const drained = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) - expect(drained?.payload.state).toBe('done') - expect(drained?.payload.turnCompletedAt).toBe(1_700_000_005_000) - - claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'next turn' }) - const nextStop = claudeEvent({ hook_event_name: 'Stop', background_tasks: [] }) - expect(nextStop?.payload.state).toBe('done') - expect(nextStop?.payload.turnCompletedAt).toBeUndefined() - } finally { - vi.useRealTimers() - } - }) - it('does not stamp an interrupted Stop even while a child still gates the pane', () => { claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'review the PR' }) claudeEvent({ @@ -717,14 +676,12 @@ describe('shared agent-hook-listener', () => { // The lead is done; answering resumes the child and holds working only while it runs. expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({ state: 'working', - turnCompletedAt: expect.any(Number), // The main agent's own fact rides beside the gated state: it finished, no verdict was given. mainAgent: { state: 'done', stateStartedAt: expect.any(Number) } }) expect(state.claudeLeadStateByPaneKey.get(PANE_KEY)).toEqual({ state: 'done', - stateStartedAt: expect.any(Number), - turnCompletedAt: expect.any(Number) + stateStartedAt: expect.any(Number) }) const drained = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) diff --git a/src/shared/agent-hook-listener-claude-task-notification.test.ts b/src/shared/agent-hook-listener-claude-task-notification.test.ts index 3bf28648462..48754bc6703 100644 --- a/src/shared/agent-hook-listener-claude-task-notification.test.ts +++ b/src/shared/agent-hook-listener-claude-task-notification.test.ts @@ -134,14 +134,8 @@ describe('Claude owed task notifications outside the captures', () => { // A stamped working row is announced at once; this Stop is ~100 ms from the wake-up turn. const owedOnly = post({ hook_event_name: 'Stop', background_tasks: [] }) + expect(owedOnly?.payload.state).toBe('working') expect(owedOnly?.payload.turnCompletedAt).toBeUndefined() - - launchAgent(post, 'a2') - const childRunning = post({ - hook_event_name: 'Stop', - background_tasks: [{ id: 'a2', type: 'subagent', status: 'running' }] - }) - expect(childRunning?.payload.turnCompletedAt).toEqual(expect.any(Number)) }) it('drops what a replaced conversation was owed', () => { diff --git a/src/shared/agent-hook-listener/providers/claude-events.ts b/src/shared/agent-hook-listener/providers/claude-events.ts index f5a35f37407..af041df65aa 100644 --- a/src/shared/agent-hook-listener/providers/claude-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-events.ts @@ -1,5 +1,8 @@ import type { ParsedAgentStatusPayload } from '../../agent-status-types' -import { isAgentStatusHeldOpenByChildWork } from '../../agent-lead-status-fold' +import { + heldTurnCountsAsFinished, + isAgentStatusHeldOpenByChildWork +} from '../../agent-lead-status-fold' import { isAskUserQuestionTool } from '../../agent-question-answered-intent' import { readClaudeBackgroundAgentTasks } from '../../claude-background-task-inventory' import { @@ -281,7 +284,8 @@ export function normalizeClaudeEvent( }) // Reuse resolved state for compact ownership instead of folding twice (#15202). const effectiveState = resolvedStatus.stateName - // Pin held turn identity by end time for later all-clear pairing; cancelled turns earn no completion stamp. + const ownPreviousLead = previousLead?.waitingAgentId ? previousLead.stateBeforeWait : previousLead + // Stamp (announce now, pair the later all-clear) only a held turn that already counts as finished; cancelled turns earn none. const turnCompletedAt = eventAgentId === undefined && isTurnBoundary && @@ -290,6 +294,7 @@ export function normalizeClaudeEvent( state: resolvedStatus.stateName, mainAgent: { state: reportedStateName } }) && + heldTurnCountsAsFinished(resolvedStatus, outcome, ownPreviousLead?.taskWakeupTurn === true) && // Owed notifications wake the lead within ~100ms; announcing first would duplicate completion. claudePaneHasRunningChildWork(state, paneKey) ? Date.now() diff --git a/src/shared/agent-hook-listener/providers/claude-pane-hold-evidence.ts b/src/shared/agent-hook-listener/providers/claude-pane-hold-evidence.ts index c9df6e9cbd3..3d3edd78e08 100644 --- a/src/shared/agent-hook-listener/providers/claude-pane-hold-evidence.ts +++ b/src/shared/agent-hook-listener/providers/claude-pane-hold-evidence.ts @@ -1,13 +1,16 @@ import { foldAgentLeadStatus, type AgentLeadStatusResolution } from '../../agent-lead-status-fold' import { agentChildWorkLivenessFromEvidence } from '../../agent-status-child-work-liveness' -import { claudeLiveOwedTaskNotificationKinds } from '../../claude-owed-task-notifications' +import { + claudeLiveOwedTaskNotificationKinds, + claudeTasksAwaitAgents +} from '../../claude-owed-task-notifications' import { claudeRosterHasWorkingSubagent } from '../../claude-subagent-roster' import type { ClaudeLeadTurnState, HookListenerState } from '../listener-state' import { mainAgentIdleSince } from '../main-agent-turn-state' /** Everything that can hold a pane whose main agent's own turn is over. */ type ClaudePaneHoldEvidence = { - /** A sub-agent the roster tracks as working. */ + /** A sub-agent the roster tracks as working, or a shell waiting on worker agents. */ runningAgent: boolean /** A running shell or a scheduled session cron. */ runningNonAgent: boolean @@ -24,13 +27,16 @@ export function claudePaneHoldEvidence( paneKey ) ): ClaudePaneHoldEvidence { + const launched = state.claudeLaunchedBackgroundTasksByPaneKey.get(paneKey) const owed = claudeLiveOwedTaskNotificationKinds( - state.claudeLaunchedBackgroundTasksByPaneKey.get(paneKey), + launched, mainAgent?.state === 'done', mainAgentIdleSince(state.claudeLeadStateByPaneKey.get(paneKey)) ) return { - runningAgent: claudeRosterHasWorkingSubagent(state.claudeSubagentRosterByPaneKey.get(paneKey)), + runningAgent: + claudeRosterHasWorkingSubagent(state.claudeSubagentRosterByPaneKey.get(paneKey)) || + claudeTasksAwaitAgents(launched), runningNonAgent: state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || state.claudeActiveSessionCronPaneKeys.has(paneKey), diff --git a/src/shared/agent-hook-listener/providers/claude-task-notification-delivery.ts b/src/shared/agent-hook-listener/providers/claude-task-notification-delivery.ts index 8451216b411..e064eafea5d 100644 --- a/src/shared/agent-hook-listener/providers/claude-task-notification-delivery.ts +++ b/src/shared/agent-hook-listener/providers/claude-task-notification-delivery.ts @@ -23,7 +23,7 @@ export function trackClaudeTaskNotificationDelivery( if (eventName === 'PostToolUse') { const toolName = readString(hookPayload, 'tool_name') const response = hookPayload['tool_response'] - const launch = readClaudeBackgroundTaskLaunch(toolName, response) + const launch = readClaudeBackgroundTaskLaunch(toolName, response, hookPayload['tool_input']) if (launch) { const launched = tasks ?? new Map() state.claudeLaunchedBackgroundTasksByPaneKey.set(paneKey, launched) @@ -57,8 +57,22 @@ export function trackClaudeTaskNotificationDelivery( settleClaudeTaskNotification(tasks, notification.taskId) } return knownTask || continuesWakeupTurn - } else if (tasks && (eventName === 'Stop' || eventName === 'StopFailure') && inventory.present) { - oweClaudeShellTaskNotifications(tasks, new Set(inventory.runningNonAgentTaskIds), Date.now()) + } else if ((eventName === 'Stop' || eventName === 'StopFailure') && inventory.present) { + if (inventory.runningAgentWaitIds.length > 0) { + tasks ??= new Map() + state.claudeLaunchedBackgroundTasksByPaneKey.set(paneKey, tasks) + for (const id of inventory.runningAgentWaitIds) { + recordClaudeBackgroundTaskLaunch(tasks, { + id, + kind: 'shell', + awaitsAgents: true, + listedOnly: true + }) + } + } + if (tasks) { + oweClaudeShellTaskNotifications(tasks, new Set(inventory.runningNonAgentTaskIds), Date.now()) + } } return false } diff --git a/src/shared/agent-lead-status-fold.ts b/src/shared/agent-lead-status-fold.ts index 7b1a80a4646..deb4ded3ee1 100644 --- a/src/shared/agent-lead-status-fold.ts +++ b/src/shared/agent-lead-status-fold.ts @@ -40,6 +40,15 @@ export function foldAgentLeadStatus(input: AgentLeadStatusFoldInput): AgentLeadS return { stateName: 'done' } } +/** A turn held open by live work still counts as finished when it failed, or when only monitoring holds a turn the user started; agent work holds it until the row reaches done, and a turn the agent started itself stays held by a monitor that will wake it again. */ +export function heldTurnCountsAsFinished( + resolution: AgentLeadStatusResolution, + outcome: AgentMainAgentStatus['outcome'], + selfStarted: boolean +): boolean { + return outcome === 'failure' || (resolution.workingMode === 'monitoring' && !selfStarted) +} + /** Derive child-held status from lead and combined states; storing it could contradict either fact. */ export function isAgentStatusHeldOpenByChildWork(row: { state: AgentStatusState diff --git a/src/shared/agent-session/requests/structured-agent-session-latest-request.ts b/src/shared/agent-session/requests/structured-agent-session-latest-request.ts index c6c8ac48809..1b3dafe3dfb 100644 --- a/src/shared/agent-session/requests/structured-agent-session-latest-request.ts +++ b/src/shared/agent-session/requests/structured-agent-session-latest-request.ts @@ -33,6 +33,8 @@ export type StructuredAgentSessionLatestRequest = { outcome: AgentJournalTurnOutcome | null /** When it settled: the turn's end, or the refusal. Undefined while it runs. */ settledAt: number | undefined + /** The agent opened this turn itself (e.g. woken by a background task) and the user steered nothing into it. */ + selfStarted?: true } /** Commands and steers have no independent request; their turns own any response. */ @@ -78,7 +80,10 @@ export function latestStructuredAgentSessionRequest( id: turn.turnId, turnState: turn.state, outcome: readAgentJournalTurnOutcome(turn), - settledAt: turn.state === 'running' ? undefined : turnEndedAt(item, turn) + settledAt: turn.state === 'running' ? undefined : turnEndedAt(item, turn), + ...(turn.userItemId === item.itemId && !steeredInto(submissions, item.itemId) + ? { selfStarted: true as const } + : {}) } } const submission = rejected.get(item.itemId) @@ -100,6 +105,14 @@ export function latestStructuredAgentSessionRequest( return null } +/** A user message steered into a turn the agent started makes it the user's turn too. */ +function steeredInto(submissions: readonly AgentJournalSubmission[], turnItemId: string): boolean { + return submissions.some( + ({ answeredInTurn }) => + answeredInTurn?.via === 'steer' && answeredInTurn.turnItemId === turnItemId + ) +} + /** Exclude sends that failed nobody and never became turns; include provider-journaled user rows, even from children/history/old hosts. */ export function hasStructuredAgentSessionRequest( items: readonly AgentJournalRenderItem[], diff --git a/src/shared/agent-status-child-work-liveness.ts b/src/shared/agent-status-child-work-liveness.ts index 9f7c3fff24b..d16cebada2c 100644 --- a/src/shared/agent-status-child-work-liveness.ts +++ b/src/shared/agent-status-child-work-liveness.ts @@ -1,3 +1,4 @@ +import { ORCA_ORCHESTRATION_WAIT_COMMAND } from './orca-cli-invocations' import type { AgentChildWorkKind, AgentChildWorkMembership, @@ -10,6 +11,8 @@ export type AgentChildWorkLiveness = 'waiting' | 'working' | 'monitoring' | null export type AgentChildWorkLivenessCandidate = { kind: AgentChildWorkKind state?: AgentChildWorkState + /** What the child runs; a producer marks a worker wait with the canonical wait command, which is agent work. */ + operation?: { input?: string } | null } export type AgentChildWorkLivenessEvidence = { @@ -24,6 +27,12 @@ export function isAgentChildWorkKind(kind: AgentChildWorkKind): boolean { return kind === 'agent' } +function isAgentChildWork(child: AgentChildWorkLivenessCandidate): boolean { + return ( + isAgentChildWorkKind(child.kind) || child.operation?.input === ORCA_ORCHESTRATION_WAIT_COMMAND + ) +} + /** Only explicit settlement retires work: missing states, unknown kinds and lost contact stay active. Roster lifetime bounds this fail-active policy (session close; Claude provider ended); in-place blocked failures would remain working until then. */ export function isLiveChildWork(child: AgentChildWorkLivenessCandidate): boolean { return child.state !== 'done' && child.state !== 'idle' @@ -57,8 +66,9 @@ export function agentChildWorkLiveness( continue } hasWaitingChildWork ||= isWaitingChildWork(child) - hasLiveAgentWork ||= isAgentChildWorkKind(child.kind) - hasLiveNonAgentWork ||= !isAgentChildWorkKind(child.kind) + const agentWork = isAgentChildWork(child) + hasLiveAgentWork ||= agentWork + hasLiveNonAgentWork ||= !agentWork } return agentChildWorkLivenessFromEvidence({ hasWaitingChildWork, diff --git a/src/shared/claude-background-task-inventory.ts b/src/shared/claude-background-task-inventory.ts index 498fdb58f50..f6629425122 100644 --- a/src/shared/claude-background-task-inventory.ts +++ b/src/shared/claude-background-task-inventory.ts @@ -1,5 +1,6 @@ import { classifyClaudeBackgroundTaskKind } from './claude-background-task-kind' import { isAgentChildWorkKind } from './agent-status-child-work-liveness' +import { runsOrcaOrchestrationWait } from './orca-cli-invocations' const CLAUDE_TERMINAL_BACKGROUND_TASK_STATUSES = new Set([ 'idle', @@ -41,6 +42,8 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record 0) { runningNonAgentTaskIds.push(id) + if (runsOrcaOrchestrationWait(obj.command)) { + runningAgentWaitIds.push(id) + } } } if (!isAgentTask) { @@ -96,5 +104,12 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record { expect(typeof monitoring?.turnCompletedAt).toBe('number') }) - it('stamps a lead that ends into a working subagent, which carries no monitoring mode', () => { + it('holds the finish of a lead that ends into a working subagent, unless the lead failed', () => { const state = createHookListenerState() claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) @@ -199,10 +199,79 @@ describe('Claude background task status', () => { hook_event_name: 'Stop', background_tasks: [{ id: 'child-1', type: 'subagent', status: 'running' }] }) - - // Child work keeps the spinner, but the ended lead still needs a notification stamp. expect(childWorking).toMatchObject({ state: 'working', workingMode: undefined }) - expect(typeof childWorking?.turnCompletedAt).toBe('number') + expect(childWorking?.turnCompletedAt).toBeUndefined() + + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'again' }) + const failed = claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'StopFailure', + background_tasks: [{ id: 'child-1', type: 'subagent', status: 'running' }] + }) + expect(failed).toMatchObject({ state: 'working', workingMode: undefined }) + expect(typeof failed?.turnCompletedAt).toBe('number') + }) + + it('holds a coordinator waiting on workers in the background, even across a listener restart', () => { + const wait = { + id: 'wait-1', + type: 'shell', + status: 'running', + description: 'Wait for next worker message', + command: + '/usr/local/bin/orca orchestration check --wait --types worker_done --json 2>/dev/null' + } + const launched = createHookListenerState() + claudeEvent(launched, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'run' }) + claudeEvent(launched, SOURCE_PANE, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: wait.command, run_in_background: true }, + tool_response: { backgroundTaskId: 'wait-1' } + }) + // Only the dev server is still listed; the ended wait's notification is still owed. + const owed = claudeEvent(launched, SOURCE_PANE, { + hook_event_name: 'Stop', + background_tasks: [RUNNING_SHELL] + }) + expect(owed).toMatchObject({ state: 'working', workingMode: undefined }) + expect(owed?.turnCompletedAt).toBeUndefined() + + const restarted = createHookListenerState() + claudeEvent(restarted, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'run' }) + const listed = claudeEvent(restarted, SOURCE_PANE, { + hook_event_name: 'Stop', + background_tasks: [wait] + }) + expect(listed).toMatchObject({ state: 'working', workingMode: undefined }) + expect(listed?.turnCompletedAt).toBeUndefined() + // Only listed, it may be a subagent's wait, whose result never reaches this agent. + expect( + claudeEvent(restarted, SOURCE_PANE, { hook_event_name: 'Stop', background_tasks: [] })?.state + ).toBe('done') + }) + + it('does not stamp a wake-up turn that ends with only a monitor that will wake it again', () => { + const state = createHookListenerState() + + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'watch' }) + claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'sleep 15', run_in_background: true }, + tool_response: { backgroundTaskId: RUNNING_SHELL.id } + }) + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }) + claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'UserPromptSubmit', + prompt: `\n${RUNNING_SHELL.id}\ntick` + }) + const wakeEnd = claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'Stop', + background_tasks: [RUNNING_SHELL] + }) + + expect(wakeEnd).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + expect(wakeEnd?.turnCompletedAt).toBeUndefined() }) it('does not stamp a turn that is still running in the foreground', () => { diff --git a/src/shared/claude-owed-task-notifications.ts b/src/shared/claude-owed-task-notifications.ts index 5b06ac98b5c..e8481309d5a 100644 --- a/src/shared/claude-owed-task-notifications.ts +++ b/src/shared/claude-owed-task-notifications.ts @@ -1,3 +1,6 @@ +import { isRecord } from './agent-status-child-work-value-guards' +import { runsOrcaOrchestrationWait } from './orca-cli-invocations' + /** Main-agent background tasks keyed by launch/notification task ID. Notifications can arrive a turn after task end and an empty Stop; owed work keeps the pane active (#23942). Captures: __fixtures__/claude-task-notification-hooks.jsonl. */ export type ClaudeLaunchedBackgroundTasks = Map @@ -11,6 +14,10 @@ type ClaudeLaunchedBackgroundTask = { settled?: true /** A delivered notification, distinct from a lease that merely gave up waiting. */ notificationDelivered?: true + /** A shell blocked on worker agents (`orca orchestration check --wait`): agent work, not monitoring. */ + awaitsAgents?: true + /** Known only from the main agent's task list (e.g. after a listener restart): a subagent may own it, so it owes the main agent nothing. */ + listedOnly?: true } /** An idle notification lease is expectation, not proof: Stop never promises an empty queue and posts can be lost/reordered. Captured deliveries took 40–130ms; Claude idle_prompt consistently fired after 60s. */ @@ -22,8 +29,13 @@ const CLAUDE_LAUNCHED_BACKGROUND_TASK_LIMIT = 256 /** The task id a main-agent PostToolUse reports for work it left running in the background. */ export function readClaudeBackgroundTaskLaunch( toolName: string | undefined, - toolResponse: unknown -): { id: string; kind: ClaudeLaunchedBackgroundTask['kind'] } | null { + toolResponse: unknown, + toolInput?: unknown +): + | (Pick & { + id: string + }) + | null { if (typeof toolResponse !== 'object' || toolResponse === null) { return null } @@ -37,22 +49,40 @@ export function readClaudeBackgroundTaskLaunch( const rawShellId = response.backgroundTaskId ?? (toolName === 'Monitor' ? response.taskId : undefined) const shellId = typeof rawShellId === 'string' ? rawShellId.trim() : '' - return shellId.length > 0 ? { id: shellId, kind: 'shell' } : null + if (shellId.length === 0) { + return null + } + const command = isRecord(toolInput) ? toolInput.command : undefined + return { + id: shellId, + kind: 'shell', + ...(runsOrcaOrchestrationWait(command) ? { awaitsAgents: true as const } : {}) + } } export function recordClaudeBackgroundTaskLaunch( tasks: ClaudeLaunchedBackgroundTasks, - launch: { id: string; kind: ClaudeLaunchedBackgroundTask['kind'] } + launch: Pick & { + id: string + } ): void { const previous = tasks.get(launch.id) if (previous) { if (previous.kind === launch.kind) { - previous.launchUnconfirmed = undefined + previous.awaitsAgents ||= launch.awaitsAgents + if (!launch.listedOnly) { + previous.launchUnconfirmed = undefined + previous.listedOnly = undefined + } } return } if (makeClaudeTaskRecordRoom(tasks)) { - tasks.set(launch.id, { kind: launch.kind }) + tasks.set(launch.id, { + kind: launch.kind, + ...(launch.awaitsAgents ? { awaitsAgents: true as const } : {}), + ...(launch.listedOnly ? { listedOnly: true as const } : {}) + }) } } @@ -84,7 +114,11 @@ export function recordClaudeUnconfirmedAgentEnd( now: number ): void { if (!tasks.has(agentId) && makeClaudeTaskRecordRoom(tasks)) { - tasks.set(agentId, { kind: 'agent', launchUnconfirmed: true, notificationOwedAt: now }) + tasks.set(agentId, { + kind: 'agent', + launchUnconfirmed: true, + notificationOwedAt: now + }) } } @@ -132,7 +166,11 @@ export function oweClaudeShellTaskNotifications( ): void { for (const [id, task] of tasks) { if (task.kind === 'shell' && !runningShellIds.has(id)) { - task.notificationOwedAt ??= now + if (task.listedOnly) { + tasks.delete(id) + } else { + task.notificationOwedAt ??= now + } } } } @@ -229,12 +267,22 @@ function claudeOwedTaskNotificationKinds(tasks: ClaudeLaunchedBackgroundTasks | const owed = { agent: false, shell: false } for (const task of tasks?.values() ?? []) { if (task.launchUnconfirmed !== true && task.notificationOwedAt !== undefined) { - owed[task.kind] = true + owed[task.awaitsAgents ? 'agent' : task.kind] = true } } return owed } +/** A recorded worker wait lives from launch until its notification is delivered, stopped or given up on. */ +export function claudeTasksAwaitAgents(tasks: ClaudeLaunchedBackgroundTasks | undefined): boolean { + for (const task of tasks?.values() ?? []) { + if (task.awaitsAgents) { + return true + } + } + return false +} + /** Return owed kinds after expiring idle debt; undefined mainAgentIdleSince means this event is the idle boundary. */ export function claudeLiveOwedTaskNotificationKinds( tasks: ClaudeLaunchedBackgroundTasks | undefined, diff --git a/src/shared/orca-browser-cli-commands.ts b/src/shared/orca-browser-cli-commands.ts index dd3cd281a52..1cae56cadcb 100644 --- a/src/shared/orca-browser-cli-commands.ts +++ b/src/shared/orca-browser-cli-commands.ts @@ -1,5 +1,4 @@ -import { COMMAND_TOKEN_SCAN_MAX_CHARS, getCommandTokenPathBasename } from './command-token-scanner' -import { tokenizeCommandLine } from './agent-command-line-entrypoint' +import { orcaCliInvocations } from './orca-cli-invocations' /** The browser CLI's command keys per handler group. Kept apart from the CLI's * lazy handler imports so the renderer and mobile can read them too. */ @@ -103,34 +102,15 @@ const BROWSER_COMMAND_WORDS = new Set( ) ) -// The published launcher names per platform, plus the dev build's. -const ORCA_CLI_BASENAME = /^orca(?:-dev|-ide)?(?:\.cmd|\.exe)?$/i -const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/ - export type OrcaBrowserCommand = { pageId: string | null } /** The first `orca ` a shell command line runs, with the page it * targets, or null when it runs none. Quoted text is an argument, never a command. */ export function parseOrcaBrowserCommand(command: string): OrcaBrowserCommand | null { - const scanned = command.slice(0, COMMAND_TOKEN_SCAN_MAX_CHARS) - for (const segment of splitUnquotedSegments(scanned)) { - const tokens = tokenizeCommandLine(segment) - let index = 0 - while (index < tokens.length && ENV_ASSIGNMENT.test(tokens[index])) { - index += 1 - } - const binary = tokens[index] - const word = tokens[index + 1] - if ( - binary !== undefined && - word !== undefined && - ORCA_CLI_BASENAME.test(getCommandTokenPathBasename(binary)) && - BROWSER_COMMAND_WORDS.has(word) - ) { - return { pageId: pageFlagValue(tokens.slice(index + 2)) } - } - } - return null + const args = orcaCliInvocations(command).find((invocation) => + BROWSER_COMMAND_WORDS.has(invocation[0] ?? '') + ) + return args ? { pageId: pageFlagValue(args.slice(1)) } : null } function pageFlagValue(args: readonly string[]): string | null { @@ -145,25 +125,3 @@ function pageFlagValue(args: readonly string[]): string | null { } return null } - -/** Splits a command line at `&&`, `||`, `;`, `|` and newlines outside quotes. */ -function splitUnquotedSegments(commandLine: string): string[] { - const segments: string[] = [] - let current = '' - let quote: '"' | "'" | null = null - for (let index = 0; index < commandLine.length; index += 1) { - const char = commandLine[index] - if (quote === null && (char === '"' || char === "'")) { - quote = char - } else if (quote === char) { - quote = null - } else if (quote === null && (char === ';' || char === '|' || char === '&' || char === '\n')) { - segments.push(current) - current = '' - continue - } - current += char - } - segments.push(current) - return segments -} diff --git a/src/shared/orca-cli-invocations.ts b/src/shared/orca-cli-invocations.ts new file mode 100644 index 00000000000..64c7b65b043 --- /dev/null +++ b/src/shared/orca-cli-invocations.ts @@ -0,0 +1,59 @@ +import { COMMAND_TOKEN_SCAN_MAX_CHARS, getCommandTokenPathBasename } from './command-token-scanner' +import { tokenizeCommandLine } from './agent-command-line-entrypoint' + +// The published launcher names per platform, plus the dev build's. +const ORCA_CLI_BASENAME = /^orca(?:-dev|-ide)?(?:\.cmd|\.exe)?$/i +const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/ + +/** The arguments of each `orca ...` a shell command line runs, in order. Quoted text is an argument, never a command. */ +export function orcaCliInvocations(command: string): string[][] { + const invocations: string[][] = [] + for (const segment of splitUnquotedSegments(command.slice(0, COMMAND_TOKEN_SCAN_MAX_CHARS))) { + const tokens = tokenizeCommandLine(segment) + let index = 0 + while (index < tokens.length && ENV_ASSIGNMENT.test(tokens[index])) { + index += 1 + } + const binary = tokens[index] + if (binary !== undefined && ORCA_CLI_BASENAME.test(getCommandTokenPathBasename(binary))) { + invocations.push(tokens.slice(index + 1)) + } + } + return invocations +} + +/** Canonical form a child row carries for a worker wait; raw commands outgrow the operation preview. */ +export const ORCA_ORCHESTRATION_WAIT_COMMAND = 'orca orchestration check --wait' + +/** A command that blocks on `orca orchestration check --wait` is waiting on worker agents, not monitoring. */ +export function runsOrcaOrchestrationWait(command: unknown): boolean { + return ( + typeof command === 'string' && + command.includes('orchestration') && + orcaCliInvocations(command).some( + (args) => args[0] === 'orchestration' && args[1] === 'check' && args.includes('--wait') + ) + ) +} + +/** Splits a command line at `&&`, `||`, `;`, `|` and newlines outside quotes. */ +function splitUnquotedSegments(commandLine: string): string[] { + const segments: string[] = [] + let current = '' + let quote: '"' | "'" | null = null + for (let index = 0; index < commandLine.length; index += 1) { + const char = commandLine[index] + if (quote === null && (char === '"' || char === "'")) { + quote = char + } else if (quote === char) { + quote = null + } else if (quote === null && (char === ';' || char === '|' || char === '&' || char === '\n')) { + segments.push(current) + current = '' + continue + } + current += char + } + segments.push(current) + return segments +} diff --git a/src/shared/program-status/agent-status-projector.test.ts b/src/shared/program-status/agent-status-projector.test.ts index b043fe3659f..ca2e7515a2c 100644 --- a/src/shared/program-status/agent-status-projector.test.ts +++ b/src/shared/program-status/agent-status-projector.test.ts @@ -123,39 +123,47 @@ describe('createProgramStatusProjector', () => { expect(cleared.subagents).toBeUndefined() }) - it("keeps a finished turn working while a child runs, stamping the turn's own end for both rows", () => { + it('keeps a finished turn working while a child runs, leaving it to the all-clear to announce', () => { const { feed, tick } = projector() feed(osc('state=working:app=pi') + osc('state=working:id=subagents/run1:app=pi-subagents')) tick(10) - expect(feed(osc('state=done:app=pi'))).toMatchObject([ - { - state: 'working', - turnCompletedAt: 1010, - mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 1010 }, - subagents: [{ id: 'subagents/run1', state: 'working' }] - } - ]) + const [held] = feed(osc('state=done:app=pi')) + expect(held).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 1010 }, + subagents: [{ id: 'subagents/run1', state: 'working' }] + }) + expect(held.turnCompletedAt).toBeUndefined() tick(10) const [settled] = feed(osc('state=done:id=subagents/run1:app=pi-subagents')) expect(settled).toMatchObject({ state: 'done', - turnCompletedAt: 1010, mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 1010 } }) + expect(settled.turnCompletedAt).toBeUndefined() expect(settled.subagents).toBeUndefined() }) - it("lapses a hold no child refreshed to the lead's done, keeping its completion stamp", () => { + it("stamps a failed turn's own end while a child still runs, for both rows", () => { + const { feed, tick } = projector() + feed(osc('state=working:app=pi') + osc('state=working:id=subagents/run1:app=pi-subagents')) + tick(10) + expect(feed(osc('state=error:app=pi'))).toMatchObject([ + { state: 'working', turnCompletedAt: 1010, mainAgent: { state: 'done', outcome: 'failure' } } + ]) + tick(10) + expect(feed(osc('state=done:id=subagents/run1:app=pi-subagents'))).toMatchObject([ + { state: 'done', turnCompletedAt: 1010 } + ]) + }) + + it("lapses a hold no child refreshed to the lead's done, keeping its verdict", () => { const { feed } = projector() feed(osc('state=working:app=pi') + osc('state=working:id=subagents/run1')) const [held] = feed(osc('state=done:app=pi')) expect(lapsedProgramStatusChildHold(held, 0, 999, 1000)).toBeNull() const lapsed = lapsedProgramStatusChildHold(held, 0, 1000, 1000) - expect(lapsed).toMatchObject({ - state: 'done', - turnCompletedAt: held.turnCompletedAt, - mainAgent: held.mainAgent - }) + expect(lapsed).toMatchObject({ state: 'done', mainAgent: held.mainAgent }) expect(lapsed?.subagents).toBeUndefined() const [settled] = feed(osc('state=done:id=subagents/run1')) expect(lapsedProgramStatusChildHold(settled, 0, 1000, 1000)).toBeNull() diff --git a/src/shared/program-status/agent-status-projector.ts b/src/shared/program-status/agent-status-projector.ts index 0f810d73748..2902c3005e4 100644 --- a/src/shared/program-status/agent-status-projector.ts +++ b/src/shared/program-status/agent-status-projector.ts @@ -4,7 +4,10 @@ import type { AgentSubagentSnapshot, AgentSubagentState } from '../agent-status- import { AGENT_SUBAGENT_ID_MAX_LENGTH } from '../agent-status-subagent-snapshot' import type { AgentTurnOutcome } from '../agent-turn-outcome' import { foldMainAgentWithRowChildWork } from '../agent-hook-row-child-work-fold' -import { isAgentStatusHeldOpenByChildWork } from '../agent-lead-status-fold' +import { + heldTurnCountsAsFinished, + isAgentStatusHeldOpenByChildWork +} from '../agent-lead-status-fold' import { createProgramStatusOscScanner } from './osc-scanner' import { programStatusDisplayText, @@ -210,13 +213,15 @@ export function createProgramStatusProjector(now: () => number = Date.now): Prog const msg = programStatusDisplayText(root?.msg) const subagents = snapshotChildren() // Why: async children outlive a finished turn (pi-subagents); failed/idle children and boundaries hold nothing. - const state = + const held = projection.state === 'done' && projection.sessionBoundary !== true ? foldMainAgentWithRowChildWork('done', { payload: { subagents: subagents.filter(isRowHoldingChild) } - }).stateName - : projection.state - rootDoneHeldOpen ||= projection.state === 'done' && state !== 'done' + }) + : null + const state = held?.stateName ?? projection.state + rootDoneHeldOpen ||= + held !== null && state !== 'done' && heldTurnCountsAsFinished(held, projection.outcome, false) const payload: ParsedAgentStatusPayload = { state, prompt: '', @@ -228,7 +233,7 @@ export function createProgramStatusProjector(now: () => number = Date.now): Prog : {}), ...(projection.sessionBoundary ? { sessionBoundary: true } : {}), ...(subagents.length > 0 ? { subagents } : {}), - // Hook rows announce a held turn's own done now; the all-clear restates it so it is not announced twice. + // A held turn that counts as finished is announced now; the all-clear restates it so it is not announced twice. ...(rootDoneHeldOpen ? { turnCompletedAt: rootStateStartedAt } : {}), mainAgent: { state: projection.state, diff --git a/tests/e2e/cross-version-wire/cross-version-structured-attention-frames.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-structured-attention-frames.unit.test.ts index f2c084814d2..4584f4364f5 100644 --- a/tests/e2e/cross-version-wire/cross-version-structured-attention-frames.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-structured-attention-frames.unit.test.ts @@ -141,7 +141,8 @@ async function framesFor( const feed = new host.StructuredAgentSessionTurnCompletionFeed({ sessions, now: () => 10, - readStatusState: () => host.projectStructuredAgentSessionStatusState(items, []) + readStatusState: () => host.projectStructuredAgentSessionStatusState(items, []), + readChildWork: () => undefined }) const hostCalls = structuredHostStub(SESSION, WORKSPACE) hostCalls.subscribeTurnCompletions = vi.fn((subscriber: unknown) => feed.subscribe(subscriber)) diff --git a/tests/e2e/structured-attention-host-read-retirement.unit.test.ts b/tests/e2e/structured-attention-host-read-retirement.unit.test.ts index 49aea0cf6f5..74dd2e7c63e 100644 --- a/tests/e2e/structured-attention-host-read-retirement.unit.test.ts +++ b/tests/e2e/structured-attention-host-read-retirement.unit.test.ts @@ -112,6 +112,7 @@ function attentionPath(directory: string) { ] ]), readStatusState: () => projectStructuredAgentSessionStatusState(items), + readChildWork: () => undefined, now: () => 42 }) feed.subscribe({ diff --git a/tests/e2e/structured-attention-read-retirement.test-fixture.tsx b/tests/e2e/structured-attention-read-retirement.test-fixture.tsx index e9323f86b51..4535f9698e6 100644 --- a/tests/e2e/structured-attention-read-retirement.test-fixture.tsx +++ b/tests/e2e/structured-attention-read-retirement.test-fixture.tsx @@ -228,6 +228,7 @@ beforeEach(() => { ] ]), readStatusState: () => projectStructuredAgentSessionStatusState(fixture.items), + readChildWork: () => undefined, now: hostClock }) fixture = { diff --git a/tests/e2e/structured-attention-read-retirement.unit.test.ts b/tests/e2e/structured-attention-read-retirement.unit.test.ts index 18ce71c3f61..fe749e1455c 100644 --- a/tests/e2e/structured-attention-read-retirement.unit.test.ts +++ b/tests/e2e/structured-attention-read-retirement.unit.test.ts @@ -179,6 +179,7 @@ describe('Explicit desktop read acknowledgements', () => { ] ]), readStatusState: () => projectStructuredAgentSessionStatusState(otherItems), + readChildWork: () => undefined, now: () => 42 }) const delivery = createStructuredAttentionMobileDelivery({