mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 08:02:54 +00:00
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
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: '<task-notification><status>completed</status></task-notification>'
|
||||
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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user