Skip to content

fix(agent-status): a cancel never hides live work - #22476

Open
brennanb2025 wants to merge 16 commits into
mainfrom
brennanb2025/lead-status-pr-c
Open

brennanb2025 wants to merge 16 commits into
mainfrom
brennanb2025/lead-status-pr-c

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 17 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1498 $\color{#cf222e}{\Huge{\mathbf{−}}}$​113 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1385
Prod 15 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​418 $\color{#cf222e}{\Huge{\mathbf{−}}}$​155 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​263

ELI5

When you cancel an agent's turn with Ctrl+C, a shell or subagent it started in the background keeps running. Orca used to react to the cancel in ways that hid that work: either the row read "done" while the shell was still going, or it kept showing the cancelled turn as "Working" because the cancel was refused. Now every surface (sidebar, worktree ps, mobile, dashboard) shows what is really happening: "Monitoring background tasks" for a shell or scheduled check, and "working" for a subagent, until that work reports its own end. Orca also no longer infers a turn cancel from a Ctrl+C at an idle prompt, so that keypress alone does not change the row. The keypress is not always harmless in the CLI itself — see Live validation below.

What Changed

Before and after, as the user experiences it

  • Claude CLI pane, a background shell started in an earlier turn, Ctrl+C in the middle of a later turn. Before: the row kept showing "Working" with the cancelled turn's prompt until the next hook, because the cancel was refused whenever a shell was registered. After: the row reads "Monitoring background tasks" at once, and the cancellation is recorded on the main agent.
  • Claude or Grok pane already reading "Monitoring background tasks", Ctrl+C at the idle prompt. Before: Claude's inferred cancel was refused, and Grok settled the row to "done / interrupted" while its task kept running. After: Orca refuses the inferred cancel for both, so the keypress alone no longer changes the row (measured on Grok 1.0.41: its background task survives an idle Ctrl+C). The keypress itself can still stop work: Claude 2.1.280 stops its background subagents on a single idle Ctrl+C (shells survive), and because Orca refuses that cancel, the row keeps showing the stopped subagent as working — a stacked follow-up PR reflects Claude's own stop (see Known limitations). Codex 0.156.1 quits outright on one idle Ctrl+C, killing its work, and the row is correctly removed.
  • Grok, a stop_cancelled or stop_failure that lists running tasks. Before: "done". After: the fold treats it like a plain stop, so at the hook level such a payload reads monitoring. Live, this does not yet hold end to end: Orca still infers a Grok cancel from the keypress itself — Esc never cancels a Grok turn (Grok's own user guide), and a mid-turn Ctrl+C first opens Grok's "Stop them?" dialog — so the row is marked done at the keypress and stays done after Grok's own cancel hook while the work runs, as on main. A stacked follow-up PR drives Grok cancel state from Grok's own hooks (see Known limitations).
  • SSH pane: the cancel now holds. Before this change a relayed Claude row stayed "Working" after Ctrl+C; with only the fold change it would flip back to "Working" on the relay's next child hook or reconnect replay, because the remote host never learns of a cancel the desktop infers.
  • Structured native chat: unchanged. It already showed monitoring, which is the divergence this closes.
  • A cancelled turn is not a completion. It carries no completion stamp, so it fires no "finished" notification, and the row keeps interrupted when it settles (Claude, Codex and Grok alike). When the background work ends, the agent's own follow-up turn (Claude's task notification) runs and announces normally.

The mechanism

  • The shared fold (foldAgentLeadStatus) no longer takes a verdict input. A cancel is a fact about the main agent, carried as mainAgent.outcome: 'cancellation' (added in feat(agent-status): publish the main agent's own state beside the combined row state #22452), never about the work the turn left running. That work leaves the row only when its own inventory omits it or the session ends. Every site that used the verdict to retire work is gone: the shell gate's interrupt parameter, the cron gate, the deletes in the inferred-cancel path, the delete block on a turn boundary, the child-lifecycle done-gate's discount, and Grok's "only a plain stop reports what it left running".
  • The row's interrupted flag keeps flowing for the readers that ship today (mobile's state dot, the dashboard row, pane hibernation, notification dispatch). Each provider now derives it from its main agent record instead of each caller restating it: the Claude status builder, the Codex builder and relay reconcile, and Grok's normalizer. The Claude turn stamp (turnCompletedAt) is derived there too, and a cancelled turn earns none.
  • The server's inferred cancel (current Claude Code 2.1.280 sends no hook on a cancel) now reads the main agent fact. For any row that publishes mainAgent it is admitted only when mainAgent.state is working; Codex also keeps the child-evidence guard (its synthesized row is a plain done, which would retire children its combine keeps working); a row from a host too old to publish mainAgent keeps only that guard. The cancelled row is the fold of the cancelled main agent with the child work its owner can see: the local listener's roster for a local pane, the row's own subagents and shell fact for a relayed pane (server-row-child-work-fold.ts), whose provider records live on the remote host.
  • The store holds the verdict against restatements that predate it (server-cancel-verdict-latch.ts). This replaces the old late-hook suppression, which only covered rows already done + interrupted. The hold is read off the row (mainAgent.outcome), not stored beside it, and ends on a new turn (a main agent prompt submission, a changed 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, so live work still shows.
  • Esc is unchanged on purpose. Orca never infers a Claude cancel from a bare Esc, because Esc also closes Claude's overlays ([Bug]: Escape dismissing Claude Code's /btw composer is inferred as a turn interrupt (false red "Interrupted") #13547).
  • No wire change: no new field, opcode or RPC. Old clients see only content differences from a new host (monitoring after a cancel, a Grok idle Ctrl+C no longer settling the row).

Why

The cancel policy was decided with the main-agent status work (a cancel never hides live work): if the shell is still running, the user should see that something is still running. Recorded evidence against Claude Code 2.1.280 settled the precondition for a mid-turn cancel: it fires no hook, kills only the foreground tool, leaves background shells and subagents running, and every later Stop inventory matched the process table, so for that case there is no stale inventory to distrust and the hiding code had nothing left to protect against. Grok 1.0.41's idle prompt likewise leaves its work running. One measured exception: a single Ctrl+C at Claude's idle prompt makes Claude itself stop its background subagents (shells survive), so an inventory recorded before that keypress does go stale — a stacked follow-up PR reflects Claude's own stop (see Known limitations). Orca does not own the agent process in a terminal pane, so it cannot stop that work on the user's behalf; showing it is the honest state.

Alternatives considered:

  • Keep hiding in the CLI lane and make the structured lane hide too. Rejected: both lanes would then show "done" for a process that is running.
  • Delete only the retirement sites and leave the server's cancel guard alone. Rejected: the guard refused the cancel whenever a shell was registered, so the CLI lane could never show monitoring right after Ctrl+C.
  • Send the inferred cancel to the remote host so its records own it. Rejected for now: it needs a capability-negotiated wire change, and old remote hosts would still need the store-side hold, so the hold is the part every version needs.
  • Retire background work on a cancel by killing it or by a timeout. Rejected: the work survives the cancel, and silence is not evidence of death.

Linked Issue

Follow-up to #22452 and #22295; no separate issue.

Visual Proof

Isolated dev Orca build of this branch (hidden window, throwaway profile, folder workspace), Claude Code 2.1.280 and Grok 1.0.41 in terminal panes, keys sent through the real terminal input path.

After, Claude: turn 1 starts a 600 s sleep in the background; turn 2 runs a 120 s foreground sleep. Mid-turn, then right after Ctrl+C (row reads "Monitoring background task…", store row working / monitoring, mainAgent: { done, cancellation }, the 600 s sleep still alive):

A1-before-ctrl-c.png
A2-after-ctrl-c-monitoring.png

A second Ctrl+C at the idle prompt changes nothing; killing the background sleep lets Claude's own task-notification turn run and the row settles to done:

B2-after-second-ctrl-c.png
C2-settled-done.png

After, Grok: a background task holds the row in monitoring; Ctrl+C at the idle prompt leaves it monitoring (store row working / monitoring, no interrupted), and the task is still alive:

D1-grok-monitoring.png
D2-after-ctrl-c-still-monitoring.png

Before and after from the first round, against #22452 (Claude, same scenario):

Before (this branch's base, #22452): after Ctrl+C the sidebar row keeps the cancelled turn's spinner and prompt, and the store row is state: working, mainAgent: { state: working } with no cancellation recorded.

before-1-mid-turn-before-ctrl-c.png
before-2-ctrl-c-stuck-working.png

After: the sidebar row reads "Monitoring background task…" and the store row is state: working, workingMode: monitoring, mainAgent: { state: done, outcome: cancellation }.

after-1-mid-turn-before-ctrl-c.png
after-2-ctrl-c-monitoring.png

Testing

  • pnpm tc exit 0; pnpm exec oxlint src/main/agent-hooks src/shared/agent-hook-listener exit 0; pnpm run check:code-quality:changed 0 findings.
  • pnpm test src/main/agent-hooks src/shared/agent-hook-listener src/relay plus the fold, background-task, parity, structured-status and Grok completion suites: 308 files, 3148 passed. All of src/shared: 12110 passed. Renderer store and hooks: 4502 passed.
  • Recorded fixtures: src/shared/__fixtures__/claude-cancel-shell-hooks.jsonl and claude-cancel-subagent-hooks.jsonl (with .meta.json sidecars) are hook payloads recorded from Claude Code 2.1.280 over a real PTY, merged in time order with the driver's cancel and kill markers, scrubbed with the repo's transcript scanner. server-claude-cancel-captures.test.ts replays them through the server's HTTP ingress and cancel inference; server-relayed-claude-cancel.test.ts replays them through a real relay-side listener into the desktop (restart with seeded subagents, a child hook after the cancel, a child permission prompt, reconnect replay); server-grok-cancel.test.ts covers Grok's idle-prompt Ctrl+C and its own stop_cancelled trailing the inferred cancel.
  • Every fix was shown red without it and green with it, one site at a time.
  • Platforms: macOS (unit + the Electron run above). No platform branches. SSH is covered by the relay-listener replays, not a live SSH host.
  • Mobile checks were not run: mobile imports none of the changed modules, and the row shape it reads (state, workingMode, interrupted, mainAgent) is unchanged.

Live validation (real CLIs in Orca terminal panes, isolated dev build at 239992f; Claude Code 2.1.280, Codex 0.156.1, Grok 1.0.41):

  • Claude, Ctrl+C or Esc mid-turn: background shells and subagents keep running and the row keeps showing them, with the cancellation recorded on the main agent — the headline claim holds.

  • Claude, a single Ctrl+C at the idle prompt: Claude itself stops its background subagents ("All background agents stopped"); shells survive. Orca refuses that cancel and keeps showing the stopped subagent as working — stacked follow-up PR.

  • Claude, rapid double Ctrl+C with background work running: Claude asks "Exit and stop tasks / Move to background and exit / Stay"; exiting removes the row (correct).

  • Codex, Ctrl+C or Esc mid-turn: background work keeps running and the row keeps showing it — holds.

  • Codex, a single Ctrl+C at the idle prompt: Codex quits and kills its work; the row is removed (correct).

  • Codex, Ctrl+C mid-turn with a live subagent: the cancel is refused (as on main), so the row stays "working" even after all the work has ended — stacked follow-up PR.

  • Grok: Orca infers a cancel from keypresses Grok does not honor (Esc never cancels a Grok turn; a mid-turn Ctrl+C opens Grok's "Stop them?" dialog), marks the row done at the keypress, and the row stays done after Grok's own cancel hook while work runs. Also on main — stacked follow-up PR.

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

AI Disclosure

Author: @BrennanKB5

Review

  • Known limitations, each being fixed in a separate stacked PR on this branch (no numbers yet):
    • Claude idle-prompt Ctrl+C: Claude 2.1.280 itself stops its background subagents on a single Ctrl+C at the idle prompt (shells survive), but Orca refuses that cancel, so the row keeps showing the stopped subagent as working.
    • Codex cancel with a live subagent: a mid-turn Ctrl+C's inferred cancel is still refused (as on main), so the row stays "working" after all the work has ended (needs a Codex cancel capture before changing).
    • Grok cancel state: Orca infers a cancel from keypresses Grok does not honor (Esc never cancels a Grok turn; Ctrl+C opens Grok's own "Stop them?" dialog), so the row is marked done at the keypress and stays done after Grok's own cancel hook while work runs (also on main). The follow-up drives Grok cancel state from Grok's own hooks.
  • Unchanged from main, not in a follow-up yet: scheduled checks on SSH rows are not mirrored to the desktop.

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

One gap in the changed flow: the delayed-hook suppression in server-status-update.ts was not extended to the new non-terminal cancel row, so a late Claude tool hook erases the cancellation and flips the row back to a live working turn. Details below.

Reviewed changes

  • Cancel is now a lead-only fact: foldAgentLeadStatus drops its interrupted input and the monitoring && !interrupted guard; the shell/cron/subagent a turn left running holds the row open until its own inventory omits it or the session ends.
  • Claude hook lane: normalizeClaudeEvent no longer retires shell/cron latches on interrupt, derives turnCompletedAt via isAgentStatusHeldOpenByChildWork + outcome !== 'cancellation', and buildClaudeStatusPayload derives interrupted/turnCompletedAt from the lead record.
  • Server inference: inferInterrupt now admits a Claude cancel when payload.lead.state === 'working', folds it through markClaudeLeadTurnInterrupted (which folds child work and returns the row), refuses at an idle prompt held open by child work, and keeps the evidence guard for rows without lead and all other providers.
  • Grok: every turn end (stop/stop_failure/stop_cancelled) reports remaining finite tasks / an active stop hook as monitoring.
  • Structured lane: drops the hardcoded interrupted: false; docs updated.
  • Tests/fixtures: captured 2.1.280 PTY fixtures replayed through server ingress, new inference-guard cases, and expanded parity/fold/background-task pins. I ran the three server suites (31 tests) plus the fold/parity suites — all green.

⚠️ A delayed tool hook still resurrects a cancelled lead that child work is holding open

server-status-update.ts suppresses a late post-Ctrl+C tool/working hook only when the previous row is state: 'done' with interrupted === true. This PR deliberately makes a cancel-with-child-work row state: 'working' (monitoring or working) with interrupted absent and the verdict on lead.outcome, so that guard no longer matches and the delayed hook is accepted — the cancellation is dropped and the row flips back to a live lead turn. I reproduced it with a throwaway probe: after an inferred cancel the row is working / workingMode: monitoring / lead { state: 'done', outcome: 'cancellation' }; a delayed same-prompt PostToolUse then advances receivedAt and republishes the row as live working work.

Technical details
# Delayed-hook suppression does not cover the new non-terminal cancel row

## Affected sites
- `src/main/agent-hooks/server/server-status-update.ts:158-186` — both suppression blocks require `previous.payload.state === 'done' && previous.payload.interrupted === true`. Unchanged by this PR, but its input shape changed.
- `src/main/agent-hooks/server/server-status-inference.ts:90-122` — the inferred cancel row is `state: 'working'` whenever child work survives, and `interrupted: true` is emitted only when `state === 'done'`.
- `src/shared/agent-hook-listener/providers/claude-events.ts:84-91, 280-287` — a non-turn-boundary lead event writes the lead record as `state: 'working'` and `continueAgentLeadStatus` drops `outcome`, so the verdict is erased by the accepted late hook.
- `src/shared/agent-hook-listener/providers/claude-status-build.ts:52` — `interrupted` is derived from the lead record and is clamped off non-done payloads by the normalizer.

## Required outcome
A delayed same-turn tool-progress hook (`isToolProgressWorkingAfterInterrupt`, i.e. `PreToolUse`/`PostToolUse`/`PostToolUseFailure` for claude/codex) arriving after an inferred cancel must not replace a cancelled-lead row with a fresh working turn, even when a shell/cron/subagent holds the row open and the row itself carries no `interrupted` flag.

## Suggested approach
Extend the two guard predicates (`server-status-update.ts:159-166` and `:170-180`) so the "previous is a cancelled turn" test also accepts the new shape, e.g. `(previous.payload.state === 'done' && previous.payload.interrupted === true) || (previous.payload.state === 'working' && previous.payload.lead?.state === 'done' && previous.payload.lead?.outcome === 'cancellation')`, keeping the existing agent-type/prompt/time-window conditions. Add a regression case alongside `server-interrupt-inference-resurrection.test.ts:90` that ingests a Claude row with `lead` + `claudeRunningNonAgentTask: true`, infers the cancel, then sends the late `PostToolUse` and asserts the row stays monitoring with the verdict intact.

## Open questions for the human (optional)
Reachability depends on Ctrl+C emitting a delayed tool hook for the killed foreground tool. The captured fixtures were recorded with Esc (`hooks_before_next_typed_prompt: []`), while the guard's comment and `isToolProgressWorkingAfterInterrupt`'s claude/codex-only scope assert Ctrl+C does emit one. If Ctrl+C genuinely fires no hook, this is latent rather than live; the fix is cheap either way.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

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.
@brennanb2025
brennanb2025 force-pushed the brennanb2025/lead-status-pr-c branch from 26e9e2d to 7b1c388 Compare September 24, 2026 01:01
@brennanb2025
brennanb2025 changed the base branch from brennanb2025/lead-status-pr-a to main September 24, 2026 01:01

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No new issues in the delta since the last review — it is a rebase onto main, not new code.

Reviewed changes

  • Retargeted the base from the stack branch brennanb2025/lead-status-pr-a to main and rebased the single commit directly onto #22452's squashed merge (8352752e54).
  • Reconciled the inferred-cancel row with main's fallback: mainAgent: folded?.mainAgent ?? (payload.mainAgent?.state === 'done' ? payload.mainAgent : { state: 'done', outcome: 'cancellation', … }). Unreachable for the Claude fold path (admission requires payload.mainAgent.state === 'working'); it restores main's behavior for non-Claude and no-mainAgent rows.
  • Adopted main's optional mainAgent return from claudeMainAgentStatusForPayload in markClaudeLeadTurnInterrupted.
  • Pulled in main's Grok verdict carry-forward (previousMainAgent / mainAgentOutcome) and a doc reword; the PR-authored semantics are unchanged.

The before/after policy and all test/fixture content are byte-identical to the previously reviewed revision apart from those reconciliation lines.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@coderabbitai

coderabbitai Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 2723f61c-afd3-4d89-a6b9-276259a84f48

📥 Commits

Reviewing files that changed from the base of the PR and between 8e90d14 and 8599dea.

📒 Files selected for processing (1)
  • docs/reference/agent-status-store.md

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.


📝 Walkthrough

Walkthrough

The shared status fold no longer uses the main agent's cancellation verdict to determine whether child work remains active. Claude hook handling retains task and cron evidence, and inferred Ctrl+C cancellation folds the main-agent state with child-work evidence. Grok and structured status paths also use the shared fold. Tests and captured fixtures cover cancelled turns with running shells, crons, and subagents.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 8599d

Child work can remain visible as monitoring after cancellation without reopening the cancelled turn. No actionable merge-blocking risk remains in the reviewed changes.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.91% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 44 functions across 26 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: preventing cancellation from hiding live agent work.
Description check ✅ Passed The description follows the required structure and provides a clear user-facing explanation, mechanism, rationale, linked issue references, visual proof, testing details, known limitations, AI disclos…
Full details: Docstring Coverage

Explanation

Docstring coverage is 40.91% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 44 functions across 26 files. (1 skipped: 1 unsupported.)


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: c245b0f2-5a38-46ec-8f19-777b33e1b9e0

📥 Commits

Reviewing files that changed from the base of the PR and between 8352752 and 7b1c388.

📒 Files selected for processing (20)
  • docs/reference/agent-status-store.md
  • src/main/agent-hooks/ended-process-reconciliation.test.ts
  • src/main/agent-hooks/server-claude-cancel-captures.test.ts
  • src/main/agent-hooks/server-interrupt-inference-guards.test.ts
  • src/main/agent-hooks/server/server-status-inference.ts
  • src/shared/__fixtures__/claude-cancel-shell-hooks.jsonl
  • src/shared/__fixtures__/claude-cancel-shell-hooks.meta.json
  • src/shared/__fixtures__/claude-cancel-subagent-hooks.jsonl
  • src/shared/__fixtures__/claude-cancel-subagent-hooks.meta.json
  • src/shared/agent-hook-listener/providers/claude-events.ts
  • src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts
  • src/shared/agent-hook-listener/providers/claude-roster-state.ts
  • src/shared/agent-hook-listener/providers/claude-status-build.ts
  • src/shared/agent-hook-listener/providers/grok-events.ts
  • src/shared/agent-lead-status-fold.test.ts
  • src/shared/agent-lead-status-fold.ts
  • src/shared/claude-background-task-status.test.ts
  • src/shared/main-agent-status-parity.test.ts
  • src/shared/structured-agent-session-agent-status.test.ts
  • src/shared/structured-agent-session-agent-status.ts
💤 Files with no reviewable changes (1)
  • src/shared/structured-agent-session-agent-status.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 5 remain after this review.

Comment on lines +96 to +98
const folded = cancelledMainAgentFolds
? markClaudeLeadTurnInterrupted(this.state, existing.paneKey, payload)
: undefined

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
rg -nP -C4 "mainAgent" src/main/agent-hooks --type=ts -g '!**/*.test.ts' | rg -n -C2 "osc|OSC|title|ingestRemote"
rg -nP -C3 "claudeLeadStateByPaneKey\.(set|get)" src --type=ts -g '!**/*.test.ts'

Repository: stablyai/orca

Length of output: 8412


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- changed file diff ---'
git diff --no-ext-diff --unified=35 80f5aae0f9f96babe221950280359a19bcfda9bf 7b1c38893d80a99749b99d53dc3d979a0b3f2e1b -- src/main/agent-hooks/server/server-status-inference.ts
printf '%s\n' '--- current inference source ---'
sed -n '1,220p' src/main/agent-hooks/server/server-status-inference.ts
printf '%s\n' '--- roster helper definitions ---'
sed -n '1,190p' src/shared/agent-hook-listener/providers/claude-roster-state.ts
sed -n '280,370p' src/shared/agent-hook-listener/providers/claude-roster-state.ts
printf '%s\n' '--- cached payload and lifecycle consumers ---'
sed -n '1,75p' src/shared/agent-hook-listener/providers/claude-status-build.ts
sed -n '1,165p' src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts
printf '%s\n' '--- relevant ingestion caller context ---'
rg -n -C8 "inferInterrupt|markClaudeLeadTurnInterrupted|buildClaudeCachedLeadStatusPayload" src/main src/shared --type=ts -g '!**/*.test.ts'

Repository: stablyai/orca

Length of output: 42298


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- interruption helper ---'
sed -n '190,235p' src/shared/agent-hook-listener/providers/claude-roster-state.ts
printf '%s\n' '--- cached lifecycle payload ---'
sed -n '110,175p' src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts
printf '%s\n' '--- lifecycle status resolution ---'
sed -n '320,370p' src/shared/agent-hook-listener/providers/claude-roster-state.ts
printf '%s\n' '--- terminal ingestion around OSC ---'
sed -n '100,175p' src/main/agent-hooks/server/server-ingest-terminal.ts
printf '%s\n' '--- payload/mainAgent construction references ---'
rg -n -C6 "mainAgent:" src/shared/agent-hook-listener/providers src/main/agent-hooks/server --type=ts -g '!**/*.test.ts'

Repository: stablyai/orca

Length of output: 19804


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- resolveClaudePaneStatus definition ---'
rg -n -C5 "function resolveClaudePaneStatus|export function resolveClaudePaneStatus|const resolveClaudePaneStatus" src/shared/agent-hook-listener/providers/claude-roster-state.ts
sed -n '320,390p' src/shared/agent-hook-listener/providers/claude-roster-state.ts
printf '%s\n' '--- legacy mainAgent compatibility ---'
sed -n '80,135p' src/main/agent-hooks/server/server-persistence-validation.ts
printf '%s\n' '--- Claude status builder ---'
sed -n '1,70p' src/shared/agent-hook-listener/providers/claude-status-build.ts

Repository: stablyai/orca

Length of output: 7476


🏁 Script executed:

sed -n '172,215p' src/shared/agent-hook-listener/providers/claude-roster-state.ts

Repository: stablyai/orca

Length of output: 2301


Keep the Claude lead record synchronized for rows without mainAgent.

When a Claude row has no mainAgent and no child work is evidenced, inferInterrupt publishes done with interrupted: true but does not call markClaudeLeadTurnInterrupted. An existing working lead record can remain stale. A later child lifecycle event reads that record and can publish the pane as working again.

Call the helper for every inferred Claude interrupt. Use its folded result only when the input row contains mainAgent.

Suggested fix
-    const folded = cancelledMainAgentFolds
-      ? markClaudeLeadTurnInterrupted(this.state, existing.paneKey, payload)
-      : undefined
+    const claudeFold =
+      agentType === 'claude'
+        ? markClaudeLeadTurnInterrupted(this.state, existing.paneKey, payload)
+        : undefined
+    const folded = cancelledMainAgentFolds ? claudeFold : undefined
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const folded = cancelledMainAgentFolds
? markClaudeLeadTurnInterrupted(this.state, existing.paneKey, payload)
: undefined
const claudeFold =
agentType === 'claude'
? markClaudeLeadTurnInterrupted(this.state, existing.paneKey, payload)
: undefined
const folded = cancelledMainAgentFolds ? claudeFold : undefined

…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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No new issues in the delta since the last review — the two behavior fixes check out.

Reviewed changes

  • Grok keeps the cancel verdict through the idle backstop: normalizeGrokEvent now derives interrupted from mainAgent.outcome === 'cancellation' instead of the current event's outcome, so the idle_prompt that settles a task a cancelled turn left running still reads interrupted. The normalizer still clamps the flag to done rows, so a monitoring row does not publish it. The captured task-completion settle is a fresh user_prompt_submit task-completed-<id> + stop turn, which clears the verdict as a new turn.
  • Relayed Claude cancel restarts its clock: markClaudeLeadTurnInterrupted passes an explicit stateStartedAt: Date.now(), so a second cancel on a relayed pane starts a fresh done clock rather than inheriting the host's local record from the first cancel (the relay never refreshes that record). The caller admits the fold only when payload.mainAgent.state === 'working', so this changes the relay case only.
  • Tests: a Grok completion test pinning the idle-backstop verdict, and an inference-guard test pinning the relayed second cancel's stateStartedAt. Both are meaningful — each fails against the un-fixed code.

I re-ran the touched suites (agent-hook-listener-grok-completion, server-interrupt-inference-guards, server-claude-cancel-captures, main-agent-status-parity, agent-lead-status-fold) — 83 tests, all green.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…rt 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No new issues in this delta. One note: the delayed-tool-hook suppression gap in server-status-update.ts that the earlier review flagged (root cause in unchanged pre-existing code) is still open, so this is not an approval.

Reviewed changes

  • Kept the shell fact on an inferred cancel: server-status-inference.ts now copies existing.claudeRunningNonAgentTask onto the row it synthesizes for an inferred Ctrl+C cancel, so hydration can still seed the settled main agent after a restart and a child's later drain can settle the row.
  • Added a restart round-trip test for that path in server-last-status-main-agent-fact.test.ts. It fails without the source change (the row stays working with the main agent unseeded) and passes with it.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟡 Minor · Do not settle monitoring on idle_prompt. · grok-events.ts:216

src/shared/agent-hook-listener/providers/grok-events.ts:216
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not settle monitoring on idle_prompt.

Notification/idle_prompt sets leadState to done, and line 216 supplies no child-work liveness for that event. The fold therefore clears monitoring even when the preceding StopCancelled reported a running shell. Grok documents Notification as an agent notification, not as task completion. Retain task liveness until a task update or session boundary, and update the test to expect monitoring for this sequence.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: f26cee21-fe52-4641-9bfe-40d5985a4f4d

📥 Commits

Reviewing files that changed from the base of the PR and between 7b1c388 and 4ae4a15.

📒 Files selected for processing (6)
  • src/main/agent-hooks/server-interrupt-inference-guards.test.ts
  • src/main/agent-hooks/server-last-status-main-agent-fact.test.ts
  • src/main/agent-hooks/server/server-status-inference.ts
  • src/shared/agent-hook-listener-grok-completion.test.ts
  • src/shared/agent-hook-listener/providers/claude-roster-state.ts
  • src/shared/agent-hook-listener/providers/grok-events.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.

…thing

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.
… 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.
…n 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Hold a cancel verdict in the store: new server-cancel-verdict-latch.ts replaces the two delayed-hook suppression blocks in server-status-update.ts. "Latched" is derived from the row (mainAgent.outcome === 'cancellation', or a legacy state: done + interrupted), never stored, and dies on a settled incoming mainAgent, a SessionStart, a prompt change, or a non-tool-progress working event outside the window. Child-attributed/replayed events carrying child work are re-folded under the latched main agent. This is the shape the earlier review reproduced, and it now holds.
  • Fold a relayed pane's cancel from its row: server-status-inference.ts splits local vs relayed. A local Claude pane records the verdict on its listener and folds its own roster; a relayed pane folds only the child work its row carries, via the new server-row-child-work-fold.ts. markClaudeLeadTurnInterrupted drops the rowSubagents parameter and the forced clock the shared path had grown for the relay.
  • A Ctrl+C at an idle main agent's prompt cancels nothing: the inference guard now switches on payload.mainAgent.state === 'working' for every provider that publishes the fact; Codex keeps the child-work guard, and rows without mainAgent keep the evidence guard.
  • Tests: new server-relayed-claude-cancel.test.ts and server-grok-cancel.test.ts; the capture loader is extracted to claude-cancel-capture.test-fixture.ts; a Codex evidence-guard pin and two revised inference-validation cases.

I re-ran the touched suites (108 + 66 tests) green, and confirmed the earlier concern is fixed on the local lane too: a throwaway probe (shell Stop → new turn → Ctrl+C → late same-prompt PostToolUse) left the row working/monitoring with mainAgent { done, cancellation } byte-identical.

ℹ️ Nitpicks

  • The local lane has no committed regression for the latched mainAgent + isToolProgressWorkingAfterInterrupt hold (the exact prior reproduction): the latch is pinned via the relayed isReplay/child-event paths and the captures replay. Since the captures show Ctrl+C emitting no hook, this is latent rather than live — a case beside server-interrupt-inference-resurrection.test.ts would be nice-to-have when convenient, not required.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

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.
…n 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — two minor observations inline.

Reviewed changes

  • A prompt submission ends the cancel verdict latch: resolveCancelVerdictLatch now ends the latch on a root, non-replay UserPromptSubmit through the new opensNewTurn, so the task notification Claude starts when background work ends is treated as a real new turn instead of being held under the cancelled main agent; the capture test now pins that transition.
  • Codex rows derive interrupted from the main agent: new codexMainAgentTurnInterrupted and its use in buildCodexStatusPayload and reconcileRemoteCodexState; a late root Stop after an inferred Codex cancel now carries interrupted: true for readers that predate mainAgent, with new local and relayed assertions in server-main-agent-turn-verdicts.test.ts.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment on lines +223 to +224
interrupted:
effectiveState === 'done' && codexMainAgentTurnInterrupted(lead) ? true : undefined,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interrupted is recomputed from the local lead unconditionally here, which drops an incoming interrupted: true from a relay host that predates mainAgent: with no mainAgent in its payload, the seeded lead carries no outcome, so the flag becomes undefined. I reproduced this through ingestRemote — a relayed Codex Stop with state: 'done', interrupted: true and no mainAgent lands as interrupted: undefined, so mobile/dashboard read the cancelled turn as a clean finish when the desktop's own inference did not record the cancel (a host-side interrupt). Consider falling back to payload.interrupted when effectiveState === 'done' and the lead carries no outcome.

Technical details
# Relayed Codex `interrupted` is dropped for hosts without `mainAgent`

## Affected sites
- `src/shared/agent-hook-listener/providers/codex-state.ts:223-224` — `reconcileRemoteCodexState` overrides `interrupted` from the local lead, discarding `payload.interrupted`.
- `src/shared/agent-hook-listener/providers/codex-state.ts:96-130` — `seedCodexStateFromSnapshot` seeds the lead from `payload.mainAgent` when present, else infers state only; an old host supplies neither outcome.
- `src/main/agent-hooks/server/server-status-update.ts:86-101` — every relayed Codex event with a `hookEventName` routes through this reconciliation.

## Required outcome
A relayed Codex `done` row that the host published as `interrupted: true` still reads `interrupted` on the desktop when the local lead has no `outcome` to derive it from.

## Suggested approach (optional)
`effectiveState === 'done' && (codexMainAgentTurnInterrupted(lead) || payload.interrupted === true)`.

Comment on lines +62 to +69
function opensNewTurn(event: AgentHookEventPayload): boolean {
return (
event.hookEventName === 'SessionStart' ||
(event.hookEventName === 'UserPromptSubmit' &&
event.toolAgentId === undefined &&
event.isReplay !== true)
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opensNewTurn treats every root, non-replay UserPromptSubmit as a new turn, but the codebase already classifies a family of harness-injected prompts (isKnownHarnessInjectedUserTurnText) that keep the cached prompt and are not the user's ask. That breadth is what makes the task notification work, but if any other injected form (an interruption notice, a teammate message) lands within the 15 s window it now ends the latch and publishes a live working row under the stale prompt. Is the broad predicate intended, or should it match only the injected shape this change targets?

Technical details
# `opensNewTurn` ignores the harness-injected classification

## Affected sites
- `src/main/agent-hooks/server/server-cancel-verdict-latch.ts:62-69` — predicate checks only `hookEventName`, `toolAgentId`, `isReplay`; never `hasExplicitPrompt` or `isKnownHarnessInjectedUserTurnText`.
- `src/shared/agent-hook-listener/prompt-fields.ts:103-106` — harness-injected turns keep the cached prompt, so the latch's `prompt` mismatch guard does not catch them.
- `src/shared/harness-injected-user-turns.ts:43-51` — injected prefixes include interruption and teammate notices.

## Required outcome
Confirm that a root `UserPromptSubmit` which is not a genuine new turn cannot arrive after a cancel; if it can, the latch should keep holding it.

## Open questions for the human (optional)
The captured Claude 2.1.280 cancels show no hook at all, so no post-cancel injected `UserPromptSubmit` is evidenced; is that still true for the other providers that emit `UserPromptSubmit` (kimi, muse, codex, devin, droid)?

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: ready for merge review

Head: 8599deadbe, rebased onto main after #22452 merged, and retargeted to main.

Review loops (5). Each loop was a fresh reviewer with the same brief; loop 5 came back clean.

  • Loop 1: Grok's idle backstop dropped interrupted on a cancelled row, so a cancelled turn could announce as a clean finish. A second cancel on an SSH pane reused the first cancel's clock. Both fixed.
  • Loop 2: the inferred cancel dropped the row's saved shell fact, so after a restart a cancelled row held open by a subagent could not settle. Fixed.
  • Loop 3: clean for local panes.
  • An architecture challenge ran after loops 1–3 kept finding the same kind of defect: a fact dropped when the desktop builds the cancelled row itself. It kept the cancel model, which does remove the root cause, and found that nothing owned the cancel on SSH panes. That led to three changes:
    • A relayed pane's cancel is now folded from the row itself, not the desktop's local records. This fixes a regression where an SSH pane showed a spinner with nothing running after a desktop restart.
    • The old late-hook suppression is replaced by a cancel hold read off the row's mainAgent. Without it, the relay's next child hook or a reconnect replay flipped the cancelled row back to "Working", and Grok's own stop_cancelled could be dropped.
    • Ctrl+C at an idle prompt is refused for every provider that publishes mainAgent, not only Claude. Grok's background task survives that Ctrl+C (measured on Grok 1.0.41), but the row used to settle to done.
  • Loop 4: fixed two gaps in that hold. It had held Claude's task-notification turn (a real new turn), and a late Codex Stop could lose interrupted.
  • Loop 5: clean. It checked every provider's new-turn and stale-hook signals against the hold.

Readiness checklist. PASS at the start and at the end. The only findings were stale text, now fixed: the PR body, and one paragraph in docs/reference/agent-status-store.md.

Electron QA (isolated dev build of 8e90d142, the code head; the final commit changes docs only; Claude Code 2.1.280 and Grok 1.0.41):

Scenario Result
Claude: Ctrl+C mid-turn with a background shell running PASS: row reads "Monitoring background task…" (working / monitoring, mainAgent: { done, cancellation }); shell still alive
Claude: second Ctrl+C at the idle prompt PASS: nothing changes
Claude: background shell ends PASS: Claude's own task-notification turn runs; row settles to done
Grok: Ctrl+C at the idle prompt of a monitoring row PASS: stays monitoring, no interrupted; task still alive

Screenshots are in the PR body.

Validation: pnpm tc, oxlint over src/main/agent-hooks and src/shared/agent-hook-listener, and check:code-quality:changed are clean. The targeted suites pass (308 files, 3148 tests), as do src/shared (12110) and the renderer store and hooks (4502). CI is green on 8599deadbe.

Not covered / follow-ups:

  • No live SSH host run; relayed behavior is covered by tests that replay recorded Claude hooks through a real relay-side listener.
  • Mobile checks were not run; mobile imports none of the changed modules.
  • Two limitations predate this PR and are unchanged by it:
    • Scheduled checks on SSH rows are not mirrored to the desktop.
    • A Codex cancel while a subagent is live is still refused; changing that needs a recording of what Codex sends on a cancel.

…tus-pr-c

# Conflicts:
#	docs/reference/agent-status-store.md
#	src/shared/agent-hook-listener/providers/claude-events.ts
#	src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts
#	src/shared/agent-hook-listener/providers/claude-roster-state.ts
#	src/shared/agent-hook-listener/providers/codex-state.ts
#	src/shared/agent-hook-listener/providers/grok-events.ts
#	src/shared/agent-lead-status-fold.ts
#	src/shared/main-agent-status-parity.test.ts

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

This run re-reviewed the merge of origin/main (85642d0) into the PR branch, the only change since the prior pullfrog review at 8e90d142b9. The branch's cancel policy survives the merge intact, and main's #22475 Codex-child-work refactor is adopted cleanly.

  • Adopted main's shared child-work refactor: foldAgentLeadStatus gains main's waiting/blocked early-returns and the AgentChildWorkLiveness waiting arm; mainAgentTurnInterrupted consolidates the Claude and Codex helpers into agent-lead-status-fold.ts; codexRosterChildWorkLiveness + resolveCodexPaneStatus replace codexRosterEffectiveState; grok-events.ts classifies a listed subagent as agent work (working) and a shell / stop hook as monitoring.
  • Preserved the cancel policy through the merge: no interrupted fold input, the inferInterrupt guard (mainAgent.state === 'working', Codex child-evidence guard, no-mainAgent fallback), the store-side cancel-verdict latch, the relayed row-child-work fold (now passing hasWaitingChildWork: false), and hasConfirmedDoneGate without the interrupt discount.
  • Reconciled docs and the parity table to main's structure while keeping the cancel text; the Grok completion test now pins a background subagent surviving a cancel.

Two minor threads from the prior review remain open and unchanged by this merge, so approval is withheld on those alone: the opensNewTurn breadth question in server-cancel-verdict-latch.ts and the relayed Codex interrupted fallback in codex-state.ts. Neither is a blocker; they carry over as noted on the PR.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Correction to my earlier status comment (#22476 (comment)): it claimed a Ctrl+C at an idle prompt cancels nothing. That is not true of the CLIs themselves. What this PR guarantees is that Orca refuses an inferred cancel at an idle prompt; the PR body and docs/reference/agent-status-store.md are updated accordingly.

Live validation at 239992f (real CLIs in Orca terminal panes):

  • Claude 2.1.280, Ctrl+C or Esc mid-turn: background shells and subagents keep running and the row keeps showing them — the headline claim holds.
  • Claude, a single Ctrl+C at the idle prompt: Claude itself stops its background subagents ("All background agents stopped"); shells survive. Orca refuses that cancel and keeps showing the stopped subagent as working.
  • Claude, rapid double Ctrl+C with background work: Claude asks Exit / Move to background / Stay; exiting removes the row (correct).
  • Codex 0.156.1, mid-turn cancel: background work keeps running and the row keeps showing it — holds. A single idle-prompt Ctrl+C quits Codex and kills its work; the row is removed (correct).
  • Codex, mid-turn Ctrl+C with a live subagent: the cancel is refused (as on main), so the row stays "working" after all the work ends.
  • Grok 1.0.41: Orca infers a cancel from keypresses Grok does not honor (Esc never cancels a Grok turn; mid-turn Ctrl+C opens Grok's "Stop them?" dialog), marks the row done at the keypress, and it stays done after Grok's own cancel hook while work runs. Also on main.

The three remaining issues — Claude's idle-prompt stop of its subagents, the refused Codex cancel with a live subagent, and Grok cancel state driven by Grok's own hooks instead of keypress inference — are each being fixed in a separate stacked PR on this branch.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues in this delta.

Reviewed changes

This run re-reviewed the only commit since the prior pullfrog review at bff608c870 (397d22fb7e): a behavior fix in the relayed row-child fold plus its regression test.

  • A relayed pane's cancel now folds a waiting child, not just working: foldMainAgentWithRowChildWork derives its child-work liveness from agentChildWorkLiveness over the row's subagent snapshots and shell fact, the same helper codexRosterChildWorkLiveness uses. A relayed subagent in waiting now holds the row waiting where the previous state === 'working' test ignored it, and any live (non-idle/non-done) child state — blocked, unverifiable — now counts as agent work.
  • A new test pins a waiting relayed child surviving a cancel: server-relayed-claude-cancel.test.ts ingests a relayed Claude row with a waiting subagent, infers Ctrl+C, and asserts the row stays waiting with mainAgent: { done, cancellation }. It fails against the old fold, which settled the row to done.

The touched suites (server-relayed-claude-cancel, server-interrupt-inference-guards, server-main-agent-turn-verdicts, main-agent-status-parity, agent-status-child-work-liveness) pass — 73 tests.

ℹ️ Nitpicks

  • docs/reference/agent-status-store.md:241 still reads "Only the Codex hook lane feeds that input today". With this commit the relayed row-child fold also feeds childWorkLiveness: 'waiting' from a child's own state, so that sentence could name the relayed lane too.

The two prior minor threads (opensNewTurn breadth in server-cancel-verdict-latch.ts:62-69; the relayed Codex interrupted fallback in codex-state.ts:223-224) remain open and unchanged by this commit, so approval is withheld on those alone.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…n 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues in this delta.

Reviewed changes

This run re-reviewed the only commit since the prior pullfrog review at 397d22fb7e (d3eb919805): an ordering fix in the hook-status ingress plus its regression test.

  • The cancel hold resolves before a child's permission card adopts a relayed main agent: server-status-update.ts now runs resolveCancelVerdictLatch on the attached payload before shouldKeepClaudePermissionVisible/withHeldChildWaitMainAgent. That hold adopts the event's mainAgent, and on a relayed pane the relay's record still says the main agent is working, so the hold previously overwrote the desktop's cancel with the stale restatement. With the latch first, a child-attributed event carrying child work is refolded under the latched cancelled main agent, and the hold then keeps that verdict.
  • A new test pins the child card staying up over a denied request's next tool: server-relayed-claude-cancel.test.ts posts a child PermissionRequest, then a different tool_use_id on the same child, and asserts the row stays waiting with mainAgent: { done, cancellation }. I verified it fails against the old ordering (mainAgent.state reverts to working).

The touched suites (server-relayed-claude-cancel, server-claude-cancel-captures, server-interrupt-inference-guards, server-grok-cancel) pass — 34 tests.

The two prior minor threads (opensNewTurn breadth in server-cancel-verdict-latch.ts:62-69; the relayed Codex interrupted fallback in codex-state.ts) remain open and unchanged by this commit, so approval is withheld on those alone.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues in this delta.

Reviewed changes

This run re-reviewed the only commit since the prior pullfrog review at d3eb919805 (0c5e474d9d): a test-only addition.

  • A regression test pins that a cancelled turn's drained row settles as stopped, not completed: server-claude-cancel-captures.test.ts replays the subagent capture up to the cancel, posts the child's post-cancel tool, then stops both children and asserts the row settles done with interrupted: true and mainAgent: { done, cancellation }, and that turnCompletedAt is never set. The assertions are exact, so the test would fail if the settle path stamped a completion or dropped the verdict.

The full captures suite passes (6 tests).

The two prior minor threads (opensNewTurn breadth in server-cancel-verdict-latch.ts:62-69; the relayed Codex interrupted fallback in codex-state.ts) remain open and unchanged, so approval is withheld on those alone.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…ok 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues in this delta.

Reviewed changes

This run re-reviewed the only commit since the prior pullfrog review at 0c5e474d9d (130f162af0): the cancel-verdict latch now survives a restarted relay and a teammate's idle.

  • A restarted relay's empty-prompt child hook no longer releases the cancel: new isChildAttributed (toolAgentId !== undefined or TeammateIdle) and restatesAnotherPrompt; a child-attributed event whose prompt is '' (a restarted relay has lost its prompt cache) is treated as unknown rather than another turn, so the latch holds and the child is re-folded under the cancelled main agent.
  • TeammateIdle counts as child-attributed: the refold gate now accepts isChildAttributed (so a teammate idle, which names its child by teammate_name and carries no agent_id, is re-folded under the latched main agent) and its empty prompt no longer restates another turn.
  • Tests: server-relayed-claude-cancel.test.ts gains createRelay/restartRelay helpers plus three cases — a restarted relay's first (child) hook keeping the cancel, a replayed prompt the desktop missed releasing it, and a teammate idle past the late-hook window still settling done + interrupted.

The touched suite passes (10 tests). I also verified the empty-prompt child path does not regress the row label: refoldUnderLatchedMainAgent carries the incoming empty prompt, but the pre-fix path applied the same empty prompt after releasing the latch, so the observable prompt is unchanged.

The two prior minor threads (opensNewTurn breadth in server-cancel-verdict-latch.ts:62-69; the relayed Codex interrupted fallback in codex-state.ts:223-224) remain open and unchanged by this commit, so approval is withheld on those alone.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: ready for review at 130f162af0

Four review rounds, a separate architecture review, readiness checks at the start and end, and live Electron QA on the final head.

What the review changed

Three commits on top of 397d22fb7e. All of them tighten the store's hold on an inferred cancel. None changes the approach.

  • d3eb919805: SSH panes, a child's permission prompt. The permission-card hold ran before the cancel hold. It copied the relay's stale "main agent working" onto the row, so a child's next tool under a sticky permission card brought the cancelled turn back as "working". The cancel hold now resolves first.
  • 0c5e474d9d: test only. When a cancelled turn's subagents finish, the row settles as done with interrupted set and no completion stamp, so the alert reads "stopped", never "finished".
  • 130f162af0: two more SSH-pane cases where the hold lost the cancel.
    • A relay that restarts after the cancel has lost its prompt cache, so a child's next hook arrives with an empty prompt. That empty prompt was read as a new turn, and the row then stayed "working" forever after the children stopped. A child's empty prompt now means "prompt unknown". A different non-empty prompt still ends the hold, because it is a newer prompt the desktop missed.
    • TeammateIdle names its child only by teammate_name, so the hold treated it as the main agent's event and let the 15 s late-hook window apply it. The cancel was lost and the row spun with nothing running. It now counts as child work.
    • Every fix has a test that fails without it. Each change was reverted on its own, and only its own test failed.

Architecture

A separate architecture review looked at replacing the prompt-text and event-name turn boundaries with a real turn identity. Claude hooks carry a per-prompt prompt_id, which Orca already normalizes to providerPromptId. Its verdict: keep the current hold in this PR, and move turn identity to a stacked follow-up.

  • Why not replace it here:
    • A child event carries the session's most recent prompt id, not the id of the turn that started it.
    • The row keeps only the last event's id.
    • A queued prompt can start a turn with no UserPromptSubmit.
    • So the id cannot be used as-is. It has to be modelled as a main-agent turn identity (mainAgent.turnId) written only from main-agent events. That field is persisted, and Codex (turn_id) and Grok (promptId) could feed it too, so it deserves its own PR.
    • Codex, hosts without mainAgent, old relays and older Claude builds still need the current rules either way, so doing it now would add a second path rather than replace one.
  • Why shipping this first is safe: the hold is read off the row, not stored beside it, so changing its release rule later is an edit, not a migration. Every hold ends on a new prompt, a session start, or the agent's own settled main agent.
  • What the follow-up fixes: two rare holds that last until the turn's Stop. One is a new turn that starts with no observed UserPromptSubmit and the same prompt text; that one also happens on main. The other is a reconnect replay of a newer turn with the same text.
  • Two points to add to the "Send the inferred cancel to the remote host" alternative:
    • agent-status-store.md says a client never writes its observations back to a host, so delivering the cancel would need an explicit exception there.
    • Having the host infer the cancel from its own PTY input is unverified: key classification lives in the renderer, and Ctrl+C may not arrive as 0x03 under an extended keyboard protocol.
    • For Codex, registering its own interrupt hook would let the host learn of the cancel directly, with no new message from the desktop.

Live Electron QA (final head 130f162af0)

Isolated background dev build, throwaway profile, folder workspace, Claude Code 2.1.280, keys sent through the terminal input, CDP screenshots only. The build was confirmed to serve the PR head (isChildAttributed present in the served latch module).

A: a background shell survives a mid-turn Ctrl+C. PASS. The row reads "Monitoring background tasks" (working / monitoring, mainAgent: { done, cancellation }), and the 600 s shell is still alive under the same process ID. After that shell ended, Claude's own follow-up turn ran and the row settled to done.

A1-before-ctrl-c.png
A2-after-ctrl-c.png
A3-settled.png

B: a background subagent survives a mid-turn Ctrl+C. PASS. The row stays working (not done), the cancel is recorded on the main agent, and the subagent is listed as working while its process runs. When the subagent finished, Claude started its own follow-up turn, and the row settled as that turn's plain done. The brief stopped state in between was not captured live; 0c5e474d9d pins it.

B1-before-ctrl-c.png
B2-after-ctrl-c.png
B3-drained.png

C: Ctrl+C at the idle prompt with only a background shell. PASS. The row is byte-identical afterwards (still monitoring, no interrupted), and the shell is still alive.

C1-monitoring.png
C2-after-idle-ctrl-c.png

Not covered live:

  • Grok: its CLI was out of usage balance (402), so this QA run was done by a Muse Spark 1.3 agent instead of a Grok one.
  • SSH: covered by the relay-listener replay tests, including a real relay restart, but not by a live SSH host run.
  • Windows and Linux: there is no platform-specific code.

Checks

  • Tests: agent-hooks, hook listener, relay, status-fold, parity and structured-status suites: 3182 passed, 13 skipped. Two failures, each a timeout or temp-directory race in src/relay/subprocess.test.ts and managed-hook-script-refresh.test.ts; both pass when run alone. The end-of-review readiness run: 1427 passed.
  • Static checks: pnpm tc, oxlint on the changed files, and check:code-quality:changed (0 findings) all pass.
  • Readiness checklist: PASS at the start (397d22fb7e) and at the end (130f162af0), with no blocking concerns.
  • CI: 20 checks pass. root directory guard, test / tests node 24 8/8 and verify fail, and none of them is caused by this PR:
    • The first two fail on untracked docs/assets/... images linked from docs/readme/*.md (check-readme-local-links.test.mjs), files this PR does not touch.
    • verify only collects the other jobs' results, so it fails because shard 8/8 did.

Still open

  • Deferred to stacked PRs, as the PR body lists:
    • Claude stopping its subagents on a Ctrl+C at the idle prompt.
    • Codex cancel with a live subagent.
    • Grok cancel state driven by Grok's own hooks.
  • New follow-up: turn identity (mainAgent.turnId), described above.
  • Low severity, not caused by this PR (the same on main): after a relay restart, a SubagentStop or TeammateIdle for a child the relay never saw start is dropped, so an SSH row can stay working until the next Stop.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant