Repository navigation
feat(orchestration): report a worker blocked on a human prompt (STA-4513, STA-3714) - #15261
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review. 📝 WalkthroughWalkthroughThe runtime detects interactive waits from prompt text, live titles, hook status, lifecycle data, and Cursor approval menus. Terminal state and worker observations expose 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)✨ Finishing Touches 💡 1📝 Generate docstrings 💡
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 6aa730de-368b-49c3-bb33-d9c516b2593d
📒 Files selected for processing (13)
src/cli/handlers/orchestration.tssrc/cli/specs/orchestration-worker-specs.tssrc/cli/terminal-format.tssrc/main/runtime/__fixtures__/cursor-agent-approval-prompt.txtsrc/main/runtime/__fixtures__/cursor-agent-idle-after-approval.txtsrc/main/runtime/__fixtures__/cursor-agent-long-tool-call.txtsrc/main/runtime/orca-runtime.tssrc/main/runtime/rpc/methods/orchestration-federation-control.tssrc/main/runtime/rpc/methods/orchestration-worker-control.tssrc/main/runtime/rpc/methods/orchestration-worker-interactive-wait.test.tssrc/main/runtime/rpc/methods/orchestration-worker-observation.tssrc/main/runtime/terminal-interactive-wait-visibility.test.tssrc/shared/runtime-types.ts
Included review availability: Your plan includes up to 10 reviews per rolling hour; 2 remain after this review.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/main/runtime/terminal-interactive-wait-visibility.test.ts (1)
350-361: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueReplace the ticket-coded pane title with a descriptive value.
'sta4513-claude'encodes a ticket number in test data. A descriptive title keeps the fixture readable when the ticket is closed.♻️ Proposed rename
- paneTitle: 'sta4513-claude', + paneTitle: 'claude',Confirm that the wait detector does not require the title to differ from the process name before applying this change.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: cb562e88-8566-4d2d-85d9-7acbbc1e9120
📒 Files selected for processing (5)
src/main/runtime/orca-runtime.tssrc/main/runtime/rpc/methods/orchestration-federation-control.tssrc/main/runtime/rpc/methods/orchestration-worker-interactive-wait.test.tssrc/main/runtime/rpc/methods/orchestration-worker-observation.tssrc/main/runtime/terminal-interactive-wait-visibility.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
- src/main/runtime/orca-runtime.ts
- src/main/runtime/rpc/methods/orchestration-worker-interactive-wait.test.ts
- src/main/runtime/rpc/methods/orchestration-federation-control.ts
- src/main/runtime/rpc/methods/orchestration-worker-observation.ts
Included review availability: Your plan includes up to 10 reviews per rolling hour; 3 remain after this review.
…513, STA-3714) A lane parked on an approval, trust, or permission prompt looked exactly like a lane that was thinking or inside a long tool call. On origin/main, driving a real cursor-agent through Orca: surface running `sleep 60` awaiting approval worktree ps agents[].state working working terminal show / list no such field no such field terminal wait --for tui-idle satisfied: true satisfied: true worker-show no agent state no agent state The runtime already fuses hook state, OSC title, and matched prompt text into a `permission` verdict inside getTerminalAgentStatus — it was reachable only from the renderer, and it was blind to cursor-agent approvals. Two gaps, one boundary. Exposure: getTerminalInteractiveWait publishes that same fusion, minus the async foreground probe, as `agentWait` on `terminal show` and on `worker-show`'s observation. It carries the evidence that proved the wait (hook, prompt-text, or title) so a coordinator can weigh it. Null means no proof; a missing field means the host predates it — absence is never read as "not waiting". Detection: cursor-agent's hook set has no approval event and beforeShellExecution fires identically for auto-allowed commands, so its rendered menu is the only authority. Matched on the key-bound choices rather than the prose, requiring two, and self-clearing when the follow-up input line returns. Its live spinner title is exempted from the staleness rule that clears startup modals, because cursor keeps spinning while it waits. Falls out of routing it through the shared verdict: `dispatch --inject` into a cursor pane on an approval now refuses with agent_prompt_blocked instead of typing the preamble into the dialog. Fixtures are captured verbatim from cursor-agent 2026.08.11-e8db854 driven through Orca; the same case matrix was replayed live against a built runtime. terminal list stays untouched: its rows would each need a full tail scan, and STA-4694 owns the one-call-per-run aggregate.
…e screen Independent review found the approval detector trusted one dismissal string, so any later output that did not contain cursor's follow-up line left the menu reading as a live wait. Reproduced: a tail of the real menu followed by two lines of ordinary output returned agent-approval-prompt, which fails tui-idle and refuses prompt injection on a healthy lane. Replaced with the structural property the string was standing in for: a live dialog owns the bottom of the screen, so the last choice may sit at most one line above the end of the retained tail. That tolerates a status footer or a partial line mid-redraw without admitting scrollback, and it drops the vendor prose. Being bottom-of-screen is also the dating this reason needed, so it no longer requires waitBlockedAt. A tail restored from terminal history carries none, and a lane parked on a prompt emits no bytes — so before this, an Orca restart made exactly the lane both issues are about go quiet for good. The startup modals keep the timestamp rule: their text lingers in scrollback with nothing to say whether it was answered. Also from review: - worker-show and federationShow reuse the verdict showTerminal already computed rather than rescanning the tail, so the two can no longer disagree. - The worker-show test now drives a real runtime, real PTY tail, and the real detector; it previously mocked getTerminalInteractiveWait, so it would have passed with detection permanently returning null. - The guard claim is now asserted against the guard: a blocked pane rejects both assertTerminalAgentSendable and sendTerminalAgentPrompt, and a working pane still passes. - Added a non-local (connectionId) pane case, since the verdict is derived from retained tail and title state on every host.
A third reviewer caught that the hook branch proved agent ownership from the pane title alone, while the shared verdict it claimed to reuse also probes the foreground process. A shell that takes a pane back usually sets something like `user@host: ~/repo`, which no title rule recognizes, and a hook row stays fresh for AGENT_STATUS_STALE_AFTER_MS — so a dead agent could be reported as waiting on a human for half an hour. Hook evidence now goes through getTerminalAgentStatus, which is the only thing that can answer whether an agent still owns this PTY. The two prompt branches skip it: a matched prompt is on the pane's screen now, so it proves itself. That makes the probe cost fall exactly where correctness needs it, and getTerminalInteractiveWait async, which only showTerminal had to absorb. Also trims the comments the same reviewer flagged as longer than the repo's rule.
A fourth reviewer noted the approval menu sits at the bottom of a dead pane's tail forever, and that no test covered process exit with no trailing output. The snapshot already refuses an exited pane, and worker-show gates agentWait on proven identity — this pins both so neither can drift into reporting a worker that needs intervention as one that needs an answer.
d6ca1c7 to
eed18e4
Compare
Automated review caught that the three worker paths which return before the wait is ever evaluated — unattached, missing, and identity_changed — then had their undefined coerced to null by the emitters. A worker whose process was replaced was reported as `agentWait: null`, which reads as "Orca looked and nobody is waiting" when Orca never looked. That is the false negative this field exists to remove. The field is now emitted only when it was evaluated, so a present null is a claim about the pane and an absent one means nobody looked — because the host predates the field, or the worker's identity could not be verified. The CLI and the worker-show note say that rather than blaming an old host. Covered on the context-only path, where the regression test fails against the previous behavior; the supervised and federated emitters take the identical one-line change. Also trims the two test-file headers to one statement of purpose.
… unknowns Fourth review round, three findings, each reproduced before acting. Matching each choice marker with an independent lastIndexOf let text outside the menu carry the anchor. An agent narrating "next time I'll suggest Run Everything" after the menu was answered pulled the match down to the bottom of the screen and revived it. The match is now confined to the last lines of the tail, and a choice is a line that ends in the key that picks it — prose writes the same words but not the same shape. The one line of slack under the dialog went with it. It was a guess; every capture of a live dialog ends on its last choice, and one line is exactly enough room for that narration. A redraw caught mid-flight now reads as no wait until the next poll, which is the safe way to be wrong. The hook branch awaited a foreground probe that reaches a PTY controller which may be a remote host, so a wedged probe stalled every caller of showTerminal — a path that never probed before. It is bounded now, and a timeout leaves the wait unevaluated rather than claiming there is none. Which is the same distinction the previous commit only fixed one level up: getTerminalInteractiveWait itself turned an unreadable pane into `null`, so showTerminal published "looked, nobody waiting" for a pane it could not read. It returns undefined there, showTerminal omits the key, and worker-show's text output prints unknown rather than rendering it the same as none.
…ose keys Fifth review round. No correctness defects in the shipped behaviour this time; two robustness holes and the documentation of the contract. The bounded probe abandoned the wait but not the request, so a coordinator watching a wedged remote host added one live probe on every poll. It is single-flighted per PTY now, the way the leaf-absence probe already is. The trailing-key rule that separates a menu row from the agent narrating a choice was written as a character class, and any lowercase run up to twelve characters satisfied it — "…suggest Run Everything (as before)" passed. Spelled out as key names instead, which also lets the glyph forms of those keys through. The contract wording said an absent agentWait meant an old host or an unverifiable identity. It also covers an unreadable pane and a probe that did not answer, and a reader diagnosing an old peer from that would be wrong. Corrected on the type, the worker-show note, and in docs/reference/remote-wire-compatibility.md, which had no entry for a field whose absent and null states mean different things. Also strengthens the worker-show agreement test, which compared the terminal and observation payloads without asserting either held the expected wait, so it passed when both were absent.
…513, STA-3714) (stablyai#15261) * feat(orchestration): report a worker blocked on a human prompt (STA-4513, STA-3714) A lane parked on an approval, trust, or permission prompt looked exactly like a lane that was thinking or inside a long tool call. On origin/main, driving a real cursor-agent through Orca: surface running `sleep 60` awaiting approval worktree ps agents[].state working working terminal show / list no such field no such field terminal wait --for tui-idle satisfied: true satisfied: true worker-show no agent state no agent state The runtime already fuses hook state, OSC title, and matched prompt text into a `permission` verdict inside getTerminalAgentStatus — it was reachable only from the renderer, and it was blind to cursor-agent approvals. Two gaps, one boundary. Exposure: getTerminalInteractiveWait publishes that same fusion, minus the async foreground probe, as `agentWait` on `terminal show` and on `worker-show`'s observation. It carries the evidence that proved the wait (hook, prompt-text, or title) so a coordinator can weigh it. Null means no proof; a missing field means the host predates it — absence is never read as "not waiting". Detection: cursor-agent's hook set has no approval event and beforeShellExecution fires identically for auto-allowed commands, so its rendered menu is the only authority. Matched on the key-bound choices rather than the prose, requiring two, and self-clearing when the follow-up input line returns. Its live spinner title is exempted from the staleness rule that clears startup modals, because cursor keeps spinning while it waits. Falls out of routing it through the shared verdict: `dispatch --inject` into a cursor pane on an approval now refuses with agent_prompt_blocked instead of typing the preamble into the dialog. Fixtures are captured verbatim from cursor-agent 2026.08.11-e8db854 driven through Orca; the same case matrix was replayed live against a built runtime. terminal list stays untouched: its rows would each need a full tail scan, and STA-4694 owns the one-call-per-run aggregate. * fix(orchestration): only call a Cursor approval live while it owns the screen Independent review found the approval detector trusted one dismissal string, so any later output that did not contain cursor's follow-up line left the menu reading as a live wait. Reproduced: a tail of the real menu followed by two lines of ordinary output returned agent-approval-prompt, which fails tui-idle and refuses prompt injection on a healthy lane. Replaced with the structural property the string was standing in for: a live dialog owns the bottom of the screen, so the last choice may sit at most one line above the end of the retained tail. That tolerates a status footer or a partial line mid-redraw without admitting scrollback, and it drops the vendor prose. Being bottom-of-screen is also the dating this reason needed, so it no longer requires waitBlockedAt. A tail restored from terminal history carries none, and a lane parked on a prompt emits no bytes — so before this, an Orca restart made exactly the lane both issues are about go quiet for good. The startup modals keep the timestamp rule: their text lingers in scrollback with nothing to say whether it was answered. Also from review: - worker-show and federationShow reuse the verdict showTerminal already computed rather than rescanning the tail, so the two can no longer disagree. - The worker-show test now drives a real runtime, real PTY tail, and the real detector; it previously mocked getTerminalInteractiveWait, so it would have passed with detection permanently returning null. - The guard claim is now asserted against the guard: a blocked pane rejects both assertTerminalAgentSendable and sendTerminalAgentPrompt, and a working pane still passes. - Added a non-local (connectionId) pane case, since the verdict is derived from retained tail and title state on every host. * fix(agent-status): stop a hook wait from outliving its agent A third reviewer caught that the hook branch proved agent ownership from the pane title alone, while the shared verdict it claimed to reuse also probes the foreground process. A shell that takes a pane back usually sets something like `user@host: ~/repo`, which no title rule recognizes, and a hook row stays fresh for AGENT_STATUS_STALE_AFTER_MS — so a dead agent could be reported as waiting on a human for half an hour. Hook evidence now goes through getTerminalAgentStatus, which is the only thing that can answer whether an agent still owns this PTY. The two prompt branches skip it: a matched prompt is on the pane's screen now, so it proves itself. That makes the probe cost fall exactly where correctness needs it, and getTerminalInteractiveWait async, which only showTerminal had to absorb. Also trims the comments the same reviewer flagged as longer than the repo's rule. * test(agent-status): pin that a dead pane stops reporting a human wait A fourth reviewer noted the approval menu sits at the bottom of a dead pane's tail forever, and that no test covered process exit with no trailing output. The snapshot already refuses an exited pane, and worker-show gates agentWait on proven identity — this pins both so neither can drift into reporting a worker that needs intervention as one that needs an answer. * fix(orchestration): never report an unchecked worker as not waiting Automated review caught that the three worker paths which return before the wait is ever evaluated — unattached, missing, and identity_changed — then had their undefined coerced to null by the emitters. A worker whose process was replaced was reported as `agentWait: null`, which reads as "Orca looked and nobody is waiting" when Orca never looked. That is the false negative this field exists to remove. The field is now emitted only when it was evaluated, so a present null is a claim about the pane and an absent one means nobody looked — because the host predates the field, or the worker's identity could not be verified. The CLI and the worker-show note say that rather than blaming an old host. Covered on the context-only path, where the regression test fails against the previous behavior; the supervised and federated emitters take the identical one-line change. Also trims the two test-file headers to one statement of purpose. * fix(agent-status): tighten the Cursor menu match and stop guessing on unknowns Fourth review round, three findings, each reproduced before acting. Matching each choice marker with an independent lastIndexOf let text outside the menu carry the anchor. An agent narrating "next time I'll suggest Run Everything" after the menu was answered pulled the match down to the bottom of the screen and revived it. The match is now confined to the last lines of the tail, and a choice is a line that ends in the key that picks it — prose writes the same words but not the same shape. The one line of slack under the dialog went with it. It was a guess; every capture of a live dialog ends on its last choice, and one line is exactly enough room for that narration. A redraw caught mid-flight now reads as no wait until the next poll, which is the safe way to be wrong. The hook branch awaited a foreground probe that reaches a PTY controller which may be a remote host, so a wedged probe stalled every caller of showTerminal — a path that never probed before. It is bounded now, and a timeout leaves the wait unevaluated rather than claiming there is none. Which is the same distinction the previous commit only fixed one level up: getTerminalInteractiveWait itself turned an unreadable pane into `null`, so showTerminal published "looked, nobody waiting" for a pane it could not read. It returns undefined there, showTerminal omits the key, and worker-show's text output prints unknown rather than rendering it the same as none. * fix(agent-status): bound the wedged probe's cost and stop matching prose keys Fifth review round. No correctness defects in the shipped behaviour this time; two robustness holes and the documentation of the contract. The bounded probe abandoned the wait but not the request, so a coordinator watching a wedged remote host added one live probe on every poll. It is single-flighted per PTY now, the way the leaf-absence probe already is. The trailing-key rule that separates a menu row from the agent narrating a choice was written as a character class, and any lowercase run up to twelve characters satisfied it — "…suggest Run Everything (as before)" passed. Spelled out as key names instead, which also lets the glyph forms of those keys through. The contract wording said an absent agentWait meant an old host or an unverifiable identity. It also covers an unreadable pane and a probe that did not answer, and a reader diagnosing an old peer from that would be wrong. Corrected on the type, the worker-show note, and in docs/reference/remote-wire-compatibility.md, which had no entry for a field whose absent and null states mean different things. Also strengthens the worker-show agreement test, which compared the terminal and observation payloads without asserting either held the expected wait, so it passed when both were absent.
…513, STA-3714) (stablyai#15261) * feat(orchestration): report a worker blocked on a human prompt (STA-4513, STA-3714) A lane parked on an approval, trust, or permission prompt looked exactly like a lane that was thinking or inside a long tool call. On origin/main, driving a real cursor-agent through Orca: surface running `sleep 60` awaiting approval worktree ps agents[].state working working terminal show / list no such field no such field terminal wait --for tui-idle satisfied: true satisfied: true worker-show no agent state no agent state The runtime already fuses hook state, OSC title, and matched prompt text into a `permission` verdict inside getTerminalAgentStatus — it was reachable only from the renderer, and it was blind to cursor-agent approvals. Two gaps, one boundary. Exposure: getTerminalInteractiveWait publishes that same fusion, minus the async foreground probe, as `agentWait` on `terminal show` and on `worker-show`'s observation. It carries the evidence that proved the wait (hook, prompt-text, or title) so a coordinator can weigh it. Null means no proof; a missing field means the host predates it — absence is never read as "not waiting". Detection: cursor-agent's hook set has no approval event and beforeShellExecution fires identically for auto-allowed commands, so its rendered menu is the only authority. Matched on the key-bound choices rather than the prose, requiring two, and self-clearing when the follow-up input line returns. Its live spinner title is exempted from the staleness rule that clears startup modals, because cursor keeps spinning while it waits. Falls out of routing it through the shared verdict: `dispatch --inject` into a cursor pane on an approval now refuses with agent_prompt_blocked instead of typing the preamble into the dialog. Fixtures are captured verbatim from cursor-agent 2026.08.11-e8db854 driven through Orca; the same case matrix was replayed live against a built runtime. terminal list stays untouched: its rows would each need a full tail scan, and STA-4694 owns the one-call-per-run aggregate. * fix(orchestration): only call a Cursor approval live while it owns the screen Independent review found the approval detector trusted one dismissal string, so any later output that did not contain cursor's follow-up line left the menu reading as a live wait. Reproduced: a tail of the real menu followed by two lines of ordinary output returned agent-approval-prompt, which fails tui-idle and refuses prompt injection on a healthy lane. Replaced with the structural property the string was standing in for: a live dialog owns the bottom of the screen, so the last choice may sit at most one line above the end of the retained tail. That tolerates a status footer or a partial line mid-redraw without admitting scrollback, and it drops the vendor prose. Being bottom-of-screen is also the dating this reason needed, so it no longer requires waitBlockedAt. A tail restored from terminal history carries none, and a lane parked on a prompt emits no bytes — so before this, an Orca restart made exactly the lane both issues are about go quiet for good. The startup modals keep the timestamp rule: their text lingers in scrollback with nothing to say whether it was answered. Also from review: - worker-show and federationShow reuse the verdict showTerminal already computed rather than rescanning the tail, so the two can no longer disagree. - The worker-show test now drives a real runtime, real PTY tail, and the real detector; it previously mocked getTerminalInteractiveWait, so it would have passed with detection permanently returning null. - The guard claim is now asserted against the guard: a blocked pane rejects both assertTerminalAgentSendable and sendTerminalAgentPrompt, and a working pane still passes. - Added a non-local (connectionId) pane case, since the verdict is derived from retained tail and title state on every host. * fix(agent-status): stop a hook wait from outliving its agent A third reviewer caught that the hook branch proved agent ownership from the pane title alone, while the shared verdict it claimed to reuse also probes the foreground process. A shell that takes a pane back usually sets something like `user@host: ~/repo`, which no title rule recognizes, and a hook row stays fresh for AGENT_STATUS_STALE_AFTER_MS — so a dead agent could be reported as waiting on a human for half an hour. Hook evidence now goes through getTerminalAgentStatus, which is the only thing that can answer whether an agent still owns this PTY. The two prompt branches skip it: a matched prompt is on the pane's screen now, so it proves itself. That makes the probe cost fall exactly where correctness needs it, and getTerminalInteractiveWait async, which only showTerminal had to absorb. Also trims the comments the same reviewer flagged as longer than the repo's rule. * test(agent-status): pin that a dead pane stops reporting a human wait A fourth reviewer noted the approval menu sits at the bottom of a dead pane's tail forever, and that no test covered process exit with no trailing output. The snapshot already refuses an exited pane, and worker-show gates agentWait on proven identity — this pins both so neither can drift into reporting a worker that needs intervention as one that needs an answer. * fix(orchestration): never report an unchecked worker as not waiting Automated review caught that the three worker paths which return before the wait is ever evaluated — unattached, missing, and identity_changed — then had their undefined coerced to null by the emitters. A worker whose process was replaced was reported as `agentWait: null`, which reads as "Orca looked and nobody is waiting" when Orca never looked. That is the false negative this field exists to remove. The field is now emitted only when it was evaluated, so a present null is a claim about the pane and an absent one means nobody looked — because the host predates the field, or the worker's identity could not be verified. The CLI and the worker-show note say that rather than blaming an old host. Covered on the context-only path, where the regression test fails against the previous behavior; the supervised and federated emitters take the identical one-line change. Also trims the two test-file headers to one statement of purpose. * fix(agent-status): tighten the Cursor menu match and stop guessing on unknowns Fourth review round, three findings, each reproduced before acting. Matching each choice marker with an independent lastIndexOf let text outside the menu carry the anchor. An agent narrating "next time I'll suggest Run Everything" after the menu was answered pulled the match down to the bottom of the screen and revived it. The match is now confined to the last lines of the tail, and a choice is a line that ends in the key that picks it — prose writes the same words but not the same shape. The one line of slack under the dialog went with it. It was a guess; every capture of a live dialog ends on its last choice, and one line is exactly enough room for that narration. A redraw caught mid-flight now reads as no wait until the next poll, which is the safe way to be wrong. The hook branch awaited a foreground probe that reaches a PTY controller which may be a remote host, so a wedged probe stalled every caller of showTerminal — a path that never probed before. It is bounded now, and a timeout leaves the wait unevaluated rather than claiming there is none. Which is the same distinction the previous commit only fixed one level up: getTerminalInteractiveWait itself turned an unreadable pane into `null`, so showTerminal published "looked, nobody waiting" for a pane it could not read. It returns undefined there, showTerminal omits the key, and worker-show's text output prints unknown rather than rendering it the same as none. * fix(agent-status): bound the wedged probe's cost and stop matching prose keys Fifth review round. No correctness defects in the shipped behaviour this time; two robustness holes and the documentation of the contract. The bounded probe abandoned the wait but not the request, so a coordinator watching a wedged remote host added one live probe on every poll. It is single-flighted per PTY now, the way the leaf-absence probe already is. The trailing-key rule that separates a menu row from the agent narrating a choice was written as a character class, and any lowercase run up to twelve characters satisfied it — "…suggest Run Everything (as before)" passed. Spelled out as key names instead, which also lets the glyph forms of those keys through. The contract wording said an absent agentWait meant an old host or an unverifiable identity. It also covers an unreadable pane and a probe that did not answer, and a reader diagnosing an old peer from that would be wrong. Corrected on the type, the worker-show note, and in docs/reference/remote-wire-compatibility.md, which had no entry for a field whose absent and null states mean different things. Also strengthens the worker-show agreement test, which compared the terminal and observation payloads without asserting either held the expected wait, so it passed when both were absent.
…513, STA-3714) (stablyai#15261) * feat(orchestration): report a worker blocked on a human prompt (STA-4513, STA-3714) A lane parked on an approval, trust, or permission prompt looked exactly like a lane that was thinking or inside a long tool call. On origin/main, driving a real cursor-agent through Orca: surface running `sleep 60` awaiting approval worktree ps agents[].state working working terminal show / list no such field no such field terminal wait --for tui-idle satisfied: true satisfied: true worker-show no agent state no agent state The runtime already fuses hook state, OSC title, and matched prompt text into a `permission` verdict inside getTerminalAgentStatus — it was reachable only from the renderer, and it was blind to cursor-agent approvals. Two gaps, one boundary. Exposure: getTerminalInteractiveWait publishes that same fusion, minus the async foreground probe, as `agentWait` on `terminal show` and on `worker-show`'s observation. It carries the evidence that proved the wait (hook, prompt-text, or title) so a coordinator can weigh it. Null means no proof; a missing field means the host predates it — absence is never read as "not waiting". Detection: cursor-agent's hook set has no approval event and beforeShellExecution fires identically for auto-allowed commands, so its rendered menu is the only authority. Matched on the key-bound choices rather than the prose, requiring two, and self-clearing when the follow-up input line returns. Its live spinner title is exempted from the staleness rule that clears startup modals, because cursor keeps spinning while it waits. Falls out of routing it through the shared verdict: `dispatch --inject` into a cursor pane on an approval now refuses with agent_prompt_blocked instead of typing the preamble into the dialog. Fixtures are captured verbatim from cursor-agent 2026.08.11-e8db854 driven through Orca; the same case matrix was replayed live against a built runtime. terminal list stays untouched: its rows would each need a full tail scan, and STA-4694 owns the one-call-per-run aggregate. * fix(orchestration): only call a Cursor approval live while it owns the screen Independent review found the approval detector trusted one dismissal string, so any later output that did not contain cursor's follow-up line left the menu reading as a live wait. Reproduced: a tail of the real menu followed by two lines of ordinary output returned agent-approval-prompt, which fails tui-idle and refuses prompt injection on a healthy lane. Replaced with the structural property the string was standing in for: a live dialog owns the bottom of the screen, so the last choice may sit at most one line above the end of the retained tail. That tolerates a status footer or a partial line mid-redraw without admitting scrollback, and it drops the vendor prose. Being bottom-of-screen is also the dating this reason needed, so it no longer requires waitBlockedAt. A tail restored from terminal history carries none, and a lane parked on a prompt emits no bytes — so before this, an Orca restart made exactly the lane both issues are about go quiet for good. The startup modals keep the timestamp rule: their text lingers in scrollback with nothing to say whether it was answered. Also from review: - worker-show and federationShow reuse the verdict showTerminal already computed rather than rescanning the tail, so the two can no longer disagree. - The worker-show test now drives a real runtime, real PTY tail, and the real detector; it previously mocked getTerminalInteractiveWait, so it would have passed with detection permanently returning null. - The guard claim is now asserted against the guard: a blocked pane rejects both assertTerminalAgentSendable and sendTerminalAgentPrompt, and a working pane still passes. - Added a non-local (connectionId) pane case, since the verdict is derived from retained tail and title state on every host. * fix(agent-status): stop a hook wait from outliving its agent A third reviewer caught that the hook branch proved agent ownership from the pane title alone, while the shared verdict it claimed to reuse also probes the foreground process. A shell that takes a pane back usually sets something like `user@host: ~/repo`, which no title rule recognizes, and a hook row stays fresh for AGENT_STATUS_STALE_AFTER_MS — so a dead agent could be reported as waiting on a human for half an hour. Hook evidence now goes through getTerminalAgentStatus, which is the only thing that can answer whether an agent still owns this PTY. The two prompt branches skip it: a matched prompt is on the pane's screen now, so it proves itself. That makes the probe cost fall exactly where correctness needs it, and getTerminalInteractiveWait async, which only showTerminal had to absorb. Also trims the comments the same reviewer flagged as longer than the repo's rule. * test(agent-status): pin that a dead pane stops reporting a human wait A fourth reviewer noted the approval menu sits at the bottom of a dead pane's tail forever, and that no test covered process exit with no trailing output. The snapshot already refuses an exited pane, and worker-show gates agentWait on proven identity — this pins both so neither can drift into reporting a worker that needs intervention as one that needs an answer. * fix(orchestration): never report an unchecked worker as not waiting Automated review caught that the three worker paths which return before the wait is ever evaluated — unattached, missing, and identity_changed — then had their undefined coerced to null by the emitters. A worker whose process was replaced was reported as `agentWait: null`, which reads as "Orca looked and nobody is waiting" when Orca never looked. That is the false negative this field exists to remove. The field is now emitted only when it was evaluated, so a present null is a claim about the pane and an absent one means nobody looked — because the host predates the field, or the worker's identity could not be verified. The CLI and the worker-show note say that rather than blaming an old host. Covered on the context-only path, where the regression test fails against the previous behavior; the supervised and federated emitters take the identical one-line change. Also trims the two test-file headers to one statement of purpose. * fix(agent-status): tighten the Cursor menu match and stop guessing on unknowns Fourth review round, three findings, each reproduced before acting. Matching each choice marker with an independent lastIndexOf let text outside the menu carry the anchor. An agent narrating "next time I'll suggest Run Everything" after the menu was answered pulled the match down to the bottom of the screen and revived it. The match is now confined to the last lines of the tail, and a choice is a line that ends in the key that picks it — prose writes the same words but not the same shape. The one line of slack under the dialog went with it. It was a guess; every capture of a live dialog ends on its last choice, and one line is exactly enough room for that narration. A redraw caught mid-flight now reads as no wait until the next poll, which is the safe way to be wrong. The hook branch awaited a foreground probe that reaches a PTY controller which may be a remote host, so a wedged probe stalled every caller of showTerminal — a path that never probed before. It is bounded now, and a timeout leaves the wait unevaluated rather than claiming there is none. Which is the same distinction the previous commit only fixed one level up: getTerminalInteractiveWait itself turned an unreadable pane into `null`, so showTerminal published "looked, nobody waiting" for a pane it could not read. It returns undefined there, showTerminal omits the key, and worker-show's text output prints unknown rather than rendering it the same as none. * fix(agent-status): bound the wedged probe's cost and stop matching prose keys Fifth review round. No correctness defects in the shipped behaviour this time; two robustness holes and the documentation of the contract. The bounded probe abandoned the wait but not the request, so a coordinator watching a wedged remote host added one live probe on every poll. It is single-flighted per PTY now, the way the leaf-absence probe already is. The trailing-key rule that separates a menu row from the agent narrating a choice was written as a character class, and any lowercase run up to twelve characters satisfied it — "…suggest Run Everything (as before)" passed. Spelled out as key names instead, which also lets the glyph forms of those keys through. The contract wording said an absent agentWait meant an old host or an unverifiable identity. It also covers an unreadable pane and a probe that did not answer, and a reader diagnosing an old peer from that would be wrong. Corrected on the type, the worker-show note, and in docs/reference/remote-wire-compatibility.md, which had no entry for a field whose absent and null states mean different things. Also strengthens the worker-show agreement test, which compared the terminal and observation payloads without asserting either held the expected wait, so it passed when both were absent.

Closes #14854 (STA-4513) and #13209 (STA-3714).
What was wrong
A worker parked on an interactive prompt was indistinguishable from one that was thinking or inside a long tool call. Measured on
origin/main@ 640e8a4, driving a realcursor-agent 2026.08.11-e8db854through Orca on macOS:sleep 60worktree psagents[].stateworkingworkingworktree pstoolName/toolInputShell/sleep 60 && echo doneShell/curl -s … | head -3terminal show/terminal listterminal show.title⠋ Cursor Agent⠇ Cursor Agentterminal wait --for tui-idlesatisfied: truesatisfied: trueorchestration worker-showorchestration checkByte-identical, and
tui-idleactively reports the blocked lane as ready.For a hook-capable agent the picture differs: a Claude pane on a permission prompt does report
state: waitingonworktree ps. But that is worktree-scoped — a coordinator holding a dispatch id has no per-lane call that surfaces it, which is exactly what STA-3714 asked for.Root cause, and why it is one fix
getTerminalAgentStatusalready fuses hook state, OSC title, and matched prompt text into apermissionverdict — the exact "blocked on a human" answer both issues want. Two things kept it from a coordinator:RuntimeTerminalSummaryhas no field for it, soterminal show,terminal list, andworker-show'sterminalall drop it.cursor-agentapproval menus, so for Cursor there was no verdict to expose in the first place.Same boundary, so one change rather than two overlapping ones.
Exposure
OrcaRuntimeService.getTerminalInteractiveWait(handle)publishes that same fusion minus the async foreground probe, and it lands asagentWaiton:terminal show(--json, and a text line above the preview)orchestration worker-show→observation.agentWait, plus aWaiting on a human: …line in text outputfederationShow), so a remote lane reports the same thingsourcenames the evidence —hook,prompt-text, ortitle— so a coordinator can weigh a hook-reported wait differently from a text match. Three properties are deliberate:nullmeans no proof of a wait; a missing field means the host predates it. Absence is never read as "not waiting" (the CLI printsunknown (host predates the field)).Detection
cursor-agent's hook set — enumerated from the shipped binary — isbeforeSubmitPrompt,sessionStart,preToolUse,postToolUse,postToolUseFailure,beforeShellExecution,beforeMCPExecution,afterAgentResponse,stop,sessionEnd. There is no approval event, andbeforeShellExecutionfires identically for auto-allowed commands, which is whynormalizeCursorEventmaps it toworking. The rendered menu is the only authority left, so this is the narrow case where text is the boundary rather than a shortcut past one.It matches the menu's key-bound choices rather than its prose (
run (once),to allowlist?,run everything,skip & tell the agent), requires at least two of them, and clears itself when cursor's follow-up input line reappears after the menu. Cursor's live spinner title is exempted from the staleness rule that clears startup modals, because cursor keeps spinning while it waits — for the startup modals that rule is untouched and pinned by a test.Falls out of it
Routing through the shared verdict means
orchestration dispatch --injectinto a cursor pane on an approval now refuses withagent_prompt_blockedinstead of typing the coordinator's preamble into the dialog — the STA-2631 / #10666 shape, observed live on the built runtime.Evidence
Deterministic —
src/main/runtime/terminal-interactive-wait-visibility.test.ts, 21 cases over fixtures captured verbatim from a real cursor-agent driven through Orca: approval pending, the same agent inside a long tool call, idle after answering, an answered menu in scrollback, a menu followed by any later output, a footer under a live dialog, a partial menu with no decision keys, a non-local (connectionId) pane, a startup trust screen, a live title clearing a stale startup prompt, a dialog restored from terminal history, a startup modal restored from history, a hook-reportedwaiting, a hook-reportedworking, a retained permission row after a shell takes the pane, an exited pane, and the guard refusing bothassertTerminalAgentSendableandsendTerminalAgentPromptwhile allowing a working lane through.Same oracle, fix reverted — all 14 new assertions fail. The substantive one:
Live, against a built runtime (isolated
serveprofile, realcursor-agent, headless — so this exercises the PTY-record branch the unit tests do not):After review: replayed through real PTYs on a rebuilt runtime, the case matrix reads
approval → agent-approval-prompt,long tool call → none,answered menu still in scrollback → none(a false positive before695beae71b).pnpm lint,pnpm run typecheck, the changed-code quality and React Doctor gates, and thesrc/main/runtime,src/shared, andsrc/clisuites are green (10,542 tests). CI is green apart from thetypecheckjob, which fails onsrc/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx:51— a file this branch does not touch, failing identically on unrelated PR #15259.Independent review (round 1)
Two fresh
gpt-5.6-lunaCodex reviewers ran against the first commit with different lenses. Their confirmed findings are fixed in695beae71b; each was reproduced before acting on it and one was declined.Fixed — stale approval menus read as live waits (correctness). The detector trusted a single dismissal string, so any later output that did not contain cursor's follow-up line left an answered menu reading as a live wait. Reproduced over a real PTY: the real menu plus two lines of ordinary output returned
agent-approval-prompt, which failstui-idleand refuses prompt injection on a healthy lane. Replaced with the structural property the string was standing in for — a live dialog owns the bottom of the screen, so the last choice may sit at most one line above the end of the tail. That tolerates a footer or a mid-redraw partial line, drops the vendor prose, and also subsumes a second reviewer finding about the follow-up heuristic misfiring.Fixed — a restored lane went quiet for good. Being bottom-of-screen is also the dating this reason needed, so it no longer requires
waitBlockedAt. A tail restored from terminal history carries none, and a lane parked on a prompt emits no bytes — so an Orca restart silenced exactly the lane both issues are about. The startup modals keep the timestamp rule: their text lingers in scrollback with nothing to say whether it was answered. Both behaviors are now pinned by tests.Fixed — the tests did not test the shipping code. The
worker-showtest mockedgetTerminalInteractiveWait, so it would have passed with detection permanently returning null. It now drives a real runtime, a real PTY tail, and the real detector, and asserts thatobservation.agentWaitequals the terminal payload it is derived from. The guard claim is likewise asserted against the guard: a blocked pane now rejects bothassertTerminalAgentSendableandsendTerminalAgentPrompt, and a working pane still passes.Fixed — duplicate tail scans.
worker-showandfederationShowreuse the verdictshowTerminalalready computed instead of rescanning (measured at ~0.11 ms per scan over a full 256 KiB tail), so the two can no longer disagree either.Declined. A reviewer read the missing SSH/WSL/Windows coverage as a gap; the verdict is synchronous and derived from retained tail and title state with no shell, path, or subprocess work, so a non-local pane case was added rather than a platform matrix. The same reviewer confirmed no old-peer, old-client, or platform break, and walked every
blockedReasonconsumer.Independent review (round 2)
Two more
gpt-5.6-lunareviewers looked at the design and at whether the issues are even still valid. Both independently confirmed the premise onorigin/mainand that neither issue should be closed as stale, with the same nuance:worktree ps --jsonalready carries hook-derivedagents[].state, so STA-3714's "zero signal" wording is too broad — but the dispatch-scoped surface it actually asks for has nothing, andworktree ps's own text formatter hides those rows.Fixed — a hook wait could outlive its agent (correctness). The hook branch proved agent ownership from the pane title alone, while
getTerminalAgentStatus, the verdict it claimed to reuse, also probes the foreground process. A shell that takes a pane back usually sets something likeuser@host: ~/repo, which no title rule recognizes, and a hook row stays fresh forAGENT_STATUS_STALE_AFTER_MS— half an hour of reporting a dead agent as waiting on a human. Hook evidence now goes through that shared verdict; the two prompt branches skip it because a matched prompt is on the pane's screen now and proves itself. The regression test fails against the previous commit.Fixed — no coverage for a dead pane. The menu sits at the bottom of an exited pane's tail forever, and a worker whose process is gone needs intervention, not an answer. The snapshot already refused an exited pane and
worker-showalready gates on proven identity; both are now pinned.Fixed — comments over the repo's concise-comment rule. Trimmed.
Corrected, not declined — my own claim about STA-4694. The first version of this description said STA-4694 owns the aggregate. Reading it, that is wrong: it lists "not PTY-output inference as a fallback" under Not asked for, so it covers only the hook-derived half and deliberately excludes exactly the Cursor evidence this PR adds. Nobody owned "one call, every lane, whatever the evidence", so STA-4740 now does, related to STA-4694. The two reviewers disagreed here and the ticket text settled it.
Noted, not fixed. The hook branch's probe means a fleet of per-lane
worker-showcalls costs one foreground read each — that is the price of not lying for half an hour, and it is why the prompt branches avoid it. And an agent that exits without its shell emitting a single byte would keep a menu at the bottom of a live PTY; shells print a prompt, so this stayed a note rather than a heuristic.Independent review (rounds 4 and 5)
Round 4 found three P1s, all mine. The Cursor menu matcher used an independent
lastIndexOfper choice marker, so text outside the menu could carry the anchor — the agent narrating "next time I'll suggest Run Everything" after a menu was answered revived it. The match is now confined to the last lines of the tail, and a choice is a line ending in the key that picks it. The one line of slack under the dialog went with it: it was a guess, every capture of a live dialog ends on its last choice, and one line is exactly enough room for that narration. Routing hook evidence throughgetTerminalAgentStatushad also put an unbounded foreground probe onshowTerminal, a path that never probed before — a wedged remote host stalled every caller. It is bounded now, and a timeout leaves the wait unevaluated. And the null-vs-unknown defect above existed one level deeper:getTerminalInteractiveWaititself turned an unreadable pane intonull, soshowTerminalpublished "looked, nobody waiting" for a pane it could not read.Round 5 found no correctness defects in shipped behaviour. Two robustness holes: the bounded probe abandoned the wait but not the request, so a wedged host accrued one live probe per poll (single-flighted per PTY now, matching the existing leaf-absence probe); and the trailing-key rule was written as a character class, so any lowercase run satisfied it and
(as before)passed — spelled out as key names, which also admits the glyph forms. The contract wording was corrected everywhere it appeared: an absentagentWaitalso covers an unreadable pane and a probe that did not answer, not just an old host, anddocs/reference/remote-wire-compatibility.mdnow carries an entry for a field whose absent and null states mean different things.Two round-4 findings were rejected as stale diffs —
origin/mainmoved mid-review, sogit diff origin/main..HEADshowed main's own commits inverted.Scope left out, on purpose
terminal listandterminal readkeep noagentWait. Each row would need its own full-tail scan, which is real cost on a list call; the per-lane surfaces are where a coordinator asks about one worker. The aggregate is STA-4740, filed off this review because STA-4694 covers only the hook-derived half.blocked/waiting:gemini,amp, andcommand-codeall map every hook event toworkingordone, exactly likecursordid. Gemini'sAllow execution/Do you want to proceed?dialogs match no existing rule either. Every rule in this PR was validated against bytes captured from a real agent driven through Orca and replayed through a real PTY; of those three only Gemini is installable here, so adding unvalidated rules would be the brittleness this PR is trying to avoid. Filed as STA-4778.codex-prefix on the existing blocked reasons is a misnomer — those prompts are matched by shape and already fire for Claude and Cursor (a Claude trust screen reportscodex-trust-workspace). Renaming them would break paired hosts mid-skew, so the new reason is vendor-neutral (agent-approval-prompt) and the union carries a comment explaining the legacy names.Wire compatibility
Rule 1 throughout: new optional fields on existing RPC results, and a new member on an already-optional
blockedReasonstring. No consumer switches exhaustively on it. A new client against an old host sees the field absent and reports it as unknown rather than as "not waiting".