diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 59027b381250..4dff79d6666a 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -94,16 +94,16 @@ The structured feed keeps its job of projecting a session's journal into a summary and streaming it to subscribers. On every publish it additionally ingests the summary into the hook server as a status row: -| Row field | From | -| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `paneKey` | `structuredAgentSessionPaneKey(tabId, sessionId)`, the key the renderer already uses; its leaf is UUID-shaped so pane-key validation accepts it | -| `tabId` | `structuredAgentSessionTabId(sessionId)` | -| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) | -| `state` | `structuredAgentSessionAgentStatus(summary).state`: the lead's own status folded with its live `backgroundTasks`, so a settled lead whose subagent still runs reads `working` | -| `workingMode` | `'monitoring'` from the same fold when watch loops are the only live child work; omitted otherwise, which clears it on the row | -| `mainAgent` | the main agent's own state before the fold, its last-turn verdict (`summary.turnOutcome`, present only while idle) and its own clock; see "The main agent fact" below | -| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it | -| prompt, tool, last message, model, provider session | the summary's fields | +| Row field | From | +| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `paneKey` | `structuredAgentSessionPaneKey(tabId, sessionId)`, the key the renderer already uses; its leaf is UUID-shaped so pane-key validation accepts it | +| `tabId` | `structuredAgentSessionTabId(sessionId)` | +| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) | +| `state` | `structuredAgentSessionAgentStatus(summary).state`: the lead's own status folded with its live `backgroundTasks`, so a settled lead whose subagent still runs reads `working` | +| `workingMode` | `'monitoring'` from the same fold when watch loops are the only live child work; omitted otherwise, which clears it on the row | +| `mainAgent` | the main agent's own state before the fold, its last-turn verdict (`summary.turnOutcome`, present only while idle) and its own clock; see "The main agent fact" below | +| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it | +| prompt, tool, last message, model, provider session | the summary's fields | Sessions with no persisted turn (`status === null`) produce no row, matching what the chat shows. When the host revokes live ownership the row is re-set @@ -243,9 +243,6 @@ divergences, pinned by name in the parity table (`src/shared/main-agent-status-parity.test.ts`) where they are reachable, so a reader does not mistake them for drift: -- A cancelled turn with a still-running shell reads `done` in the hook lane - and `monitoring` in the structured lane; the cancel policy that removes it - flips that row. - The Claude hook lane holds a child's permission wait in one slot on the displaced main agent record (`waitingAgentId`, `stateBeforeWait`), not on the child. It publishes the displaced state as `mainAgent`, but the next @@ -258,6 +255,36 @@ reader does not mistake them for drift: child transcripts, so a still-running or still-asking child stops holding the row. +How the main agent's turn ended is not a fold input. A cancel is a verdict on +the main agent, carried as `mainAgent.outcome: 'cancellation'` (and, for +readers that predate `mainAgent`, as the row's `interrupted` flag on a `done` +row); it never retires a shell, scheduled check or subagent the turn left +running. That work leaves the row only when its own inventory omits it or the +session ends, so a cancelled turn with a still-running shell reads +`monitoring` in every lane, and the parity table in +`src/shared/main-agent-status-parity.test.ts` drives that story through all of +them. The same rule governs the cancel Orca infers from Ctrl+C: for any row +that publishes `mainAgent`, the inference is admitted only when +`mainAgent.state` is `working`, so Orca does not treat a Ctrl+C at the idle +prompt of a row held open by child work as a turn cancel (Codex also keeps the +child-evidence guard, and a row without `mainAgent` keeps only that guard). +The keypress itself is not inert, though: measured live, Claude 2.1.280 stops +its background subagents on a single idle-prompt Ctrl+C (shells survive) and +Codex 0.156.1 quits outright, so refusing the inference can leave the row +showing a subagent its CLI already stopped. The synthesized row is the fold +of the cancelled main agent with the child work the pane's owner can see: the +local listener's roster for a local pane, the row's own subagents and shell fact +for a relayed one, whose provider records live on the relay. + +The store holds that verdict against restatements that predate it +(`server-cancel-verdict-latch.ts`), because a relay never learns of a cancel +the desktop infers and some TUIs emit late same-turn hooks. The hold is read +off the row (`mainAgent.outcome: 'cancellation'`), never stored beside it, and +dies on a new turn (a main agent prompt submission, a changed or explicit +prompt, a session start) or the provider's own settled `mainAgent`. Child and +replayed events under the hold keep the cancelled main agent and are re-folded +with their own child evidence. + ## PR 1b: the runtime's retained row store is deleted Landed. `RuntimeAgentRowStore` is gone, and with it the retained-versus-hook diff --git a/src/main/agent-hooks/claude-cancel-capture.test-fixture.ts b/src/main/agent-hooks/claude-cancel-capture.test-fixture.ts new file mode 100644 index 000000000000..839145a352c2 --- /dev/null +++ b/src/main/agent-hooks/claude-cancel-capture.test-fixture.ts @@ -0,0 +1,69 @@ +// Loads the Claude Code 2.1.280 cancel captures (src/shared/__fixtures__/claude-cancel-*-hooks.jsonl, +// sidecars beside them): hook payloads recorded over a real PTY, merged in time order with the +// driver's cancel and kill markers. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { AGENT_INTERRUPT_SETTLE_MS } from '../../shared/agent-interrupt-intent' + +export type CapturedHook = { + kind: 'hook' + t: number + index: number + /** `ps` rows for the rig's sleep processes, taken inside the hook. */ + sleep_procs: string[] + payload: Record +} +export type CapturedCancel = { + kind: 'cancel' + t: number + label: string + interrupted_painted: boolean + /** Hook indices between the cancel key and the next prompt the driver typed. */ + hooks_before_next_typed_prompt: number[] +} +export type CapturedKill = { kind: 'kill'; t: number; needle: string } +export type CapturedRecord = CapturedHook | CapturedCancel | CapturedKill + +export function loadCapture(name: string): CapturedRecord[] { + return readFileSync( + join(__dirname, '..', '..', 'shared', '__fixtures__', `${name}.jsonl`), + 'utf8' + ) + .trim() + .split('\n') + .map((line) => { + // JSON.parse returns any; the kind check below is what proves the record shape. + const parsed: CapturedRecord = JSON.parse(line) + if (parsed.kind !== 'hook' && parsed.kind !== 'cancel' && parsed.kind !== 'kill') { + throw new Error(`Unknown capture record: ${line}`) + } + return parsed + }) +} + +export function hookAt(records: CapturedRecord[], index: number): CapturedHook { + const hook = records.find((record) => record.kind === 'hook' && record.index === index) + if (hook?.kind !== 'hook') { + throw new Error(`Captured hook ${index} not found`) + } + return hook +} + +export function cancelLabelled(records: CapturedRecord[], label: string): CapturedCancel { + const cancel = records.find((record) => record.kind === 'cancel' && record.label === label) + if (cancel?.kind !== 'cancel') { + throw new Error(`Captured cancel ${label} not found`) + } + return cancel +} + +/** Whether a hook landed inside the settle window on the capture's own clock, which is when the + * renderer's baseline check drops the inference instead of sending it. */ +export function hookSupersedesCancel(records: CapturedRecord[], cancel: CapturedCancel): boolean { + return records.some( + (record) => + record.kind === 'hook' && + record.t > cancel.t && + (record.t - cancel.t) * 1000 < AGENT_INTERRUPT_SETTLE_MS + ) +} diff --git a/src/main/agent-hooks/ended-process-reconciliation.test.ts b/src/main/agent-hooks/ended-process-reconciliation.test.ts index 0719b584d8a2..a460040bf662 100644 --- a/src/main/agent-hooks/ended-process-reconciliation.test.ts +++ b/src/main/agent-hooks/ended-process-reconciliation.test.ts @@ -87,8 +87,9 @@ describe('reconcileEndedProcessForPaneKeys', () => { }) it('clears Claude latches even when the stored row already reads done', async () => { - // An interrupted lead suppresses the gate while leaving the latch set, so a row can read `done` - // with a latch that would re-gate `working` on the pane's very next event. + // A latch can outlive the row it gated (a restored row, or one written before the inventory + // arrived), so a row can read `done` with a latch that would re-gate `working` on the pane's + // very next event. const server = await startServer() try { claudeRow(server, 'done') diff --git a/src/main/agent-hooks/server-claude-cancel-captures.test.ts b/src/main/agent-hooks/server-claude-cancel-captures.test.ts new file mode 100644 index 000000000000..196c41fbea7f --- /dev/null +++ b/src/main/agent-hooks/server-claude-cancel-captures.test.ts @@ -0,0 +1,359 @@ +// A cancel never hides live work. These stories replay hook payloads recorded from Claude Code +// 2.1.280 over a real PTY (src/shared/__fixtures__/claude-cancel-*-hooks.jsonl, sidecars beside +// them) through the server's own HTTP ingress, and stand in for the renderer at the moments the +// driver pressed the cancel key. The captures established that a cancel fires no hook at all, +// kills only the foreground tool, and that every later Stop inventory matched the process table; +// the rules below are written against those payloads, not a remembered screen. +// +// The driver cancelled with Esc. Orca treats a bare Esc on a Claude pane as navigation (it also +// closes the /btw composer) and infers a cancel only from Ctrl+C, so each `cancel` record is +// replayed as the Ctrl+C inference the renderer would have sent. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer, _internals } from './server' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' +import { + cancelLabelled, + hookAt, + hookSupersedesCancel, + loadCapture, + type CapturedHook +} from './claude-cancel-capture.test-fixture' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ + track: trackMock +})) + +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: getCohortAtEmitMock +})) + +beforeEach(() => { + _internals.resetCachesForTests() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +async function startServer(): Promise { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + return server +} + +function row(server: AgentHookServer) { + const entry = server.getStatusSnapshotForPane(PANE)[0] + if (!entry) { + throw new Error('the pane has no row') + } + return entry +} + +async function post(server: AgentHookServer, hook: CapturedHook): Promise { + await expect(postHookEvent(server, buildBody(hook.payload))).resolves.toMatchObject({ + status: 204 + }) +} + +/** The renderer's part of a cancel: capture the row as the baseline and, once the settle window + * passes with no hook, ask the server to infer the interrupt. */ +function pressCtrlC(server: AgentHookServer): boolean { + const baseline = row(server) + return server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: baseline.prompt, + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) +} + +describe('a Claude cancel with a background shell (captured)', () => { + const records = loadCapture('claude-cancel-shell-hooks') + + it('keeps a shell the cancelled turn left running as monitoring, in both cancel shapes', async () => { + const server = await startServer() + try { + for (const index of [0, 1, 2, 3, 4]) { + await post(server, hookAt(records, index)) + } + // The Stop that started the shell lists it; the main agent is settled and the row monitors. + expect(hookAt(records, 4).payload.background_tasks).toEqual([ + expect.objectContaining({ type: 'shell', status: 'running', command: 'sleep 600' }) + ]) + expect(row(server)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done' } + }) + + // A foreground turn starts on top of it and is cancelled mid-tool. + await post(server, hookAt(records, 5)) + await post(server, hookAt(records, 6)) + expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + expect(row(server).workingMode).toBeUndefined() + + const midTool = cancelLabelled(records, 'ESC-during-tool') + expect(midTool.interrupted_painted).toBe(true) + expect(midTool.hooks_before_next_typed_prompt).toEqual([]) + expect(hookSupersedesCancel(records, midTool)).toBe(false) + expect(pressCtrlC(server)).toBe(true) + // Why: the cancel is the main agent's verdict; the shell is the inventory's fact. The row shows + // the shell, the verdict rides `mainAgent.outcome`, and `interrupted` (a done-row flag) is absent. + expect(row(server)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + expect(row(server).interrupted).toBeUndefined() + + // The next typed turn ends with the same inventory, and its plain Stop carries no verdict. + await post(server, hookAt(records, 7)) + await post(server, hookAt(records, 8)) + expect(hookAt(records, 8).sleep_procs).toEqual([expect.stringContaining('sleep 600')]) + expect(row(server)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done' } + }) + expect(row(server).mainAgent).not.toHaveProperty('outcome') + + // Cancelled again while streaming a reply, with no tool running. + await post(server, hookAt(records, 9)) + expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + const midStream = cancelLabelled(records, 'ESC-during-stream') + expect(hookSupersedesCancel(records, midStream)).toBe(false) + expect(pressCtrlC(server)).toBe(true) + expect(row(server)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + } finally { + server.stop() + } + }) + + it('lets the shell leave only when an inventory omits it, and then settles to done', async () => { + const server = await startServer() + try { + for (const index of [0, 1, 2, 3, 4, 5, 6]) { + await post(server, hookAt(records, index)) + } + expect(pressCtrlC(server)).toBe(true) + for (const index of [7, 8, 9]) { + await post(server, hookAt(records, index)) + } + expect(pressCtrlC(server)).toBe(true) + await post(server, hookAt(records, 10)) + await post(server, hookAt(records, 11)) + expect(row(server)).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + + // The rig SIGKILLs the shell. That is not evidence yet: nothing has reported it. + const kill = records.find((record) => record.kind === 'kill' && record.needle === 'sleep 600') + expect(kill).toBeDefined() + expect(row(server)).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + + // The CLI notices within a second and injects a task-notification turn whose Stop reports + // an empty inventory; that Stop is what retires the shell. + const notification = hookAt(records, 12) + expect(notification.payload.hook_event_name).toBe('UserPromptSubmit') + expect(String(notification.payload.prompt)).toContain('') + await post(server, notification) + expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + + const allClear = hookAt(records, 13) + expect(allClear.payload).toMatchObject({ hook_event_name: 'Stop', background_tasks: [] }) + expect(allClear.sleep_procs).toEqual([]) + await post(server, allClear) + expect(row(server)).toMatchObject({ state: 'done', mainAgent: { state: 'done' } }) + expect(row(server).workingMode).toBeUndefined() + expect(row(server).interrupted).toBeUndefined() + expect(row(server).mainAgent).not.toHaveProperty('outcome') + } finally { + server.stop() + } + }) + + it('reads the last inventory until the next one, even for a cancel right after an external kill', async () => { + const server = await startServer() + try { + for (const index of [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 + ]) { + await post(server, hookAt(records, index)) + } + // A second shell, listed by its Stop. + expect(hookAt(records, 20).payload.background_tasks).toEqual([ + expect.objectContaining({ command: 'sleep 500', status: 'running' }) + ]) + expect(row(server)).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + await post(server, hookAt(records, 21)) + expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + + // The shell is killed, then the turn is cancelled. In the capture the CLI's death + // notification landed 0.17 s after the cancel key, inside the renderer's settle window, so + // the renderer never sent this inference; the notification turn superseded it. + const afterKill = cancelLabelled(records, 'ESC-during-tool-after-kill') + expect(afterKill.hooks_before_next_typed_prompt).toEqual([22, 23]) + expect(hookSupersedesCancel(records, afterKill)).toBe(true) + + // Had the inference won the race, the row would still read the last inventory: a process + // death is not evidence until an inventory reports it, and the next Stop does so at once. + expect(pressCtrlC(server)).toBe(true) + expect(row(server)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + // The shell's death notification opens a real turn, so the cancel's verdict gives way to it. + const notification = hookAt(records, 22) + expect(String(notification.payload.prompt)).toContain('') + await post(server, notification) + expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + expect(row(server).workingMode).toBeUndefined() + const settled = hookAt(records, 23) + expect(settled.payload).toMatchObject({ hook_event_name: 'Stop', background_tasks: [] }) + await post(server, settled) + expect(row(server)).toMatchObject({ state: 'done', mainAgent: { state: 'done' } }) + expect(row(server).workingMode).toBeUndefined() + } finally { + server.stop() + } + }) + + it('keeps a scheduled session check the cancelled turn left registered as monitoring', async () => { + // No capture registered a cron (`session_crons` was `[]` on every Stop), so this story takes + // the captured Stop and substitutes one entry; the listener reads only the list's length. + const server = await startServer() + try { + for (const index of [0, 1, 2, 3]) { + await post(server, hookAt(records, index)) + } + const stopWithCron = hookAt(records, 4) + expect(stopWithCron.payload.session_crons).toEqual([]) + await post(server, { + ...stopWithCron, + payload: { + ...stopWithCron.payload, + background_tasks: [], + session_crons: [{ id: 'cron-1', description: 'synthetic: check the build' }] + } + }) + expect(row(server)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done' } + }) + await post(server, hookAt(records, 5)) + await post(server, hookAt(records, 6)) + expect(pressCtrlC(server)).toBe(true) + expect(row(server)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + expect(server._getStateForTests().claudeActiveSessionCronPaneKeys.has(PANE)).toBe(true) + } finally { + server.stop() + } + }) +}) + +describe('a Claude cancel with a live subagent (captured)', () => { + const records = loadCapture('claude-cancel-subagent-hooks') + + it('keeps the subagent working while the main agent reads cancelled', async () => { + const server = await startServer() + try { + for (const index of [0, 1, 2, 3, 4, 5]) { + await post(server, hookAt(records, index)) + } + // 2.1.280 launches the Agent tool asynchronously: the main agent Stops with the child running. + expect(hookAt(records, 3).payload.tool_response).toMatchObject({ status: 'async_launched' }) + expect(hookAt(records, 5).payload.background_tasks).toEqual([ + expect.objectContaining({ type: 'subagent', status: 'running' }) + ]) + expect(row(server)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done' }, + subagents: [expect.objectContaining({ state: 'working' })] + }) + expect(row(server).workingMode).toBeUndefined() + + // The main agent starts a foreground turn beside the child and is cancelled mid-tool. + for (const index of [6, 7, 8]) { + await post(server, hookAt(records, index)) + } + expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + const cancel = cancelLabelled(records, 'ESC-during-tool-with-bg-agent') + expect(cancel.interrupted_painted).toBe(true) + expect(hookSupersedesCancel(records, cancel)).toBe(false) + expect(pressCtrlC(server)).toBe(true) + expect(row(server)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation' }, + subagents: [expect.objectContaining({ state: 'working' })] + }) + expect(row(server).workingMode).toBeUndefined() + + // The child's tool activity after the cancel keeps the row working and does not resurrect + // the main agent; the next typed turn's Stop still lists the child. + await post(server, hookAt(records, 9)) + expect(hookAt(records, 9).payload.agent_id).toBeDefined() + expect(row(server)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + await post(server, hookAt(records, 10)) + await post(server, hookAt(records, 11)) + expect(hookAt(records, 11).sleep_procs).toHaveLength(2) + expect(row(server)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done' }, + subagents: [expect.objectContaining({ state: 'working' })] + }) + expect(row(server).mainAgent).not.toHaveProperty('outcome') + } finally { + server.stop() + } + }) + + it('settles the drained row as a stopped turn, never a completed one', async () => { + const server = await startServer() + try { + for (const index of [0, 1, 2, 3, 4, 5, 6, 7, 8]) { + await post(server, hookAt(records, index)) + } + expect(pressCtrlC(server)).toBe(true) + await post(server, hookAt(records, 9)) + expect(row(server)).toMatchObject({ state: 'working' }) + expect(row(server).turnCompletedAt).toBeUndefined() + + // Why: a completion stamp or a done without `interrupted` is what renderers announce as finished. + for (const index of [4, 9]) { + await post(server, { + ...hookAt(records, index), + payload: { ...hookAt(records, index).payload, hook_event_name: 'SubagentStop' } + }) + } + expect(row(server)).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + expect(row(server).turnCompletedAt).toBeUndefined() + } finally { + server.stop() + } + }) +}) diff --git a/src/main/agent-hooks/server-grok-cancel.test.ts b/src/main/agent-hooks/server-grok-cancel.test.ts new file mode 100644 index 000000000000..62fd17495b4b --- /dev/null +++ b/src/main/agent-hooks/server-grok-cancel.test.ts @@ -0,0 +1,130 @@ +// Grok 1.0.41, measured: Ctrl+C mid-turn fires `stop_cancelled` listing the finite tasks the turn +// left running, and Ctrl+C at the idle prompt kills no background task. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer, _internals } from './server' +import { buildBody, PANE } from './server.test-fixtures' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) + +beforeEach(() => { + _internals.resetCachesForTests() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) +}) + +afterEach(() => vi.restoreAllMocks()) + +const RUNNING_TASK = { id: 'task-1', type: 'shell', status: 'running' } + +async function postGrokHook( + server: AgentHookServer, + payload: Record +): Promise { + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/grok`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody({ sessionId: 'session-1', ...payload })) + }) + expect(response.status).toBe(204) +} + +function row(server: AgentHookServer) { + const entry = server.getStatusSnapshotForPane(PANE)[0] + if (!entry) { + throw new Error('the pane has no row') + } + return entry +} + +function pressCtrlC(server: AgentHookServer): boolean { + const baseline = row(server) + return server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: baseline.prompt, + baselineAgentType: 'grok', + intent: 'ctrl-c' + }) +} + +async function startTaskThenSettle(server: AgentHookServer): Promise { + await postGrokHook(server, { + hookEventName: 'user_prompt_submit', + promptId: 'prompt-1', + prompt: 'start a background task' + }) + await postGrokHook(server, { + hookEventName: 'stop', + promptId: 'prompt-1', + reason: 'end_turn', + stopHookActive: false, + backgroundTasks: [RUNNING_TASK] + }) + expect(row(server)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done' } + }) +} + +describe('a Grok cancel never hides a running task', () => { + it('refuses Ctrl+C at the idle prompt of a row a task holds open', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + await startTaskThenSettle(server) + const before = row(server) + expect(pressCtrlC(server)).toBe(false) + expect(row(server)).toEqual(before) + } finally { + server.stop() + } + }) + + it("shows the task when Grok's own cancel hook trails the inferred cancel", async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + await startTaskThenSettle(server) + await postGrokHook(server, { + hookEventName: 'user_prompt_submit', + promptId: 'prompt-2', + prompt: 'now do something else' + }) + expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + + // Why: the inference can win the settle race; the row cannot see the task behind a working main agent. + expect(pressCtrlC(server)).toBe(true) + expect(row(server)).toMatchObject({ + state: 'done', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + + await postGrokHook(server, { + hookEventName: 'stop_cancelled', + promptId: 'prompt-2', + stopHookActive: false, + backgroundTasks: [RUNNING_TASK] + }) + expect(row(server)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + } finally { + server.stop() + } + }) +}) diff --git a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts index 9033f182bed0..ff68f7492e58 100644 --- a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts @@ -145,7 +145,9 @@ describe('AgentHookServer listener replay', () => { } }) - it('does not infer an interrupt while Claude reports a background shell', () => { + it('does not infer an interrupt while Claude reports a background shell and the row carries no main agent fact', () => { + // Why: a row from a host too old to publish `mainAgent` cannot say whether its `working` is the + // main agent's own turn or a shell holding an idle main agent open, so the evidence guard stays for it. vi.useFakeTimers() vi.setSystemTime(1_000) try { @@ -204,6 +206,196 @@ describe('AgentHookServer listener replay', () => { } }) + it('infers a cancel of a live Claude main agent turn and keeps the shell it leaves running as monitoring', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + claudeRunningNonAgentTask: true, + payload: { + state: 'working', + prompt: 'run in background', + agentType: 'claude', + mainAgent: { state: 'working', stateStartedAt: 900 } + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'run in background', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + ).toBe(true) + // Why: Ctrl+C ended the main agent's turn, not the shell the inventory reported. The synthesized + // row is the fold of the cancelled main agent with that shell, so it monitors; the verdict rides + // `mainAgent.outcome`, and `interrupted` is a done-row flag the normalizer keeps off it. + const inferred = server.getStatusSnapshot()[0] + expect(inferred).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 1_500 } + }) + expect(inferred.interrupted).toBeUndefined() + expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it("starts a relayed Claude pane's cancel clock at each cancel, not at an earlier one", () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const relayTurn = (prompt: string, startedAt: number): void => + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt, + agentType: 'claude', + mainAgent: { state: 'working', stateStartedAt: startedAt } + } + }, + 'conn-1' + ) + const cancel = (prompt: string): boolean => { + const baseline = server.getStatusSnapshot()[0] + return server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: prompt, + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + } + relayTurn('first', 900) + vi.setSystemTime(1_500) + expect(cancel('first')).toBe(true) + + // Why: the relay owns this pane's main agent record, so the host's local one still says the + // first cancel; the second cancel must not inherit that clock. + vi.setSystemTime(5_000) + relayTurn('second', 5_000) + vi.setSystemTime(6_000) + expect(cancel('second')).toBe(true) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 6_000 } + }) + } finally { + vi.useRealTimers() + } + }) + + it('infers a cancel of a live Claude main agent turn beside a working subagent and keeps the row working', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'delegate', + agentType: 'claude', + subagents: [{ id: 'a1', state: 'working', startedAt: 900 }], + mainAgent: { state: 'working', stateStartedAt: 900 } + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'delegate', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + ).toBe(true) + // Why: the child outlives the cancel (a relayed pane has no local roster, so the row's own + // snapshots are the evidence); the main agent's verdict is recorded without retiring the child. + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + subagents: [{ id: 'a1', state: 'working' }], + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + expect(server.getStatusSnapshot()[0].workingMode).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) + + it('does not infer a cancel at the idle prompt of a Claude row held open by child work', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + claudeRunningNonAgentTask: true, + payload: { + state: 'working', + workingMode: 'monitoring', + prompt: 'run in background', + agentType: 'claude', + mainAgent: { state: 'done', stateStartedAt: 900 } + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + // Why: the main agent is already settled; Ctrl+C at its prompt stops nothing the row shows. + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'run in background', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + ).toBe(false) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done' } + }) + } finally { + vi.useRealTimers() + } + }) + it('blocks local HTTP interrupt inference for provider-owned work without exposing transport metadata', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) diff --git a/src/main/agent-hooks/server-interrupt-inference-validation.test.ts b/src/main/agent-hooks/server-interrupt-inference-validation.test.ts index 5818aa607bd7..aa8568f08d80 100644 --- a/src/main/agent-hooks/server-interrupt-inference-validation.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-validation.test.ts @@ -400,7 +400,46 @@ describe('the main agent fact on an inferred interrupt', () => { } }) - it('keeps an already settled main agent behind a watch loop as it was', () => { + it('refuses a cancel of a working Codex main agent beside a live child', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'delegate', + agentType: 'codex', + subagents: [{ id: 'child-1', state: 'working', startedAt: 900 }], + mainAgent: { state: 'working', stateStartedAt: 900 } + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + vi.setSystemTime(1_500) + // Why: the synthesized Codex row is a plain done; it would retire the child its combine keeps working. + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'delegate', + baselineAgentType: 'codex', + intent: 'ctrl-c' + }) + ).toBe(false) + expect(server.getStatusSnapshot()[0]).toEqual(baseline) + } finally { + vi.useRealTimers() + } + }) + + it('refuses a cancel at the idle prompt of a main agent a watch loop holds open', () => { vi.useFakeTimers() vi.setSystemTime(1_000) try { @@ -432,12 +471,9 @@ describe('the main agent fact on an inferred interrupt', () => { baselineAgentType: 'grok', intent: 'ctrl-c' }) - ).toBe(true) - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'done', - interrupted: true, - mainAgent: settled - }) + ).toBe(false) + // Why: the main agent already settled; Ctrl+C at its prompt stops nothing the row shows. + expect(server.getStatusSnapshot()[0]).toEqual(baseline) } finally { vi.useRealTimers() } diff --git a/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts b/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts index 9d5345dc4235..fa9c7aa5fe19 100644 --- a/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts +++ b/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts @@ -102,6 +102,54 @@ describe('The main agent fact across a restart', () => { } }) + // Current Claude sends no hook on a cancel, so the inferred Ctrl+C is the path users take. + it('round-trips an inferred cancel held open by a child through disk and back', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + await postHookEvent( + firstServer, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'finish after child' }) + ) + await postHookEvent( + firstServer, + buildBody({ hook_event_name: 'SubagentStart', agent_id: 'arestored-child' }) + ) + const baseline = firstServer.getStatusSnapshot()[0] + expect( + firstServer.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: baseline.prompt, + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + ).toBe(true) + const live = firstServer.getStatusSnapshot()[0] + expect(live).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + await postHookEvent( + server, + buildBody({ hook_event_name: 'SubagentStop', agent_id: 'arestored-child' }) + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + } finally { + server.stop() + } + }) + it('maps the legacy child-only flag onto an absent main agent, dated by the turn end', async () => { const receivedAt = recentTs() writeEntry({ diff --git a/src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts b/src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts index abd8a78791f0..4f7d01903f1f 100644 --- a/src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts +++ b/src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts @@ -106,6 +106,8 @@ describe('main agent turn verdicts and clocks', () => { vi.setSystemTime(1_002_000) await post('/hook/codex', { hook_event_name: 'Stop' }) + // Readers that predate `mainAgent` must still read the restated turn as stopped, not finished. + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', interrupted: true }) // Past the late-event suppression window, a child's activity republishes the main agent. vi.setSystemTime(1_060_000) await post('/hook/codex', { hook_event_name: 'SubagentStart', agent_id: 'child-1' }) @@ -138,6 +140,7 @@ describe('main agent turn verdicts and clocks', () => { vi.setSystemTime(1_002_000) relayed('Stop', { state: 'done' }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', interrupted: true }) vi.setSystemTime(1_060_000) relayed( 'SubagentStart', diff --git a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts new file mode 100644 index 000000000000..ce93f46f8861 --- /dev/null +++ b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts @@ -0,0 +1,400 @@ +// The captured Claude cancels (see server-claude-cancel-captures.test.ts) replayed on an SSH pane: +// the hooks go through a real relay-side listener, which owns the provider records, and reach the +// desktop only as relayed payloads. The relay never learns of the cancel the desktop infers from +// Ctrl+C, so everything it restates afterwards still says the main agent is working. +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RelayAgentHookServer } from '../../relay/agent-hook-server' +import { AgentHookServer, _internals } from './server' +import { buildBody, PANE } from './server.test-fixtures' +import { hookAt, loadCapture, type CapturedHook } from './claude-cancel-capture.test-fixture' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) + +const temporaryPaths: string[] = [] +const running: { stop: () => void }[] = [] + +beforeEach(() => { + _internals.resetCachesForTests() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) +}) + +afterEach(() => { + for (const server of running.splice(0)) { + server.stop() + } + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +function temporaryDir(prefix: string): string { + const path = mkdtempSync(join(tmpdir(), prefix)) + temporaryPaths.push(path) + return path +} + +type SshPane = { + relay: RelayAgentHookServer + /** The desktop the relay forwards to; a desktop restart swaps it. */ + desktop: AgentHookServer + post: (payload: Record) => Promise +} + +function createRelay(forwardTo: () => AgentHookServer): RelayAgentHookServer { + return new RelayAgentHookServer({ + endpointDir: temporaryDir('orca-relayed-cancel-'), + token: 'relayed-cancel-token', + forward: (envelope) => forwardTo().ingestRemote(envelope, 'conn-1') + }) +} + +async function startSshPane(desktop: AgentHookServer): Promise { + const pane: SshPane = { + desktop, + relay: createRelay(() => pane.desktop), + post: async (payload) => { + const { port, token } = pane.relay.getCoordinates() + const response = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Orca-Agent-Hook-Token': token }, + body: JSON.stringify(buildBody(payload)) + }) + expect(response.status).toBe(204) + } + } + running.push(pane.relay, desktop) + await pane.relay.start({ publishEndpoint: false }) + return pane +} + +/** The relay process restarts (upgrade, crash) with fresh listener state; the remote agent keeps running. */ +async function restartRelay(pane: SshPane): Promise { + pane.relay.stop() + pane.relay = createRelay(() => pane.desktop) + running.push(pane.relay) + await pane.relay.start({ publishEndpoint: false }) +} + +async function postCaptured(pane: SshPane, hooks: CapturedHook[]): Promise { + for (const hook of hooks) { + await pane.post(hook.payload) + } +} + +function row(server: AgentHookServer) { + const entry = server.getStatusSnapshotForPane(PANE)[0] + if (!entry) { + throw new Error('the pane has no row') + } + return entry +} + +function pressCtrlC(server: AgentHookServer): boolean { + const baseline = row(server) + return server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: baseline.prompt, + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) +} + +describe('a relayed Claude cancel with a live subagent (captured)', () => { + const records = loadCapture('claude-cancel-subagent-hooks') + const upToCancel = [0, 1, 2, 3, 4, 5, 6, 7, 8].map((index) => hookAt(records, index)) + const subagentStop = (index: number): Record => ({ + ...hookAt(records, index).payload, + hook_event_name: 'SubagentStop', + tool_name: undefined, + tool_input: undefined + }) + + it("keeps the cancel when the child's next hook restates the relay's working main agent", async () => { + const pane = await startSshPane(new AgentHookServer()) + await postCaptured(pane, upToCancel) + expect(pressCtrlC(pane.desktop)).toBe(true) + expect(row(pane.desktop)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation' }, + subagents: [expect.objectContaining({ state: 'working' })] + }) + + // The child's tool activity after the cancel: the relay's record still has the main agent working. + const childTool = hookAt(records, 9) + expect(childTool.payload.agent_id).toBeDefined() + await pane.post(childTool.payload) + expect(row(pane.desktop)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + + // Both children finish on the remote; with nothing left running the cancelled row settles. + await pane.post(subagentStop(4)) + await pane.post(subagentStop(9)) + expect(row(pane.desktop)).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + expect(row(pane.desktop).subagents).toBeUndefined() + }) + + it("keeps the cancel through a child's permission prompt and its approval", async () => { + const pane = await startSshPane(new AgentHookServer()) + await postCaptured(pane, upToCancel) + expect(pressCtrlC(pane.desktop)).toBe(true) + + const childTool = hookAt(records, 6).payload + await pane.post({ ...childTool, hook_event_name: 'PermissionRequest' }) + expect(row(pane.desktop)).toMatchObject({ + state: 'waiting', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + await pane.post(childTool) + expect(row(pane.desktop)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + }) + + it("keeps the cancel while a child's permission card stays up over its next tool", async () => { + const pane = await startSshPane(new AgentHookServer()) + await postCaptured(pane, upToCancel) + expect(pressCtrlC(pane.desktop)).toBe(true) + + const childTool = hookAt(records, 6).payload + await pane.post({ ...childTool, hook_event_name: 'PermissionRequest' }) + // A denied request runs no tool; the child moves on to a different one, and the card stays up. + await pane.post({ ...childTool, tool_use_id: 'toolu_after_denial' }) + expect(row(pane.desktop)).toMatchObject({ + state: 'waiting', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + + await pane.post(subagentStop(4)) + expect(row(pane.desktop)).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + }) + + it('keeps the cancel through a reconnect replay of the relay cache', async () => { + const pane = await startSshPane(new AgentHookServer()) + await postCaptured(pane, upToCancel) + expect(pressCtrlC(pane.desktop)).toBe(true) + + expect(pane.relay.replayCachedPayloadsForPanes()).toBe(1) + expect(row(pane.desktop)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation' }, + subagents: [expect.objectContaining({ state: 'working' })] + }) + }) + + it("keeps the cancel when a restarted relay's first hook is the child's", async () => { + const pane = await startSshPane(new AgentHookServer()) + await postCaptured(pane, upToCancel) + expect(pressCtrlC(pane.desktop)).toBe(true) + + // Why: the restarted relay has no prompt cache or main agent record, so the child's hook arrives with an empty prompt. + await restartRelay(pane) + await pane.post(hookAt(records, 9).payload) + expect(row(pane.desktop)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + + await pane.post(subagentStop(4)) + await pane.post(subagentStop(9)) + expect(row(pane.desktop)).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + }) + + it('releases the cancel on a replayed child hook whose prompt the desktop missed', async () => { + const pane = await startSshPane(new AgentHookServer()) + await postCaptured(pane, upToCancel) + const desktop = pane.desktop + expect(pressCtrlC(desktop)).toBe(true) + + // The link drops: the next prompt and the child's hook reach only the relay. + pane.desktop = new AgentHookServer() + running.push(pane.desktop) + await pane.post(hookAt(records, 10).payload) + await pane.post(hookAt(records, 9).payload) + pane.desktop = desktop + expect(pane.relay.replayCachedPayloadsForPanes()).toBe(1) + expect(row(desktop)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + expect(row(desktop).mainAgent).not.toHaveProperty('outcome') + }) + + it('does not read a restart-seeded local roster for a relayed pane', async () => { + const userDataPath = temporaryDir('orca-relayed-cancel-restart-') + const firstDesktop = new AgentHookServer() + await firstDesktop.start({ env: 'production', userDataPath }) + const pane = await startSshPane(firstDesktop) + // The main agent Stops with the child running, and the desktop restarts. + await postCaptured( + pane, + [0, 1, 2, 3, 4, 5].map((index) => hookAt(records, index)) + ) + expect(row(firstDesktop)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done' }, + subagents: [expect.objectContaining({ state: 'working' })] + }) + firstDesktop.flushStatusPersistSync() + firstDesktop.stop() + + const desktop = new AgentHookServer() + await desktop.start({ env: 'production', userDataPath }) + pane.desktop = desktop + running.push(desktop) + // Hydration seeds the desktop's own roster from the saved row, relayed or not. + expect(desktop._getStateForTests().claudeSubagentRosterByPaneKey.has(PANE)).toBe(true) + + // The child finishes on the remote, then a new turn starts and is cancelled. + await pane.post(subagentStop(4)) + expect(row(desktop)).toMatchObject({ state: 'done' }) + await pane.post(hookAt(records, 7).payload) + expect(row(desktop)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + expect(pressCtrlC(desktop)).toBe(true) + + // Why: nothing runs on the remote; the desktop's seed is not the relay's roster. + expect(row(desktop)).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + }) +}) + +describe('a relayed Claude cancel with a background shell (captured)', () => { + const records = loadCapture('claude-cancel-shell-hooks') + + it('holds the cancel through a replay and releases it at the next prompt', async () => { + const pane = await startSshPane(new AgentHookServer()) + await postCaptured( + pane, + [0, 1, 2, 3, 4, 5, 6].map((index) => hookAt(records, index)) + ) + expect(pressCtrlC(pane.desktop)).toBe(true) + expect(row(pane.desktop)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + + expect(pane.relay.replayCachedPayloadsForPanes()).toBe(1) + expect(row(pane.desktop)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + + // Why: a new turn is the main agent's own fact again; the held verdict must not outlive it. + await pane.post(hookAt(records, 7).payload) + expect(row(pane.desktop)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + await pane.post(hookAt(records, 8).payload) + expect(row(pane.desktop)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done' } + }) + expect(row(pane.desktop).mainAgent).not.toHaveProperty('outcome') + }) +}) + +it("keeps the cancel when a teammate's idle hook arrives after the late-hook window", async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(1_790_000_000_000) + try { + const pane = await startSshPane(new AgentHookServer()) + await pane.post({ hook_event_name: 'SessionStart', source: 'startup' }) + await pane.post({ hook_event_name: 'UserPromptSubmit', prompt: 'coordinate the team' }) + await pane.post({ + hook_event_name: 'SubagentStart', + agent_id: 'areviewer-6d3cb5b52120b7bf', + agent_type: 'reviewer' + }) + await pane.post({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'sleep 45' }, + tool_use_id: 'toolu_1' + }) + vi.setSystemTime(Date.now() + 2_000) + expect(pressCtrlC(pane.desktop)).toBe(true) + + // Why: TeammateIdle names its child by `teammate_name` only, so it carries no agent id. + vi.setSystemTime(Date.now() + 20_000) + await pane.post({ hook_event_name: 'TeammateIdle', teammate_name: 'reviewer' }) + expect(row(pane.desktop)).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + } finally { + vi.useRealTimers() + } +}) + +it('keeps a relayed waiting child visible when the main agent is cancelled', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'coordinate reviewers', + agentType: 'claude', + mainAgent: { state: 'working', stateStartedAt: 900 }, + subagents: [{ id: 'reviewer-1', state: 'waiting', startedAt: 900 }] + } + }, + 'conn-1' + ) + const baseline = row(server) + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: baseline.prompt, + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + ).toBe(true) + expect(row(server)).toMatchObject({ + state: 'waiting', + mainAgent: { state: 'done', outcome: 'cancellation' }, + subagents: [{ id: 'reviewer-1', state: 'waiting' }] + }) + } finally { + vi.useRealTimers() + } +}) diff --git a/src/main/agent-hooks/server/server-cancel-verdict-latch.ts b/src/main/agent-hooks/server/server-cancel-verdict-latch.ts new file mode 100644 index 000000000000..8bfc3fe897bf --- /dev/null +++ b/src/main/agent-hooks/server/server-cancel-verdict-latch.ts @@ -0,0 +1,132 @@ +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { AgentMainAgentStatus } from '../../../shared/agent-status-types' +import { INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS } from './server-constants' +import { foldMainAgentWithRowChildWork } from './server-row-child-work-fold' +import { isToolProgressWorkingAfterInterrupt } from './server-status-identity' +import type { EnrichedAgentHookEventPayload } from './server-types' + +export type CancelVerdictLatchDecision = + | { hold: true } + | { hold: false; event: AgentHookEventPayload } + +const HOLD: CancelVerdictLatchDecision = { hold: true } + +/** Derived from the row, never stored: a row whose main agent reads cancelled (or, from a host too + * old to publish `mainAgent`, a done row flagged interrupted). */ +function isCancelVerdictLatched(previous: EnrichedAgentHookEventPayload): boolean { + const mainAgent = previous.payload.mainAgent + return mainAgent + ? mainAgent.outcome === 'cancellation' + : previous.payload.state === 'done' && previous.payload.interrupted === true +} + +/** An event that restates child work can be re-folded with it; one that carries none has nothing to add. */ +function carriesChildWork(event: AgentHookEventPayload): boolean { + return event.payload.subagents !== undefined || event.claudeRunningNonAgentTask !== undefined +} + +function refoldUnderLatchedMainAgent( + previous: EnrichedAgentHookEventPayload, + latched: AgentMainAgentStatus, + incoming: AgentHookEventPayload +): AgentHookEventPayload { + // Why: a child's own attention state is child work, not the main agent's; only `working` is the stale restatement. + const resolved = foldMainAgentWithRowChildWork( + incoming.payload.state === 'working' ? latched.state : incoming.payload.state, + { + claudeRunningNonAgentTask: + incoming.claudeRunningNonAgentTask ?? previous.claudeRunningNonAgentTask, + payload: incoming.payload + } + ) + const { + workingMode: _workingMode, + interrupted: _interrupted, + turnCompletedAt: _turnCompletedAt, + ...rest + } = incoming.payload + return { + ...incoming, + payload: { + ...rest, + state: resolved.stateName, + ...(resolved.workingMode ? { workingMode: resolved.workingMode } : {}), + ...(resolved.stateName === 'done' ? { interrupted: true } : {}), + mainAgent: latched + } + } +} + +/** A main agent's own prompt submission always opens a turn, including a harness-injected one that + * keeps the cached prompt (the task notification Claude starts when background work ends). */ +function opensNewTurn(event: AgentHookEventPayload): boolean { + return ( + event.hookEventName === 'SessionStart' || + (event.hookEventName === 'UserPromptSubmit' && + event.toolAgentId === undefined && + event.isReplay !== true) + ) +} + +/** A child's own event: one naming its agent id, or a teammate's idle, which names it by `teammate_name` only. */ +function isChildAttributed(event: AgentHookEventPayload): boolean { + return event.toolAgentId !== undefined || event.hookEventName === 'TeammateIdle' +} + +/** A child restates its listener's cached prompt, which a restarted relay has lost; empty there is unknown, not another turn. */ +function restatesAnotherPrompt( + previous: EnrichedAgentHookEventPayload, + incoming: AgentHookEventPayload +): boolean { + const prompt = incoming.payload.prompt + return prompt !== previous.payload.prompt && (prompt !== '' || !isChildAttributed(incoming)) +} + +/** + * The store's hold on a cancel verdict against restatements that predate it: a relay never learns + * of the cancel the desktop infers, and TUIs emit late same-turn hooks after Ctrl+C. The latch dies + * on the provider's own verdict (any settled `mainAgent`) or a new turn (another prompt, an + * explicit prompt, a prompt submission, a session start). Child-attributed and replayed events keep the latched main + * agent and are re-folded with their own child evidence; late main agent work is held. + */ +export function resolveCancelVerdictLatch( + previous: EnrichedAgentHookEventPayload | undefined, + incoming: AgentHookEventPayload, + now: number +): CancelVerdictLatchDecision { + const apply: CancelVerdictLatchDecision = { hold: false, event: incoming } + if ( + !previous || + !isCancelVerdictLatched(previous) || + previous.payload.agentType !== incoming.payload.agentType || + restatesAnotherPrompt(previous, incoming) || + incoming.payload.mainAgent?.state === 'done' || + opensNewTurn(incoming) + ) { + return apply + } + const latched = previous.payload.mainAgent + // Why: Codex's combine is not this fold; its child events already come reconciled against main's marked record. + if ( + latched && + incoming.payload.agentType !== 'codex' && + incoming.payload.state !== 'done' && + (isChildAttributed(incoming) || incoming.isReplay === true) && + carriesChildWork(incoming) + ) { + return { hold: false, event: refoldUnderLatchedMainAgent(previous, latched, incoming) } + } + const withinWindow = now - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS + if (incoming.payload.state === 'done') { + return previous.payload.state === 'done' && withinWindow ? HOLD : apply + } + if ( + incoming.payload.state === 'working' && + (incoming.isReplay === true || + isToolProgressWorkingAfterInterrupt(incoming) || + (incoming.hasExplicitPrompt !== true && withinWindow)) + ) { + return HOLD + } + return apply +} diff --git a/src/main/agent-hooks/server/server-row-child-work-fold.ts b/src/main/agent-hooks/server/server-row-child-work-fold.ts new file mode 100644 index 000000000000..30c00b259456 --- /dev/null +++ b/src/main/agent-hooks/server/server-row-child-work-fold.ts @@ -0,0 +1,28 @@ +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import { + foldAgentLeadStatus, + type AgentLeadStatusResolution +} from '../../../shared/agent-lead-status-fold' +import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' +import type { AgentStatusState, AgentSubagentSnapshot } from '../../../shared/agent-status-types' + +type RowChildWork = Pick & { + payload: { subagents?: readonly AgentSubagentSnapshot[] } +} + +/** Fold a main agent state with the child work a row itself carries: its subagent snapshots and the + * shell/cron fact restated beside them. For a relayed pane that is all the desktop can see, because + * the provider records live on the relay. */ +export function foldMainAgentWithRowChildWork( + leadState: AgentStatusState, + row: RowChildWork +): AgentLeadStatusResolution { + const childWorkLiveness = agentChildWorkLiveness([ + ...(row.payload.subagents?.map((child) => ({ kind: 'agent' as const, state: child.state })) ?? + []), + ...(row.claudeRunningNonAgentTask + ? [{ kind: 'command' as const, state: 'working' as const }] + : []) + ]) + return foldAgentLeadStatus({ leadState, childWorkLiveness }) +} diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 65459e8953ae..7dbe26208900 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -17,6 +17,7 @@ import { AGENT_STATUS_STALE_AFTER_MS, type AgentType } from '../../../shared/age import type { EnrichedAgentHookEventPayload } from './server-types' import { equivalentInterruptAgentType, isValidPaneKey } from './server-status-identity' import { AgentHookServerRowOwnership } from './server-row-ownership' +import { foldMainAgentWithRowChildWork } from './server-row-child-work-fold' export abstract class AgentHookServerStatusInference extends AgentHookServerRowOwnership { inferInterrupt(request: AgentInterruptInferenceRequest): boolean { @@ -73,45 +74,65 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO if (isNavigationEscapeIntent(agentType, request.intent)) { return false } - // Why: a 'working' pane can be child-driven; Ctrl+C doesn't stop background children, so inferring done would retire live child rows. - if (payload.subagents?.some((subagent) => subagent.state !== 'idle')) { - return false - } - // Why: Escape/Ctrl+C at Claude's idle prompt does not stop provider-owned shells or session crons. + const childWorkEvidenced = + payload.subagents?.some((subagent) => subagent.state !== 'idle') === true || + (agentType === 'claude' && + (this.state.claudeRunningNonAgentTaskPaneKeys.has(existing.paneKey) || + this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey))) + // Why: a 'working' pane can be child-driven, and Ctrl+C at the idle prompt of a main agent that + // child work holds open cancels nothing, so the main agent fact decides. A row from a host too + // old to publish `mainAgent` keeps the evidence guard, and so does Codex: its synthesized row is + // a plain done, which would retire the live children its combine keeps working. if ( - agentType === 'claude' && - (this.state.claudeRunningNonAgentTaskPaneKeys.has(existing.paneKey) || - this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey)) + payload.mainAgent + ? payload.mainAgent.state !== 'working' || (agentType === 'codex' && childWorkEvidenced) + : childWorkEvidenced ) { return false } - // Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane. - if (agentType === 'claude') { - markClaudeLeadTurnInterrupted(this.state, existing.paneKey) - } + // Why: whoever owns the provider records folds the cancel with the child work the turn left + // running. A local pane's listener record must learn it too, or a later child event re-emits the + // stale 'working' state; a relayed pane's records live on the relay, so only its row is evidence. + const local = + agentType === 'claude' && !existing.connectionId + ? markClaudeLeadTurnInterrupted(this.state, existing.paneKey) + : undefined + const relayed = + agentType === 'claude' && existing.connectionId + ? foldMainAgentWithRowChildWork('done', existing) + : undefined if (agentType === 'codex') { markCodexLeadTurnInterrupted(this.state, existing.paneKey) } + const state = local?.state ?? relayed?.stateName ?? 'done' + const workingMode = local?.workingMode ?? relayed?.workingMode const inferred = this.applyNormalizedStatus({ paneKey: existing.paneKey, tabId: existing.tabId, worktreeId: existing.worktreeId, connectionId: existing.connectionId, providerSession: existing.providerSession, + // Why: a cancel leaves the shell fact as it was; dropping it would stop restart from seeding + // the cancelled main agent, so a child's later drain could never settle the row. + ...(existing.claudeRunningNonAgentTask !== undefined + ? { claudeRunningNonAgentTask: existing.claudeRunningNonAgentTask } + : {}), payload: { - state: 'done', + state, + ...(workingMode ? { workingMode } : {}), prompt: payload.prompt, agentType, ...(payload.model ? { model: payload.model } : {}), - interrupted: true, + // Why: `interrupted` is the settled row's restatement of the verdict for readers that + // predate `mainAgent`; a row the cancel left monitoring carries it on `mainAgent.outcome` only. + ...(state === 'done' ? { interrupted: true } : {}), // Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore. ...(payload.subagents ? { subagents: payload.subagents } : {}), - // Why: the interrupt ends a running main agent's turn; one a watch loop held open had already - // settled, so it keeps its own clock and verdict. - mainAgent: - payload.mainAgent?.state === 'done' - ? payload.mainAgent - : { state: 'done', outcome: 'cancellation', stateStartedAt: Date.now() } + mainAgent: local?.mainAgent ?? { + state: 'done', + outcome: 'cancellation', + stateStartedAt: Date.now() + } } }) if (!inferred) { diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 65fd1acc7143..a37cbb02eb44 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -6,7 +6,6 @@ import { resolveAgentStatusIdentity, shouldSuppressInheritedTerminalStatus } from '../../../shared/agent-status-identity' -import { INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS } from './server-constants' import type { EnrichedAgentHookEventPayload } from './server-types' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' @@ -17,7 +16,7 @@ import { withHeldChildWaitMainAgent } from './server-claude-status-rules' import { isStaleGrokTurnEnd } from './server-grok-status-rules' -import { isToolProgressWorkingAfterInterrupt } from './server-status-identity' +import { resolveCancelVerdictLatch } from './server-cancel-verdict-latch' import { AgentHookServerStatusApplication } from './server-status-application' export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusApplication { @@ -152,7 +151,21 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA ...rootContextPreservingPayload, payload: { ...rootContextPreservingPayload.payload, agentType: identity.agentType } } - const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) + const attachedPayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) + // Why before the permission hold: that hold adopts the event's `mainAgent`, and a relay's + // restatement of a main agent the desktop cancelled must not replace the cancel. + const latch = resolveCancelVerdictLatch(previous, attachedPayload, Date.now()) + if (latch.hold) { + if ( + attachedPayload.payload.agentType === 'codex' && + attachedPayload.payload.state === 'working' + ) { + markCodexLeadTurnInterrupted(this.state, attachedPayload.paneKey) + } + this.commitStatusRowMutation(rowBefore, previous) + return previous + } + const effectivePayload = latch.event if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { const held = withHeldChildWaitMainAgent(previous, effectivePayload) // Why: a child's prompt leaves the main agent running, so the held row takes its `mainAgent` and @@ -173,35 +186,6 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA } return held } - // Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row. - if ( - previous?.payload.state === 'done' && - previous.payload.interrupted === true && - effectivePayload.payload.state === 'done' && - previous.payload.agentType === effectivePayload.payload.agentType && - previous.payload.prompt === effectivePayload.payload.prompt && - Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS - ) { - this.commitStatusRowMutation(rowBefore, previous) - return previous - } - if ( - previous?.payload.state === 'done' && - previous.payload.interrupted === true && - effectivePayload.payload.state === 'working' && - previous.payload.agentType === effectivePayload.payload.agentType && - previous.payload.prompt === effectivePayload.payload.prompt && - (effectivePayload.isReplay === true || - isToolProgressWorkingAfterInterrupt(effectivePayload) || - (effectivePayload.hasExplicitPrompt !== true && - Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS)) - ) { - if (effectivePayload.payload.agentType === 'codex') { - markCodexLeadTurnInterrupted(this.state, effectivePayload.paneKey) - } - this.commitStatusRowMutation(rowBefore, previous) - return previous - } if ( effectivePayload.payload.state !== 'done' || effectivePayload.payload.lastAssistantMessage diff --git a/src/shared/__fixtures__/claude-cancel-shell-hooks.jsonl b/src/shared/__fixtures__/claude-cancel-shell-hooks.jsonl new file mode 100644 index 000000000000..df4b6a43ce88 --- /dev/null +++ b/src/shared/__fixtures__/claude-cancel-shell-hooks.jsonl @@ -0,0 +1,32 @@ +{"t": 0.0, "kind": "hook", "index": 0, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "hook_event_name": "SessionStart", "source": "startup", "model": "claude-fable-5-1[1m]"}} +{"t": 5.107, "kind": "hook", "index": 1, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "hook_event_name": "UserPromptSubmit", "prompt": "QA harness step 1: run the shell command `sleep 600` with the Bash tool and run_in_background set to true. Do not wait for it. Then reply with exactly the word STARTED."}} +{"t": 7.383, "kind": "hook", "index": 2, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 600", "description": "Sleep for 600 seconds in background", "run_in_background": true}, "tool_use_id": "toolu_019CFdP5Te6J1GaLQnPKGs7m"}} +{"t": 7.589, "kind": "hook", "index": 3, "sleep_procs": ["9440 9436 S 00:00 sleep 600"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "PostToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 600", "description": "Sleep for 600 seconds in background", "run_in_background": true}, "tool_response": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false, "backgroundTaskId": "b8o5jb42q"}, "tool_use_id": "toolu_019CFdP5Te6J1GaLQnPKGs7m", "duration_ms": 126}} +{"t": 9.258, "kind": "hook", "index": 4, "sleep_procs": ["9440 9436 S 00:01 sleep 600"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "Stop", "stop_hook_active": false, "last_assistant_message": "STARTED", "background_tasks": [{"id": "b8o5jb42q", "type": "shell", "status": "running", "description": "Sleep for 600 seconds in background", "command": "sleep 600"}], "session_crons": []}} +{"t": 12.338, "kind": "hook", "index": 5, "sleep_procs": ["9440 9436 S 00:04 sleep 600"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "hook_event_name": "UserPromptSubmit", "prompt": "QA harness step 2: run this exact command with the Bash tool in the FOREGROUND (run_in_background false), wait for it to finish, then reply with exactly the word DONE: python3 -c 'import time; time.sleep(45); print(\"waited\")'"}} +{"t": 14.834, "kind": "hook", "index": 6, "sleep_procs": ["9440 9436 S 00:07 sleep 600"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "python3 -c 'import time; time.sleep(45); print(\"waited\")'", "timeout": 120000, "description": "Run Python script that waits 45 seconds then prints", "run_in_background": false}, "tool_use_id": "toolu_01BwEqs6HYacU3Ayzurh1MTu"}} +{"t": 17.983, "kind": "cancel", "label": "ESC-during-tool", "keystroke": "\u001b", "interrupted_painted": true, "hooks_before_next_typed_prompt": []} +{"t": 52.299, "kind": "hook", "index": 7, "sleep_procs": ["9440 9436 S 00:44 sleep 600"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "hook_event_name": "UserPromptSubmit", "prompt": "QA harness step 3: reply with exactly the word OK. Do not use any tools."}} +{"t": 53.924, "kind": "hook", "index": 8, "sleep_procs": ["9440 9436 S 00:46 sleep 600"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "Stop", "stop_hook_active": false, "last_assistant_message": "OK", "background_tasks": [{"id": "b8o5jb42q", "type": "shell", "status": "running", "description": "Sleep for 600 seconds in background", "command": "sleep 600"}], "session_crons": []}} +{"t": 57.018, "kind": "hook", "index": 9, "sleep_procs": ["9440 9436 S 00:49 sleep 600"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "hook_event_name": "UserPromptSubmit", "prompt": "QA harness step 4: without using any tools, write a 500-word essay about lighthouses."}} +{"t": 63.124, "kind": "cancel", "label": "ESC-during-stream", "keystroke": "\u001b", "interrupted_painted": true, "hooks_before_next_typed_prompt": []} +{"t": 97.391, "kind": "hook", "index": 10, "sleep_procs": ["9440 9436 S 01:29 sleep 600"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "hook_event_name": "UserPromptSubmit", "prompt": "QA harness step 5: reply with exactly the word OK. Do not use any tools."}} +{"t": 99.377, "kind": "hook", "index": 11, "sleep_procs": ["9440 9436 S 01:31 sleep 600"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "Stop", "stop_hook_active": false, "last_assistant_message": "OK", "background_tasks": [{"id": "b8o5jb42q", "type": "shell", "status": "running", "description": "Sleep for 600 seconds in background", "command": "sleep 600"}], "session_crons": []}} +{"t": 101.626, "kind": "kill", "needle": "sleep 600"} +{"t": 101.711, "kind": "hook", "index": 12, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "hook_event_name": "UserPromptSubmit", "prompt": "\nb8o5jb42q\ntoolu_019CFdP5Te6J1GaLQnPKGs7m\n/private/tmp/claude-501/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000/tasks/b8o5jb42q.output\nfailed\nBackground command \"Sleep for 600 seconds in background\" failed with exit code 137\n"}} +{"t": 104.319, "kind": "hook", "index": 13, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "Stop", "stop_hook_active": false, "last_assistant_message": "The background `sleep 600` from step 1 has ended with exit code 137, which means it was killed with SIGKILL rather than finishing on its own. No further action is pending on my side.", "background_tasks": [], "session_crons": []}} +{"t": 104.558, "kind": "hook", "index": 14, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "hook_event_name": "UserPromptSubmit", "prompt": "QA harness step 6: reply with exactly the word OK. Do not use any tools."}} +{"t": 106.843, "kind": "hook", "index": 15, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "Stop", "stop_hook_active": false, "last_assistant_message": "OK", "background_tasks": [], "session_crons": []}} +{"t": 107.486, "kind": "hook", "index": 16, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "hook_event_name": "UserPromptSubmit", "prompt": "QA harness step 7: run the shell command `sleep 500` with the Bash tool and run_in_background set to true. Do not wait for it. Then reply with exactly the word STARTED."}} +{"t": 109.811, "kind": "hook", "index": 17, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 500", "description": "Sleep for 500 seconds in background", "run_in_background": true}, "tool_use_id": "toolu_014ZQtcX7nUoTYyd4uUbjkEf"}} +{"t": 109.919, "kind": "hook", "index": 18, "sleep_procs": ["27126 27122 S 00:00 sleep 500"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "PostToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 500", "description": "Sleep for 500 seconds in background", "run_in_background": true}, "tool_response": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false, "backgroundTaskId": "bm8pwgjt2"}, "tool_use_id": "toolu_014ZQtcX7nUoTYyd4uUbjkEf", "duration_ms": 8}} +{"t": 110.608, "kind": "hook", "index": 19, "sleep_procs": ["27126 27122 S 00:01 sleep 500"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "hook_event_name": "UserPromptSubmit", "prompt": "QA harness step 8: run this exact command with the Bash tool in the FOREGROUND (run_in_background false), wait for it to finish, then reply with exactly the word DONE: python3 -c 'import time; time.sleep(45); print(\"waited\")'"}} +{"t": 111.374, "kind": "hook", "index": 20, "sleep_procs": ["27126 27122 S 00:01 sleep 500"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "Stop", "stop_hook_active": false, "last_assistant_message": "STARTED", "background_tasks": [{"id": "bm8pwgjt2", "type": "shell", "status": "running", "description": "Sleep for 500 seconds in background", "command": "sleep 500"}], "session_crons": []}} +{"t": 114.445, "kind": "hook", "index": 21, "sleep_procs": ["27126 27122 S 00:04 sleep 500"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "python3 -c 'import time; time.sleep(45); print(\"waited\")'", "timeout": 120000, "description": "Run Python script that waits 45 seconds then prints", "run_in_background": false}, "tool_use_id": "toolu_01EnjkgR7hGZarB7BcBpzYj4"}} +{"t": 117.583, "kind": "kill", "needle": "sleep 500"} +{"t": 119.7, "kind": "cancel", "label": "ESC-during-tool-after-kill", "keystroke": "\u001b", "interrupted_painted": true, "hooks_before_next_typed_prompt": [22, 23]} +{"t": 119.869, "kind": "hook", "index": 22, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "hook_event_name": "UserPromptSubmit", "prompt": "\nbm8pwgjt2\ntoolu_014ZQtcX7nUoTYyd4uUbjkEf\n/private/tmp/claude-501/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000/tasks/bm8pwgjt2.output\nfailed\nBackground command \"Sleep for 500 seconds in background\" failed with exit code 137\n"}} +{"t": 122.848, "kind": "hook", "index": 23, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "Stop", "stop_hook_active": false, "last_assistant_message": "The foreground command in step 8 was rejected before it ran, so I stopped there and did not reply DONE.\n\nSeparately, the background `sleep 500` from step 7 has ended with exit code 137, meaning it was killed rather than completing. Nothing is running now. I'm waiting for your next instruction.", "background_tasks": [], "session_crons": []}} +{"t": 126.975, "kind": "hook", "index": 24, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "hook_event_name": "UserPromptSubmit", "prompt": "QA harness step 9: reply with exactly the word OK. Do not use any tools."}} +{"t": 128.829, "kind": "hook", "index": 25, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "Stop", "stop_hook_active": false, "last_assistant_message": "OK", "background_tasks": [], "session_crons": []}} +{"t": 132.534, "kind": "hook", "index": 26, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SessionEnd", "reason": "prompt_input_exit"}} diff --git a/src/shared/__fixtures__/claude-cancel-shell-hooks.meta.json b/src/shared/__fixtures__/claude-cancel-shell-hooks.meta.json new file mode 100644 index 000000000000..14c2fb686d10 --- /dev/null +++ b/src/shared/__fixtures__/claude-cancel-shell-hooks.meta.json @@ -0,0 +1,16 @@ +{ + "source_scenario": "shell-cancel", + "claude_version": "2.1.280 (Claude Code)", + "argv": [ + "claude", + "--settings", + "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/settings.json", + "--dangerously-skip-permissions" + ], + "platform": "darwin", + "cols": 120, + "rows": 40, + "recorded_at": "2026-09-22", + "clock": "t is seconds since the first record; hook records keep the ps snapshot taken inside the hook", + "note": "Claude Code driven over a real PTY with every hook logged to stdin-capture. A background `sleep 600` is started, then a foreground turn is cancelled with Esc twice (mid-tool, mid-stream); the shell is later SIGKILLed from outside, a `sleep 500` is started and killed, and a turn is cancelled after that kill. Esc fires no hook and kills only the foreground tool; every Stop inventory matched the process table. Scrubbed: username replaced by a same-length placeholder." +} diff --git a/src/shared/__fixtures__/claude-cancel-subagent-hooks.jsonl b/src/shared/__fixtures__/claude-cancel-subagent-hooks.jsonl new file mode 100644 index 000000000000..436e494739ff --- /dev/null +++ b/src/shared/__fixtures__/claude-cancel-subagent-hooks.jsonl @@ -0,0 +1,14 @@ +{"t": 0.0, "kind": "hook", "index": 0, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "hook_event_name": "SessionStart", "source": "startup", "model": "claude-fable-5-1[1m]"}} +{"t": 5.055, "kind": "hook", "index": 1, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "hook_event_name": "UserPromptSubmit", "prompt": "QA harness step 1: use the Agent tool with subagent_type general-purpose, run_in_background true, and this exact prompt: 'Run this exact command with the Bash tool in the foreground and wait for it, then reply done: python3 -c \"import time; time.sleep(120)\"' Do not wait for the agent. Reply with exactly the word STARTED."}} +{"t": 8.091, "kind": "hook", "index": 2, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "PreToolUse", "tool_name": "Agent", "tool_input": {"description": "Run 120s sleep command", "prompt": "Run this exact command with the Bash tool in the foreground and wait for it, then reply done: python3 -c \"import time; time.sleep(120)\"", "subagent_type": "general-purpose"}, "tool_use_id": "toolu_01Jya3MyAKGw27fRoTu5q29K"}} +{"t": 8.183, "kind": "hook", "index": 3, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "PostToolUse", "tool_name": "Agent", "tool_input": {"description": "Run 120s sleep command", "prompt": "Run this exact command with the Bash tool in the foreground and wait for it, then reply done: python3 -c \"import time; time.sleep(120)\"", "subagent_type": "general-purpose"}, "tool_response": {"isAsync": true, "status": "async_launched", "agentId": "a89b41394dcd804b3", "description": "Run 120s sleep command", "resolvedModel": "claude-fable-5-1[1m]", "prompt": "Run this exact command with the Bash tool in the foreground and wait for it, then reply done: python3 -c \"import time; time.sleep(120)\"", "outputFile": "/private/tmp/claude-501/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000/tasks/a89b41394dcd804b3.output", "canReadOutputFile": true}, "tool_use_id": "toolu_01Jya3MyAKGw27fRoTu5q29K", "duration_ms": 3}} +{"t": 8.183, "kind": "hook", "index": 4, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "agent_id": "a89b41394dcd804b3", "agent_type": "general-purpose", "hook_event_name": "SubagentStart"}} +{"t": 9.709, "kind": "hook", "index": 5, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "Stop", "stop_hook_active": false, "last_assistant_message": "STARTED", "background_tasks": [{"id": "a89b41394dcd804b3", "type": "subagent", "status": "running", "description": "Run 120s sleep command", "agent_type": "general-purpose"}], "session_crons": []}} +{"t": 10.82, "kind": "hook", "index": 6, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "agent_id": "a89b41394dcd804b3", "agent_type": "general-purpose", "effort": {"level": "high"}, "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "python3 -c \"import time; time.sleep(120)\"", "timeout": 180000, "description": "Sleep for 120 seconds"}, "tool_use_id": "toolu_01Q5DBAHrLE83NDJaQEMhbvA"}} +{"t": 13.916, "kind": "hook", "index": 7, "sleep_procs": ["78989 71460 Ss 00:03 /bin/zsh -c source /Users/uuuuuuuuuuuuu/.claude/shell-snapshots/snapshot-zsh-1790146573399-brrdnw.sh 2>/dev/null || true && setopt NO_EXTENDED_GLOB NO_BARE_GLOB_QUAL 2>/dev/null || true && { \\builtin unalias -- 'unsetenv'; \\builtin unset -f -- 'unsetenv'; } >/dev/null 2>&1 || true && eval 'python3 -c \"import time; time.sleep(120)\"' < /dev/null && pwd -P >| /tmp/claude-6f1c-cwd", "78991 78989 S 00:03 /opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/Python.framework/Versions/3.14/Resources/Python.app/Contents/MacOS/Python -c import time; time.sleep(120)"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "hook_event_name": "UserPromptSubmit", "prompt": "QA harness step 2: run this exact command with the Bash tool in the FOREGROUND (run_in_background false), wait for it to finish, then reply with exactly the word DONE: python3 -c 'import time; time.sleep(45); print(\"waited\")'"}} +{"t": 16.298, "kind": "hook", "index": 8, "sleep_procs": ["78989 71460 Ss 00:05 /bin/zsh -c source /Users/uuuuuuuuuuuuu/.claude/shell-snapshots/snapshot-zsh-1790146573399-brrdnw.sh 2>/dev/null || true && setopt NO_EXTENDED_GLOB NO_BARE_GLOB_QUAL 2>/dev/null || true && { \\builtin unalias -- 'unsetenv'; \\builtin unset -f -- 'unsetenv'; } >/dev/null 2>&1 || true && eval 'python3 -c \"import time; time.sleep(120)\"' < /dev/null && pwd -P >| /tmp/claude-6f1c-cwd", "78991 78989 S 00:05 /opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/Python.framework/Versions/3.14/Resources/Python.app/Contents/MacOS/Python -c import time; time.sleep(120)"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "python3 -c 'import time; time.sleep(45); print(\"waited\")'", "description": "Sleep 45 seconds then print", "run_in_background": false}, "tool_use_id": "toolu_01G1JZD4vggVf9bNbXB1YXSL"}} +{"t": 19.421, "kind": "cancel", "label": "ESC-during-tool-with-bg-agent", "keystroke": "\u001b", "interrupted_painted": true, "hooks_before_next_typed_prompt": [9]} +{"t": 40.827, "kind": "hook", "index": 9, "sleep_procs": ["78989 71460 Ss 00:30 /bin/zsh -c source /Users/uuuuuuuuuuuuu/.claude/shell-snapshots/snapshot-zsh-1790146573399-brrdnw.sh 2>/dev/null || true && setopt NO_EXTENDED_GLOB NO_BARE_GLOB_QUAL 2>/dev/null || true && { \\builtin unalias -- 'unsetenv'; \\builtin unset -f -- 'unsetenv'; } >/dev/null 2>&1 || true && eval 'python3 -c \"import time; time.sleep(120)\"' < /dev/null && pwd -P >| /tmp/claude-6f1c-cwd", "78991 78989 S 00:30 /opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/Python.framework/Versions/3.14/Resources/Python.app/Contents/MacOS/Python -c import time; time.sleep(120)"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "agent_id": "a5bdef686e5dc3779", "effort": {"level": "high"}, "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "python3 -c \"import time; time.sleep(120)\"", "timeout": 180000, "description": "Sleep for 120 seconds"}, "tool_use_id": "toolu_01AWMoEBQ9UX4Yaup9ovj39G"}} +{"t": 54.597, "kind": "hook", "index": 10, "sleep_procs": ["78989 71460 Ss 00:44 /bin/zsh -c source /Users/uuuuuuuuuuuuu/.claude/shell-snapshots/snapshot-zsh-1790146573399-brrdnw.sh 2>/dev/null || true && setopt NO_EXTENDED_GLOB NO_BARE_GLOB_QUAL 2>/dev/null || true && { \\builtin unalias -- 'unsetenv'; \\builtin unset -f -- 'unsetenv'; } >/dev/null 2>&1 || true && eval 'python3 -c \"import time; time.sleep(120)\"' < /dev/null && pwd -P >| /tmp/claude-6f1c-cwd", "78991 78989 S 00:44 /opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/Python.framework/Versions/3.14/Resources/Python.app/Contents/MacOS/Python -c import time; time.sleep(120)"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "hook_event_name": "UserPromptSubmit", "prompt": "QA harness step 3: reply with exactly the word OK. Do not use any tools."}} +{"t": 55.649, "kind": "hook", "index": 11, "sleep_procs": ["78989 71460 Ss 00:45 /bin/zsh -c source /Users/uuuuuuuuuuuuu/.claude/shell-snapshots/snapshot-zsh-1790146573399-brrdnw.sh 2>/dev/null || true && setopt NO_EXTENDED_GLOB NO_BARE_GLOB_QUAL 2>/dev/null || true && { \\builtin unalias -- 'unsetenv'; \\builtin unset -f -- 'unsetenv'; } >/dev/null 2>&1 || true && eval 'python3 -c \"import time; time.sleep(120)\"' < /dev/null && pwd -P >| /tmp/claude-6f1c-cwd", "78991 78989 S 00:45 /opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/Python.framework/Versions/3.14/Resources/Python.app/Contents/MacOS/Python -c import time; time.sleep(120)"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "Stop", "stop_hook_active": false, "last_assistant_message": "OK", "background_tasks": [{"id": "a89b41394dcd804b3", "type": "subagent", "status": "running", "description": "Run 120s sleep command", "agent_type": "general-purpose"}], "session_crons": []}} +{"t": 61.48, "kind": "hook", "index": 12, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/scratch", "prompt_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SessionEnd", "reason": "prompt_input_exit"}} diff --git a/src/shared/__fixtures__/claude-cancel-subagent-hooks.meta.json b/src/shared/__fixtures__/claude-cancel-subagent-hooks.meta.json new file mode 100644 index 000000000000..16dd97d6bc00 --- /dev/null +++ b/src/shared/__fixtures__/claude-cancel-subagent-hooks.meta.json @@ -0,0 +1,16 @@ +{ + "source_scenario": "bg-subagent-cancel", + "claude_version": "2.1.280 (Claude Code)", + "argv": [ + "claude", + "--settings", + "/Users/uuuuuuuuuuuuu/orca-qa/phase0-cancel/settings.json", + "--dangerously-skip-permissions" + ], + "platform": "darwin", + "cols": 120, + "rows": 40, + "recorded_at": "2026-09-22", + "clock": "t is seconds since the first record; hook records keep the ps snapshot taken inside the hook", + "note": "Claude Code driven over a real PTY with every hook logged. The main agent launches an Agent-tool subagent (2.1.280 launches it asynchronously and Stops), then a foreground turn on the main agent is cancelled with Esc. No hook fires for the cancel; the subagent and its shell survive it and the next Stop still lists the subagent as running. Scrubbed: username replaced by a same-length placeholder." +} diff --git a/src/shared/agent-hook-listener-grok-completion.test.ts b/src/shared/agent-hook-listener-grok-completion.test.ts index c62222510d0d..263c9e01a03f 100644 --- a/src/shared/agent-hook-listener-grok-completion.test.ts +++ b/src/shared/agent-hook-listener-grok-completion.test.ts @@ -130,6 +130,35 @@ describe('Grok completion observations', () => { }) }) + it('keeps the cancellation on the idle backstop that settles a task the cancel left running', () => { + normalize({ + hookEventName: 'UserPromptSubmit', + sessionId: 's-1', + promptId: 'p-1', + prompt: 'go' + }) + expect( + normalize({ + hookEventName: 'StopCancelled', + sessionId: 's-1', + promptId: 'p-1', + backgroundTasks: [{ id: 'task-1', type: 'shell', status: 'running' }] + }) + ).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + // A settled row without `interrupted` would announce the cancelled turn as a clean finish. + expect( + normalize({ + hookEventName: 'Notification', + sessionId: 's-1', + notificationType: 'idle_prompt' + }) + ).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + }) + // Pins grok-events.ts: finite task predicate and sessionCrons omission; treating monitors/crons as finite must redden. it.each([ { @@ -264,14 +293,17 @@ describe('Grok completion observations', () => { expect(row?.workingMode).toBeUndefined() }) - it('settles a cancel or a session boundary even while a background subagent runs', () => { - expect( - normalize({ - hookEventName: 'StopCancelled', - reason: 'user_interrupt', - backgroundTasks: [backgroundSubagent] - }) - ).toMatchObject({ state: 'done', interrupted: true }) + it('keeps a background subagent working past a cancel, and settles only at a session boundary', () => { + const cancelled = normalize({ + hookEventName: 'StopCancelled', + reason: 'user_interrupt', + backgroundTasks: [backgroundSubagent] + }) + expect(cancelled).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + expect(cancelled?.interrupted).toBeUndefined() expect( normalize({ hookEventName: 'Stop', diff --git a/src/shared/agent-hook-listener/providers/claude-events.ts b/src/shared/agent-hook-listener/providers/claude-events.ts index 335bf8c49fbb..7ff7a59ac590 100644 --- a/src/shared/agent-hook-listener/providers/claude-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-events.ts @@ -1,5 +1,5 @@ import type { ParsedAgentStatusPayload } from '../../agent-status-types' -import { mainAgentTurnInterrupted } from '../../agent-lead-status-fold' +import { isAgentStatusHeldOpenByChildWork } from '../../agent-lead-status-fold' import { isAskUserQuestionTool } from '../../agent-question-answered-intent' import { readClaudeBackgroundAgentTasks } from '../../claude-background-task-inventory' import { @@ -73,24 +73,23 @@ export function normalizeClaudeEvent( }) } const previousLead = state.claudeLeadStateByPaneKey.get(paneKey) - // Why: only a turn boundary may declare an interrupt or carry a prior one forward; any other event starts a fresh turn and drops it. + // Why: only a turn boundary may declare a verdict or carry a prior cancellation forward; any + // other event starts a fresh turn and drops it. The verdict is a fact about the main agent's turn and + // nothing else: a cancel never touches the shell, cron or subagent the turn left running. const isTurnBoundary = eventName === 'Stop' || eventName === 'StopFailure' - const interrupted = - isTurnBoundary && - ((eventAgentId === undefined && hookPayload['is_interrupt'] === true) || - mainAgentTurnInterrupted(previousLead)) - ? true - : undefined // Why: absent means unknown — a plain Stop never becomes `success`, so a cancel can never read as a // success. Current Claude sends NO hook on a cancel and no `is_interrupt` on Stop, so the // cancellation normally arrives through Orca's own inferred interrupt // (`markClaudeLeadTurnInterrupted`) and is carried forward here; `is_interrupt` on a turn // boundary is kept as the secondary source for builds that do send it. - const outcome = interrupted - ? ('cancellation' as const) - : isTurnBoundary && eventName === 'StopFailure' - ? ('failure' as const) - : undefined + const outcome = !isTurnBoundary + ? undefined + : (eventAgentId === undefined && hookPayload['is_interrupt'] === true) || + previousLead?.outcome === 'cancellation' + ? ('cancellation' as const) + : eventName === 'StopFailure' + ? ('failure' as const) + : undefined const backgroundTasks = readClaudeBackgroundAgentTasks(hookPayload) const sessionCrons = hookPayload['session_crons'] const sessionCronInventoryPresent = Array.isArray(sessionCrons) @@ -129,15 +128,10 @@ export function normalizeClaudeEvent( return null } if (backgroundTasks.present && eventAgentId === undefined) { - updateClaudeRunningNonAgentTask( - state, - paneKey, - backgroundTasks.hasRunningNonAgentTask, - interrupted === true - ) + updateClaudeRunningNonAgentTask(state, paneKey, backgroundTasks.hasRunningNonAgentTask) } if (sessionCronInventoryPresent && eventAgentId === undefined) { - if (hasActiveSessionCron && interrupted !== true) { + if (hasActiveSessionCron) { state.claudeActiveSessionCronPaneKeys.add(paneKey) } else { state.claudeActiveSessionCronPaneKeys.delete(paneKey) @@ -205,9 +199,7 @@ export function normalizeClaudeEvent( ) return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { ...resolveClaudePaneStatus(state, paneKey, restored), - updateToolSnapshot: true, - interrupted: mainAgentTurnInterrupted(restored), - turnCompletedAt: restored.turnCompletedAt + updateToolSnapshot: true }) } @@ -251,11 +243,6 @@ export function normalizeClaudeEvent( : undefined const waitingToolUseId = eventToolUseId ?? previousLead?.waitingToolUseId - if (interrupted && eventAgentId === undefined) { - state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - state.claudeActiveSessionCronPaneKeys.delete(paneKey) - } - if (isManualCompactCompletion) { // Why: a manual /compact only ever completes at an idle prompt, so a child that exists ONLY as // a disk snapshot has nothing live behind it and must not keep the pane spinning — that @@ -272,20 +259,22 @@ export function normalizeClaudeEvent( } } - const resolvedStatus = resolveClaudePaneStatus(state, paneKey, { - state: reportedStateName, - outcome - }) + const resolvedStatus = resolveClaudePaneStatus(state, paneKey, { state: reportedStateName }) // Why: #15202's compact-completion guard reads the resolved state; this branch replaced the // resolver with one that also reports workingMode, so bridge rather than resolve twice. const effectiveState = resolvedStatus.stateName - // Why: the lead already ended — the pane stays `working` only because background inventory is still registered. `stateStartedAt` is pinned for that whole run, so this end time is the per-turn identity and the later all-clear's pair key. + // Why: the main agent already ended — the pane stays `working` only because background inventory is + // still registered. `stateStartedAt` is pinned for that whole run, so this end time is the + // per-turn identity and the later all-clear's pair key. A cancelled turn is not a completion: + // the row still reads what the shell says, but it earns no completion stamp to announce. const turnCompletedAt = eventAgentId === undefined && isTurnBoundary && - reportedStateName === 'done' && - resolvedStatus.stateName === 'working' && - interrupted !== true + outcome !== 'cancellation' && + isAgentStatusHeldOpenByChildWork({ + state: resolvedStatus.stateName, + mainAgent: { state: reportedStateName } + }) ? Date.now() : undefined @@ -324,11 +313,9 @@ export function normalizeClaudeEvent( return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { ...resolvedStatus, updateToolSnapshot: true, - interrupted, // Why: a finished compact is a session-shaped boundary, not a completed turn. Without this the // clearing `done` would fire completion notifications, unread counts and automation-run // completion evidence for work nobody did. - sessionBoundary: isManualCompactCompletion ? true : undefined, - turnCompletedAt + sessionBoundary: isManualCompactCompletion ? true : undefined }) } diff --git a/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts b/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts index ada041a07f6e..3db154574cd1 100644 --- a/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts @@ -9,7 +9,6 @@ import { } from '../../claude-subagent-roster' import type { HookListenerState } from '../listener-state' import { readString } from '../tool-input-preview' -import { mainAgentTurnInterrupted } from '../../agent-lead-status-fold' import { clearClaudePendingWaitForAgent, getOrCreateClaudeSubagentRoster, @@ -83,9 +82,10 @@ export function normalizeClaudeSubagentLifecycleEvent( } const workingChildEvidence = claudeRosterHasRuntimeWorkingSubagent(roster) const hasUnconfirmedChild = claudeRosterHasRestoredSnapshotSubagent(roster) + // Why: a shell or cron the inventory positively reported is live evidence whatever verdict + // ended the main agent's turn; a cancel never discounts it. const hasConfirmedDoneGate = cachedLead?.state === 'done' && - !mainAgentTurnInterrupted(cachedLead) && (state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || state.claudeActiveSessionCronPaneKeys.has(paneKey)) const restoredOnlyDoneGate = @@ -132,14 +132,10 @@ export function buildClaudeCachedLeadStatusPayload( return null } } + // Why: draining the last background child is this turn's all-clear; the builder repeats the + // record's turn stamp so a consumer can pair it with the announcement already sent. return buildClaudeStatusPayload(state, eventName, '', paneKey, hookPayload, { - ...resolveClaudePaneStatus(state, paneKey, { - state: leadState, - outcome: lead?.outcome - }), - updateToolSnapshot: false, - interrupted: mainAgentTurnInterrupted(lead), - // Why: draining the last background child is this turn's all-clear; the stamp lets a consumer pair it with the announcement already sent. - turnCompletedAt: lead?.turnCompletedAt + ...resolveClaudePaneStatus(state, paneKey, { state: leadState }), + updateToolSnapshot: false }) } diff --git a/src/shared/agent-hook-listener/providers/claude-roster-state.ts b/src/shared/agent-hook-listener/providers/claude-roster-state.ts index c35fa2c03c6c..c8e123398281 100644 --- a/src/shared/agent-hook-listener/providers/claude-roster-state.ts +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -1,5 +1,6 @@ import type { AgentMainAgentStatus, + AgentStatusState, AgentSubagentSnapshot, AgentWorkingMode } from '../../agent-status-types' @@ -109,16 +110,14 @@ export function getOrCreateClaudeSubagentRoster( return roster } +/** The inventory is the only judge of a running shell: it retires the gate when it omits the + * shell, and nothing about how the main agent's turn ended may override what it positively reports. */ export function updateClaudeRunningNonAgentTask( state: HookListenerState, paneKey: string, - hasRunningNonAgentTask: boolean, - /** Lead-turn property. Pass `false` from any non-lead fold: an interrupt clears the gate even when - * the inventory positively reports a running shell, which is a live-shell judgement no new call - * site may inherit by copying this signature. */ - interrupted: boolean + hasRunningNonAgentTask: boolean ): void { - if (hasRunningNonAgentTask && !interrupted) { + if (hasRunningNonAgentTask) { state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) } else { state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) @@ -165,11 +164,10 @@ export function claudeMainAgentStatusForPayload( export function resolveClaudePaneStatus( state: HookListenerState, paneKey: string, - lead: Pick + lead: Pick ): ClaudePaneStatusResolution { return foldAgentLeadStatus({ leadState: lead.state, - interrupted: mainAgentTurnInterrupted(lead), childWorkLiveness: agentChildWorkLivenessFromEvidence({ // A child's permission wait displaces the main agent record itself (`waitingAgentId`, // `stateBeforeWait`) instead of living on the roster, so the roster never carries one. @@ -183,11 +181,29 @@ export function resolveClaudePaneStatus( }) }) } -/** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C with no Stop; current Claude sends no hook on a cancel, and a bare Esc is never inferred for Claude); else a later child lifecycle event resurrects the cancelled pane. This is the primary source of `mainAgent.outcome: 'cancellation'` in the CLI lane. */ -export function markClaudeLeadTurnInterrupted(state: HookListenerState, paneKey: string): void { - setClaudeMainAgentTurnState(state, paneKey, { state: 'done', outcome: 'cancellation' }) - state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - state.claudeActiveSessionCronPaneKeys.delete(paneKey) + +/** The SERVER inferred a cancel of a LOCAL pane outside the hook stream (Ctrl+C with no Stop; + * current Claude sends no hook on a cancel, and a bare Esc is never inferred for Claude): record + * the main agent's verdict and fold it with the child work the turn left running, exactly as a + * Stop would be. This is the primary source of `mainAgent.outcome: 'cancellation'` in the CLI + * lane, and the record is what keeps a later child lifecycle event from resurrecting the + * cancelled main agent. Nothing here retires a shell, cron or subagent: they outlive the cancel + * and leave only when their inventory says so. */ +export function markClaudeLeadTurnInterrupted( + state: HookListenerState, + paneKey: string +): { state: AgentStatusState; workingMode?: AgentWorkingMode; mainAgent?: AgentMainAgentStatus } { + const record = setClaudeMainAgentTurnState(state, paneKey, { + state: 'done', + outcome: 'cancellation' + }) + const resolved = resolveClaudePaneStatus(state, paneKey, record) + const mainAgent = claudeMainAgentStatusForPayload(record) + return { + state: resolved.stateName, + ...(resolved.workingMode ? { workingMode: resolved.workingMode } : {}), + ...(mainAgent ? { mainAgent } : {}) + } } /** Rebuild a pane's working roster from a persisted snapshot; live activity confirms a seed, a complete task inventory may reap an unconfirmed one whose finish hook arrived while Orca was offline. */ diff --git a/src/shared/agent-hook-listener/providers/claude-status-build.ts b/src/shared/agent-hook-listener/providers/claude-status-build.ts index 9facff06d397..9f6423c401c4 100644 --- a/src/shared/agent-hook-listener/providers/claude-status-build.ts +++ b/src/shared/agent-hook-listener/providers/claude-status-build.ts @@ -8,6 +8,7 @@ import { claudeRosterToSnapshots } from '../../claude-subagent-roster' import { resolvePrompt, resolveToolState } from '../prompt-fields' import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' import type { HookListenerState } from '../listener-state' +import { mainAgentTurnInterrupted } from '../../agent-lead-status-fold' import { claudeMainAgentStatusForPayload } from './claude-roster-state' export function buildClaudeStatusPayload( @@ -20,9 +21,7 @@ export function buildClaudeStatusPayload( stateName: AgentStatusState workingMode?: AgentWorkingMode updateToolSnapshot: boolean - interrupted?: boolean sessionBoundary?: boolean - turnCompletedAt?: number } ): ParsedAgentStatusPayload | null { // Why: child-driven refreshes are roster bookkeeping, not lead tool activity; read the cached snapshot without merging so they can't clear a live AskUserQuestion card or clobber the tool preview. @@ -32,9 +31,12 @@ export function buildClaudeStatusPayload( }) : (state.lastToolByPaneKey.get(paneKey) ?? {}) + // Why: every path writes the main agent record before building, so the row's `mainAgent`, its + // `interrupted` flag and its turn stamp are all read off that one record rather than restated by + // each caller. The normalizer clamps `interrupted` to done payloads, so a row held open by child + // work drops it; the record keeps the verdict for the eventual done. const mainAgentRecord = state.claudeLeadStateByPaneKey.get(paneKey) // Why: validate directly — the JSON stringify/parse round trip other normalizers use is pure overhead on this hot per-hook path. - // The normalizer clamps `interrupted` to done payloads, so a gated 'working' emit drops it; claudeLeadStateByPaneKey preserves it for the eventual done. return normalizeAgentStatusPayload({ state: options.stateName, workingMode: options.workingMode, @@ -48,11 +50,10 @@ export function buildClaudeStatusPayload( interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, - interrupted: options.interrupted, + interrupted: mainAgentTurnInterrupted(mainAgentRecord), sessionBoundary: options.sessionBoundary, - turnCompletedAt: options.turnCompletedAt, + turnCompletedAt: mainAgentRecord?.turnCompletedAt, subagents: claudeRosterToSnapshots(state.claudeSubagentRosterByPaneKey.get(paneKey)), - // Why: every path writes the main agent record before building, so the row's `mainAgent` is that record. mainAgent: mainAgentRecord ? claudeMainAgentStatusForPayload(mainAgentRecord) : undefined }) } diff --git a/src/shared/agent-hook-listener/providers/codex-events.ts b/src/shared/agent-hook-listener/providers/codex-events.ts index c7f615213704..b253736a9e00 100644 --- a/src/shared/agent-hook-listener/providers/codex-events.ts +++ b/src/shared/agent-hook-listener/providers/codex-events.ts @@ -5,7 +5,10 @@ import { } from '../../agent-status-types' import { normalizeOptionalField } from '../../agent-status-field-normalization' import { isAskUserQuestionTool } from '../../agent-question-answered-intent' -import type { AgentLeadStatusResolution } from '../../agent-lead-status-fold' +import { + mainAgentTurnInterrupted, + type AgentLeadStatusResolution +} from '../../agent-lead-status-fold' import { codexRosterToSnapshots, finishCodexSubagent, @@ -59,6 +62,7 @@ export function buildCodexStatusPayload( interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, + interrupted: mainAgentTurnInterrupted(lead), subagents: codexRosterToSnapshots(state.codexSubagentRosterByPaneKey.get(paneKey)), mainAgent: codexMainAgentStatusForPayload(lead) }) diff --git a/src/shared/agent-hook-listener/providers/codex-state.ts b/src/shared/agent-hook-listener/providers/codex-state.ts index e2f7e2f5ae3e..291489f1cd6a 100644 --- a/src/shared/agent-hook-listener/providers/codex-state.ts +++ b/src/shared/agent-hook-listener/providers/codex-state.ts @@ -82,11 +82,10 @@ export function codexOutcomeRestatedByStop( export function resolveCodexPaneStatus( state: HookListenerState, paneKey: string, - record: Pick + record: Pick ): AgentLeadStatusResolution { return foldAgentLeadStatus({ leadState: record.state, - interrupted: mainAgentTurnInterrupted(record), childWorkLiveness: codexRosterChildWorkLiveness(state.codexSubagentRosterByPaneKey.get(paneKey)) }) } @@ -232,6 +231,8 @@ export function reconcileRemoteCodexState( prompt, state: resolution.stateName, workingMode: resolution.workingMode, + interrupted: + resolution.stateName === 'done' && mainAgentTurnInterrupted(lead) ? true : undefined, model: lead.model ?? payload.model, subagents: codexRosterToSnapshots(roster), // Why: main's cache outlives a relay restart, so it is the main agent fact for a relayed row too. diff --git a/src/shared/agent-hook-listener/providers/grok-events.ts b/src/shared/agent-hook-listener/providers/grok-events.ts index 4ba6c58d17ff..e44d7e097964 100644 --- a/src/shared/agent-hook-listener/providers/grok-events.ts +++ b/src/shared/agent-hook-listener/providers/grok-events.ts @@ -120,10 +120,10 @@ function grokRunningFiniteTasks( }) } -/** What a plain `stop` leaves running behind the main agent. A background subagent is agent work +/** What a turn end leaves running behind the main agent. A background subagent is agent work * and keeps the pane `working`; a shell, or a still-active stop hook holding the turn, is watch * work and reads as monitoring. */ -function grokChildWorkLivenessAfterStop( +function grokChildWorkLivenessAfterTurnEnd( hookPayload: Record ): AgentChildWorkLiveness { const stopHookActive = aliasedField(hookPayload, 'stopHookActive', 'stop_hook_active') @@ -222,15 +222,14 @@ export function normalizeGrokEvent( : isGrokEvent(eventName, 'stop_failure') ? ('failure' as const) : undefined - // Only a plain end-of-turn `stop` reports what it left running; a cancel, a failure and a - // session boundary settle the pane whatever the inventory says, as they always have. + // Why: every turn end reports what it left running, and a task leaves only when it reports its + // own end or the session ends — a cancelled or failed turn with a still-running task reads + // monitoring exactly like a plain `stop`. Only a session boundary settles the pane whatever + // the inventory says. const resolution = foldAgentLeadStatus({ leadState, - interrupted: outcome === 'cancellation', childWorkLiveness: - isGrokEvent(eventName, 'stop') && !sessionBoundary - ? grokChildWorkLivenessAfterStop(hookPayload) - : null + isTurnEnd && !sessionBoundary ? grokChildWorkLivenessAfterTurnEnd(hookPayload) : null }) const stateName = resolution.stateName const previousMainAgent = state.grokMainAgentStatusByPaneKey.get(paneKey) @@ -271,7 +270,8 @@ export function normalizeGrokEvent( lastAssistantMessage: snapshot.lastAssistantMessage, lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, ...(resolution.workingMode ? { workingMode: resolution.workingMode } : {}), - ...(outcome === 'cancellation' ? { interrupted: true } : {}), + // Why: derived from the main agent, so the idle backstop that settles a cancelled turn held open by a task still reads interrupted. + ...(mainAgent.outcome === 'cancellation' ? { interrupted: true } : {}), ...(sessionBoundary ? { sessionBoundary: true } : {}), mainAgent }) diff --git a/src/shared/agent-lead-status-fold.test.ts b/src/shared/agent-lead-status-fold.test.ts index aba8033f45fa..6113e7cb9d40 100644 --- a/src/shared/agent-lead-status-fold.test.ts +++ b/src/shared/agent-lead-status-fold.test.ts @@ -9,62 +9,55 @@ import { describe('foldAgentLeadStatus', () => { it('keeps a lead that is not settled, whatever its children do', () => { - expect( - foldAgentLeadStatus({ - leadState: 'blocked', - interrupted: false, - childWorkLiveness: 'working' - }) - ).toEqual({ stateName: 'blocked' }) + expect(foldAgentLeadStatus({ leadState: 'blocked', childWorkLiveness: 'working' })).toEqual({ + stateName: 'blocked' + }) }) it('reads a settled lead with live agent work as working', () => { - expect( - foldAgentLeadStatus({ leadState: 'done', interrupted: false, childWorkLiveness: 'working' }) - ).toEqual({ stateName: 'working' }) + expect(foldAgentLeadStatus({ leadState: 'done', childWorkLiveness: 'working' })).toEqual({ + stateName: 'working' + }) }) it('reads a settled lead with only watch loops as monitoring', () => { - expect( - foldAgentLeadStatus({ - leadState: 'done', - interrupted: false, - childWorkLiveness: 'monitoring' - }) - ).toEqual({ stateName: 'working', workingMode: 'monitoring' }) + expect(foldAgentLeadStatus({ leadState: 'done', childWorkLiveness: 'monitoring' })).toEqual({ + stateName: 'working', + workingMode: 'monitoring' + }) }) - it('does not read a watch loop as monitoring after an interrupt, but keeps agent work', () => { - expect( - foldAgentLeadStatus({ leadState: 'done', interrupted: true, childWorkLiveness: 'monitoring' }) - ).toEqual({ stateName: 'done' }) - expect( - foldAgentLeadStatus({ leadState: 'done', interrupted: true, childWorkLiveness: 'working' }) - ).toEqual({ stateName: 'working' }) + it("takes no verdict: how the mainAgent's turn ended is not a fold input", () => { + // A cancel is a fact about the main agent, carried on `mainAgent.outcome`; the work it left running + // reads exactly as it would after a plain end of turn. + const input: Parameters[0] = { + leadState: 'done', + childWorkLiveness: 'monitoring' + } + expect(Object.keys(input)).toEqual(['leadState', 'childWorkLiveness']) + expect(foldAgentLeadStatus(input)).toEqual({ stateName: 'working', workingMode: 'monitoring' }) }) it('settles when nothing is running', () => { - expect( - foldAgentLeadStatus({ leadState: 'done', interrupted: false, childWorkLiveness: null }) - ).toEqual({ stateName: 'done' }) + expect(foldAgentLeadStatus({ leadState: 'done', childWorkLiveness: null })).toEqual({ + stateName: 'done' + }) }) describe('a child waiting on a human', () => { - it('makes a working or settled main agent wait, even after an interrupt', () => { + it('makes a working or settled main agent wait', () => { for (const leadState of ['working', 'done'] as const) { - for (const interrupted of [false, true]) { - expect( - foldAgentLeadStatus({ leadState, interrupted, childWorkLiveness: 'waiting' }) - ).toEqual({ stateName: 'waiting' }) - } + expect(foldAgentLeadStatus({ leadState, childWorkLiveness: 'waiting' })).toEqual({ + stateName: 'waiting' + }) } }) it("yields to the main agent's own request for a human, in the main agent's own vocabulary", () => { for (const leadState of ['waiting', 'blocked'] as const) { - expect( - foldAgentLeadStatus({ leadState, interrupted: false, childWorkLiveness: 'waiting' }) - ).toEqual({ stateName: leadState }) + expect(foldAgentLeadStatus({ leadState, childWorkLiveness: 'waiting' })).toEqual({ + stateName: leadState + }) } }) }) @@ -116,19 +109,17 @@ describe('isAgentTimeAccruing', () => { const leadStates = ['working', 'waiting', 'blocked', 'done'] as const const liveness = ['waiting', 'working', 'monitoring', null] as const for (const leadState of leadStates) { - for (const interrupted of [false, true]) { - for (const childWorkLiveness of liveness) { - const folded = foldAgentLeadStatus({ leadState, interrupted, childWorkLiveness }) - const accrues = isAgentTimeAccruing({ - state: folded.stateName, - workingMode: folded.workingMode - }) - // A child waiting on a human pauses the row, whatever the main agent is doing. - expect(accrues).toBe( - childWorkLiveness !== 'waiting' && - (leadState === 'working' || (leadState === 'done' && childWorkLiveness === 'working')) - ) - } + for (const childWorkLiveness of liveness) { + const folded = foldAgentLeadStatus({ leadState, childWorkLiveness }) + const accrues = isAgentTimeAccruing({ + state: folded.stateName, + workingMode: folded.workingMode + }) + // A child waiting on a human pauses the row, whatever the main agent is doing. + expect(accrues).toBe( + childWorkLiveness !== 'waiting' && + (leadState === 'working' || (leadState === 'done' && childWorkLiveness === 'working')) + ) } } }) diff --git a/src/shared/agent-lead-status-fold.ts b/src/shared/agent-lead-status-fold.ts index f2a38b11b563..854fea121dce 100644 --- a/src/shared/agent-lead-status-fold.ts +++ b/src/shared/agent-lead-status-fold.ts @@ -5,9 +5,6 @@ export type AgentLeadStatusFoldInput = { /** The main agent's own turn state. Anything but `done` wins over child work, except that a * child waiting on a human outranks a working main agent. */ leadState: AgentStatusState - /** A lead turn that ended by interrupt keeps a watch loop from reading as monitoring; - * live agent work still counts, because it outlives the interrupt. */ - interrupted: boolean childWorkLiveness: AgentChildWorkLiveness } @@ -16,7 +13,8 @@ export type AgentLeadStatusResolution = { workingMode?: AgentWorkingMode } -/** A cancelled turn is the one verdict the display fold reads off the main agent record. */ +/** The row's `interrupted` flag for readers that predate `mainAgent`, derived from the main agent's + * verdict. The display fold never reads it. */ export function mainAgentTurnInterrupted( record: Pick | undefined ): boolean { @@ -29,6 +27,10 @@ export function mainAgentTurnInterrupted( * agent with live agent work is still working, and one with only watch loops is * monitoring. Every lane derives the liveness from its own evidence, but the * policy must not differ. + * + * How the main agent's turn ended is not an input. A cancel is a verdict on the main agent + * (`mainAgent.outcome`), never on the shell or subagent it left running: that work + * leaves the fold only when it reports its own end or the session ends. */ export function foldAgentLeadStatus(input: AgentLeadStatusFoldInput): AgentLeadStatusResolution { // The main agent's own request for a human keeps its own vocabulary (`blocked` in the @@ -45,7 +47,7 @@ export function foldAgentLeadStatus(input: AgentLeadStatusFoldInput): AgentLeadS if (input.childWorkLiveness === 'working') { return { stateName: 'working' } } - if (input.childWorkLiveness === 'monitoring' && !input.interrupted) { + if (input.childWorkLiveness === 'monitoring') { return { stateName: 'working', workingMode: 'monitoring' } } return { stateName: 'done' } diff --git a/src/shared/claude-background-task-status.test.ts b/src/shared/claude-background-task-status.test.ts index ced77047dcf0..b823dd532387 100644 --- a/src/shared/claude-background-task-status.test.ts +++ b/src/shared/claude-background-task-status.test.ts @@ -228,7 +228,7 @@ describe('Claude background task status', () => { expect(midTurn?.turnCompletedAt).toBeUndefined() }) - it('does not stamp an interrupted lead, which settles done instead of monitoring', () => { + it('reads monitoring after an inferred cancel whose next Stop lists the shell, with no completion stamp', () => { const state = createHookListenerState() claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'start it' }) @@ -238,8 +238,15 @@ describe('Claude background task status', () => { background_tasks: [RUNNING_SHELL] }) - expect(interrupted?.state).toBe('done') - expect(interrupted?.workingMode).toBeUndefined() + // Why: the cancel is the main agent's verdict, the shell is the inventory's fact, and the two never + // trade: the row shows the shell, the verdict rides `mainAgent.outcome`. A cancelled turn is not a + // completion, so nothing is stamped for the notification path to announce. + expect(interrupted).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + expect(interrupted?.interrupted).toBeUndefined() expect(interrupted?.turnCompletedAt).toBeUndefined() }) @@ -297,38 +304,48 @@ describe('Claude background task status', () => { ).toBe('working') }) - it('keeps an interrupted Stop terminal even when its task inventory is still running', () => { + it('keeps a shell the interrupted Stop still lists until an inventory omits it', () => { const state = createHookListenerState() + const monitoring = { + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + } const interrupted = claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', is_interrupt: true, background_tasks: [RUNNING_SHELL] }) - expect(interrupted).toMatchObject({ state: 'done', interrupted: true }) + expect(interrupted).toMatchObject(monitoring) expect( claudeEvent(state, SOURCE_PANE, { hook_event_name: 'SubagentStop', agent_id: 'a70fdf2986e38302b', background_tasks: [RUNNING_SHELL] }) - ).toMatchObject({ state: 'done', interrupted: true }) + ).toMatchObject(monitoring) expect( claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }) - ).toMatchObject({ state: 'done', interrupted: true }) + ).toMatchObject(monitoring) expect( claudeEvent(state, SOURCE_PANE, { hook_event_name: 'SubagentStop', agent_id: 'a8ab60ba5d4410c47', background_tasks: [RUNNING_SHELL] }) - ).toMatchObject({ state: 'done', interrupted: true }) + ).toMatchObject(monitoring) + // Why: the shell leaves when its inventory omits it; the verdict the main agent carried into that + // Stop is what the settled row's `interrupted` flag restates for older readers. + expect( + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', background_tasks: [] }) + ).toMatchObject({ state: 'done', interrupted: true, mainAgent: { outcome: 'cancellation' } }) }) - it('keeps an interrupted Stop terminal while a session cron remains', () => { + it('keeps a session cron the interrupted Stop still lists', () => { const state = createHookListenerState() expect( @@ -337,7 +354,12 @@ describe('Claude background task status', () => { is_interrupt: true, session_crons: [{ id: 'cron-1' }] }) - ).toMatchObject({ state: 'done', interrupted: true }) + ).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + expect(state.claudeActiveSessionCronPaneKeys.has(SOURCE_PANE)).toBe(true) }) it('keeps a session cron working through child lifecycle events until a drained inventory', () => { @@ -412,7 +434,7 @@ describe('Claude background task status', () => { expect(legacyStopState.claudeActiveSessionCronPaneKeys.has(SOURCE_PANE)).toBe(true) }) - it('treats an interrupted StopFailure as terminal', () => { + it('keeps a shell an interrupted StopFailure still lists', () => { const state = createHookListenerState() expect( @@ -421,8 +443,12 @@ describe('Claude background task status', () => { is_interrupt: true, background_tasks: [RUNNING_SHELL] }) - ).toMatchObject({ state: 'done', interrupted: true }) - expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(false) + ).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true) }) it('keeps a failed turn working while its background shell runs', () => { @@ -472,14 +498,18 @@ describe('Claude background task status', () => { prompt: 'completed', background_tasks: [RUNNING_SHELL] }) - ).toMatchObject({ state: 'working' }) + ).toMatchObject({ state: 'working', workingMode: undefined, mainAgent: { state: 'working' } }) expect( claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', is_interrupt: true, background_tasks: [RUNNING_SHELL] }) - ).toMatchObject({ state: 'done', interrupted: true }) + ).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) expect( claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', @@ -704,14 +734,54 @@ describe('Claude background task status', () => { expect(state.claudeRunningNonAgentTaskPaneKeys.size).toBe(0) }) - it('clears background gating when the server infers an interruption', () => { + it('keeps background gating when the server infers an interruption and folds the cancel with it', () => { const state = createHookListenerState() claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', - background_tasks: [RUNNING_SHELL] + background_tasks: [RUNNING_SHELL], + session_crons: [{ id: 'cron-1' }] }) + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'now this' }) + + // Why: Ctrl+C ends the main agent's turn, not the shell or cron the inventory reported; the row the + // server synthesizes for the cancel is the same fold every Stop goes through. + expect(markClaudeLeadTurnInterrupted(state, SOURCE_PANE)).toEqual({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: expect.any(Number) } + }) + expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true) + expect(state.claudeActiveSessionCronPaneKeys.has(SOURCE_PANE)).toBe(true) + }) + it('keeps a live shell as a confirmed done-gate for child lifecycle events after a cancel', () => { + const state = createHookListenerState() + // A child restored from disk, then a truncated inventory that registers a shell without + // retiring the restored seed. + seedClaudeSubagentRosterFromSnapshots(state, SOURCE_PANE, [ + { id: 'restored-1', state: 'working', startedAt: 1 } + ]) + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'resume' }) + claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'Stop', + background_tasks: [{ id: 'shell-x', status: 'running' }] + }) + expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true) markClaudeLeadTurnInterrupted(state, SOURCE_PANE) - expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(false) + + // Why: the shell the inventory reported is live evidence whatever verdict ended the turn, so + // a child ending beside it is not a restored-only boundary and the row stays confirmed. + const ended = normalizeHookPayload( + state, + 'claude', + { paneKey: SOURCE_PANE, payload: { hook_event_name: 'SubagentStop', agent_id: 'other' } }, + 'production' + ) + expect(ended?.restoredUnconfirmed).toBeUndefined() + expect(state.claudeUnconfirmedRestoredStatusPaneKeys.has(SOURCE_PANE)).toBe(false) + expect(ended?.payload).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) }) }) diff --git a/src/shared/main-agent-status-parity.test.ts b/src/shared/main-agent-status-parity.test.ts index 35792d210ec0..b85fed425924 100644 --- a/src/shared/main-agent-status-parity.test.ts +++ b/src/shared/main-agent-status-parity.test.ts @@ -53,16 +53,14 @@ function published(payload: ParsedAgentStatusPayload | null | undefined): Publis } } -/** The main agent's own state and verdict, restated as the fold's inputs. */ +/** The main agent's own state, restated as the fold's input. The verdict is not one: a cancel is + * a fact about the main agent, and the work it left running reads as it would after any end of + * turn. */ function refold( mainAgent: Published['mainAgent'], childWorkLiveness: AgentChildWorkLiveness ): Published { - const resolution = foldAgentLeadStatus({ - leadState: mainAgent.state, - interrupted: mainAgent.outcome === 'cancellation', - childWorkLiveness - }) + const resolution = foldAgentLeadStatus({ leadState: mainAgent.state, childWorkLiveness }) return { state: resolution.stateName, ...(resolution.workingMode ? { workingMode: resolution.workingMode } : {}), @@ -273,19 +271,22 @@ const STORIES: Story[] = [ } }, { - // KNOWN DIVERGENCE, pinned on purpose. The hook lane hides a still-running shell after an - // interrupted turn; the structured lane never feeds the verdict into the fold and keeps - // showing the shell. The cancel policy (PR C) flips the hook-lane rows to monitoring and - // must update this story, not delete it. The Claude row here is the primary path: Orca's - // inferred cancel, carried by the main agent record into the next Stop, which lists the shell. - name: 'interrupted with a watch loop (known divergence: CLI done / structured monitoring)', + // A cancel never hides live work: the shell the cancelled turn left running reads monitoring + // in every lane, and the cancellation survives only as the main agent's verdict. The Claude + // row is the primary path: Orca's inferred cancel, carried by the main agent record into the + // next Stop, which lists the shell. + name: 'interrupted with a watch loop', claude: { events: [ { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, ORCA_INFERRED_INTERRUPT, { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] } ], - expect: { state: 'done', mainAgent: { state: 'done', outcome: 'cancellation' } } + expect: { + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + } }, structured: { status: 'idle', @@ -302,7 +303,11 @@ const STORIES: Story[] = [ { hookEventName: 'user_prompt_submit', prompt: 'go' }, { hookEventName: 'stop_cancelled', backgroundTasks: [RUNNING_SHELL] } ], - expect: { state: 'done', mainAgent: { state: 'done', outcome: 'cancellation' } } + expect: { + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + } } }, { @@ -327,14 +332,54 @@ const STORIES: Story[] = [ } }, { - // Secondary source: a build that does send `is_interrupt` on its Stop. Same known divergence. + // Secondary source: a build that does send `is_interrupt` on its Stop. Same result. name: 'interrupted by a Stop that carries is_interrupt, with a watch loop (older builds)', claude: { events: [ { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, { hook_event_name: 'Stop', is_interrupt: true, background_tasks: [RUNNING_SHELL] } ], - expect: { state: 'done', mainAgent: { state: 'done', outcome: 'cancellation' } } + expect: { + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + } + } + }, + { + // The cancel itself, before any Stop: the row Orca synthesizes must fold the same way. + name: 'interrupted while a watch loop is already registered', + claude: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'start it' }, + { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }, + { hook_event_name: 'UserPromptSubmit', prompt: 'now this' }, + ORCA_INFERRED_INTERRUPT + ], + expect: { + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + } + } + }, + { + name: 'interrupted with a live subagent', + claude: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'SubagentStart', agent_id: 'agent-1' }, + { hook_event_name: 'Stop', background_tasks: [RUNNING_AGENT] }, + { hook_event_name: 'UserPromptSubmit', prompt: 'and this' }, + ORCA_INFERRED_INTERRUPT + ], + expect: { state: 'working', mainAgent: { state: 'done', outcome: 'cancellation' } } + }, + structured: { + status: 'idle', + turnOutcome: 'cancellation', + backgroundTasks: [AGENT_TASK], + expect: { state: 'working', mainAgent: { state: 'done', outcome: 'cancellation' } } } } ] @@ -369,9 +414,11 @@ describe('mainAgent status parity across lanes', () => { if (payload === ORCA_INFERRED_INTERRUPT) { if (source === 'codex') { markCodexLeadTurnInterrupted(state, PANE_KEY) - } else { - markClaudeLeadTurnInterrupted(state, PANE_KEY) + continue } + // What the server publishes for the cancel, shaped like the row the lane would build. + const folded = markClaudeLeadTurnInterrupted(state, PANE_KEY) + last = { ...(last ?? { prompt: '' }), ...folded, interrupted: folded.state === 'done' } continue } const event = normalizeHookPayload( @@ -418,12 +465,7 @@ describe('mainAgent status parity across lanes', () => { turnOutcome: lane.turnOutcome }) expect(row).toEqual(lane.expect) - // This lane never feeds the verdict into the fold: refold with the verdict masked. - const masked = { state: row.mainAgent.state } - expect(refold(masked, agentChildWorkLiveness(lane.backgroundTasks))).toEqual({ - ...row, - mainAgent: masked - }) + expect(refold(row.mainAgent, agentChildWorkLiveness(lane.backgroundTasks))).toEqual(row) }) }) @@ -432,8 +474,9 @@ describe('mainAgent status parity across lanes', () => { const payload = drive('grok', lane.events) const row = published(payload) expect(row).toEqual(lane.expect) - // Grok's child evidence lives only on its final plain `stop`: a listed subagent is agent - // work, a shell or an active stop hook is watch work, and nothing else ever holds the pane. + // Grok's child evidence lives only on its final turn end: a listed subagent is agent work, a + // shell or an active stop hook is watch work, whatever verdict ended the turn, and nothing + // else ever holds the pane. const last = lane.events.at(-1) ?? {} const tasks: unknown[] = Array.isArray(last.backgroundTasks) ? last.backgroundTasks : [] const hasType = (type: string) => @@ -442,7 +485,9 @@ describe('mainAgent status parity across lanes', () => { typeof task === 'object' && task !== null && 'type' in task && task.type === type ) const liveness: AgentChildWorkLiveness = - last.hookEventName !== 'stop' + last.hookEventName !== 'stop' && + last.hookEventName !== 'stop_failure' && + last.hookEventName !== 'stop_cancelled' ? null : hasType('subagent') ? 'working' diff --git a/src/shared/structured-agent-session-agent-status.test.ts b/src/shared/structured-agent-session-agent-status.test.ts index 097da311c522..1e9b58e40b92 100644 --- a/src/shared/structured-agent-session-agent-status.test.ts +++ b/src/shared/structured-agent-session-agent-status.test.ts @@ -89,7 +89,7 @@ describe('structuredAgentSessionAgentStatus', () => { }) // The verdict is a fact about a finished turn; the fold never reads it, so a cancelled turn with - // a watch loop still reads monitoring here (the hook lane's known divergence, until PR C). + // a watch loop reads monitoring, as it does in the hook lane. it('carries the turn verdict on the main agent only while the main agent is done', () => { expect( structuredAgentSessionAgentStatus({ status: 'idle', turnOutcome: 'cancellation' }) diff --git a/src/shared/structured-agent-session-agent-status.ts b/src/shared/structured-agent-session-agent-status.ts index 78a455c4cfb4..618c97ff34a6 100644 --- a/src/shared/structured-agent-session-agent-status.ts +++ b/src/shared/structured-agent-session-agent-status.ts @@ -30,11 +30,6 @@ export function structuredAgentSessionAgentStatus( const leadState = structuredAgentSessionLeadState(summary.status) const resolution = foldAgentLeadStatus({ leadState, - // Known divergence from the hook lane, kept on purpose until the cancel policy lands: that - // lane hides a still-running shell after an interrupted turn (`updateClaudeRunningNonAgentTask` - // calls it a live-shell judgement), so a cancelled turn with a watch loop reads `done` there - // and `monitoring` here. This lane never feeds the verdict into the fold — see `mainAgent.outcome`. - interrupted: false, childWorkLiveness: agentChildWorkLiveness(summary.backgroundTasks) }) return {