Skip to content

fix(agents): keep a pane working while its agent's descendants are live - #19722

Closed
brennanb2025 wants to merge 5 commits into
mainfrom
brennanb2025/descendant-pane-lifecycle
Closed

brennanb2025 wants to merge 5 commits into
mainfrom
brennanb2025/descendant-pane-lifecycle

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 5 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​609 $\color{#cf222e}{\Huge{\mathbf{−}}}$​50 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​559
Prod 16 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​752 $\color{#cf222e}{\Huge{\mathbf{−}}}$​190 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​562

The rule

A pane is idle only when its lead agent session is idle and no descendant is still running. A descendant's lifecycle event may add or remove a child row; it may never settle the pane or fire completion.

That rule existed twice in the codebase — Claude's roster and Codex's — and was missing entirely for every other provider. Grok and Pi are two reports of that one gap.

Root cause per issue

STA-6982 (Grok, urgent). src/shared/agent-hook-listener/providers/grok-events.ts:54 mapped stop / session_end / stop_failure to pane done with no notion of a child. Grok stamps subagentType on the payload of every event that can fire inside a subagent and omits it in the main session; nested children inherit the parent pane's ORCA_PANE_KEY. One correction to the report: a child's turn gate is remapped to SubagentStop, and Grok's Stop payload carries no subagentType — so the events that actually reached the pane are the child's own SessionEnd and StopFailure, both of which Orca installs and both of which carry the field. Each one settled the parent and drove the Agent Task Complete notification.

STA-6378 (Pi, high). src/shared/agent-hook-listener/providers/pi-family-events.ts mapped agent_end straight to done. Async child runs delegated through pi's subagent extension outlive the parent turn, and nothing in Orca knew they existed: the parent settled, the spinner stopped, and completion could fire while children ran.

The fix

src/shared/codex-subagent-roster.ts was already provider-neutral in substance, so it becomes src/shared/agent-descendant-roster.ts — one descendant roster and one agentDescendantEffectiveState derivation, reused rather than re-implemented. Codex's behaviour is unchanged (rename plus a widened state type).

The rule is then applied once, at provider-dispatch.ts — the boundary every provider funnels through:

  • a hook event that describes a descendant never reaches the provider normalizer. It updates the roster and republishes the lead's last state, so it cannot settle the pane, cannot relabel the row with the child's tool or prompt, and does not count as a user submit (hasExplicitPrompt);
  • every other event's result is gated: a lead done publishes as working while a descendant is live.

descendant-events.ts holds the only per-provider part as a single table keyed by provider. Each entry answers both halves together — how to read a child out of that CLI's events, and which event proves its children can no longer be alive — because a provider that opts into descendants without answering recovery silently inherits "no recovery". A Record over the source union makes a new provider a compile error there, so the question cannot be skipped.

Claude keeps its own roster. Its children carry teammate parking, background_tasks folding and restored-snapshot provenance that no other provider has, and it already implements this same pane rule in providers/claude-roster-state.ts. Folding it into the generic path would mean either losing that reconciliation or pushing Claude-only fields into every provider.

Supporting changes: Grok's SubagentStart / SubagentStop are now installed (older builds ignore unregistered event names, the StopFailure precedent) so a child can be named rather than only inferred; and the managed pi status extension forwards the async child-run lifecycle from pi's process event bus.

Losing a child's finish must not wedge the pane

A roster entry's only power is to hold a pane working, so a claim nothing can retract is worse than settling late. Three things guarantee one can always be retracted:

  • The pi extension posts the full live child set, not a start/complete delta. Its transport (agent-status-extension-source.ts) is a latest-only single slot: while a delivery is in flight, a newer post overwrites the pending one and the overwritten message is never sent. Every other caller posts a complete snapshot, so a dropped one is harmless — a dropped delta would strand a finished child forever. Sending the whole set restores the invariant the transport was built on: the newest message is complete, so it repairs whatever was dropped before it. This is why the payload is a set rather than deltas with their own queue; a second delivery path would add ordering between two queues and a new failure mode instead of removing one.
  • An interrupted grok turn clears its children. An interrupt skips the stop gate entirely and reports StopCancelled, so a child of that turn never sends a finish — the cancel is the only proof it is gone. That event is now installed and a lead-scoped one resets the pane's descendants; a child-scoped one (a child's own turn limit or declined permission) ends just that child. SessionStart cannot do this job: it does not fire for a subagent's own session and fires once per session, so it never reaches a mid-session orphan.
  • A quiet-window reaper backstops both. Every provider loses a stop hook sometimes — disabled, untrusted, timed out, process killed. A descendant unmentioned for 30 minutes is dropped, pruned lazily on the pane's next event so the roster stays passive. Deliberately generous: a wrongly reaped child merely settles the pane early, which the lead's next event corrects, whereas too short a window would reintroduce the bug this roster exists to fix.
  • A cancelled turn publishes an interrupted settle. Clearing the roster is not enough on its own: grok's normalizer had no stop_cancelled case, so it returned nothing and the pane kept its pre-cancel row — spinner running, children listed — while the roster was already empty. A lead-scoped cancel now publishes done with interrupted, the same shape claude's interrupt path publishes, so the row reads Interrupted and a notification says "stopped" rather than "finished".

Behaviour change to be explicit about: a completion notification can now fire on cancel where it could not before. The notification is dispatched off terminal-title transitions and process exit, with hook status acting only as a veto (isFreshNonDoneAgentStatus); a pane stuck on working was incidentally holding that veto shut. Publishing done lifts it. That is the deliberate trade — the alternative leaves the pane wrong until some later turn happens to end, and the idle-prompt ping cannot be relied on to settle it, because grok cancels that ping as soon as the user sends the next message, which is exactly what interrupting to redirect does. Any notification on this path is the "stopped" variant, not "finished".

STA-6677 (Claude): investigated, not closed, and not this bug

STA-6677 was originally grouped with these two. It does not belong here, and the fix it asks for should not be made.

  1. The harm this cluster is about is already absent on Claude's path. The drain's done repeats the gated lead Stop's turnCompletedAt, and src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts:164-172 early-returns on a turnCompletedAt already handled. The completion notification therefore fires exactly once, at the lead Stop — there is no early or duplicate "Agent Task Complete" here.
  2. What remains is sidebar colour for the window between the child's stop and the parent's next hook.
  3. Flipping it would reverse a deliberate design. I implemented the reporter's fix; it broke six tests in src/shared/agent-hook-listener-claude-subagents.test.ts (lines 45, 221, 453 among them) that assert the opposite with stated reasons, e.g. "the lead already stopped before the wait; draining the child must resolve to done, not pin the pane on an invented 'working'." Those comments name the stuck-spinner class the current behaviour guards against. I reverted the change and left the tests intact.
  4. It is a different root cause. Unlike Grok and Pi, Orca is not settling on a descendant event here: the lead's own Stop genuinely fired and Orca cached it faithfully. The reporter saw the parent still generating after that Stop, which points upstream — at why the lead Stop lands while the parent is still working — rather than at pane-state derivation. That needs its own root-causing.

Also considered, left out

  • STA-6881 (Pi extension prompts should read Blocked, not Working) — already fixed on main, after the reported 1.4.197. src/main/pi/agent-status-ui-prompt-source.ts forwards ui_prompt_start / ui_prompt_end and pi-family-events.ts:37 normalizes them to waiting. Both reported symptoms are covered: waiting and blocked both map to permission (runtime-worktree-status-projection.ts:179-181), which is exactly what populates agentWait (orca-runtime-get-terminal-interactive-wait.ts:46), so the agentWait: null in the report no longer reproduces. No mechanism left to build, and none of it rides along on the descendant work.
  • STA-6495 (missed OMP completion leaves the spinner stuck) — a delivery failure, not a descendant. It needs run-generation ids, POST retry and receiver-side reconciliation. Different mechanism; not folded in.

Tests

New: src/shared/agent-hook-listener-descendant-lifecycle.test.ts (20) drives normalizeHookPayload, the entry both the main process and the relay call. src/main/pi/agent-status-async-subagent.test.ts (8) executes the real generated extension source and feeds its POST body through that same entry.

Ablation, every arm re-run at the final head — 20/20 and 8/8 pass there:

Arm (single lever unless noted) Result
provider-dispatch.ts at main, new modules kept (shipping path severed) 21/28 fail
lead cancel maps to no state (the reported interrupt defect) both cancel cases fail
interrupted flag dropped from the cancel settle both cancel cases fail
child ask_user_question wait dropped the child-wait case fails
pi extension posts start/complete deltas instead of the live set the coalescer-race case fails
grok lead-turn cancel dropped from the scope reset both cancel cases fail
quiet-window reaper removed the lost-finish case fails
StopCancelled dropped from the installed grok events the hook-config case fails
all production files at main (full revert) the descendant suite cannot load at all; 7/27 fail in the suites that still do

Three defects were caught by testing rather than review. The Pi harness found that the process-bus bind guard aborted the extension factory on an in-process reload. The coalescer-race arm initially passed against the delta payload it was meant to pin — bursting the starts as well as the completions meant a dropped start cancelled out a dropped completion, so the pane settled for the wrong reason; the starts are now delivered one at a time. And the interrupt regression test asserts the published payload, not the roster: the earlier roster-only assertion was exactly why a cleared roster with a stale renderer passed review and CI.

Regression at the final head: 16282 tests across src/main/pi, src/main/grok, src/main/agent-hooks, src/main/codex, src/shared, src/relay and src/renderer/.../terminal-pane. Two runs each surfaced one different timeout under the parallel load (spool.test.ts, pty-handler-retired-pane-surface.test.ts); both pass twice in isolation and neither touches this change. All three tsconfig projects typecheck clean, pnpm run build:cli succeeds, and check:code-quality:changed, oxlint and the native-plugins config all exit 0.

Known limits

  • The reaper is lazy — it prunes on the pane's next event, so a pane that goes completely silent keeps its last published row until something arrives. That is the pre-existing shape of any lost terminal transition rather than something the roster introduces, but it means the reaper bounds the damage rather than eliminating it.
  • Whether the completion notification is actually suppressed on the descendant path is not proven end to end. The link is code reading: keeping the status working is what isFreshNonDoneAgentStatus tests, and the status staying working is measured. An attempt to instrument the dispatch directly did not install, and its "zero notifications" reading is not being claimed as evidence.

Cross-cutting

Remote wire (Rule 3 — content, not shape). No new field, no new opcode. subagents is an existing optional field every client already normalizes for Claude and Codex; Grok and Pi panes now populate it, and an old client renders those child rows the same way. The state change is done → working, both long-standing members of AGENT_STATUS_STATES with unchanged meaning — the pane simply settles later. Both skew directions were reasoned through and behave: a new hook event reaching an old listener normalizes to nothing and is dropped (old hosts keep the old bug, they gain no new one), and a new listener paired with an old, un-reinstalled Grok hook config still fixes STA-6982, because the subagentType guard needs no SubagentStart. cross-version-agent-session-wire and cross-version-terminal-wire pass (23).

SSH / execution boundary. Hooks fire on the execution host and the roster lives in that host's listener state; the main process and the relay each hold their own, as they already did for Claude and Codex. Nothing here reads local process state or treats loss of contact as evidence.

Cross-platform. Pure event normalization plus a generated extension string — no paths, no shells, no platform branches.

Folder workspaces. Pane-keyed throughout; no worktree or git assumption.

Types

HookListenerState gains two maps; agentDescendantEffectiveState and buildCodexStatusPayload's stateName widen from the three-value union to AgentStatusState; ProviderDispatchResult gains an optional descendantScoped; DescendantEventFacts is a discriminated union of a per-child delta and an authoritative live set. All three tsconfig projects were typechecked separately and are clean.

Closes STA-6982
Closes STA-6378

Merge Sim added 2 commits September 9, 2026 03:19
A pane is idle only when its lead session is idle AND no descendant is still
running. That rule existed twice — Claude's roster and Codex's — and nowhere
else, so a Grok nested subagent's SessionEnd/StopFailure and a Pi async
subagent run were read as the pane's own completion.

Generalize the Codex roster into a provider-agnostic descendant roster and
apply the rule once, at the provider-dispatch boundary every provider funnels
through. A descendant's event now updates the child list and republishes the
LEAD's state; it never settles the pane, never relabels the row with the
child's tool or prompt, and never counts as a user submit. Claude keeps its
own roster: its children carry teammate parking, background-task folding and
restored-snapshot provenance that no other provider has.

Install Grok's SubagentStart/SubagentStop so a child can be named rather than
only inferred, and forward pi's async subagent run lifecycle from the managed
status extension.
…e pane

Covers STA-6378 end to end: the generated status extension is executed, its
POST body goes through the real listener entry, and the pane state asserted is
the one a pi pane would show.

Also fixes two defects the harness exposed: the process-bus bind guard aborted
the extension factory on an in-process reload (skipping every handler after it),
and an unidentified bus payload would have been published as a child nothing
could later clear.
@coderabbitai

coderabbitai Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

📝 Walkthrough

Walkthrough

The change adds provider-agnostic descendant roster APIs and pane-state tracking. Grok registers descendant lifecycle and cancellation hooks. Pi publishes complete async-subagent live sets from its process bus. Provider dispatch extracts descendant lifecycle facts, updates per-pane rosters, gates lead state, and resets descendant scope. Tests cover Grok and Pi lifecycle transitions, prompt handling, listener registration, and pane-state bookkeeping.

Priority: ➖ Normal

Merge Risk: 🟡 Moderate · up to ae84a

A pending child request for human input can disappear after a later lead completion event, making the pane appear settled while user action is still required. This should be fixed and regression-tested before merge.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.96% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 46 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 primary change: keeping a pane working while agent descendants remain active.
Description check ✅ Passed The description is comprehensive and covers the problem, root causes, implementation, testing, compatibility, known limits, and linked issues. It does not reproduce every template heading or include e…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

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: cfd0cfce-b02b-44c1-88cf-1ca8a1e260fe

📥 Commits

Reviewing files that changed from the base of the PR and between 8c18e42 and f10acfa.

📒 Files selected for processing (19)
  • src/main/grok/grok-hook-config.ts
  • src/main/grok/hook-service.test.ts
  • src/main/pi/agent-status-async-subagent-source.ts
  • src/main/pi/agent-status-async-subagent.test.ts
  • src/main/pi/agent-status-extension-source.ts
  • src/main/pi/agent-status-extension-test-harness.ts
  • src/main/pi/agent-status-handler-source.ts
  • src/shared/agent-descendant-roster.test.ts
  • src/shared/agent-descendant-roster.ts
  • src/shared/agent-hook-listener-descendant-lifecycle.test.ts
  • src/shared/agent-hook-listener.ts
  • src/shared/agent-hook-listener/descendant-events.ts
  • src/shared/agent-hook-listener/descendant-pane-state.ts
  • src/shared/agent-hook-listener/listener-state.ts
  • src/shared/agent-hook-listener/provider-dispatch.ts
  • src/shared/agent-hook-listener/providers/codex-events.ts
  • src/shared/agent-hook-listener/providers/codex-state.ts
  • src/shared/codex-subagent-transcript.test.ts
  • src/shared/codex-subagent-transcript.ts

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

@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

Reviewed the full 19-file diff end-to-end plus the affected listener, Codex, Pi and Grok paths; verified pnpm tc:node passes and ran the new/changed suites (49 passing). The change is a clean, well-scoped generalization of an existing rule rather than a parallel implementation.

  • codex-subagent-roster → agent-descendant-roster — provider-neutral rename of the shared roster module; Codex keeps its own codexSubagentRosterByPaneKey map (type widened to AgentDescendantRoster), behavior unchanged.
  • Generic descendant gating in provider-dispatch.ts — Grok/Pi descendant lifecycle events update a shared roster and republish the lead's cached state, so a child can add/remove a row but never settle the pane; a lead done gates to working while a descendant is live.
  • descendant-events.ts / descendant-pane-state.ts — per-provider "read a child out of this CLI's events" as an exhaustive switch, plus the pane-state gate/reset helpers; Claude and Codex route around it because they own their rosters.
  • Grok SubagentStart/SubagentStop install — names children so Grok panes can track them (STA-6982).
  • Pi async-subagent forwarding — generated extension binds pi's process event bus exactly once and forwards subagent_async_started/subagent_async_complete (STA-6378).
  • State bookkeeping — HookListenerState gains descendantRosterByPaneKey/descendantLeadStateByPaneKey, wired through clear/move/clearAll and paneHasStateClaims.
  • hasExplicitPrompt suppression — descendant events no longer count as a user submit.

The tests are real: they drive normalizeHookPayload (the entry both main and relay use), the ablation table in the PR body shows they fail when the fix is severed, and the assertions pin exact state sequences rather than loose truthiness.

ℹ️ Descendant state is not cleared on SSH connection loss (unlike Codex/Claude)

clearStatusEntriesForConnection clears the Codex and Claude rosters when a transport disconnects ("don't merge it with the lost connection's children"), but the new descendantRosterByPaneKey/descendantLeadStateByPaneKey are not wired into that path. This is almost certainly intentional — loss of contact is not evidence of process death, and Grok/Pi recover through their SessionStart/session_start scope reset — but the asymmetry is undocumented, and the PR body's SSH section asserts correctness without mentioning it. Worth a one-line confirmation.

Technical details
# Confirm connection-loss cleanup for descendant state

## Affected sites
- src/main/agent-hooks/server/server-cleanup.ts:155 — `clearStatusEntriesForConnection` deletes codex/claude roster maps on disconnect but not the new descendant maps.
- src/shared/agent-hook-listener/descendant-events.ts — `isDescendantScopeResetEvent` is the only path that clears descendant scope (grok `SessionStart`, pi/omp/prime `session_start`).

## Required outcome
- Either wire `descendantRosterByPaneKey`/`descendantLeadStateByPaneKey` into the connection-loss cleanup for grok/pi, or document why scope reset alone is sufficient.

## Open questions for the human
- On SSH reconnect to a still-running grok/pi process (no `SessionStart`/`session_start`), does the stale roster in the listener state ever gate a pane incorrectly, or do surviving child events rebuild it?

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

Merge Sim added 2 commits September 9, 2026 03:50
Three defects in the descendant roster, all of which could pin a pane
'working' with nothing left to clear it.

The pi status extension's transport is a latest-only single slot: while a
delivery is in flight a newer post overwrites the pending one, which is then
never sent. Every other caller posts a complete snapshot, so a dropped one is
harmless; start/complete deltas were not. Post the full live child set instead,
which makes this caller idempotent like the rest — the newest message carries
the whole truth and repairs whatever was dropped before it.

An interrupted grok turn skips its stop gate entirely and reports StopCancelled,
so a child spawned by that turn never sent a finish. Install that event and
treat a lead-scoped cancel as proof its children are gone; a child-scoped one
ends only that child. SessionStart could not cover this: it does not fire for a
subagent and never reaches a mid-session orphan.

Neither of those, nor any lost stop hook, had a backstop. Add a quiet-window
reaper: a roster entry can only ever hold a pane 'working', so a claim nothing
can retract is worse than settling late.

Fold the two independent per-provider switches into one table so opting into
descendants forces answering recovery, which also fixes the exhaustiveness
lint. Fix TS2835 in the new test under the CLI project's module resolution, and
stop the lead-state cache from reading as a pane state claim.
The burst also coalesced the STARTS, so the dropped start cancelled out the
dropped completion and the pane settled for the wrong reason — the case passed
against the delta payload it was meant to pin. Deliver the starts one at a time
so only completions are lost in flight.

@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

🧹 Nitpick comments (1)
src/shared/agent-hook-listener-descendant-lifecycle.test.ts (1)

164-164: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert which sibling survives the child cancel.

The assertion checks only the roster size. A defect that removed sub-2 instead of sub-1 would still produce size 1 and pass. Assert the surviving id so the test proves the cancel targets the named child.

💚 Proposed test tightening
-      expect(state.descendantRosterByPaneKey.get(PANE_KEY)?.size).toBe(1)
+      expect(
+        Array.from(state.descendantRosterByPaneKey.get(PANE_KEY)?.keys() ?? [])
+      ).toEqual(['sub-2'])

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: af42f3df-7b84-4c06-9a2a-fc7e7947deb8

📥 Commits

Reviewing files that changed from the base of the PR and between f10acfa and 5b7103f.

📒 Files selected for processing (11)
  • src/main/grok/grok-hook-config.ts
  • src/main/grok/hook-service.test.ts
  • src/main/pi/agent-status-async-subagent-source.ts
  • src/main/pi/agent-status-async-subagent.test.ts
  • src/main/pi/agent-status-extension-source.ts
  • src/shared/agent-descendant-roster.ts
  • src/shared/agent-hook-listener-descendant-lifecycle.test.ts
  • src/shared/agent-hook-listener/descendant-events.ts
  • src/shared/agent-hook-listener/descendant-pane-state.ts
  • src/shared/agent-hook-listener/listener-state.ts
  • src/shared/agent-hook-listener/provider-dispatch.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/shared/agent-hook-listener/descendant-pane-state.ts

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

Comment on lines +44 to +46
" post('subagent_async_state', {",
' subagent_runs: Array.from(piAsyncSubagentRuns.values()),',
' })',

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🤖 get_repo_knowledge executed:

get_repo_knowledge stablyai/orca /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/architecture /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/learnings /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/conventions

Length of output: 47692


🏁 Script executed:

#!/bin/bash
set -eu
file="src/main/pi/agent-status-async-subagent-source.ts"
printf '%s\n' "--- file ---"
cat -n "$file"
printf '%s\n' "--- related symbols ---"
rg -n -C 4 "piAsyncSubagentRuns|postAsyncSubagentState|subagent_async_state|async-started|agent_end|extension registration|register" src test tests 2>/dev/null || true

Repository: stablyai/orca

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' "--- candidate files ---"
git ls-files | rg '(^|/)(agent-status|subagent|process-bus|pi).*(ts|js|md)$|(^|/)(README|CLAUDE).*'
printf '%s\n' "--- exact source ---"
cat -n src/main/pi/agent-status-async-subagent-source.ts

Repository: stablyai/orca

Length of output: 14004


🏁 Script executed:

#!/bin/bash
set -eu
cat -n src/main/pi/agent-status-async-subagent-source.ts

Repository: stablyai/orca

Length of output: 4578


🏁 Script executed:

#!/bin/bash
set -eu
file="src/main/pi/agent-status-async-subagent-source.ts"
wc -l "$file"
cat -n "$file"

Repository: stablyai/orca

Length of output: 4631


🌐 Web query:

github stablyai/orca src/main/pi/agent-status-async-subagent-source.ts

💡 Result:

The file path src/main/pi/agent-status-async-subagent-source.ts does not appear to exist within the stablyai/orca GitHub repository [1][2]. Stably AI maintains a repository named stablyai/orca, which is an AI-driven development environment (ADE) for managing fleets of parallel coding agents [1][2]. While the project makes extensive use of TypeScript and manages subagents, status tracking, and asynchronous task execution [3][4][5][6][7], the specific file naming convention agent-status-async-subagent-source.ts does not correspond to the project structure of stablyai/orca or its known dependencies [1][8][3]. It is possible that the file path requested refers to a different internal module, a dependency package (such as those under the pi-subagents or @selesai/code namespace, which contain similarly named files like async-status.ts [3][4][5][6][7]), or is an incorrectly constructed path.

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- exact references ---'
rg -n -C 5 --glob 'src/main/pi/**' --glob 'src/main/agent-hooks/**' \
  'piAsyncSubagentRuns|subagent_async_state|async_subagent|subagentRuns|subagent_runs' \
  src/main/pi src/main/agent-hooks
printf '%s\n' '--- lifecycle source/test symbols ---'
rg -n -C 4 --glob 'src/main/pi/**' \
  'getPiAgentStatusAsyncSubagentSourceLines|register|reload|extension|agent_end|async-started|async-complete' \
  src/main/pi

Repository: stablyai/orca

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -eu
rg -l --glob 'src/main/pi/**' 'subagent_async_state|piAsyncSubagentRuns' | sort

Repository: stablyai/orca

Length of output: 246


🏁 Script executed:

#!/bin/bash
set -eu
for f in $(rg -l --glob 'src/main/pi/**' 'subagent_async_state|piAsyncSubagentRuns'); do
  echo "--- $f ---"
  rg -n -C 8 'subagent_async_state|piAsyncSubagentRuns' "$f"
done

Repository: stablyai/orca

Length of output: 3230


🏁 Script executed:

#!/bin/bash
set -eu
log=/tmp/coderabbit-shell-logs/shell-output-PQ8u76
rg -n -C 12 --glob '*.ts' \
  'subagent_async_state|piAsyncSubagentRuns|async-started|async-complete' \
  "$log" | head -n 300

Repository: stablyai/orca

Length of output: 28262


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- receiver state and session boundaries ---'
rg -n -C 8 \
  'descendantRosterByPaneKey|subagent_async_state|createHookListenerState|session_start|reset.*descendant|clear.*descendant|listener state' \
  src/shared/agent-hook-listener src/shared/agent-hook-listener-descendant-lifecycle.test.ts

Repository: stablyai/orca

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -eu
fd -i 'listener-state|descendant' src/shared/agent-hook-listener src/shared | sort

Repository: stablyai/orca

Length of output: 606


Republish the retained live set when the receiver reconnects.

postAsyncSubagentState runs only from the two process-bus lifecycle handlers. A new receiver creates an empty in-memory descendantRosterByPaneKey, while piAsyncSubagentRuns survives only an in-process extension reload. If Orca restarts after subagent:async-started and before agent_end, the parent can report done while the child still runs. Add a reconnect handshake that posts the retained live set, and cover this sequence in the harness.

@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 — one minor edge case inline.

Reviewed changes

Reviewed the delta since the prior Pullfrog review (f10acfa): the pi extension now posts its full live child set (subagent_async_state) instead of start/complete deltas, grok installs StopCancelled to clear children on an interrupted lead turn, and a 30-minute quiet-window reaper backstops every provider's lost-finish case.

  • Loss-tolerant pi transport — agent-status-async-subagent-source.ts keeps the live set in a module-scoped map and posts the whole set on every start/complete, so the transport's latest-only coalescer can drop an intermediate message without stranding a finished child; replaceAgentDescendants rebuilds the receiver's roster from each post.
  • Grok interrupt cleanup — StopCancelled is now installed; a lead-scoped cancel resets the pane's descendant scope, while a child-scoped cancel (own turn limit / declined permission) ends just that child.
  • Quiet-window reaper — agent-descendant-roster.ts adds lastEventAt and pruneStaleAgentDescendants (30 min), run lazily on the pane's next event, so a finish that never arrives can no longer pin the pane working for the life of the process.
  • paneHasStateClaims narrowed — descendantLeadStateByPaneKey is no longer a state claim on its own; a pane that only cached a lead verdict is not held open.

The new tests are real rather than theatre: the coalescer-race arm delivers starts one-at-a-time so the dropped-completion case actually fails against the old delta shape, and the reload, interrupt and reaper arms each pin their failure mode.

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

continue
}
live.add(normalizedId)
upsertAgentDescendant(roster, normalizedId, { ...child, state: 'working' }, now)

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.

