Repository navigation
fix(agent-status): retire a pane's rows only after its agent's exit is verified, and tell every reader - #24322
Closed
brennanb2025 wants to merge 21 commits into
Closed
brennanb2025 wants to merge 21 commits into
brennanb2025 wants to merge 21 commits into
Conversation
…s verified, and tell every reader A command end (OSC 133;D) in a pane holding launch authority deleted the pane's host status rows without a pane clear, so the desktop window, dashboard popout, tab-title spinner and session stats kept a row worktree ps and mobile had dropped; and it did so on a mark a full-screen agent's nested shell can leak while the agent still runs. Launch authority still ends at the mark (the token lives on in the shell's env): the runtime drops its token/receipt/launch agent and the hook server revokes commitments, forgets token observations and strips the token from kept rows. Ingest derives whether an event's token is still honoured from the runtime's live launch token for the pane or a surviving commitment. Rows, the pane fence and the reader clear wait for a verified exit (foreground shell, else the hook's own pid); unverifiable keeps the rows. retirePaneAuthority now emits the pane clear like every other host removal, and keeps the resume remnant on a command end. The renderer no longer ignores a host clear for a Done row.
…nt-emits-pane-clear
8 of 9 tasks
… ended-process clear A command end (OSC 133;D) only ended a pane's rows when that pane still held launch authority, and the first command end consumed that authority. A leaked 133;D under a live full-screen agent therefore spent the one check, and the agent's real exit later left a Done row in the sidebar, dashboard, worktree ps and mobile. A shell's name in the foreground also outranked a live agent pid, and the verdict fenced the pane, so a wrong verdict hid a live agent. Now every command end of a PTY whose panes hold a live host row asks again, whatever launched the agent: - the hook's own agent pid still running keeps the row; - the spawned shell owning the foreground with no stopped job clears it through the store's ended-process clear, keeping the resume identity and fencing nothing; - anything else keeps the row until the next command end asks again. The clear skips a row that changed while the check ran, a command end that lands mid-check is checked too, and both the byte path and the daemon path use the same check. Full retirement stays for pane close and PTY exit. Launch-token liveness is now derived where authority becomes durable: at every row write (hook ingest, relay, replay, transcript poll, assistant retry), when an authority observation is recorded, and when the status file is persisted. The runtime answers from the PTY record whether or not it is connected. Command-end revocation no longer rewrites live rows, so a live Codex transcript poll keeps running.
… Done list Once the host's pane clear started reaching the renderer for Done rows, a Done agent that quit to the shell became a sticky "Done" in the sidebar and dashboard (and unread), while worktree ps and mobile showed nothing: the clear removed the live row without the retention suppressor that the renderer's own drop plants, and the renderer's later drop found no row. The host clears a pane only when its agent ended or the pane went away, so the clear listener now removes the row with agentGone: it plants the retention suppressor and drops a retained snapshot already taken for that pane. Nothing is echoed back to main, which already removed its row.
…nt wiring The suite dropped the retirement options and never wired the launch-authority reader, so it stayed green even with the reader wiring deleted. It now wires the hook server to the runtime the way main-process-runtime-service.ts does.
…xited An SSH provider has no shell-owns-the-foreground check, so the command-end verifier always kept an SSH pane's row. A headless host (orca serve, orcad, orchestration workers) then kept an exited agent's Done row for the life of the terminal, where it used to be deleted at the command end. The verifier now reads the relay's fenced foreground evidence, admitted the way the desktop pane admits it: the shell's own process group in front with no agent named in it means the agent exited. Anything else keeps the row. The evidence has no stopped-job signal, so a Ctrl+Z'd SSH agent reads as exited, as it already does on a mounted desktop pane.
… one place The desktop, orcad and the specs each spelled out how the runtime reads and clears the hook server's status store and retires launch authority. The retirement and ended-process options are optional, so a host that dropped one still typechecked and every spec stayed green, while every command end would then fully retire a live agent's pane. agent-hook-runtime-deps.ts now builds those deps and wires the runtime as the hook server's launch-authority reader. The desktop, orcad, the command-end specs and the STA-4557 suite all use it, and a unit test pins that both options reach the hook server.
…rocess evidence The terminal daemon, the default local backend, answers its shell confirm from its byte scanner, which only proves a shell after a full-screen exit, and every command end resets it. So a Codex run in the normal screen buffer that quit was never verified: its Done row stayed on hidden desktop panes, on headless hosts, in worktree ps and on mobile. When the provider's shell confirm says no, the verifier now reads the provider's fenced foreground evidence through the existing process inspection, the same check SSH panes already use: the shell's own process group in front with no agent named in it. A local node-pty reports no such evidence, so it keeps its stricter check. A daemon too old to send evidence keeps the row, as before. The evidence cannot see a stopped job, so a Ctrl+Z'd agent on a daemon pane reads as exited, as on SSH. The verifier also logs, per pane, a check or clear that throws instead of leaving an unhandled rejection.
…s for it A row the agent wrote before its command ended still carries the launch token. If the runtime later pruned that pane's PTY record (an SSH record after an unverifiable disconnect, whose row survives), the next save found no record, kept the token, and wrote a commitment that vouched for the dead token again after a restart. Saving now fails closed: a row's token is hashed and committed only when the runtime holds it or a commitment of this host still vouches for it. Ingest keeps passing a token for a pane with no PTY record.
…exit was proven On every SSH reconnect main asks the relay to replay the status it cached for each pane. A verified command-end exit no longer fences the pane, so that replay brought an exited agent's Done row back on every surface; a pane the user wasn't typing in kept it. Each ended-process clear now records which session ended (agent type and provider session). The status disposition, which every ingest path calls, refuses a replay of that session. Anything newer -- a live event, a new turn, another session's replay -- is admitted and drops the record, as do clearPaneState and a size bound. Live late hooks are not refused: an exit verdict can be wrong, and the agent's next event is how it recovers. An anchored clear also skips a resume-only remnant, so a Claude exit the pid check already cleared no longer tells every reader twice.
The host cannot inspect a WSL guest's processes and wsl.exe is not a shell, so a WSL pane could never prove an exit: a hidden pane, worktree ps and mobile kept an exited agent's Done row for the life of the terminal, while a visible WSL pane's desktop drops it at the command end. For a WSL PTY the host now treats the command end as the exit, as the desktop pane does, after the agent pid check (which still keeps a live agent the host can probe). A nested shell's 133;D under a live WSL TUI drops its row, as the desktop pane already does; that stays until a guest-side proof exists.
The command-end verifier asked each provider a yes/no shell question, so it could not tell 'something else is in front' from 'this host cannot tell', and needed a WSL-only branch. Hosts that cannot tell (a WSL guest, a Windows daemon or relay, a host that predates foreground evidence) kept an exited agent's row on hidden panes, worktree ps and mobile, while the desktop pane and main's old retirement dropped it. Each provider now answers proveShellForeground with shell, other or unprovable, and rejects when its host cannot be reached: - local node-pty: its process-table check; a wsl.exe root is unprovable; - terminal daemon: its own confirm, then its fenced evidence; Windows and daemons without evidence are unprovable; - SSH: the relay's fenced evidence; a Windows relay or one without evidence is unprovable, a client-only answer rejects. The verifier keeps the row for a live pid, for other and for an unreachable host, and takes the command end as the exit for shell and for unprovable, as the desktop pane does. The WSL-specific branch is gone.
…nt-emits-pane-clear
The local provider answered 'cannot tell' for any root process outside the known shell names, so a pane rooted in an unlisted POSIX shell (yash, oksh) took every command end as an exit although its own process table can say. Only a wsl.exe root, whose guest the host cannot see, now answers that.
…rity wiring Both deps are optional, so dropping the launch-authority spread or the runtime's token-reader wiring from main-process-runtime-service.ts still typechecked and every spec stayed green.
…exit capture The daemon and SSH relay answer from a shared process-table capture that can be up to 500 ms old, or already running. Read right after a command end it could still show the exiting agent in front, so the row was kept and nothing asked again until the user ran another command in that pane. - Evidence whose capture can have begun before the command end no longer counts. That, an unreadable table, unadmitted evidence and an unreachable host are 'unread' and are asked again past the cache's lifetime, then once more; a proven 'other' in front waits for its own command end. A PTY exit drops a scheduled re-ask. - Daemons from v27 until the shell-confirm request existed answer it as an unknown request; that alone now falls through to their fenced evidence. Any other failure still rejects and keeps the row. - When the exiting agent's own late hook rewrites its row mid-check, the check runs again (bounded); a different session's row is still left alone. - The verdict names 'host cannot tell' apart from 'exited': clearing the row there is a presentation choice, not a proven exit.
The check that refuses a process-table capture from before the command end bounded the capture's start by the request's start minus its age. The age runs from the start of the `ps` to the reply, so even a capture the check itself triggered failed it: every daemon and SSH exit took a second inspection and cleared about 0.6 s later. The daemon is on this machine, so its reply arrives as it is sent: the bound now uses the reply's arrival there. SSH keeps the request-start bound, since Orca can't tell when the relay's reply left. After a proven exit, a row rewritten mid-check is now checked again whoever wrote it (a new agent then reads as in front), so an agent without a session id gets its second look too; where the host cannot tell, only the same session's rewrite is. Drops an unreachable reason from the 'something else in front' set.
…'s process table QA on macOS (default terminal-daemon backend): a parked pane's Gemini quit to the shell and its Done row stayed on the sidebar, dashboard and worktree ps, also after a later command end. On macOS the daemon spawns the shell under /usr/bin/login for TCC attribution, so the PTY's root process is login and the shell is its child in its own process group. The daemon's fenced evidence names the root and the terminal's foreground group, and the proof required them to be the same process: never true there, so every answer was 'something else in front' and the row was kept at every command end. The daemon runs on this machine, so the proof now takes only the PTY's root from the daemon and reads this machine's process table fresh: the shallowest shell under the root holds the foreground and no job is stopped, as the local provider's check does. That also sees a Ctrl+Z'd agent and ignores a shared capture taken before the exit. SSH keeps the relay's evidence, whose root is the shell.
…its root c37fee5 read the fence's root pid without narrowing the fence's platform, which tc:node rejects.
…ck window The keep-row cases now wait for main's own process-table read, and the clear cases rely on a single inspect to show no re-ask was needed.
7 of 9 tasks
Contributor
Author
|
Closing this older retirement implementation and rebuilding the rule on current main. Create an issue for premature row removal and readers disagreeing after an agent exits before closing. The remaining problem is tracked in #27677. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ELI5
When an agent that Orca launched in a terminal finishes and drops back to the shell, Orca takes away that terminal's "launched agent" authority. Until now that step also quietly deleted the agent's status row on the host, without telling the desktop window. So the sidebar, the dashboard popout, the spinning tab title and the session stats could keep showing an agent that
orca worktree psand the phone had already dropped.It also deleted the row on a signal that doesn't prove the agent has exited. Some full-screen agents run nested shells that print the same "command finished" marker while the agent is still running. Its row then disappeared from
psand the phone, and froze on the desktop.Now the authority still ends at once. The row is removed only once Orca has checked that the agent really exited, and every screen is told in the same step.
What Changed
What you see:
orca worktree psand mobile together. Before, the desktop could keep it, including as Done.psand mobile lost it, and the desktop row froze because later updates from the agent were ignored.nodewrapper with no process identity), the row stays and a warning is logged. Nothing waits on the check.How it works:
Authority and rows are separate steps now. At a command end (the shell's OSC 133;D marker), the runtime still drops the pane's launch token, its restored-authority receipt and its launch agent at once. The hook server then:
retirePaneAuthority(..., { authorityOnly: true })).It does not lock the pane and does not delete rows.
A verified exit removes the rows.
verifyCommandEndAgentExitmakes a fresh foreground-process read on the PTY's execution host:live; a shell meansexited;unverifiable.Only
exitedruns the full retirement: lock the pane against late events from the exited process, delete the rows, keep the resume record, emit the pane clear. A PTY exit still retires at once, and now also notifies.Every host row removal notifies.
retirePaneAuthoritywas the only host removal path that told no reader. It now emits the pane clear for each live row it removes, likeclearPaneStateand tab close do. A new contract test runs every host removal operation and checks that a reader hears it.Whether the inherited token still counts is derived, not stored. Every process the shell starts inherits the launch token from its environment, so after a command end the token proves nothing. The three ingest paths (local HTTP, SSH relay, spool replay) now keep an event's token only while one of these is true:
runtime.readPaneLaunchAuthority, injected the same way the presence check is);A pane the runtime has no live PTY for keeps today's rule. orcad wires no attestation or retirement, so it is unchanged.
The renderer no longer ignores a host clear when its own row is Done.
docs/reference/agent-status-store.mdgains a short "Removing a row" paragraph stating the rule.Why
Why the bug existed.
retirePaneAuthoritywas written as the host half of a retirement the renderer starts after a pane closes, so the renderer had already removed its own rows. The runtime later reused it on every command end for any pane holding launch authority. That includes every agent launched from the UI, orchestration workers, and restored panes that aworktree psor mobile listing gave a receipt. Nobody did the renderer half there.Why rows wait for a verified exit and authority does not. The renderer already defers its own row drop at a command end, because a full-screen agent's nested shells leak that marker. The host must not be less careful than its own reader. The launch token is different: it lives on in the shell's environment, so every later process inherits it, and its authority has to end at the marker.
Revert history. This area has been reverted before:
opencode-finished-session-authority.test.tspins the three ways authority leaked.This PR does not defer authority. Only row removal waits, and the three tests pass unchanged. The new test "never vouches for a later process that inherits it, before or after a restart" covers the inherited-token case end to end with real wiring:
Alternatives rejected:
psand mobile when a nested shell leaks a marker under a live agent.orca serve) would keep exited agents' rows.Differences from the reference mechanism. The common pattern is that one owner applies the change and tells every reader in the same step; pane-hosted designs delete live status when the agent's process is gone, after checking the process.
unverifiablekeeps rows without locking the pane: INTENDED. Losing contact is never evidence of exit (ssh-execution-boundary.md). Those rows still leave by the other paths: the renderer's own drop, PTY exit, the presence check, or staleness.Wire compatibility. No wire shape changes. The host now sends a pane clear (an existing message type) after retirements that used to be silent, and older renderers and clients already handle it. The only new type is an in-process function between the runtime and the hook server.
PR #24292 (
opencode run). It orders its clear before this retirement, which stays correct. That ordering stops mattering once both land; the second to land should update its "why before retiring" comments.Linked Issue
Follow-up to the review of #24292 (no separate issue).
Visual Proof
N/A. The change is in host status bookkeeping and has no new UI. It is pinned by the runtime tests below, which drive the real hook server through command ends and PTY exits.
Testing
src/main/agent-hooks/server-row-removal-notifies-readers.test.ts(new, 13 tests):authorityOnlykeeps the row, emits no clear, doesn't lock the pane, and stops vouching for the token.src/main/runtime/launch-authority-retirement-reader-clear.test.ts(new, 15 tests, realAgentHookServer+OrcaRuntimeService):retireAgentHookCompatibilityAuthorityis now called with{ authorityOnly: true }at a command end).opencode-finished-session-authority.test.ts: unchanged and passing.orca-runtime.test.tsruns the runtime spec harness and passes (1340 tests). In the full combined run, 10 rendererpty-connection-*files failed under load; they pass when run on their own (139 tests). The core suites were re-run after rebasing onto currentorigin/main.setPaneLaunchAuthorityReaderhookup inmain-process-runtime-service.ts. The tests wire the same call themselves.tc:nodeandtc:webpass. Changed-code quality gate passes.Review
Agent skill upstream boundary
Notes
unverifiable, neverexited.confirmForegroundProcess. A WSL or wrapper process name is neither an agent nor a shell, so it falls back and then readsunverifiable(safe).worktree ps: they read the same host store and see the same timing as the desktop now.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred). Rantc:nodeandtc:web, the changed-code quality gate, oxlint and oxfmt on the changed files, and the suites above. Did not run the fullpnpm lint,pnpm testorpnpm build.