From bc6c53877c2012feb3aec03d4f1ecfb596ffd019 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:50:36 -0700 Subject: [PATCH] fix(agent-status): stop leftover Claude shells from holding a finished lead at working A lead Stop with a still-running background shell published working, and a later start-less SubagentStop re-emitted that latch forever because an idle parent never sends another inventory. Leftover non-agent inventory no longer participates in pane state; live child agents and session crons still gate working. STA-4119 --- STA-4119-PLAN.md | 90 ++++++++++ STA-4119-REPRO.md | 162 ++++++++++++++++++ .../server-last-status-write.test.ts | 2 +- src/relay/agent-hook-server.test.ts | 2 +- ...tion-background-turn-notifications.test.ts | 6 +- ...ent-hook-listener-claude-subagents.test.ts | 39 ++--- src/shared/agent-hook-listener.ts | 11 +- .../claude-background-task-status.test.ts | 40 +++-- sta-4119-macos-last-status-entry.json | 57 ++++++ sta-4119-windows-last-status-fields.json | 19 ++ 10 files changed, 384 insertions(+), 44 deletions(-) create mode 100644 STA-4119-PLAN.md create mode 100644 STA-4119-REPRO.md create mode 100644 sta-4119-macos-last-status-entry.json create mode 100644 sta-4119-windows-last-status-fields.json diff --git a/STA-4119-PLAN.md b/STA-4119-PLAN.md new file mode 100644 index 00000000000..39bc27ff533 --- /dev/null +++ b/STA-4119-PLAN.md @@ -0,0 +1,90 @@ +# STA-4119 plan — leftover non-agent inventory must not hold a finished lead at `working` + +## Decision + +A Claude lead turn that has ended (`Stop` / `StopFailure` with `lead.state === 'done'`) must publish **`done`** even when a background shell / monitor is still in `background_tasks`. Live **child agents** on the roster still hold the pane at `working`. Session crons stay on the existing cron gate (out of scope). + +This is a write-path change in `resolveClaudePaneState`, not a PTY-liveness backstop. Lane STA-4612 owns dead-pane / exit-path recovery. This lane owns the live pane whose lead is over and whose leftover process inventory currently upgrades `done` back to `working`. + +## Why this, not a refresh loop + +`claudeRunningNonAgentTaskPaneKeys` is written from `background_tasks` on lead events (`updateClaudeRunningNonAgentTask` in `src/shared/agent-hook-listener.ts`). An idle parent never emits another inventory, so the set is a one-way latch until the next lead turn. + +Two ways to “re-evaluate the gate”: + +1. **Keep the latch and poll / wait for a later inventory.** That reconciles after the fact, needs a signal the idle parent does not emit, and still publishes `working` while the shell is actually alive. It fails the acceptance oracle in `STA-4119-REPRO.md` (card/tab must reach `done` **while the background shell is still alive**). +2. **Stop letting leftover non-agent inventory participate in pane `state`.** Then a finished lead cannot be represented as `working` just because a child process lives. The latch can still be recorded for interrupt inference; it must not be an input to `resolveClaudePaneState`. + +(2) makes the reported state unrepresentable. (1) does not. + +`UserPromptSubmit` / `PreToolUse` / other mid-turn events still report `working` before `resolveClaudePaneState` sees a done lead. The pane stays `working` for a genuine lead turn, including one that happens to have a shell. That is the regression the coordinator forbade. + +## Code change (single decision, few call sites) + +In `resolveClaudePaneState` (`src/shared/agent-hook-listener.ts`), when `lead.state === 'done'`, return `working` only if: + +- the roster has a working child, or +- the lead is not interrupted **and** `claudeActiveSessionCronPaneKeys` has the pane. + +Remove `claudeRunningNonAgentTaskPaneKeys` from that predicate. + +Same leftover-shell predicate is inlined in two other places and must be aligned so a shell cannot keep looking like a confirmed working gate after the lead is done: + +- SubagentStop `hasConfirmedDoneGate` (~2682). That flag only decides whether a restored-child drain is “confirmed leftover inventory” vs “unconfirmed restore.” After this change a leftover shell is no longer a confirmed *working* gate, so this check must not treat the shell set as confirmation either. Otherwise a start-less `SubagentStop` (the reporter’s last event) can still mark the pane unconfirmed. After the resolve change, the re-emit is `done` if the cached lead is `done` and no child is working. +- The restored-snapshot guard on a lead Stop (~3110) currently requires `!claudeRunningNonAgentTaskPaneKeys.has(paneKey)` before marking `claudeUnconfirmedRestoredStatusPaneKeys`. A leftover shell would then skip that mark. Align this guard with the new resolve predicate (shells do not count as confirmation). Cron can stay. + +Do **not**: + +- add a PTY / pid liveness join (STA-4612) +- register `SessionEnd` +- map TUI-idle to `done` while the lead turn is still `working` +- invent a `backgroundOnly` render marker (closed #14620). Acceptance is `state === 'done'` on the card/tab. + +Keep writing `claudeRunningNonAgentTaskPaneKeys` on lead inventories. `server.ts` still consults it so Escape/Ctrl+C at an idle prompt does not infer “interrupted done” over a provider-owned shell. That is interrupt inference, not sidebar `state`. + +`turnCompletedAt` is only stamped when a lead Stop is gated **up** to `working`. After this change a shell-only Stop publishes `done` and does not need that stamp; the working→done edge is the completion notification. Stamping remains for a Stop that is still gated by a **working child** (existing #14580 behavior, unchanged). + +## Tests (red before the resolve change, green after) + +Add in `src/shared/claude-background-task-status.test.ts` (or the existing Claude subagent listener file) a STA-4119 case that is **red on current main**: + +1. `UserPromptSubmit` → `working` +2. Lead `Stop` with `background_tasks: [{ type: 'shell', status: 'running' }]` and no child → **`done`** (today: `working`) +3. Start-less `SubagentStop` (native unmatched child stop) → still **`done`**, last published row is not `working` (today: `SubagentStop` / `working` — the reporter’s `last-status.json`) +4. `UserPromptSubmit` again → **`working`** (lead is active; leftover shell must not be “force everything to done”) +5. `Stop` while a roster child is `working` → still **`working`** + `turnCompletedAt` (do not ignore real child work) + +Update existing tests that currently require `Stop` + `RUNNING_SHELL` → `working` so they expect `done` when no child is working. Keep tests that require `Stop` + running **subagent** → `working`. + +Notification tests in `agent-hook-completion-background-turn-notifications.test.ts`: the shell-only sequence should announce on the `done` Stop, not on a gated-working Stop. The subagent-gated sequence stays on `turnCompletedAt`. + +No new hook opcode, no new persisted field. Publishing `done` instead of `working` for this inventory is the intended product change; mixed-version remotes already understand both states. + +## Hibernation / keep-awake (accepted tradeoff) + +`agent-hibernation-planner.ts` refuses any pane with `state !== 'done'`. After this change a finished lead with a live leftover shell is hibernation-eligible. That matches “the lead is idle.” Killing a long-lived `npm run dev` by sleeping the worktree is the same class of leftover-process risk the reporter already works around by putting servers in a dedicated tab. Not in scope to invent a third pane state. + +## Precedent (local reference checkouts; names omitted from this file on purpose) + +A peer agent-session tracker in the local reference tree uses this exact state machine for hook-driven session status: + +- `userPromptSubmit` / `preToolUse` / `postToolUse` → working +- `stop` → **idle** +- `subagentStop` → **unchanged** (a child finishing does not make the parent working) +- process-exit is a backstop only for a session that has **ended**, not for leftover children of a live idle parent + +That is the same split this plan uses: lead Stop is idle; leftover child processes are session inventory; dead-pane recovery is a different ticket. + +I did not find a peer that treats “provider-owned background shell still running” as “the lead turn is still working.” If we kept that latch we would be the unusual one, and we would fail the acceptance oracle. + +## Acceptance (from `STA-4119-REPRO.md`) + +After implement, re-run the exact macOS repro: + +- TUI idle, `1 shell still running`, PTY alive +- card/tab **`done`** (not `working`) +- last-status not stuck on `SubagentStop` / `working` +- control (no shell) still `Stop` / `done` +- a turn that is actually running tools still shows `working` + +Do not implement until a Claude Fable review and a fresh Codex verify of this plan both return clean. diff --git a/STA-4119-REPRO.md b/STA-4119-REPRO.md new file mode 100644 index 00000000000..161ebc49465 --- /dev/null +++ b/STA-4119-REPRO.md @@ -0,0 +1,162 @@ +# STA-4119 reproduction + +**Verdict: reproduced on both macOS and Windows.** The reported mechanism is real and is not a Windows-only artifact. It is also not the phantom-working bug fixed by #14375 / #15082. + +The pane stays `working` after the lead turn ends because a live background shell (and a start-less `SubagentStop`) keeps the background-task / child gate up. The owning Claude PTY is still alive, sitting at an idle `❯` prompt. A hooks-independent PTY-liveness join (STA-4612 as specified) does **not** clear this case. + +## Builds + +| Surface | App | Contains #14375 / #15082 / #14580? | Claude Code | +| --- | --- | --- | --- | +| Local macOS Orca (authoritative "has the merged status PRs" build) | `1.4.185-adhoc.20260817215434` | Yes. Source worktree HEAD `8cd338357ee35e7348cb08d3a0522fee161b2e28` is a descendant of `d137bb93e1` (#14375), `60805f5c45` (#15082), and `5e9e38fa75` (#14580). The running adhoc binary stamps `turnCompletedAt` on the gated working row, which is the #14580 signal. | v2.1.233 | +| Windows high spec remote (`runtimeId` `70a74ef9-bd02-4f76-b08b-846e230d2dc5`) | `1.4.184` (runtime ready; desktop app not running) | No. 1.4.184 predates those merges. Confirmed in-product: the working row has **no** `turnCompletedAt`. | v2.1.234 | +| Windows low spec | Unreachable (`ok: false`) | n/a | n/a | + +This lane did not cherry-pick anything. The macOS run is the "still broken after the two merged status PRs" result. The Windows run is the original-platform result on the shipped 1.4.184 the reporter's era used. + +## Exact steps (what I actually ran) + +Throwaway fixture, not the orca repo (no setup hooks, no sibling-worktree discovery): + +1. Create `/tmp/sta-4119-bg-fixture` with `package.json` `dev` → `python3 serve.py` (sleeps 3600s). `orca repo add --path /tmp/sta-4119-bg-fixture`. +2. **macOS:** `orca worktree create --repo id:a2200db1-ead2-44b2-98ef-305f17644241 --name sta-4119-repro-macos --no-parent --agent claude --setup skip` + - handle `term_0a08d384-10a2-49ec-a5da-a6defc516cac` + - paneKey `b1a746f4-93d0-483a-9b56-841d3d38b3c5:45175c2f-d16d-4ff4-bc95-809fed804872` +3. **Windows:** `orca worktree create --environment "Windows high spec" --repo id:be84c937-38c9-426c-aa55-300e8f658a83 --name sta-4119-repro-win --no-parent --agent claude --setup skip` + - handle `term_ab5aff7b-d73e-4946-b445-dfdfc729b68f` + - paneKey `0eefb1da-3af1-40cb-ab8c-309eea05e5cb:d96df5f8-1870-42e2-b11b-a50bbc6c6985` + - path `C:/Users/neil/orca/workspaces/orca-setup-shell-repro/sta-4119-repro-win` +4. Accept Claude's workspace-trust prompt (`1. Yes, I trust this folder`). +5. Send the same lead prompt on both panes: + + > Do exactly these steps, then end your turn. Do not start a subagent. Do not ask questions. + > 1. Start this long-lived process as a background/async Bash shell and do NOT wait for it to exit: `python3 -c "import time; print(\"sta-4119-bg-started\", flush=True); time.sleep(3600)"` (fallback `python` / `sleep 3600`). + > 2. Confirm the process is running in the background. + > 3. Reply with exactly `STA-4119-TURN-DONE`. + +6. Wait until `orca terminal wait --for tui-idle` is satisfied and the TUI shows the final assistant line plus an idle `❯`. +7. Read `last-status.json` for that pane and `orca worktree ps` card/agent state. Re-check ~5 minutes later. + +**Control (same macOS adhoc build, no background work):** worktree `sta-4119-control-macos`, prompt "Reply with exactly STA-4119-CONTROL-DONE and end your turn. Do not run any shell command." + +## What I observed + +### Both platforms (the reported bug) + +After the lead printed `STA-4119-TURN-DONE`: + +- Claude TUI sat at an idle `❯`. Footer: `1 shell` / `1 shell still running`. Status line also showed `← for agents` even though the prompt forbade a subagent. +- `orca terminal wait --for tui-idle` returned satisfied. The PTY was still running. +- Worktree card / agent row stayed `working`. Windows tab title kept the working glyph (`✳ STA-4119 background shell test`). +- Last hook Orca stored for the pane was **`SubagentStop` with `payload.state: "working"`**. No later hook arrived while the background shell lived. +- The `SubagentStop` carried a `toolAgentId` that never had a matching visible `SubagentStart` in the pane status. That matches the already-measured fact that start-less `SubagentStop` is native traffic. + +Re-check at 16:37:44 local, ~5 minutes after the macOS `SubagentStop` (16:32:58) and the Windows one (`receivedAt` 1787009583714): **same last event, still `working`.** This is not a brief race. + +Windows extra: Claude printed `Background command "Start long-lived background process" failed with exit code 49` and still showed `1 shell still running`. The inventory Orca consumed was still a live shell as far as Claude's own status line was concerned. + +### macOS 1.4.185-adhoc (has #14375 / #15082 / #14580) + +`~/Library/Application Support/orca/agent-hooks/last-status.json` pane entry (full dump: `sta-4119-macos-last-status-entry.json`): + +```json +{ + "hookEventName": "SubagentStop", + "toolAgentId": "ac4a0c329a0e18cd9", + "payload": { + "state": "working", + "lastAssistantMessage": "STA-4119-TURN-DONE", + "toolName": "Bash", + "turnCompletedAt": 1787009576866 + }, + "receivedAt": 1787009578408, + "stateStartedAt": 1787009567895 +} +``` + +`orca worktree ps`: card `status=working`, agent `state=working`, last message `STA-4119-TURN-DONE`. Unchanged 5 minutes later. + +`turnCompletedAt` is present. That is the #14580 stamp: lead `Stop` was gated back up to `working` by the background shell, and the listener recorded the turn-end time so the completion coordinator *can* announce without waiting for `done`. + +### Windows 1.4.184 (original platform, pre-#14580) + +`C:\Users\neil\AppData\Roaming\orca\agent-hooks\last-status.json` for pane `0eefb1da-…:d96df5f8-…` (fields: `sta-4119-windows-last-status-fields.json`; full entry on the host at `sta-4119-win-last-status.json` in that worktree): + +- `hookEventName=SubagentStop` +- `state=working` +- `turnCompletedAt=None` — **not in `payloadKeys`** +- `lastAssistantMessage=STA-4119-TURN-DONE` +- `toolName=Read` +- `toolAgentId=a0dc6d9fd3ea4d8be` +- `receivedAt=1787009583714` + +This is the reporter's evidence, reproduced on the reported OS, on 1.4.184. + +### Control (macOS adhoc, no background shell) + +```json +{ + "hookEventName": "Stop", + "payload": { + "state": "done", + "lastAssistantMessage": "STA-4119-CONTROL-DONE" + } +} +``` + +Card `status=active`, agent `state=done`. Same build, same Claude major, no background inventory → the working→done edge happens. + +## Did the completion notification fire? + +| Build | Notification | +| --- | --- | +| Windows 1.4.184 | **No path for it to fire.** Last row is `working` with no `turnCompletedAt` and no later `done`. The old coordinator only announces on a working→done (or equivalent) edge. Matches the reporter ("no completion notification, no sound, no chip"). | +| macOS 1.4.185-adhoc | **Stamp present, banner not independently observed.** The gated working row carries `turnCompletedAt=1787009576866`, which is exactly the signal #14580 added so the coordinator can announce the finished turn while the pane stays `working`. I searched `~/Library/Application Support/orca/logs/main.trace.ndjson` and `daemon.log` for `agent-task-complete` / this paneKey / `STA-4119-TURN-DONE` and found no dispatch log. I did not see an OS banner from this session. I am **not** claiming the live banner fired; I am claiming the data the coordinator needs is on the row. | + +#14580 (`fix(agent-status): announce Claude turn complete while background work runs`) is already on this source HEAD. #14620 (stop *rendering* the leftover gate as foreground work) was **closed unmerged**. So on current main the remaining user-visible half of STA-4119 is the spinner/card staying "Claude is working right now" while the TUI is idle. + +## Mechanism (checked against this tree, not the handoff line numbers) + +`resolveClaudePaneState` (`src/shared/agent-hook-listener.ts`) returns `working` when the lead is `done` if a roster child is working **or** `claudeRunningNonAgentTaskPaneKeys` / session-cron gates are set. + +`updateClaudeRunningNonAgentTask` runs on lead events that carry `background_tasks`. A running `type: "shell"` is a non-agent task (`src/shared/claude-background-task-inventory.ts`). Folding the inventory into the roster happens on a **lead turn boundary** (`Stop` / `StopFailure`). An idle parent never emits another inventory. + +A later start-less `SubagentStop` (native traffic; we saw one on both platforms after a prompt that forbade subagents) re-emits the cached lead as `working`. That is why `last-status.json` ends on `SubagentStop` / `working` — same as the reporter — rather than on the lead `Stop`. + +This is **not** the #14375 phantom: the gate has positive evidence (Claude's own "1 shell still running", a `turnCompletedAt` stamp on the adhoc build, a live PTY). Working is what the current state machine intends given that inventory. + +## STA-4612 subsumption verdict: **does not subsume this case** + +STA-4612 proposes a hooks-independent backstop that is either: + +1. process-exit of the owning agent, keyed by pid + start time, or +2. a read-time liveness join with "the owning session/PTY is still live" + +so a *dead* pane cannot stay `working`. + +**Here the pane is alive.** Claude is at an idle prompt. `orca terminal wait --for tui-idle` succeeds. The background shell is a **child of that still-live PTY**, not a replacement for it. Intersecting status with "PTY live" still yields live + `working`. Process-exit never fires because the Claude process has not exited. + +STA-4612's write-up also lists "background-task gate never refreshed" for the *other* failure: a background task **ends** while the parent stays idle, so the inventory is stale and nothing clears the gate. That is a sibling, not this ticket. This ticket's reporter case is "the shell is still running, the turn is over, stop looking like foreground work / fire the finish notification." PTY liveness does not distinguish those. + +A TUI-idle → `done` fallback (the reporter's first suggestion) would also fight keep-awake / hibernation, which currently treat `state !== 'done'` as live work. #14620's closed approach — keep `state=working` for those systems, but stop *drawing* it as foreground activity — is the product-shaped remaining fix if notification-on-`turnCompletedAt` is accepted as sufficient for the banner half. + +## Post-fix (this worktree) + +Plan: `STA-4119-PLAN.md`. Reviewed CLEAN by Claude Fable (`task_599c4da8f712`) then independently verified CLEAN by a fresh Codex worker (`task_fa35d597c48c`) before any production edit. + +The reporter sequence is now a unit test (`does not hold a finished lead at working for a leftover background shell`). It was **red** on the pre-change listener (`Stop` + running shell published `working`) and is **green** after removing leftover shells from `resolveClaudePaneState` / the two sibling predicates. + +147 related tests pass (`claude-background-task-status`, Claude subagent listener, background-turn notifications, last-status write/lead-boundary, hook ingest, interrupt inference, relay hook server). + +Live UI re-run against this commit is **not** possible in the currently running desktop (`1.4.185-adhoc.20260817215434`): that binary does not load this worktree. The leftover `sta-4119-repro-macos` / `sta-4119-repro-win` panes will stay `working` until a build that contains this change processes a new hook (or the session is restarted on that build). + +## What is left + +- Merge the PR that implements `STA-4119-PLAN.md`. +- Re-run the live macOS repro on a desktop that includes this commit: card/tab must be `done` while `1 shell still running`. +- Repro worktrees left running on purpose: `sta-4119-repro-macos` (local) and `sta-4119-repro-win` (Windows high spec). Kill the background `sleep`/python if they should not keep a core parked. + +## Evidence files + +- `sta-4119-macos-last-status-entry.json` — full local last-status rows for repro + control +- `sta-4119-windows-last-status-fields.json` — extracted Windows last-status fields diff --git a/src/main/agent-hooks/server-last-status-write.test.ts b/src/main/agent-hooks/server-last-status-write.test.ts index f693c2d4c36..6a88e162316 100644 --- a/src/main/agent-hooks/server-last-status-write.test.ts +++ b/src/main/agent-hooks/server-last-status-write.test.ts @@ -77,7 +77,7 @@ describe('Last-status persistence', () => { worktreeId: 'wt-1', receivedAt: expect.any(Number), stateStartedAt: expect.any(Number), - payload: expect.objectContaining({ state: 'working', prompt: 'persist me' }) + payload: expect.objectContaining({ state: 'done', prompt: 'persist me' }) }) expect(file.entries[PANE].launchToken).toBeUndefined() expect(file.entries[PANE].launchTokenHash).toBe( diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 3458814ed5b..103db5fdb05 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -99,7 +99,7 @@ describe('RelayAgentHookServer', () => { expect(forward.mock.calls[0][0]).toMatchObject({ claudeRunningNonAgentTask: true, - payload: { state: 'working', agentType: 'claude' } + payload: { state: 'done', agentType: 'claude' } }) } finally { server.stop() diff --git a/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts index 8583c3bb0de..cf6a0c85330 100644 --- a/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts @@ -205,14 +205,16 @@ describe('Claude background-turn completion notifications', () => { } ]) + // Why: a leftover shell no longer gates Stop to working, so this turn + // announces on the done quiet edge instead of turnCompletedAt. expect(banners).toHaveLength(2) expect(banners[0]).toMatchObject({ - at: 1_700_000_001_000, + at: 1_700_000_001_000 + HOOK_DONE_QUIET_MS, body: 'Dev server is up.', stateStartedAt: 1_700_000_001_000 }) expect(banners[1]).toMatchObject({ - at: 1_700_000_011_000, + at: 1_700_000_011_000 + HOOK_DONE_QUIET_MS, body: 'Route added.', stateStartedAt: 1_700_000_011_000 }) diff --git a/src/shared/agent-hook-listener-claude-subagents.test.ts b/src/shared/agent-hook-listener-claude-subagents.test.ts index 5278247cf8d..180b6015aa9 100644 --- a/src/shared/agent-hook-listener-claude-subagents.test.ts +++ b/src/shared/agent-hook-listener-claude-subagents.test.ts @@ -97,26 +97,25 @@ describe('shared agent-hook-listener', () => { expect(stop?.payload.turnCompletedAt).toBeUndefined() }) - it.each([ - { - label: 'a running shell task', - eventName: 'Stop', - payload: { background_tasks: [{ id: 'shell-1', type: 'shell', status: 'running' }] } - }, - { - label: 'a pending session cron', - eventName: 'StopFailure', - payload: { session_crons: [{ id: 'cron-1' }] } - } - ])( - 'reports Stop as working for $label without adding a subagent row', - ({ eventName, payload }) => { - claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'run in background' }) - const stop = claudeEvent({ hook_event_name: eventName, ...payload }) - expect(stop?.payload.state).toBe('working') - expect(stop?.payload.subagents).toBeUndefined() - } - ) + it('reports Stop as done for a leftover shell without adding a subagent row', () => { + claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'run in background' }) + const stop = claudeEvent({ + hook_event_name: 'Stop', + background_tasks: [{ id: 'shell-1', type: 'shell', status: 'running' }] + }) + expect(stop?.payload.state).toBe('done') + expect(stop?.payload.subagents).toBeUndefined() + }) + + it('reports StopFailure as working for a pending session cron without adding a subagent row', () => { + claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'run in background' }) + const stop = claudeEvent({ + hook_event_name: 'StopFailure', + session_crons: [{ id: 'cron-1' }] + }) + expect(stop?.payload.state).toBe('working') + expect(stop?.payload.subagents).toBeUndefined() + }) it('reports Stop as working while a background subagent is still running', () => { claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'review the PR' }) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index e471c55d25e..cca50e9d6a6 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -2602,10 +2602,11 @@ function resolveClaudePaneState( return lead.state } const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) + // Why: leftover provider shells/monitors are session inventory, not lead-turn + // work. Gating done→working on them latches the pane forever because an idle + // parent never emits another background_tasks list (STA-4119). return claudeRosterHasWorkingSubagent(roster) || - (!lead.interrupted && - (state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || - state.claudeActiveSessionCronPaneKeys.has(paneKey))) + (!lead.interrupted && state.claudeActiveSessionCronPaneKeys.has(paneKey)) ? 'working' : 'done' } @@ -2679,8 +2680,7 @@ function normalizeClaudeSubagentLifecycleEvent( const hasConfirmedDoneGate = cachedLead?.state === 'done' && cachedLead.interrupted !== true && - (state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || - state.claudeActiveSessionCronPaneKeys.has(paneKey)) + state.claudeActiveSessionCronPaneKeys.has(paneKey) const restoredOnlyDoneGate = cachedLead?.state === 'done' && !hasConfirmedDoneGate && hasUnconfirmedChild if (roster?.size === 0) { @@ -3107,7 +3107,6 @@ function normalizeClaudeEvent( effectiveState === 'working' && claudeRosterHasRestoredSnapshotSubagent(effectiveRoster) && !claudeRosterHasRuntimeWorkingSubagent(effectiveRoster) && - !state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) && !state.claudeActiveSessionCronPaneKeys.has(paneKey) ) { // Why: a legacy or partial Stop confirms the lead boundary, not a child restored from disk; keep the child-only gate eligible for reconciliation. diff --git a/src/shared/claude-background-task-status.test.ts b/src/shared/claude-background-task-status.test.ts index ecf685706fe..e9e4987bb31 100644 --- a/src/shared/claude-background-task-status.test.ts +++ b/src/shared/claude-background-task-status.test.ts @@ -105,7 +105,7 @@ describe('Claude background task status', () => { } }) - it('stays working through Claude 2.1.220 Stop and SubagentStop until the shell finishes', () => { + it('does not hold a finished lead at working for a leftover background shell', () => { const state = createHookListenerState() expect( @@ -117,29 +117,37 @@ describe('Claude background task status', () => { expect( claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', + last_assistant_message: 'STA-4119-TURN-DONE', background_tasks: [RUNNING_SHELL] - })?.state - ).toBe('working') + }) + ).toMatchObject({ state: 'done', lastAssistantMessage: 'STA-4119-TURN-DONE' }) expect( claudeEvent(state, SOURCE_PANE, { hook_event_name: 'SubagentStop', agent_id: 'a70fdf2986e38302b', background_tasks: [RUNNING_SHELL] })?.state - ).toBe('working') + ).toBe('done') + expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true) expect( claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', - prompt: 'completed' + prompt: 'next turn' })?.state ).toBe('working') + + claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'SubagentStart', + agent_id: 'child-1', + agent_type: 'general-purpose' + }) expect( claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', - background_tasks: [] + background_tasks: [RUNNING_SHELL, { id: 'child-1', type: 'subagent', status: 'running' }] })?.state - ).toBe('done') + ).toBe('working') }) it('keeps pending task state when pane authority moves', () => { @@ -156,7 +164,8 @@ describe('Claude background task status', () => { hook_event_name: 'SubagentStop', agent_id: 'a70fdf2986e38302b' })?.state - ).toBe('working') + ).toBe('done') + expect(state.claudeRunningNonAgentTaskPaneKeys.has(TARGET_PANE)).toBe(true) }) it('keeps an interrupted Stop terminal even when its task inventory is still running', () => { @@ -287,7 +296,7 @@ describe('Claude background task status', () => { expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(false) }) - it('keeps a failed turn working while its background shell runs', () => { + it('does not hold a failed turn at working for a leftover background shell', () => { const state = createHookListenerState() expect( @@ -295,7 +304,8 @@ describe('Claude background task status', () => { hook_event_name: 'StopFailure', background_tasks: [RUNNING_SHELL] })?.state - ).toBe('working') + ).toBe('done') + expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true) expect( claudeEvent(state, SOURCE_PANE, { hook_event_name: 'StopFailure', @@ -317,7 +327,7 @@ describe('Claude background task status', () => { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }) - ).toMatchObject({ state: 'working', interrupted: undefined }) + ).toMatchObject({ state: 'done', interrupted: undefined }) }) it('resumes on task notifications and user slash commands after interruption', () => { @@ -497,7 +507,8 @@ describe('Claude background task status', () => { agent_id: 'child-1', background_tasks: [] })?.state - ).toBe('working') + ).toBe('done') + expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true) }) it('ignores TeammateIdle inventories for lead-owned background work', () => { @@ -513,7 +524,7 @@ describe('Claude background task status', () => { teammate_name: 'reviewer', background_tasks: [] })?.state - ).toBe('working') + ).toBe('done') expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true) claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', background_tasks: [] }) @@ -550,7 +561,8 @@ describe('Claude background task status', () => { background_tasks: [RUNNING_SHELL] }) - expect(claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop' })?.state).toBe('working') + expect(claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop' })?.state).toBe('done') + expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true) clearPaneCacheState(state, SOURCE_PANE) expect(state.claudeRunningNonAgentTaskPaneKeys.size).toBe(0) diff --git a/sta-4119-macos-last-status-entry.json b/sta-4119-macos-last-status-entry.json new file mode 100644 index 00000000000..3627aa25fef --- /dev/null +++ b/sta-4119-macos-last-status-entry.json @@ -0,0 +1,57 @@ +{ + "capturedAtLocal": "2026-08-17T16:37:44-07:00", + "localAppVersion": "1.4.185-adhoc.20260817215434", + "sourceWorktreeHead": "8cd338357ee35e7348cb08d3a0522fee161b2e28", + "reproPane": { + "paneKey": "b1a746f4-93d0-483a-9b56-841d3d38b3c5:45175c2f-d16d-4ff4-bc95-809fed804872", + "source": "claude", + "tabId": "b1a746f4-93d0-483a-9b56-841d3d38b3c5", + "worktreeId": "a2200db1-ead2-44b2-98ef-305f17644241::/Users/brennanbenson/orca/workspaces/sta-4119-bg-fixture/sta-4119-repro-macos", + "connectionId": null, + "hasExplicitPrompt": false, + "hookEventName": "SubagentStop", + "providerPromptId": "cd77def5-25b9-40b3-a721-b4a66b6c8004", + "toolAgentId": "ac4a0c329a0e18cd9", + "providerSession": { + "key": "session_id", + "id": "4598b6d9-e09d-4a1c-896a-df301d180f44", + "transcriptPath": "/Users/brennanbenson/.claude/projects/-Users-brennanbenson-orca-workspaces-sta-4119-bg-fixture-sta-4119-repro-macos/4598b6d9-e09d-4a1c-896a-df301d180f44.jsonl" + }, + "payload": { + "state": "working", + "prompt": "Do exactly these steps, then end your turn. Do not start a subagent. Do not ask questions. 1. Start this long-lived process as a background/async Bash shell and do NOT wait for it to exit: python3 ", + "agentType": "claude", + "toolName": "Bash", + "toolInput": "cat /private/tmp/claude-501/-Users-brennanbenson-orca-workspaces-sta-4119-bg-fixture-sta-4119-repro-macos/4598b6d9-e09d-4a1c-896a-df301d180f44/tasks/b0icpu59e.o", + "lastAssistantMessage": "STA-4119-TURN-DONE", + "turnCompletedAt": 1787009576866 + }, + "receivedAt": 1787009578408, + "stateStartedAt": 1787009567895, + "launchTokenHash": "dee6f186298eb5a60bb0a854ae0a6ced88cebfa4dc3988bd5dc48d84cce6896c" + }, + "controlPane": { + "paneKey": "032f97d0-4e98-4833-a2c9-b360e1ecd162:92bc0766-7177-4cd6-89dd-a45130e36e70", + "source": "claude", + "tabId": "032f97d0-4e98-4833-a2c9-b360e1ecd162", + "worktreeId": "a2200db1-ead2-44b2-98ef-305f17644241::/Users/brennanbenson/orca/workspaces/sta-4119-bg-fixture/sta-4119-control-macos", + "connectionId": null, + "hasExplicitPrompt": false, + "hookEventName": "Stop", + "providerPromptId": "982d1b47-9d7b-47d0-bfb3-ccfb217e850d", + "providerSession": { + "key": "session_id", + "id": "e8ddb7ac-d5ec-424f-bead-4b76ff64ce16", + "transcriptPath": "/Users/brennanbenson/.claude/projects/-Users-brennanbenson-orca-workspaces-sta-4119-bg-fixture-sta-4119-control-macos/e8ddb7ac-d5ec-424f-bead-4b76ff64ce16.jsonl" + }, + "payload": { + "state": "done", + "prompt": "Reply with exactly STA-4119-CONTROL-DONE and end your turn. Do not run any shell command. Do not start a background process. Do not start a subagent.", + "agentType": "claude", + "lastAssistantMessage": "STA-4119-CONTROL-DONE" + }, + "receivedAt": 1787009672621, + "stateStartedAt": 1787009672621, + "launchTokenHash": "e3a4bc871a4a496087b8f220b65468476673ab9a7d297e9b7da4f5c4a7d5956f" + } +} diff --git a/sta-4119-windows-last-status-fields.json b/sta-4119-windows-last-status-fields.json new file mode 100644 index 00000000000..93ed34dceb3 --- /dev/null +++ b/sta-4119-windows-last-status-fields.json @@ -0,0 +1,19 @@ +{ + "capturedAtLocal": "2026-08-17T16:38:30-07:00", + "windowsEnvironment": "Windows high spec", + "windowsAppVersion": "1.4.184", + "windowsRuntimeId": "70a74ef9-bd02-4f76-b08b-846e230d2dc5", + "lastStatusPath": "C:\\Users\\neil\\AppData\\Roaming\\orca\\agent-hooks\\last-status.json", + "paneKey": "0eefb1da-3af1-40cb-ab8c-309eea05e5cb:d96df5f8-1870-42e2-b11b-a50bbc6c6985", + "hookEventName": "SubagentStop", + "state": "working", + "turnCompletedAt": null, + "lastAssistantMessage": "STA-4119-TURN-DONE", + "toolName": "Read", + "toolAgentId": "a0dc6d9fd3ea4d8be", + "receivedAt": 1787009583714, + "stateStartedAt": 1787009565488, + "payloadKeys": ["state", "prompt", "agentType", "toolName", "toolInput", "lastAssistantMessage"], + "subagents": null, + "note": "Fields extracted on the Windows host from last-status.json. Full entry also written on that host as C:/Users/neil/orca/workspaces/orca-setup-shell-repro/sta-4119-repro-win/sta-4119-win-last-status.json" +}