At the 32-child cap, upsertAgentDescendant silently drops a new live child, and the eviction loop below then removes every tracked child not in the new set — so a subagent_async_state post that swaps the whole child set while full can empty the roster and settle the pane with live children, exactly the invariant this roster exists to protect. Evicting non-live entries before the upsert loop (or otherwise making room first) keeps the newest message authoritative at capacity.

Technical details
# replaceAgentDescendants evicts live children at the storage cap

## Affected sites
- src/shared/agent-descendant-roster.ts:103-115 — upserts happen before the `!live.has(id)` eviction loop; when `roster.size` is at `AGENT_STATUS_MAX_SUBAGENTS` (32), `upsertAgentDescendant` returns without adding the incoming child, then the loop deletes the previously-tracked children it just refused to replace.

## Required outcome
- At capacity, a full live-set swap must not empty the roster. The pane must keep showing `working` while any live child exists (up to the 32 cap).

## Suggested approach
- Compute the `live` set first, evict tracked ids not in it, then run the upsert loop (surviving children keep their `startedAt`/`model` via the existing merge, stale ones are freed first).

Clearing the descendant scope on a lead StopCancelled reset the roster but
published nothing: grok-events had no stop_cancelled case, so the normalizer
returned null and the pane kept its pre-cancel row — spinner running, children
listed — while main's roster was already empty.

