From 7b1c38893d80a99749b99d53dc3d979a0b3f2e1b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:48:52 -0700 Subject: [PATCH 01/15] fix(agent-status): a cancel never hides live work After the user cancels a turn, a background shell, scheduled check or subagent that is still running keeps reading as it truly is in both lanes. The fold no longer takes a verdict input; the cancellation survives only as lead.outcome, restated as the row's interrupted flag on a settled row for readers that predate lead. --- docs/reference/agent-status-store.md | 31 +- .../ended-process-reconciliation.test.ts | 5 +- .../server-claude-cancel-captures.test.ts | 384 ++++++++++++++++++ .../server-interrupt-inference-guards.test.ts | 144 ++++++- .../server/server-status-inference.ts | 49 ++- .../claude-cancel-shell-hooks.jsonl | 32 ++ .../claude-cancel-shell-hooks.meta.json | 16 + .../claude-cancel-subagent-hooks.jsonl | 14 + .../claude-cancel-subagent-hooks.meta.json | 16 + .../providers/claude-events.ts | 65 ++- .../providers/claude-lifecycle-events.ts | 16 +- .../providers/claude-roster-state.ts | 56 ++- .../providers/claude-status-build.ts | 14 +- .../providers/grok-events.ts | 15 +- src/shared/agent-lead-status-fold.test.ts | 49 +-- src/shared/agent-lead-status-fold.ts | 9 +- .../claude-background-task-status.test.ts | 124 +++++- src/shared/main-agent-status-parity.test.ts | 97 +++-- ...uctured-agent-session-agent-status.test.ts | 2 +- .../structured-agent-session-agent-status.ts | 5 - 20 files changed, 947 insertions(+), 196 deletions(-) create mode 100644 src/main/agent-hooks/server-claude-cancel-captures.test.ts create mode 100644 src/shared/__fixtures__/claude-cancel-shell-hooks.jsonl create mode 100644 src/shared/__fixtures__/claude-cancel-shell-hooks.meta.json create mode 100644 src/shared/__fixtures__/claude-cancel-subagent-hooks.jsonl create mode 100644 src/shared/__fixtures__/claude-cancel-subagent-hooks.meta.json diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 3bec9b9e9358..cdba5261dc30 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -234,17 +234,26 @@ repaint, an inferred answer) keeps it only while `mainAgent` is unchanged. A chi sticky permission prompt still records the main agent's own progress and background evidence in the held row, and pushes the held row to subscribers when `mainAgent` changes. -Two combining rules remain outside the shared fold and are named so a reader -does not mistake them for drift: - -- Codex keeps `codexRosterEffectiveState` for its combined `state` (a waiting - child wins, a settled root with any live child reads `working`, never - monitoring) and publishes `mainAgent` from its root record; moving that combine - onto the fold needs a waiting-child input the fold does not have yet. -- A cancelled turn with a still-running shell reads `done` in the hook lane - and `monitoring` in the structured lane. The parity table in - `src/shared/main-agent-status-parity.test.ts` pins this as a known - divergence; the cancel policy that removes it flips that row. +One combining rule remains outside the shared fold and is named so a reader +does not mistake it for drift: Codex keeps `codexRosterEffectiveState` for its +combined `state` (a waiting child wins, a settled root with any live child +reads `working`, never monitoring) and publishes `mainAgent` from its root record; +moving that combine onto the fold needs a waiting-child input the fold does +not have yet. + +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. In the Claude hook lane the same rule governs the cancel Orca infers +from Ctrl+C: the inference is admitted when the row's `mainAgent.state` is +`working` (a Ctrl+C at the idle prompt of a row held open by child work +cancels nothing) and the synthesized row is the fold of the cancelled main +agent with the child work the row already evidences. ## PR 1b: the runtime's retained row store is deleted 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..0b74f25d8620 --- /dev/null +++ b/src/main/agent-hooks/server-claude-cancel-captures.test.ts @@ -0,0 +1,384 @@ +// 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 { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_INTERRUPT_SETTLE_MS } from '../../shared/agent-interrupt-intent' +import { AgentHookServer, _internals } from './server' +import { buildBody, PANE, postHookEvent } 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() +}) + +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 +} +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[] +} +type CapturedKill = { kind: 'kill'; t: number; needle: string } +type CapturedRecord = CapturedHook | CapturedCancel | CapturedKill + +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 + }) +} + +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 +} + +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 +} + +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' + }) +} + +/** 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. */ +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 + ) +} + +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' } + }) + await post(server, hookAt(records, 22)) + 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() + } + }) +}) 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 fcdec35c8e6e..639ea1ebc133 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,146 @@ 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('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/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 02b61595832d..97cfea51571e 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -76,25 +76,30 @@ 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. - if ( - agentType === 'claude' && - (this.state.claudeRunningNonAgentTaskPaneKeys.has(existing.paneKey) || - this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey)) - ) { - 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) - } + 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. For a Claude row the main agent fact decides: Ctrl+C at + // the idle prompt of a row held open by a shell, cron or subagent cancels nothing, while a + // cancel of a live main agent turn is folded with the child work it leaves running, exactly as a + // Stop would be. A row from a host too old to publish `mainAgent`, and every other provider (whose + // combine does not run through the fold here), keeps the evidence guard: Ctrl+C does not stop + // background children, so inferring `done` there would retire live child rows. + const cancelledMainAgentFolds = agentType === 'claude' && payload.mainAgent !== undefined + if (cancelledMainAgentFolds ? payload.mainAgent?.state !== 'working' : childWorkEvidenced) { + return false + } + // Why: keep the provider's main agent-turn record in sync, or a later child event re-emits the stale + // 'working' state and resurrects the cancelled pane. + const folded = cancelledMainAgentFolds + ? markClaudeLeadTurnInterrupted(this.state, existing.paneKey, payload) + : undefined if (agentType === 'codex') { markCodexLeadTurnInterrupted(this.state, existing.paneKey) } + const state = folded?.state ?? 'done' const inferred = this.applyNormalizedStatus({ paneKey: existing.paneKey, tabId: existing.tabId, @@ -102,19 +107,23 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO connectionId: existing.connectionId, providerSession: existing.providerSession, payload: { - state: 'done', + state, + ...(folded?.workingMode ? { workingMode: folded.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' + folded?.mainAgent ?? + (payload.mainAgent?.state === 'done' ? payload.mainAgent - : { state: 'done', outcome: 'cancellation', stateStartedAt: Date.now() } + : { state: 'done', outcome: 'cancellation', stateStartedAt: Date.now() }) } }) if (!inferred) { 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/providers/claude-events.ts b/src/shared/agent-hook-listener/providers/claude-events.ts index 5d39b50aa63e..7ff7a59ac590 100644 --- a/src/shared/agent-hook-listener/providers/claude-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-events.ts @@ -1,4 +1,5 @@ import type { ParsedAgentStatusPayload } from '../../agent-status-types' +import { isAgentStatusHeldOpenByChildWork } from '../../agent-lead-status-fold' import { isAskUserQuestionTool } from '../../agent-question-answered-intent' import { readClaudeBackgroundAgentTasks } from '../../claude-background-task-inventory' import { @@ -17,7 +18,6 @@ import { normalizeClaudeSubagentLifecycleEvent } from './claude-lifecycle-events' import { - claudeMainAgentTurnInterrupted, getOrCreateClaudeSubagentRoster, resolveClaudePaneStatus, setClaudeMainAgentTurnState, @@ -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) || - claudeMainAgentTurnInterrupted(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: claudeMainAgentTurnInterrupted(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 4d3f70a2b997..3db154574cd1 100644 --- a/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts @@ -10,7 +10,6 @@ import { import type { HookListenerState } from '../listener-state' import { readString } from '../tool-input-preview' import { - claudeMainAgentTurnInterrupted, clearClaudePendingWaitForAgent, getOrCreateClaudeSubagentRoster, resolveClaudePaneStatus @@ -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' && - !claudeMainAgentTurnInterrupted(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: claudeMainAgentTurnInterrupted(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 80c68037390b..0a3137d98d05 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' @@ -108,16 +109,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) @@ -126,7 +125,9 @@ export function updateClaudeRunningNonAgentTask( export type ClaudePaneStatusResolution = AgentLeadStatusResolution -/** A cancelled turn is the one verdict the display fold still reads. */ +/** The row's `interrupted` flag, derived from the main agent's verdict for the readers that + * predate `mainAgent` (mobile, the dashboard, notification dispatch). The display fold never + * reads it. */ export function claudeMainAgentTurnInterrupted( record: Pick | undefined ): boolean { @@ -171,26 +172,47 @@ export function claudeMainAgentStatusForPayload( export function resolveClaudePaneStatus( state: HookListenerState, paneKey: string, - lead: Pick + lead: Pick, + /** Agent work the row itself evidences. A relayed pane has no local roster, so the server's + * inferred cancel passes the row's snapshots; every hook path leaves this empty. */ + rowSubagents: readonly AgentSubagentSnapshot[] = [] ): ClaudePaneStatusResolution { return foldAgentLeadStatus({ leadState: lead.state, - interrupted: claudeMainAgentTurnInterrupted(lead), childWorkLiveness: agentChildWorkLivenessFromEvidence({ - hasLiveAgentWork: claudeRosterHasWorkingSubagent( - state.claudeSubagentRosterByPaneKey.get(paneKey) - ), + hasLiveAgentWork: + claudeRosterHasWorkingSubagent(state.claudeSubagentRosterByPaneKey.get(paneKey)) || + rowSubagents.some((child) => child.state === 'working'), hasLiveNonAgentWork: state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || state.claudeActiveSessionCronPaneKeys.has(paneKey) }) }) } -/** 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 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, + row: { subagents?: readonly AgentSubagentSnapshot[] } = {} +): { state: AgentStatusState; workingMode?: AgentWorkingMode; mainAgent?: AgentMainAgentStatus } { + const record = setClaudeMainAgentTurnState(state, paneKey, { + state: 'done', + outcome: 'cancellation' + }) + const resolved = resolveClaudePaneStatus(state, paneKey, record, row.subagents) + 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..1d0ec37f46ae 100644 --- a/src/shared/agent-hook-listener/providers/claude-status-build.ts +++ b/src/shared/agent-hook-listener/providers/claude-status-build.ts @@ -8,7 +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 { claudeMainAgentStatusForPayload } from './claude-roster-state' +import { claudeMainAgentStatusForPayload, claudeMainAgentTurnInterrupted } from './claude-roster-state' export function buildClaudeStatusPayload( state: HookListenerState, @@ -20,9 +20,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 +30,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 +49,10 @@ export function buildClaudeStatusPayload( interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, - interrupted: options.interrupted, + interrupted: claudeMainAgentTurnInterrupted(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/grok-events.ts b/src/shared/agent-hook-listener/providers/grok-events.ts index 4b384a173d13..fa6626597f70 100644 --- a/src/shared/agent-hook-listener/providers/grok-events.ts +++ b/src/shared/agent-hook-listener/providers/grok-events.ts @@ -105,10 +105,10 @@ function grokHasRunningFiniteTask(hookPayload: Record): boolean }) } -/** What a plain `stop` leaves running behind the main agent. Grok reports its finite tasks without a +/** What a turn end leaves running behind the main agent. Grok reports its finite tasks without a * kind the roster could classify as agent work, and a still-active stop hook holds the turn the * same way, so both read as watch work: the pane stays `working` in monitoring mode. */ -function grokChildWorkLivenessAfterStop( +function grokChildWorkLivenessAfterTurnEnd( hookPayload: Record ): AgentChildWorkLiveness { const stopHookActive = aliasedField(hookPayload, 'stopHookActive', 'stop_hook_active') @@ -206,15 +206,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) diff --git a/src/shared/agent-lead-status-fold.test.ts b/src/shared/agent-lead-status-fold.test.ts index 75ab3da89dd1..9aa89cbb2f21 100644 --- a/src/shared/agent-lead-status-fold.test.ts +++ b/src/shared/agent-lead-status-fold.test.ts @@ -7,44 +7,39 @@ 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' + }) }) }) diff --git a/src/shared/agent-lead-status-fold.ts b/src/shared/agent-lead-status-fold.ts index c74ae7df1f67..6d4402650fd2 100644 --- a/src/shared/agent-lead-status-fold.ts +++ b/src/shared/agent-lead-status-fold.ts @@ -4,9 +4,6 @@ import type { AgentMainAgentStatus, AgentStatusState, AgentWorkingMode } from '. export type AgentLeadStatusFoldInput = { /** The lead's own turn state. Anything but `done` wins outright. */ 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 } @@ -20,6 +17,10 @@ export type AgentLeadStatusResolution = { * with live agent work is still working, and a settled lead with only watch * loops is monitoring. The hook lane and the structured session lane derive * the liveness from different 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 { if (input.leadState !== 'done') { @@ -28,7 +29,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..da1d359484df 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,72 @@ 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' } + }) + }) + + it('folds an inferred cancel with the subagents the row itself evidences', () => { + const state = createHookListenerState() + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) + + // Why: a relayed pane has no local roster, so the row's own snapshots are the agent-work + // evidence; a working child keeps the cancelled main agent's row working, an idle one does not. + expect( + markClaudeLeadTurnInterrupted(state, SOURCE_PANE, { + subagents: [{ id: 'child-1', state: 'working', startedAt: 1 }] + }) + ).toMatchObject({ state: 'working', mainAgent: { state: 'done', outcome: 'cancellation' } }) + expect( + markClaudeLeadTurnInterrupted(state, SOURCE_PANE, { + subagents: [{ id: 'child-1', state: 'idle', startedAt: 1 }] + }) + ).toMatchObject({ state: 'done', 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 25112754034c..db5b7eada218 100644 --- a/src/shared/main-agent-status-parity.test.ts +++ b/src/shared/main-agent-status-parity.test.ts @@ -52,16 +52,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 } : {}), @@ -215,19 +213,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', @@ -244,18 +245,62 @@ 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' } + } } }, { - // 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' } } } } ] @@ -296,7 +341,9 @@ describe('mainAgent status parity across lanes', () => { let last: ParsedAgentStatusPayload | null = null for (const payload of events) { if (payload === ORCA_INFERRED_INTERRUPT) { - markClaudeLeadTurnInterrupted(state, PANE_KEY) + // What the server publishes for the cancel, shaped like the row the lane would build. + const folded = markClaudeLeadTurnInterrupted(state, PANE_KEY, last ?? {}) + last = { ...(last ?? { prompt: '' }), ...folded, interrupted: folded.state === 'done' } continue } const event = normalizeHookPayload( @@ -340,12 +387,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) }) }) @@ -354,12 +396,15 @@ 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 finite task 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 finite task 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 = Array.isArray(last.backgroundTasks) ? last.backgroundTasks : [] const liveness: AgentChildWorkLiveness = - last.hookEventName === 'stop' && (tasks.length > 0 || last.stopHookActive === true) + (last.hookEventName === 'stop' || + last.hookEventName === 'stop_failure' || + last.hookEventName === 'stop_cancelled') && + (tasks.length > 0 || last.stopHookActive === true) ? 'monitoring' : null expect(row).toEqual(refold(row.mainAgent, liveness)) diff --git a/src/shared/structured-agent-session-agent-status.test.ts b/src/shared/structured-agent-session-agent-status.test.ts index 15dc6995748b..d41563aa5d94 100644 --- a/src/shared/structured-agent-session-agent-status.test.ts +++ b/src/shared/structured-agent-session-agent-status.test.ts @@ -80,7 +80,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 { From 0f4ecdb96444044782e691ba5c4ebe3808d27c78 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:13:45 -0700 Subject: [PATCH 02/15] fix(agent-status): keep a cancel's verdict and clock on every settle path A Grok turn cancelled while a task ran now reads monitoring, and the idle_prompt backstop that later settles it restated done without the row's `interrupted` flag, so notification readers announced the cancelled turn as a clean finish. Derive `interrupted` from the main agent's outcome, as the Claude builder already does. The inferred Claude cancel now folds through the host's local main agent record, which a relayed pane never refreshes, so a second cancel on an SSH pane inherited the first cancel's clock. The caller admits only a working main agent, so the cancel always starts a new done clock. --- .../server-interrupt-inference-guards.test.ts | 50 +++++++++++++++++++ ...gent-hook-listener-grok-completion.test.ts | 29 +++++++++++ .../providers/claude-roster-state.ts | 5 +- .../providers/grok-events.ts | 3 +- 4 files changed, 85 insertions(+), 2 deletions(-) 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 639ea1ebc133..bbeef6b8613d 100644 --- a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts @@ -255,6 +255,56 @@ describe('AgentHookServer listener replay', () => { } }) + 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) diff --git a/src/shared/agent-hook-listener-grok-completion.test.ts b/src/shared/agent-hook-listener-grok-completion.test.ts index e82d1e73900d..44383014708c 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([ { 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 0a3137d98d05..56d0f239a3ef 100644 --- a/src/shared/agent-hook-listener/providers/claude-roster-state.ts +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -202,9 +202,12 @@ export function markClaudeLeadTurnInterrupted( paneKey: string, row: { subagents?: readonly AgentSubagentSnapshot[] } = {} ): { state: AgentStatusState; workingMode?: AgentWorkingMode; mainAgent?: AgentMainAgentStatus } { + // Why: the caller admits only a working main agent, so the cancel always starts a done clock; a + // relayed pane's local record is not the relay's and may still hold an earlier cancel. const record = setClaudeMainAgentTurnState(state, paneKey, { state: 'done', - outcome: 'cancellation' + outcome: 'cancellation', + stateStartedAt: Date.now() }) const resolved = resolveClaudePaneStatus(state, paneKey, record, row.subagents) const mainAgent = claudeMainAgentStatusForPayload(record) diff --git a/src/shared/agent-hook-listener/providers/grok-events.ts b/src/shared/agent-hook-listener/providers/grok-events.ts index fa6626597f70..d2431f7dc96e 100644 --- a/src/shared/agent-hook-listener/providers/grok-events.ts +++ b/src/shared/agent-hook-listener/providers/grok-events.ts @@ -254,7 +254,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 }) From 4ae4a15e7b14e24ccad1e7deebfdf32f75570f14 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:22:50 -0700 Subject: [PATCH 03/15] fix(agent-status): keep the shell fact on an inferred cancel so restart can seed it An inferred Ctrl+C cancel beside a working subagent publishes a row held open by child work, but the synthesized event dropped the row's paired claudeRunningNonAgentTask fact because mainAgent changed. Hydration seeds a settled main agent only when that fact says no shell ran, so after a restart the child's drain left the row working with no mainAgent. Carry the fact forward: a cancel does not change what the shell inventory said. --- ...server-last-status-main-agent-fact.test.ts | 48 +++++++++++++++++++ .../server/server-status-inference.ts | 5 ++ 2 files changed, 53 insertions(+) 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/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 97cfea51571e..0199e7e03b1a 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -106,6 +106,11 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO 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, ...(folded?.workingMode ? { workingMode: folded.workingMode } : {}), From 5bbb1912bda3f37ca5be9e1d01f8417c4a180e7d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:10:26 -0700 Subject: [PATCH 04/15] fix(agent-status): a Ctrl+C at an idle main agent's prompt cancels nothing Every row that publishes the main agent fact now admits an inferred cancel only while that main agent is working. Grok's Ctrl+C at the idle prompt leaves its background task running, so settling the monitoring row to done hid live work. Rows without the fact keep the evidence guard, and Codex keeps it too because its synthesized row is a plain done. --- .../agent-hooks/server-grok-cancel.test.ts | 95 +++++++++++++++++++ ...ver-interrupt-inference-validation.test.ts | 11 +-- .../server/server-status-inference.ts | 30 +++--- 3 files changed, 114 insertions(+), 22 deletions(-) create mode 100644 src/main/agent-hooks/server-grok-cancel.test.ts 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..0e5a7dda17fa --- /dev/null +++ b/src/main/agent-hooks/server-grok-cancel.test.ts @@ -0,0 +1,95 @@ +// 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() + } + }) +}) 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..51f7dfecda0f 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,7 @@ 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 at the idle prompt of a main agent a watch loop holds open', () => { vi.useFakeTimers() vi.setSystemTime(1_000) try { @@ -432,12 +432,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/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 0199e7e03b1a..f8596c9e4a0b 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -81,16 +81,18 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO (agentType === 'claude' && (this.state.claudeRunningNonAgentTaskPaneKeys.has(existing.paneKey) || this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey))) - // Why: a 'working' pane can be child-driven. For a Claude row the main agent fact decides: Ctrl+C at - // the idle prompt of a row held open by a shell, cron or subagent cancels nothing, while a - // cancel of a live main agent turn is folded with the child work it leaves running, exactly as a - // Stop would be. A row from a host too old to publish `mainAgent`, and every other provider (whose - // combine does not run through the fold here), keeps the evidence guard: Ctrl+C does not stop - // background children, so inferring `done` there would retire live child rows. - const cancelledMainAgentFolds = agentType === 'claude' && payload.mainAgent !== undefined - if (cancelledMainAgentFolds ? payload.mainAgent?.state !== 'working' : childWorkEvidenced) { + // 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 ( + payload.mainAgent + ? payload.mainAgent.state !== 'working' || (agentType === 'codex' && childWorkEvidenced) + : childWorkEvidenced + ) { return false } + const cancelledMainAgentFolds = agentType === 'claude' && payload.mainAgent !== undefined // Why: keep the provider's main agent-turn record in sync, or a later child event re-emits the stale // 'working' state and resurrects the cancelled pane. const folded = cancelledMainAgentFolds @@ -122,13 +124,11 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO ...(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: - folded?.mainAgent ?? - (payload.mainAgent?.state === 'done' - ? payload.mainAgent - : { state: 'done', outcome: 'cancellation', stateStartedAt: Date.now() }) + mainAgent: folded?.mainAgent ?? { + state: 'done', + outcome: 'cancellation', + stateStartedAt: Date.now() + } } }) if (!inferred) { From 1db96751cfd4f905617c71041b34cd45618f5274 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:11:10 -0700 Subject: [PATCH 05/15] fix(agent-status): fold a relayed pane's cancel from its row, not the desktop's records The inferred Claude cancel read and wrote the desktop's own listener records for every pane. For an SSH pane those records are not the relay's: hydration seeds them from the saved row and nothing reaps them, so a subagent that finished on the remote after a desktop restart kept a cancelled row spinning with nothing running. A local pane still records the verdict on its listener and folds its own roster; a relayed pane folds only the child work its row carries. The relayed-pane parameter and forced clock the shared record path grew for this are gone. --- .../claude-cancel-capture.test-fixture.ts | 69 ++++++++ .../server-claude-cancel-captures.test.ts | 73 +-------- .../server-relayed-claude-cancel.test.ts | 152 ++++++++++++++++++ .../server/server-row-child-work-fold.ts | 27 ++++ .../server/server-status-inference.ts | 25 +-- .../providers/claude-roster-state.ts | 35 ++-- .../claude-background-task-status.test.ts | 18 --- src/shared/main-agent-status-parity.test.ts | 2 +- 8 files changed, 286 insertions(+), 115 deletions(-) create mode 100644 src/main/agent-hooks/claude-cancel-capture.test-fixture.ts create mode 100644 src/main/agent-hooks/server-relayed-claude-cancel.test.ts create mode 100644 src/main/agent-hooks/server/server-row-child-work-fold.ts 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/server-claude-cancel-captures.test.ts b/src/main/agent-hooks/server-claude-cancel-captures.test.ts index 0b74f25d8620..ec2f5904596d 100644 --- a/src/main/agent-hooks/server-claude-cancel-captures.test.ts +++ b/src/main/agent-hooks/server-claude-cancel-captures.test.ts @@ -8,12 +8,16 @@ // 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 { readFileSync } from 'node:fs' -import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AGENT_INTERRUPT_SETTLE_MS } from '../../shared/agent-interrupt-intent' 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(), @@ -39,58 +43,6 @@ afterEach(() => { vi.restoreAllMocks() }) -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 -} -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[] -} -type CapturedKill = { kind: 'kill'; t: number; needle: string } -type CapturedRecord = CapturedHook | CapturedCancel | CapturedKill - -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 - }) -} - -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 -} - -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 -} - async function startServer(): Promise { const server = new AgentHookServer() await server.start({ env: 'production' }) @@ -125,17 +77,6 @@ function pressCtrlC(server: AgentHookServer): boolean { }) } -/** 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. */ -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 - ) -} - describe('a Claude cancel with a background shell (captured)', () => { const records = loadCapture('claude-cancel-shell-hooks') 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..29e9caa34113 --- /dev/null +++ b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts @@ -0,0 +1,152 @@ +// 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 +} + +async function startSshPane(desktop: AgentHookServer): Promise { + const pane: SshPane = { + desktop, + relay: new RelayAgentHookServer({ + endpointDir: temporaryDir('orca-relayed-cancel-'), + token: 'relayed-cancel-token', + forward: (envelope) => pane.desktop.ingestRemote(envelope, 'conn-1') + }), + 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 +} + +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 subagentStop = (index: number): Record => ({ + ...hookAt(records, index).payload, + hook_event_name: 'SubagentStop', + tool_name: undefined, + tool_input: undefined + }) + + 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' } + }) + }) +}) 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..ca6f385cbb95 --- /dev/null +++ b/src/main/agent-hooks/server/server-row-child-work-fold.ts @@ -0,0 +1,27 @@ +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import { + foldAgentLeadStatus, + type AgentLeadStatusResolution +} from '../../../shared/agent-lead-status-fold' +import { agentChildWorkLivenessFromEvidence } 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 { + return foldAgentLeadStatus({ + leadState, + childWorkLiveness: agentChildWorkLivenessFromEvidence({ + hasLiveAgentWork: row.payload.subagents?.some((child) => child.state === 'working') === true, + hasLiveNonAgentWork: row.claudeRunningNonAgentTask === true + }) + }) +} diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index f8596c9e4a0b..14a7b1ca1794 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -16,6 +16,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 { @@ -92,16 +93,22 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO ) { return false } - const cancelledMainAgentFolds = agentType === 'claude' && payload.mainAgent !== undefined - // Why: keep the provider's main agent-turn record in sync, or a later child event re-emits the stale - // 'working' state and resurrects the cancelled pane. - const folded = cancelledMainAgentFolds - ? markClaudeLeadTurnInterrupted(this.state, existing.paneKey, payload) - : undefined + // 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 = folded?.state ?? 'done' + const state = local?.state ?? relayed?.stateName ?? 'done' + const workingMode = local?.workingMode ?? relayed?.workingMode const inferred = this.applyNormalizedStatus({ paneKey: existing.paneKey, tabId: existing.tabId, @@ -115,7 +122,7 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO : {}), payload: { state, - ...(folded?.workingMode ? { workingMode: folded.workingMode } : {}), + ...(workingMode ? { workingMode } : {}), prompt: payload.prompt, agentType, ...(payload.model ? { model: payload.model } : {}), @@ -124,7 +131,7 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO ...(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 } : {}), - mainAgent: folded?.mainAgent ?? { + mainAgent: local?.mainAgent ?? { state: 'done', outcome: 'cancellation', stateStartedAt: Date.now() 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 56d0f239a3ef..741050b39742 100644 --- a/src/shared/agent-hook-listener/providers/claude-roster-state.ts +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -172,17 +172,14 @@ export function claudeMainAgentStatusForPayload( export function resolveClaudePaneStatus( state: HookListenerState, paneKey: string, - lead: Pick, - /** Agent work the row itself evidences. A relayed pane has no local roster, so the server's - * inferred cancel passes the row's snapshots; every hook path leaves this empty. */ - rowSubagents: readonly AgentSubagentSnapshot[] = [] + lead: Pick ): ClaudePaneStatusResolution { return foldAgentLeadStatus({ leadState: lead.state, childWorkLiveness: agentChildWorkLivenessFromEvidence({ - hasLiveAgentWork: - claudeRosterHasWorkingSubagent(state.claudeSubagentRosterByPaneKey.get(paneKey)) || - rowSubagents.some((child) => child.state === 'working'), + hasLiveAgentWork: claudeRosterHasWorkingSubagent( + state.claudeSubagentRosterByPaneKey.get(paneKey) + ), hasLiveNonAgentWork: state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || state.claudeActiveSessionCronPaneKeys.has(paneKey) @@ -190,26 +187,22 @@ export function resolveClaudePaneStatus( }) } -/** The SERVER inferred a cancel 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. */ +/** 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, - row: { subagents?: readonly AgentSubagentSnapshot[] } = {} + paneKey: string ): { state: AgentStatusState; workingMode?: AgentWorkingMode; mainAgent?: AgentMainAgentStatus } { - // Why: the caller admits only a working main agent, so the cancel always starts a done clock; a - // relayed pane's local record is not the relay's and may still hold an earlier cancel. const record = setClaudeMainAgentTurnState(state, paneKey, { state: 'done', - outcome: 'cancellation', - stateStartedAt: Date.now() + outcome: 'cancellation' }) - const resolved = resolveClaudePaneStatus(state, paneKey, record, row.subagents) + const resolved = resolveClaudePaneStatus(state, paneKey, record) const mainAgent = claudeMainAgentStatusForPayload(record) return { state: resolved.stateName, diff --git a/src/shared/claude-background-task-status.test.ts b/src/shared/claude-background-task-status.test.ts index da1d359484df..b823dd532387 100644 --- a/src/shared/claude-background-task-status.test.ts +++ b/src/shared/claude-background-task-status.test.ts @@ -784,22 +784,4 @@ describe('Claude background task status', () => { mainAgent: { state: 'done', outcome: 'cancellation' } }) }) - - it('folds an inferred cancel with the subagents the row itself evidences', () => { - const state = createHookListenerState() - claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) - - // Why: a relayed pane has no local roster, so the row's own snapshots are the agent-work - // evidence; a working child keeps the cancelled main agent's row working, an idle one does not. - expect( - markClaudeLeadTurnInterrupted(state, SOURCE_PANE, { - subagents: [{ id: 'child-1', state: 'working', startedAt: 1 }] - }) - ).toMatchObject({ state: 'working', mainAgent: { state: 'done', outcome: 'cancellation' } }) - expect( - markClaudeLeadTurnInterrupted(state, SOURCE_PANE, { - subagents: [{ id: 'child-1', state: 'idle', startedAt: 1 }] - }) - ).toMatchObject({ state: 'done', 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 db5b7eada218..496ed1674a4d 100644 --- a/src/shared/main-agent-status-parity.test.ts +++ b/src/shared/main-agent-status-parity.test.ts @@ -342,7 +342,7 @@ describe('mainAgent status parity across lanes', () => { for (const payload of events) { if (payload === ORCA_INFERRED_INTERRUPT) { // What the server publishes for the cancel, shaped like the row the lane would build. - const folded = markClaudeLeadTurnInterrupted(state, PANE_KEY, last ?? {}) + const folded = markClaudeLeadTurnInterrupted(state, PANE_KEY) last = { ...(last ?? { prompt: '' }), ...folded, interrupted: folded.state === 'done' } continue } From bf47c27456087683cbf44e05662a14cc04d6761d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:11:51 -0700 Subject: [PATCH 06/15] fix(agent-status): hold a cancel verdict in the store until a new turn or the provider's own A relay never learns of the cancel the desktop infers from Ctrl+C, so its next child hook or reconnect replay restated the main agent as working and flipped the row back. The late-hook suppression that guarded this keyed on a done row flagged interrupted, which a cancel held open by a shell or subagent no longer is; it also dropped Grok's own stop_cancelled when the inference won the settle race, hiding the task that hook reported. The suppression is replaced by a latch derived from the row: its main agent reads cancelled (or, from an older host, a done row flagged interrupted). A settled incoming main agent, another prompt, an explicit prompt or a session start releases it. Child and replayed events keep the latched main agent and are re-folded with their own child evidence; late main agent work is held as before, and Codex keeps its record re-mark. --- .../agent-hooks/server-grok-cancel.test.ts | 35 ++++++ .../server-relayed-claude-cancel.test.ts | 98 ++++++++++++++++ .../server/server-cancel-verdict-latch.ts | 107 ++++++++++++++++++ .../server/server-status-update.ts | 42 ++----- 4 files changed, 252 insertions(+), 30 deletions(-) create mode 100644 src/main/agent-hooks/server/server-cancel-verdict-latch.ts diff --git a/src/main/agent-hooks/server-grok-cancel.test.ts b/src/main/agent-hooks/server-grok-cancel.test.ts index 0e5a7dda17fa..62fd17495b4b 100644 --- a/src/main/agent-hooks/server-grok-cancel.test.ts +++ b/src/main/agent-hooks/server-grok-cancel.test.ts @@ -92,4 +92,39 @@ describe('a Grok cancel never hides a running task', () => { 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-relayed-claude-cancel.test.ts b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts index 29e9caa34113..26f016079ad7 100644 --- a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts +++ b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts @@ -103,6 +103,7 @@ function pressCtrlC(server: AgentHookServer): boolean { 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', @@ -110,6 +111,67 @@ describe('a relayed Claude cancel with a live subagent (captured)', () => { 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 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('does not read a restart-seeded local roster for a relayed pane', async () => { const userDataPath = temporaryDir('orca-relayed-cancel-restart-') const firstDesktop = new AgentHookServer() @@ -150,3 +212,39 @@ describe('a relayed Claude cancel with a live subagent (captured)', () => { }) }) }) + +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') + }) +}) 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..b7a4b6ca7855 --- /dev/null +++ b/src/main/agent-hooks/server/server-cancel-verdict-latch.ts @@ -0,0 +1,107 @@ +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 + } + } +} + +/** + * 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 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 || + previous.payload.prompt !== incoming.payload.prompt || + incoming.payload.mainAgent?.state === 'done' || + incoming.hookEventName === 'SessionStart' + ) { + 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' && + (incoming.toolAgentId !== undefined || 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-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 65fd1acc7143..bbde097d1df5 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,9 +151,9 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA ...rootContextPreservingPayload, payload: { ...rootContextPreservingPayload.payload, agentType: identity.agentType } } - const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) - if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { - const held = withHeldChildWaitMainAgent(previous, effectivePayload) + const attachedPayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) + if (previous && shouldKeepClaudePermissionVisible(previous, attachedPayload)) { + const held = withHeldChildWaitMainAgent(previous, attachedPayload) // Why: a child's prompt leaves the main agent running, so the held row takes its `mainAgent` and // must take the same event's background evidence; a main agent's own prompt blocks it, so not there. if (previous.toolAgentId) { @@ -173,35 +172,18 @@ 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) + 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 ( effectivePayload.payload.state !== 'done' || effectivePayload.payload.lastAssistantMessage From a380c212bca64a18edc639e3579f668de1a77efc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:15:29 -0700 Subject: [PATCH 07/15] test(agent-status): pin Codex's evidence guard beside the main agent fact --- ...ver-interrupt-inference-validation.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) 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 51f7dfecda0f..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,6 +400,45 @@ describe('the main agent fact on an inferred interrupt', () => { } }) + 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) From d302b387d154a94095f3d718eb8ba007be5e8ba1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:27:44 -0700 Subject: [PATCH 08/15] fix(agent-status): a prompt submission ends the cancel verdict latch The task notification Claude starts when background work ends is a real turn, but it keeps the cached prompt and carries no explicit prompt, so within 15 s of a cancel the latch held its prompt submission and every tool event after it: the turn read as monitoring under a cancelled main agent until its Stop. The captured shell cancel has exactly this: the notification lands 0.17 s after the cancel key. --- .../server-claude-cancel-captures.test.ts | 7 ++++++- .../server/server-cancel-verdict-latch.ts | 15 +++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/main/agent-hooks/server-claude-cancel-captures.test.ts b/src/main/agent-hooks/server-claude-cancel-captures.test.ts index ec2f5904596d..c3513637fd9e 100644 --- a/src/main/agent-hooks/server-claude-cancel-captures.test.ts +++ b/src/main/agent-hooks/server-claude-cancel-captures.test.ts @@ -215,7 +215,12 @@ describe('a Claude cancel with a background shell (captured)', () => { workingMode: 'monitoring', mainAgent: { state: 'done', outcome: 'cancellation' } }) - await post(server, hookAt(records, 22)) + // 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) diff --git a/src/main/agent-hooks/server/server-cancel-verdict-latch.ts b/src/main/agent-hooks/server/server-cancel-verdict-latch.ts index b7a4b6ca7855..430731d4a1cd 100644 --- a/src/main/agent-hooks/server/server-cancel-verdict-latch.ts +++ b/src/main/agent-hooks/server/server-cancel-verdict-latch.ts @@ -57,11 +57,22 @@ function refoldUnderLatchedMainAgent( } } +/** 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) + ) +} + /** * 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 session start). Child-attributed and replayed events keep the latched main + * 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( @@ -76,7 +87,7 @@ export function resolveCancelVerdictLatch( previous.payload.agentType !== incoming.payload.agentType || previous.payload.prompt !== incoming.payload.prompt || incoming.payload.mainAgent?.state === 'done' || - incoming.hookEventName === 'SessionStart' + opensNewTurn(incoming) ) { return apply } From 8e90d142b92bb8e46c46d7edde2e8d960ad80dfb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:27:46 -0700 Subject: [PATCH 09/15] fix(agent-status): derive a Codex row's interrupted flag from its main agent The cancel verdict latch lets any settled mainAgent through, so a late root Stop after an inferred Codex cancel now applies where the old same-prompt window held it. It restates the cancellation on mainAgent but, unlike Claude and Grok rows, carried no interrupted flag, so mobile, the dashboard and notification text read the cancelled turn as finished. Codex rows (local and relayed) now derive the flag from the main agent record, like the other providers that publish one. --- .../server-main-agent-turn-verdicts.test.ts | 3 +++ .../agent-hook-listener/providers/codex-events.ts | 2 ++ .../agent-hook-listener/providers/codex-state.ts | 11 ++++++++++- 3 files changed, 15 insertions(+), 1 deletion(-) 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/shared/agent-hook-listener/providers/codex-events.ts b/src/shared/agent-hook-listener/providers/codex-events.ts index d056b2de4dbc..f92903ba1ee6 100644 --- a/src/shared/agent-hook-listener/providers/codex-events.ts +++ b/src/shared/agent-hook-listener/providers/codex-events.ts @@ -23,6 +23,7 @@ import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' import { readString } from '../tool-input-preview' import { codexMainAgentStatusForPayload, + codexMainAgentTurnInterrupted, codexOutcomeRestatedByStop, getOrCreateCodexSubagentRoster, getOrCreateCodexSubagentTranscriptState, @@ -57,6 +58,7 @@ export function buildCodexStatusPayload( interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, + interrupted: codexMainAgentTurnInterrupted(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 bd86f77f8d85..9c53cafeb32e 100644 --- a/src/shared/agent-hook-listener/providers/codex-state.ts +++ b/src/shared/agent-hook-listener/providers/codex-state.ts @@ -87,6 +87,12 @@ export function codexMainAgentStatusForPayload( : undefined } +/** The row's `interrupted` flag for readers that predate `mainAgent`, derived from the main agent's + * verdict so any restatement of the cancelled turn (a late Stop, a child's drain) still carries it. */ +export function codexMainAgentTurnInterrupted(record: CodexLeadTurnState | undefined): boolean { + return record?.state === 'done' && record.outcome === 'cancellation' +} + export function seedCodexStateFromSnapshot( state: HookListenerState, paneKey: string, @@ -209,10 +215,13 @@ export function reconcileRemoteCodexState( agentId && payload.prompt.length === 0 && previous?.agentType === 'codex' ? previous.prompt : payload.prompt + const effectiveState = codexRosterEffectiveState(roster, lead.state) return { ...payload, prompt, - state: codexRosterEffectiveState(roster, lead.state), + state: effectiveState, + interrupted: + effectiveState === 'done' && codexMainAgentTurnInterrupted(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. From 8599deadbe9c09b25df47948feb80f24d16abcaa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:43:57 -0700 Subject: [PATCH 10/15] docs(agent-status): describe cancel admission for every provider and the store's cancel-verdict hold --- docs/reference/agent-status-store.md | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index cdba5261dc30..65cdef6b7aef 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -249,11 +249,23 @@ 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. In the Claude hook lane the same rule governs the cancel Orca infers -from Ctrl+C: the inference is admitted when the row's `mainAgent.state` is -`working` (a Ctrl+C at the idle prompt of a row held open by child work -cancels nothing) and the synthesized row is the fold of the cancelled main -agent with the child work the row already evidences. +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` (a Ctrl+C at the idle prompt of a row held open +by child work cancels nothing; Codex also keeps the child-evidence guard, and a +row without `mainAgent` keeps only that guard). 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 From bff608c8700e0cdd41a4555dfa980ffc48da73c5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:48:30 -0700 Subject: [PATCH 11/15] docs(agent-status): correct the idle-prompt Ctrl+C claim to the measured CLI behavior --- docs/reference/agent-status-store.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index a8c803cf6e81..4dff79d6666a 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -265,9 +265,13 @@ session ends, so a cancelled turn with a still-running shell reads `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` (a Ctrl+C at the idle prompt of a row held open -by child work cancels nothing; Codex also keeps the child-evidence guard, and a -row without `mainAgent` keeps only that guard). The synthesized row is the fold +`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. From 397d22fb7eb2f261ca06cf748759df87303bfbf6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:18:34 -0700 Subject: [PATCH 12/15] fix(agent-status): preserve waiting relay children on cancel --- .../server-relayed-claude-cancel.test.ts | 43 +++++++++++++++++++ .../server/server-row-child-work-fold.ts | 19 ++++---- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts index 26f016079ad7..60a3ae6d75c7 100644 --- a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts +++ b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts @@ -248,3 +248,46 @@ describe('a relayed Claude cancel with a background shell (captured)', () => { expect(row(pane.desktop).mainAgent).not.toHaveProperty('outcome') }) }) + +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-row-child-work-fold.ts b/src/main/agent-hooks/server/server-row-child-work-fold.ts index d998764cbc39..30c00b259456 100644 --- a/src/main/agent-hooks/server/server-row-child-work-fold.ts +++ b/src/main/agent-hooks/server/server-row-child-work-fold.ts @@ -3,7 +3,7 @@ import { foldAgentLeadStatus, type AgentLeadStatusResolution } from '../../../shared/agent-lead-status-fold' -import { agentChildWorkLivenessFromEvidence } from '../../../shared/agent-status-child-work-liveness' +import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' import type { AgentStatusState, AgentSubagentSnapshot } from '../../../shared/agent-status-types' type RowChildWork = Pick & { @@ -17,13 +17,12 @@ export function foldMainAgentWithRowChildWork( leadState: AgentStatusState, row: RowChildWork ): AgentLeadStatusResolution { - return foldAgentLeadStatus({ - leadState, - childWorkLiveness: agentChildWorkLivenessFromEvidence({ - // Only the Codex lane feeds a child's wait into the fold, and Codex never folds here. - hasWaitingChildWork: false, - hasLiveAgentWork: row.payload.subagents?.some((child) => child.state === 'working') === true, - hasLiveNonAgentWork: row.claudeRunningNonAgentTask === true - }) - }) + 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 }) } From d3eb9198059e67cf427de01d7984626658100b93 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:54:38 -0700 Subject: [PATCH 13/15] fix(agent-status): resolve the cancel hold before a child's permission card adopts a relayed main agent The permission-card hold took the incoming event's mainAgent before the cancel hold ran, so on an SSH pane a child's next tool under a sticky card restated the relay's stale working main agent and dropped the cancellation the desktop had inferred. --- .../server-relayed-claude-cancel.test.ts | 22 ++++++++++++++ .../server/server-status-update.ts | 30 ++++++++++--------- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts index 60a3ae6d75c7..da640f18d875 100644 --- a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts +++ b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts @@ -159,6 +159,28 @@ describe('a relayed Claude cancel with a live subagent (captured)', () => { }) }) + 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) diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index bbde097d1df5..a37cbb02eb44 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -152,8 +152,22 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA payload: { ...rootContextPreservingPayload.payload, agentType: identity.agentType } } const attachedPayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) - if (previous && shouldKeepClaudePermissionVisible(previous, attachedPayload)) { - const held = withHeldChildWaitMainAgent(previous, attachedPayload) + // 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 // must take the same event's background evidence; a main agent's own prompt blocks it, so not there. if (previous.toolAgentId) { @@ -172,18 +186,6 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA } return held } - 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 ( effectivePayload.payload.state !== 'done' || effectivePayload.payload.lastAssistantMessage From 0c5e474d9db0b49300a5f1587d689df2bda9b520 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:59:00 -0700 Subject: [PATCH 14/15] test(agent-status): pin that a cancelled turn's drained subagent settles as stopped, not completed --- .../server-claude-cancel-captures.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/main/agent-hooks/server-claude-cancel-captures.test.ts b/src/main/agent-hooks/server-claude-cancel-captures.test.ts index c3513637fd9e..196c41fbea7f 100644 --- a/src/main/agent-hooks/server-claude-cancel-captures.test.ts +++ b/src/main/agent-hooks/server-claude-cancel-captures.test.ts @@ -327,4 +327,33 @@ describe('a Claude cancel with a live subagent (captured)', () => { 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() + } + }) }) From 130f162af07f30f90ca6cdac9abe1b8f115f3d5e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:44:45 -0700 Subject: [PATCH 15/15] fix(agent-status): keep a cancel through a restarted relay's child hook and a teammate's idle A relay that restarts after a desktop-inferred cancel has lost its prompt cache, so the child's next hook arrived with an empty prompt, read as a new turn, and replaced the cancelled main agent with none; the row then stayed working after every child stopped. A child's empty prompt is now unknown, not another turn; a non-empty different one still releases, since it is the listener's newer prompt. TeammateIdle names its child by teammate_name and carries no agent id, so the latch treated it as the main agent's and let the late-hook window apply it after 15 s, reviving the cancelled turn. It is now re-folded as child work. --- .../server-relayed-claude-cancel.test.ts | 95 ++++++++++++++++++- .../server/server-cancel-verdict-latch.ts | 18 +++- 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts index da640f18d875..ce93f46f8861 100644 --- a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts +++ b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts @@ -52,14 +52,18 @@ type SshPane = { 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: new RelayAgentHookServer({ - endpointDir: temporaryDir('orca-relayed-cancel-'), - token: 'relayed-cancel-token', - forward: (envelope) => pane.desktop.ingestRemote(envelope, 'conn-1') - }), + 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`, { @@ -75,6 +79,14 @@ async function startSshPane(desktop: AgentHookServer): Promise { 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) @@ -194,6 +206,45 @@ describe('a relayed Claude cancel with a live subagent (captured)', () => { }) }) + 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() @@ -271,6 +322,40 @@ describe('a relayed Claude cancel with a background shell (captured)', () => { }) }) +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) diff --git a/src/main/agent-hooks/server/server-cancel-verdict-latch.ts b/src/main/agent-hooks/server/server-cancel-verdict-latch.ts index 430731d4a1cd..8bfc3fe897bf 100644 --- a/src/main/agent-hooks/server/server-cancel-verdict-latch.ts +++ b/src/main/agent-hooks/server/server-cancel-verdict-latch.ts @@ -68,6 +68,20 @@ function opensNewTurn(event: AgentHookEventPayload): boolean { ) } +/** 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 @@ -85,7 +99,7 @@ export function resolveCancelVerdictLatch( !previous || !isCancelVerdictLatched(previous) || previous.payload.agentType !== incoming.payload.agentType || - previous.payload.prompt !== incoming.payload.prompt || + restatesAnotherPrompt(previous, incoming) || incoming.payload.mainAgent?.state === 'done' || opensNewTurn(incoming) ) { @@ -97,7 +111,7 @@ export function resolveCancelVerdictLatch( latched && incoming.payload.agentType !== 'codex' && incoming.payload.state !== 'done' && - (incoming.toolAgentId !== undefined || incoming.isReplay === true) && + (isChildAttributed(incoming) || incoming.isReplay === true) && carriesChildWork(incoming) ) { return { hold: false, event: refoldUnderLatchedMainAgent(previous, latched, incoming) }