Skip to content

fix(agent-status): a Grok cancel is decided by Grok's own hooks, never by keypresses - #22724

Open
brennanb2025 wants to merge 8 commits into
mainfrom
brennanb2025/grok-cancel-by-hooks
Open

brennanb2025 wants to merge 8 commits into
mainfrom
brennanb2025/grok-cancel-by-hooks

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 11 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​837 $\color{#cf222e}{\Huge{\mathbf{−}}}$​85 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​752
Prod 11 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​373 $\color{#cf222e}{\Huge{\mathbf{−}}}$​93 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​280

ELI5

Pressing Esc or Ctrl+C on a Grok terminal used to make Orca guess "the agent was cancelled" and mark the pane finished — even while Grok kept working, or kept a subagent and background commands running. Now Orca never guesses for Grok: it waits for Grok's own "turn cancelled" report, and keeps showing the work Grok says is still running.

What Changed

Before (measured live on Grok 1.0.41):

  • Esc in the middle of a Grok turn does not cancel anything — Grok just shows a "Press Ctrl+c to cancel the turn" toast and keeps going. Orca still marked the pane done / interrupted for ~27 seconds until a later hook happened to correct it.
  • Ctrl+C mid-turn while subagents are running opens Grok's "Subagents are still running. Stop them?" dialog; the turn is not cancelled until you answer, and you can answer "Continue to run". Orca marked the pane done / interrupted at the keypress, and the row stayed that way while Grok's own footer said "1 command · 1 subagent still running".

After:

  • Esc mid-turn changes nothing in Orca; the pane keeps showing the running turn.
  • Ctrl+C changes nothing at the keypress. When Grok actually cancels the turn, its own StopCancelled hook lands the verdict on the main agent (mainAgent.outcome: 'cancellation'), and the row keeps reading working while a subagent is still running (or "Monitoring background tasks" when only shells remain). The row settles only when Grok's reported work is gone or the session ends; a settled cancelled row still shows interrupted.
  • After a cancel, a background shell that keeps running holds the row at "Monitoring background tasks" until that shell's own completion arrives, then the row settles done / interrupted. (See mechanism 3.)
  • A normal turn that ends with a long-running shell (for example a dev server) still announces "finished" once when Grok goes idle, as on main, while the row keeps reading "Monitoring background tasks" instead of claiming the work is done.

Mechanism:

  1. A shared predicate (providerReportsOwnCancel in agent-interrupt-intent.ts) marks Grok as an agent that reports its own cancels. The renderer no longer sends a cancel inference for either keypress, and the server's inferInterrupt refuses one independently, so a stale or direct request cannot fabricate a cancel. Other providers are unchanged.
  2. Grok's stop_cancelled hook carries no backgroundTasks inventory (measured — an ordinary end-of-turn stop does carry it). The listener now keeps the inventory Grok last reported per pane (grokBackgroundTasksByPaneKey): a turn end that carries the backgroundTasks key restates it whole; SubagentStart adds the subagent it names, and a main-session PostToolUse whose structured result is a bash BackgroundTaskStarted adds that shell (so a shell started in the turn that gets cancelled is covered too); a subagent's SubagentStop/SessionEnd or a shell's own task_complete notification removes it; a session boundary or pane close clears it. Every Grok turn end folds with that inventory through the existing shared fold (foldAgentLeadStatus).
  3. When a task's own end is the only signal, the listener re-derives the row that inventory was holding open. A killed subagent emits just its child SessionEnd; a finished shell emits a task_complete notification (Background task completed: <task id>, the same id the stop inventory lists). That second signal matters: a user cancel suppresses Grok's usual auto-wake turn when a background task finishes, so after a cancel no later stop would ever restate the inventory. The re-derived row carries the settled main agent, which is exactly what releases the cancel-verdict hold (server-cancel-verdict-latch.ts).
  4. Orca's managed Grok hook config now subscribes to SubagentStart/SubagentStop (it did not before). Without this, a subagent spawned in the very turn that gets cancelled is invisible — it is in no stop inventory yet — and the live check showed the row settling while the subagent ran. Install rewrites the managed entries on every startup, so existing installs converge without a migration.
  5. Grok child hooks no longer stamp the child's session id over the pane's resume identity (providerSession), and child-attributed rows preserve the root's providerSession/model the way Codex child hooks already do.
  6. Because idle_prompt no longer settles a row held open by reported work, a held-open normal turn earns the existing completion stamp (turnCompletedAt, the one Claude rows already use) when Grok goes idle, so the renderer announces "finished" once at that moment and stays silent at the later all-clear. Grok earns it at idle_prompt rather than at stop (where Claude earns it) to keep the existing Grok policy of announcing only when Grok is actually finished, since Grok wakes itself when background work ends. A cancelled turn earns no stamp, as for Claude.

Known limit: the inventory lives in the hook listener's memory (desktop or relay host), so if that process restarts while a cancelled row is held open, the task's later end finds nothing to remove and the row waits for the next prompt, the session end, or the sidebar's staleness fade. Every entry otherwise has an exit driven by Grok's own hooks.

Why

The user decision for this issue was "go by the hooks": Grok tells us when a turn was really cancelled (StopCancelled fires on every real cancel — Ctrl+C, declined permission, max-turns), so keypress guessing only adds a way to be wrong — and both of its wrong modes hide live work. The alternative of keeping inference but special-casing the dialog cannot work: at the keypress the outcome (stop vs continue) is not yet decided, so no keypress-time rule can be right.

Keeping the last reported inventory (instead of treating a missing backgroundTasks key as "no tasks") is forced by the measured payloads: the cancel hook simply does not carry the inventory. The chosen shape mirrors the existing Claude subagent roster in the same listener (per-pane state maintained by the provider's own lifecycle hooks, with re-emission when a child's end changes the row). Unlike that roster, it is also rebuilt wholesale from every stop that reports an inventory, so a missed start or end hook is corrected at the next turn end. No time-based expiry is used: a quiet subagent or a long shell sends no hooks while it runs, so an expiry would reintroduce the original bug.

All rules here are pinned to a real capture committed as src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl (hook payloads recorded over a real PTY with per-hook ps snapshots of the rig's task processes, scrubbed; sidecar describes the scenario), replayed through the server's HTTP ingress in server-grok-cancel-captures.test.ts.

Linked Issue

Fixes the Grok findings from the live cancel QA of #22476 (no standalone issue; #22476 has merged).

Visual Proof

(screenshots attached in a comment below: before from the base-branch QA run, after from a live dev-build session with real Grok 1.0.41)

Testing

  • Captured real Grok 1.0.41 hook payloads over a PTY for: background shell + background subagent turn end; Esc mid-turn; Ctrl+C -> dialog -> "Continue to run"; Ctrl+C -> dialog -> "Stop running"; session end. Committed as the fixture above.

  • Replay tests over the capture (src/main/agent-hooks/server-grok-cancel-captures.test.ts) and hook-shape tests (server-grok-cancel.test.ts, hook-service.test.ts, renderer agent-interrupt-inference.test.ts). Each mechanism was ablated separately (server guard, renderer skip, inventory fold, subagent lifecycle, child-session shielding) and the pinning tests went red each time.

  • pnpm tc, oxlint on touched dirs, check:code-quality:changed, and the agent-hooks / listener / relay / parity suites all green (one unrelated src/relay/subprocess.test.ts timeout under machine load passes when run alone).

  • Live Electron check (macOS, isolated dev instance, real Grok 1.0.41 in a folder-workspace terminal pane), the full sequence polled against ps: Esc mid-turn left the row working; Ctrl+C opened the dialog and changed nothing; answering "Continue to run" left the row working with mainAgent done/cancellation while the subagent and command ran; Grok's later idle_prompt restatement no longer settled the held-open row (it did before the third commit — caught live); when the tasks finished, the row settled. That run predates the last two commits (a user cancel turns off Grok's auto-wake turn, so on the final head the row settles on each task's own end hook: the child's SessionEnd or the shell's task_complete); the QA comment below re-checks the final head. A Ctrl+C with no background work settled the row done / interrupted immediately.

  • Review follow-ups add replay and unit tests for: a cancelled row held open by a surviving shell settling on that shell's task_complete (through the server's HTTP ingress and cancel-verdict hold); a shell started in the cancelled turn holding the row; task_complete during a live turn, for an unknown id, for a monitor, and from a child session being no-ops; and the idle-time completion stamp announcing exactly once for a held-open turn, never for a cancelled one, and fresh after a new prompt. Each was ablated and went red.

  • The live check also caught the missing SubagentStart subscription (second commit): before it, a subagent spawned in the cancelled turn itself was invisible and the row settled while it ran.

  • I manually tested these changes locally

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

AI Disclosure

Author: @BrennanKB5

Review

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.

Notes

  • SSH/relay: the listener state lives with whoever ingests the hooks (desktop or relay host); no new wire fields or opcodes — the re-derived rows use the existing payload shape, and toolAgentId is an existing optional field now also stamped for Grok child hooks. An older client paired with a newer host degrades only by not preserving providerSession across a child-driven restatement.
  • Folder workspaces: validated live in a non-git folder workspace.
  • Cross-platform: no platform-dependent code; the managed hook config change is the same JSON on all platforms.

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)

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Visual proof (real Grok 1.0.41, live Electron dev instance, folder workspace)

Before (base branch, from the #22476 live cancel QA):

Esc mid-turn — Grok keeps running (its toast says "Press Ctrl+c to cancel the turn"), but the sidebar row flips to done / interrupted:

grok-2-midturn-esc.png

After Ctrl+C -> "Continue to run" — Grok's own footer says "2 commands · 1 subagent still running", the row stays done / interrupted:

grok-1c-after-continue-to-run.png

After (this PR):

Esc mid-turn — the row stays working (store: state working, mainAgent working before and after the keypress):

12-esc-mid-turn-r2.png

Ctrl+C mid-turn with a subagent running — Grok's "Subagents are still running. Stop them?" dialog opens, nothing changes at the keypress:

23-dialog-final.png

After answering "2 Continue to run" — "Turn cancelled by user … 1 command · 1 subagent still running" and the pane keeps reading as live work (store: state working, mainAgent { state done, outcome cancellation }, ps shows both processes alive):

24-after-continue-final.png

Polled to the end: the row stayed working through Grok's later idle restatement and settled only when the tasks actually finished (Grok's completion auto-wake turn).

@brennanb2025
brennanb2025 marked this pull request as ready for review September 24, 2026 23:49

@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 consistency gap in the Grok child-event handling: the routing predicate and the shielding predicate disagree, so a name-matched SubagentStop that carries no subagentType can be re-derived while its sessionId is still read as the pane's providerSession. Details inline.

Reviewed changes

  • Grok cancels stop being inferred from keys. providerReportsOwnCancel('grok') gates the renderer tracker and the server inferInterrupt, so neither an Esc nor a Ctrl+C can fabricate a Grok cancel; the server re-check is the boundary for a stale or old-client request.
  • Per-pane background-task inventory. grokBackgroundTasksByPaneKey keeps the last reported backgroundTasks (restated whole on a turn end that carries the key, adjusted by SubagentStart/SubagentStop/child SessionEnd, cleared at a session boundary) and folds it into every turn end and idle_prompt.
  • Child-end re-derivation. When a child's own end drops the entry holding a settled row open, normalizeGrokSubagentLifecycleEvent re-emits the row with the stored settled main agent, releasing the cancel-verdict hold.
  • Hook config + child identity. Orca's managed Grok config now subscribes to SubagentStart/SubagentStop; Grok child hooks emit providerSession = null plus a toolAgentId, and server-status-update.ts generalizes the Codex child-attributed root identity/model preservation to Grok.
  • Capture + tests. A real Grok 1.0.41 cancel capture is replayed through the server's HTTP ingress, alongside listener, renderer, and hook-shape tests.

ℹ️ Nitpicks

  • docs/reference/agent-status-store.md:266-277 describes the Ctrl+C inference admission rule (only when mainAgent.state is working) but not the new provider-level rule that Grok never infers a cancel from a keypress. A sentence there would keep that reference accurate for the next reader.

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

Comment on lines +56 to +57
(source === 'grok' &&
readFirstString(hookPayloadRecord, ['subagentType', 'subagent_type']) !== 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.

The Grok child-event predicate here keys on subagentType alone, but normalizeGrokEvent routes subagent_start/subagent_stop by event name too (grok-events.ts:91-96). So a SubagentStop that lacks subagentType still reaches normalizeGrokSubagentLifecycleEvent, deletes its entry, and re-derives the row — yet this shield does not fire, so extractAgentProviderSession can read the child's sessionId as the pane's providerSession (and the toolAgentId twin at :202-203 stays unset, skipping the root model/identity preservation). The two places should share one predicate.

Technical details
# Unify the Grok child-event predicate across routing and shielding

## Affected sites
- `src/shared/agent-hook-listener.ts:56-57` — `providerSession = null` only when `subagentType` is present.
- `src/shared/agent-hook-listener.ts:202-203` — `toolAgentId` only stamped when `subagentType` is present.
- `src/shared/agent-hook-listener/providers/grok-events.ts:91-96` — routes `subagent_start`/`subagent_stop` by event name, without requiring `subagentType`.

## Required outcome
- A payload that the router treats as a Grok child lifecycle event must also be treated as one for `providerSession` (null) and `toolAgentId` (stamped from `subagentId`/`subagentId`→`sessionId`), so a child event can never overwrite the pane's resume identity or drop the root's model.

## Suggested approach (optional)
- Extract one shared predicate (e.g. `isGrokChildEventPayload(eventName, hookPayload)` = subagentType present OR event is `subagent_start`/`subagent_stop`) and use it in both `grok-events.ts` and `agent-hook-listener.ts`.

## Open questions for the human
- The capture has no `SubagentStop` payload, so its shape is unmeasured. Does Grok 1.0.41's `SubagentStop` carry `subagentType`, and is its `sessionId` the child's (as the child `SessionEnd` is) or the parent's (as `SubagentStart` is)? That determines whether the current gap is inert or replaces the resume identity. Related: a child `SessionEnd` without `subagentType` still falls to the main path and clears the parent inventory — pre-existing, but the same predicate would cover it.

…ypress inference

Measured on Grok 1.0.41 (fixture grok-cancel-subagent-dialog-hooks.jsonl):
Esc never cancels a turn and fires no hook, and Ctrl+C with subagents
running opens a dialog that cancels nothing until answered - so no
keypress may infer a Grok cancel; the renderer skips the request and the
server refuses it. Grok's real stop_cancelled carries no backgroundTasks
inventory, so turn ends now fold with the inventory Grok last reported,
kept per pane and adjusted by subagent lifecycle hooks; a killed
subagent's own SessionEnd re-derives the row it held open. Grok child
hooks no longer stamp the child's session id over the pane's resume
identity, and child-attributed rows preserve the root providerSession
the way Codex children already do.
…ntStop

Live check found the gap: a subagent spawned in the very turn that gets
cancelled is in no stop inventory yet, and Orca's managed Grok hook did
not subscribe to subagent lifecycle events, so the cancel settled the
row while the subagent ran. The managed config now registers both
events (install rewrites managed entries wholesale on every startup, so
existing installs converge without a migration), and the same-turn
spawn-then-cancel flow is pinned.
…entory

Measured live: idle_prompt fires on 'turn over, user idle' while a
subagent and command still run, and the old idle backstop settled the
held-open cancelled row. The idle restatement now folds with the same
inventory as a turn end; the row settles when the inventory drops the
work (subagent end, next reported inventory) or the session ends.
It fixes a separate install bug unrelated to cancel handling; tracked on its own.
@brennanb2025
brennanb2025 force-pushed the brennanb2025/grok-cancel-by-hooks branch from 82a4774 to f942682 Compare September 25, 2026 04:29
@brennanb2025
brennanb2025 changed the base branch from brennanb2025/lead-status-pr-c to main September 25, 2026 04:29
@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

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

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: 8672252f-f8e1-4db6-9d1c-722fcce28cb3

📥 Commits

Reviewing files that changed from the base of the PR and between 3dccd7f and dcb3162.

📒 Files selected for processing (7)
  • src/main/agent-hooks/server-grok-cancel.test.ts
  • src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts
  • src/shared/agent-hook-listener-grok-task-inventory.test.ts
  • src/shared/agent-hook-listener/listener-state.ts
  • src/shared/agent-hook-listener/main-agent-turn-state.ts
  • src/shared/agent-hook-listener/providers/grok-events.ts
  • src/shared/agent-hook-listener/providers/grok-task-inventory.ts

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


📝 Walkthrough

Walkthrough

Grok hook processing now tracks background shells and subagents across turn-end reports and lifecycle events. Inventory-less cancellation hooks retain the tracked work, while subagent and session-end events update the row’s status. Grok keypresses no longer trigger inferred cancellation in the renderer or server. Tests and captured hook fixtures cover these status transitions and cancellation outcomes.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to dcb31

The Grok cancellation and background-work behavior appears ready to merge after normal checks.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to dcb31

Hook-reported cancellation should prevent keypresses from incorrectly marking active work finished. The remaining design uncertainty is whether delayed task events can make a later turn appear active or complete at the wrong time. No verified security compromise is established.

Retained concerns

  • Medium · architecture · inferred: Task starts and completions use pane and task ID but do not verify session or turn ownership. If old hooks arrive late or IDs are reused, they can add stale work or remove current work, skewing later status and completion notifications. The required ordering and uniqueness guarantees remain unverified.
Security review details

Security Blast Radius

  • inferred — For a correctly routed hook, the newly tracked effects are concentrated in its pane’s status and completion notifications. The inspected path does not establish a new privileged sink or cross-service authority.

Trust Boundaries and Controls

  • observed — Identified turn-end hooks are compared with the active prompt and, when available on both sides, session. Task-completion notifications instead require a recognized message prefix and an inventory-held task ID; they do not make that turn comparison.

Hardening Proposals

  • proposed — Bind task inventory updates and removals to a session or turn identity, or establish and enforce producer ordering and ID-uniqueness guarantees before relying on pane-and-ID matching across resets.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 39 functions across 20 files. 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 main change: Grok cancellation is determined by Grok hooks instead of keypress inference.
Description check ✅ Passed The description is complete and follows the repository template. It explains the user impact, mechanism, rationale, linked issue reference, visual proof, testing, AI disclosure, compatibility consider…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR

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: 7eada0d2-6d6c-402c-aff4-74a1d4473ae5

📥 Commits

Reviewing files that changed from the base of the PR and between a0e2490 and f942682.

📒 Files selected for processing (19)
  • src/main/agent-hooks/agent-cancel-capture.test-fixture.ts
  • src/main/agent-hooks/server-claude-cancel-captures.test.ts
  • src/main/agent-hooks/server-grok-cancel-captures.test.ts
  • src/main/agent-hooks/server-grok-cancel.test.ts
  • src/main/agent-hooks/server-relayed-claude-cancel.test.ts
  • src/main/agent-hooks/server/server-status-inference.ts
  • src/main/agent-hooks/server/server-status-update.ts
  • src/main/grok/grok-hook-config.ts
  • src/main/grok/hook-service.test.ts
  • src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts
  • src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts
  • src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl
  • src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.meta.json
  • src/shared/agent-hook-listener-grok-completion.test.ts
  • src/shared/agent-hook-listener.ts
  • src/shared/agent-hook-listener/listener-state.ts
  • src/shared/agent-hook-listener/providers/grok-events.ts
  • src/shared/agent-hook-listener/providers/grok-task-inventory.ts
  • src/shared/agent-interrupt-intent.ts

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

Comment thread src/shared/agent-hook-listener/providers/grok-events.ts
…te hook

A Grok cancel arms a wake barrier: a background shell that outlives the
cancel finishes without a follow-up turn or `stop`, so the row kept
reading "monitoring" after the work was gone. Grok fires a task_complete
Notification for every finished task; the pane's inventory now drops
that id and re-derives a row the inventory held open, sharing one path
with the subagent-end drop. A main-session PostToolUse whose result is a
bash BackgroundTaskStarted adds the shell, so a shell started in the
cancelled turn itself holds the row too.

@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 in this commit. One stale-description note below; the earlier predicate-mismatch thread on agent-hook-listener.ts is untouched by this commit and remains open.

Reviewed changes

  • A background shell now brackets itself in the inventory. recordGrokBackgroundTaskStarted adds a main-session post_tool_use whose tool result is a bash BackgroundTaskStarted, so a shell started in the very turn that gets cancelled holds the row open before any stop can restate the inventory.
  • A task's own task_complete settles a held-open row. normalizeGrokTaskCompleteNotification parses the Background task completed: <id> literal, drops the id from the pane's inventory, and shares one dropGrokFinishedTask path with the subagent-end drop, so a cancelled row whose last task reports its own end settles to done / interrupted instead of reading monitoring forever.
  • Refactor + pinning tests. The subagent lifecycle handler was reduced to addGrokInventoryTask/dropGrokFinishedTask, MAX_GROK_ID_LENGTH was extracted (value unchanged), and replay/listener tests pin the shell-start, shell-complete, live-turn, and monitor/unknown-id cases.

ℹ️ The PR body's "Known bounded gap" paragraph now describes removed behavior

The "Known bounded gap, on purpose" paragraph states that a shell started in the same turn that gets cancelled "is not in any reported inventory yet, so it does not hold the row open." This commit adds exactly that shell through its PostToolUse BackgroundTaskStarted result, so the paragraph is now stale and should be updated or dropped.

Technical details
# Retire the stale "known bounded gap" paragraph

## Affected sites
- PR description, "Known bounded gap, on purpose" paragraph — claims a shell started in the cancelled turn is in no inventory and does not hold the row open.

## Required outcome
- The description matches the shipped behavior: `recordGrokBackgroundTaskStarted` (`src/shared/agent-hook-listener/providers/grok-task-inventory.ts:120`) adds the shell from its `PostToolUse` result, and `normalizeGrokTaskCompleteNotification` (`:178`) drops it on its own `task_complete`, so it holds the row until it reports its end.

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

…l running

Folding Grok's idle_prompt with the task inventory kept a row honest while a
shell or subagent runs, but it also removed the only event that announced a
turn that leaves long-running work behind (a dev server): the row stayed
working and the user never heard the turn finished.

The idle restatement now stamps the main agent's finished turn with
turnCompletedAt (its own done clock) while child work holds the row open, the
same field Claude's held-open turns carry. The renderer already announces a
stamped working row once and pairs the later all-clear with it, so the turn
announces when Grok goes idle and the task_complete / subagent-end all-clear
stays silent. Later restatements of that turn carry the stamp; a new prompt
drops it with the main agent's done state; a cancelled turn earns none.

The stamp is earned at idle, not at the stop: Grok wakes itself when its
background work ends and that woken turn is the one that announces, so
stamping the stop would ping twice for one request.

@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 commit. The earlier Grok child-event predicate thread on agent-hook-listener.ts is untouched and remains open, so this is not an approval.

Reviewed changes

This run covers dcb3162 (the commit since the prior pullfrog review at 3dccd7f).

  • Stamped a held-open Grok turn at its idle restatement. grokTurnCompletedAt (grok-events.ts:85) records the finished turn's own stateStartedAt as turnCompletedAt when the event is an idle_prompt and isAgentStatusHeldOpenByChildWork says child work holds the row open, so the renderer's existing stamped-working-row path announces the turn once.
  • Threaded the stamp through every restatement. dropGrokFinishedTask strips the stamp off mainAgent and re-emits it top-level on the re-derived all-clear row, a re-delivered stop keeps it via previous?.turnCompletedAt, and a new prompt drops it because any non-done state returns undefined.
  • Excluded cancels. A stop_cancelled turn earns no stamp (mainAgent.outcome === 'cancellation' guard), so a cancelled held-open row never announces as a completion.
  • Typed the Grok main-agent record. grokMainAgentStatusByPaneKey is now Map<string, GrokMainAgentTurnState> so the record carries the stamp across restatements without changing the published mainAgent shape.
  • Added tests. Server replay for the stamp-at-idle and its pairing (server-grok-cancel.test.ts), listener stamp/re-delivery (agent-hook-listener-grok-task-inventory.test.ts), and renderer announcement/pairing for Grok (agent-hook-completion-background-turn-notifications.test.ts).

Verified locally: pnpm tc:node clean, and src/shared/agent-hook-listener, src/main/agent-hooks, src/renderer/src/components/terminal-pane/agent-completion*, plus the renderer Grok notification test all green (141 files / 1485 tests).

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

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Live QA on the final head (dcb3162)

Grok's API quota was exhausted (402), so no real Grok turn could run. The hooks below are Grok 1.0.41's real captured payloads (src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl and grok-background-completion-hooks.jsonl, with only cwd/workspaceRoot rewritten), replayed through the app's real hook path from inside a real terminal pane.

Setup: the dev build at dcb3162 with an isolated profile, and a folder workspace. In each pane a stand-in process named grok ran in the foreground in raw mode, like the Grok TUI. It fed each payload to the exact managed hook command the app installed in its Grok hook config, using the pane's own environment, so every hook reached the app through its real hook server.

Esc and Ctrl+C were real keypresses into the pane's terminal. The stand-in logged receiving \x1b and \x03. The shell task_complete and the idle_prompt notifications were synthesized with the captured field set, because no capture has them at those moments. Readouts come from the renderer store plus the host status snapshot.

A. Keypresses never cancel

  • Esc, then a 2.5 s wait (the settle window is 500 ms): the row stays working with mainAgent working and no interrupted; updatedAt is unchanged. A2
  • Ctrl+C, then a 2.5 s wait: same result. A3

B. Ctrl+C answered "Stop running" (fixture turn 1's stop, then turn 3)

  • Turn 3's stop_cancelled: working, mainAgent done/cancellation, no workingMode (the subagent is still inventoried). B1
  • The child's session_end: working, workingMode: monitoring, mainAgent done/cancellation. The sidebar reads "Monitoring background tasks". B2
  • The shell's task_complete: done with interrupted: true. The sidebar shows the red dot and "Interrupted by user". No completion notification is sent. B3

C. Ctrl+C answered "Continue to run" (fixture turn 2)

  • stop_cancelled: working, mainAgent done/cancellation. C1
  • idle_prompt: still working, no turnCompletedAt, no notification. C2

D. Normal turn held open by a shell (pane on a background tab, so the unread marker is observable)

  • A stop listing one running shell: working/monitoring with mainAgent done, and no announcement yet. D1
  • idle_prompt: still working/monitoring. The host row now carries turnCompletedAt, equal to the stop's done time. Exactly one completion dispatch reached the attention sink: the workspace and the pane were each marked unread once. D2
  • The shell's task_complete: done, and still exactly one dispatch. D3

E. Session end

  • Main session_end while the shell is still inventoried: done, sessionBoundary: true, interrupted: true. E1

Deviations and things to look at

  • A cancelled row that is still held open records the cancel only on mainAgent.outcome. Row-level interrupted and "Interrupted by user" appear only once the row settles (B3, E1). While a subagent keeps the row open (B1, C1, C2), the sidebar shows a working spinner next to the foreground command the cancel killed (run_terminal_command: sleep 244), so it looks like that command is still running. The PR's tests expect this behavior, but it confuses a user.
  • When Grok quits it sends session_end and then a stop with reason shutdown (both are in the capture). That trailing stop wipes the cancel: the row turns from a red dot with "Interrupted by user" into a green check with the last assistant message, because interrupted and mainAgent.outcome are cleared. The wrong state is brief here, since the row is removed when the process exits. E2
  • After D3, replaying the captured auto-woken follow-up turn (user_prompt_submit + stop) sent a second completion notification. A task that is still running when Grok goes idle therefore pings once at idle and again when Grok's woken turn ends. Each ping is a separate turn end. D4
  • The keypress refusal was not compared live against a build without this change; the unit tests cover that.
  • Not covered: a real Grok TUI (no terminal title or screen output from Grok), SSH, and desktop banners. Banners were turned off in the QA profile, so dispatch was observed at the renderer's attention sink.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: ready for review (head dcb3162ef4)

Summary: five review loops, two real defects fixed and pushed, the design re-challenged and kept, CI green, and live Electron QA passed on the final head (QA comment). No open blockers.

What the review changed

  • 3dccd7fc90: after a Ctrl+C, a row held open by a surviving background shell never settled. Grok turns off its "task finished, start a new turn" behavior after a user cancel, and the cancel does not kill background shells. So no later stop restated the task list, and the row stayed "Monitoring background tasks" after the shell ended. The fix drops a shell from the task list on Grok's own task_complete notification, which Grok sends for every finished task either way, and re-derives the row. A shell started in the cancelled turn itself is now tracked from its structured BackgroundTaskStarted tool result, which closes the gap the PR originally disclosed.
  • dcb3162ef4: a normal turn that leaves a long-running shell (for example a dev server) lost its "finished" notification. Main announced it about 60 seconds later, when idle_prompt settled the row; this PR had stopped idle_prompt from settling held-open rows. The fix uses the existing completion stamp (turnCompletedAt), earned at idle_prompt to keep Grok's announce-when-actually-idle policy. It announces once and stays quiet at the later all-clear. Cancelled turns get no stamp, as for Claude.
  • The PR body was updated to match: mechanisms 3 and 6, the known limit, and a corrected Testing sentence.

Checks

  • Review loops: 1 clean · 2 fixed 1 · 3 clean on code (raised the notification regression) · 4 fixed 1 · 5 clean.
  • Architecture challenge: run because the same kind of defect came up twice (a stored task entry with no exit). Verdict: keep. Grok's cancel hook carries no task list, and nothing else Grok exposes gives an authoritative current task list, so a per-pane list kept up to date by Grok's hooks is the smallest correct design. Every stop that reports the task list rebuilds it, and every entry now has an exit driven by Grok's own hooks. A time-based expiry was rejected because quiet subagents and long shells send no hooks, so it would bring back the original bug.
  • Readiness checklist: passed at both the start and end of the session. The only start-of-session failure was that CI had never run on the old head; it is now green.
  • CI on dcb3162ef4: green, including all 8 test shards, typecheck, static analysis, relay integration, packaging, managed hooks on Node 18, and the SSH terminal/hooks e2e.
  • Local: listener, agent-hooks, relay and completion suites pass, and tc:node, tc:web, oxlint and check:code-quality:changed are clean. Each new test was ablated and went red.

Live QA

Grok's API quota was exhausted during this pass, so the QA seat drove the real dev build with Grok 1.0.41's real captured hook payloads. They went through the app's real hook path from inside a real terminal pane, and the Esc/Ctrl+C keypresses were real. All five scenarios passed:

  • keypresses refused
  • Ctrl+C then "Stop running" ends at "Monitoring background tasks", then settles done / interrupted on the shell's task_complete
  • Ctrl+C then "Continue to run" stays working through idle_prompt
  • a held-open normal turn announces exactly once
  • session end settles the row

Not caused by this PR (possible follow-ups)

  • A row held open by a live subagent after the main turn ends shows the main agent's last tool line, even when a cancel killed that tool. This is shared presentation: every provider, Grok's plain stop on main, and Claude's cancelled rows since fix(agent-status): a cancel never hides live work #22476. A separate change could show the held-open state instead.
  • interrupted appears only once a cancelled row settles. The shared status normalizer keeps it only on done rows, for every provider.
  • Quitting Grok after a cancel briefly flips "Interrupted by user" to a finished check, because Grok's shutdown stop carries no verdict. This is unchanged from main and lasts until the row disappears (about 3 seconds).
  • A turn whose background task is still running at idle notifies at idle, then again when Grok's own follow-up turn finishes. The count is the same as main (existing Grok notification policy).

Known limit (disclosed in the body)

The task list lives in the hook listener's memory. If that process restarts while a cancelled row is held open, the row waits for the next prompt, the session end, or the sidebar's staleness fade.

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