Map a lead-scoped cancel to done with `interrupted`, the same shape claude's
interrupt path publishes. The row reads Interrupted and a notification says
"stopped" rather than "finished". This does lift the non-done veto that a stuck
`working` status was incidentally providing, so a completion notification can
now fire on cancel where it could not before; leaving the pane wrong until some
later turn ended is the worse trade, and the idle ping cannot be relied on to
settle it — grok cancels that ping as soon as the user sends the next message,
which is what interrupting to redirect does.

Also restore a child's ask_user_question wait. Routing child events away from
the lead normalizer dropped it, and a pane silently waiting on a human answer
is the worst state to hide; it surfaces even when the event names no child.

The regression test asserts the PUBLISHED payload. The previous one asserted
the roster, which is why it stayed green while the renderer went stale.

@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: 2db56432-02dd-4e87-924b-73233044b5f0

📥 Commits

Reviewing files that changed from the base of the PR and between 5b7103f and ae84a32.

📒 Files selected for processing (4)
  • src/shared/agent-hook-listener-descendant-lifecycle.test.ts
  • src/shared/agent-hook-listener/descendant-events.ts
  • src/shared/agent-hook-listener/descendant-pane-state.ts
  • src/shared/agent-hook-listener/providers/grok-events.ts

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

Comment on lines +66 to +69
const effectiveState =
facts.kind === 'child' && facts.waiting === true
? 'waiting'
: agentDescendantEffectiveState(state.descendantRosterByPaneKey.get(paneKey), leadState)

@coderabbitai coderabbitai Bot Sep 9, 2026 •

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.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Persist waiting state when the child ID is absent.

A child fact with waiting: true and no id skips the roster update at Line 42. This branch publishes waiting only once. A later lead Stop derives state from the unchanged roster and can hide the pending human-input request.

Store a pane-scoped anonymous waiting marker until a child resume, end, or scope-reset event clears it. Add a regression test that sends a no-ID child wait followed by a lead Stop.

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.

Concrete shape for the one substantive blocker, since everything else is green:

The one-shot publish is the gap. With facts.waiting === true and no facts.id, the roster is never updated, so waiting is published for that event and then forgotten — the next lead event (e.g. Stop → agentDescendantEffectiveState(undefined, 'done')) settles the pane and the pending human-input request disappears.

Minimal fix that fits the existing structures:

  • Add a pane-scoped marker, e.g. descendantAnonymousWaitByPaneKey: Map<string, number> on HookListenerState (timestamp, so the existing AGENT_DESCENDANT_QUIET_REAP_MS bound applies).
  • Set it on kind === 'child' && waiting && !facts.id; delete it on any later child fact that isn't a wait, and in clearDescendantScope.
  • Consult it in both applyDescendantEventToPane and gatePaneStateOnDescendants — including ahead of gatePaneStateOnDescendants' if (!roster) return payload early return, otherwise a lead done with an empty roster still settles the pane. Keep it out of the roster itself so it never renders as a child row in subagents.

Regression test: no-ID child wait → lead Stop → assert the published payload is still waiting; then a child resume or scope reset → assert it settles. Happy to send a small PR against this branch if that's useful.

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.

Use this command on a human-authored review finding. CodeRabbit findings already use the standard resolution workflow.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Status: ready

All three readiness gates cleared at ae84a327e7. CI 0 failed (3 jobs still running at time of writing).

Independent review — CLEAN WITH NITS

Two NOT CLEAN rounds before this. The findings were mechanism-level, not style:

  • A confirmed wedge. The Pi extension's post() is a latest-only single-slot coalescer. Every prior caller sent an idempotent snapshot; child add/remove were deltas, so a dropped removal was unrecoverable and pinned the pane working permanently. A probe with three children completing in one in-flight window produced 8 posts for 9 events and a permanently working pane. Fixed by carrying the full live child set, restoring the invariant the coalescer was built on. Re-verified by differential: same input ends working at the pre-fix head and done at this one, with the transport still dropping a post — so the race is genuinely exercised, not flushed away.
  • A non-exhaustive switch that contradicted the design claim. Replaced with one per-provider record answering both halves, so opting into descendants forces answering recovery, and a new provider is a compile error.
  • Extension-less imports failing only the CLI tsconfig, which reddened three CI jobs behind one root cause.

Manual validation — run in the real app, with limits

A real Grok pane in an isolated dev instance, driving the real installed hook script with the real pane env.

Validated: the pane held working across a child's SubagentStop for 25s and settled to done only on the parent's own Stop, with the nested child row visible in the sidebar and then gone. A child's SessionEnd — the exact event main mishandles — also failed to settle the pane.

Validation found a defect the review missed. A lead StopCancelled left the pane stuck working with a phantom child row, measured at +12s and +27s. The scope reset fired and the main-side roster was clean, but stop_cancelled had no case in the normalizer, so nothing republished and the renderer kept the pre-cancel payload. Two ablations pinned it. The existing tests passed because they asserted the roster, not the published payload — now corrected, with the new assertions reading what is published.

Behaviour change, stated plainly: a lead cancel now publishes done + interrupted, which lifts a notification veto that a stuck working was incidentally holding shut. A completion notification can now fire on cancel where it could not before. It is the "stopped" variant rather than "finished", and Claude panes already publish this shape, so this aligns Grok with existing behaviour rather than diverging.

Not validated: Grok model turns were impossible (account balance exhausted, 402), so a model-driven spawn_subagent run never ran; payload fidelity was established instead by extracting the serde field table from the shipped Grok binary. Whether the completion notification is suppressed is unproven — the dispatch is title/process-exit driven and the rig never armed it. Pi (STA-6378) is blocked: no pi binary and no pi-subagents on the validation host, and nothing was installed to manufacture one.

Known limits

The reaper is lazy, so a pane emitting nothing further keeps its last published row. A lead cancel also drops a background child from the pane — inferred from Grok's cancel RPC, not measured; if wrong, the failure is a missing claim rather than a false 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 new issues in this delta — the one previously-flagged edge case remains open.

Reviewed changes

Reviewed the delta since the prior Pullfrog review (5b7103f): the grok lead cancel now publishes an interrupted settle, and a child blocked on ask_user_question surfaces the pane's waiting state.

  • Grok interrupted settle — a lead-scoped StopCancelled now normalizes to done with interrupted: true (previously returned nothing, leaving the pane on the pre-cancel row), so the pane reads "Interrupted" and any completion notification reads "stopped" rather than "finished"; the interrupt regression test now asserts the published payload rather than the roster.
  • Child ask surfaces waiting — a grok child's ask_user_question PreToolUse carries waiting: true, so the pane shows waiting (and stays waiting while the lead's own turn ends underneath it) instead of silently working.
  • New coverage — a runtime-cancel (max_turns) case and a child-wait case were added alongside the user-interrupt case, each pinning its exact state sequence.

The new tests are real: each asserts the published payload's state/interrupted/subagents and would fail against the prior head. Verified 27/27 passing across the descendant lifecycle and roster suites.

The replaceAgentDescendants storage-cap edge case flagged in the prior review (open thread on src/shared/agent-descendant-roster.ts) is untouched by this delta and is the only item standing between this PR and approval.

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

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Correction to the status comment above

Two things in my previous status comment were wrong. Correcting them rather than editing, so the record shows what changed.

1. The validation evidence was gathered at a superseded commit. That comment described validation run at 5b7103f37c, while the head was already ae84a327e7 — the commit fixing the interrupt defect, which touched two of the files those arms exercise. Evidence from an earlier commit does not carry to a later one. Validation has been re-run in full at ae84a327e7; everything below is at that head.

2. "Whether the completion notification is suppressed is unproven" is no longer true. It is now measured. The earlier claim that the path was unobservable was wrong twice over: markAgentCompletionPaneUnread fires on every completion dispatch before the OS-suppression return, so the unread map is a faithful observable, and a real turn ending on a 402 still produces a genuine turn-end, so the path arms.

Measured, matched conditions, tab backgrounded, each turn confirmed to have actually run

condition pane state completion
no child live, real turn ends done raised
child live, same real turn ends working suppressed (held 20s+)
child then finishes done raised once, stable 30s

That is this PR's central claim measured rather than inferred: not while the parent is working, then exactly once when it settles.

Child lifecycle, re-verified at this head

A child's SessionEnd and StopFailure (both carrying subagentType) each held the pane working for 8s; the parent's own Stop settled it. With two children live, each SubagentStop drained its row and the pane stayed working with an empty roster for 12s before the lead Stop.

Negative control — identical payloads with subagentType removed and nothing else changed: both SessionEnd and StopFailure settle the pane to done. Same event names, opposite outcome, so the presence of subagentType is the only lever and the arm above is not passing incidentally.

Interrupt path — a lead StopCancelled now publishes done + interrupted with an empty roster, stable 13s. A child-scoped StopCancelled removed only that child; its sibling survived and the pane stayed working.

One behaviour confirmed by measurement, and accepted deliberately

A lead interrupt does raise a completion notification. I had asked that it not; that requirement was under-informed and I am withdrawing it. interrupted: true flows into notification-options.ts:63 — untouched by this PR — where agentState === 'done' && agentInterrupted selects "stopped" rather than "finished", so the notification reads "… - Grok stopped". Claude publishes the same flag into the same shared formatter, so this aligns Grok with existing behaviour. Making Grok silent on interrupt would change shared behaviour affecting Claude too, and belongs in its own decision rather than folded in here.

Still not validated

Grok's own event generation was never exercised — the account balance is exhausted (402 on both models), so no model-driven spawn_subagent run was possible. Payload field names were taken from the shipped Grok 1.0.24 binary's serde table rather than invented. Pi (STA-6378) remains blocked: no pi binary, no pi-subagents, nothing installed and nothing substituted. The before/after arm against main was skipped as not cheap, so "main settled early" remains code reading rather than measurement.

@I-am-Sage

Copy link
Copy Markdown

The descendant-roster rule here is the right one (a child event must not settle the pane or fire Agent Task Complete).

Please do not treat it as covering every Grok false complete. #19557 is Grok-in-Grok spawn_subagent (subagentType on the child payload). There is also Claude/Codex as lead, Grok as a nested process: full Grok SessionStart/Stop on the parent ORCA_PANE_KEY, CLAUDECODE=1, no Grok subagentType. Filtering descendant events in provider-dispatch will not see those.

Live on 1.4.198: that nested Grok Stop is what drives Agent Task Complete on the Claude pane. Dropping the managed Grok hook POST when CLAUDECODE / CLAUDE_JOB_DIR is set stops it. That skip has to live in the stock grok-hook.sh template (kr() / grok-hook-script.ts); refreshManagedScripts overwrites local edits.

Native Grok can still chime mid-run via Notification footer-text idle matching and spinner-less titles. Details + repro: #20109.

@nwparker

Copy link
Copy Markdown
Contributor

Independent OMP-source review (read-only /Users/nwparker/projects/oh-my-pi) found a production-blocking verification gap before merge:

  • Current OMP exposes the ExtensionAPI.events EventBus and native task:subagent:lifecycle events.
  • The generated Pi source in this PR listens on process.on('subagent:async-started'/'subagent:async-complete').
  • The installed pi-subagents v0.40.0 emits through ctx.pi.events.emit(...); its source has no matching process lifecycle bridge. The tests mock a synthetic process bus, so they do not prove the production listener receives events.

Please rework the integration against OMP's EventBus/native lifecycle contract or add a real installed-plugin bridge and exercise it in a Pi runtime. Until then, the Pi half is high merge risk even though the Grok validation is useful. #18032 has the same event-contract problem.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Closing this older combined implementation. Main already handles part of the child-work problem; the remaining cases where a parent looks finished while helpers run should be rebuilt on current main and remain tracked.

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.

4 participants