Files
orca/src/preload/api/agent-status-api.ts
T
Brennan Benson 94f231737d fix(agent-status): retire panes whose agent process is gone (STA-4612) (#15212)
* fix(agent-status): retire panes whose agent process is gone (STA-4612)

Agent status can hold `working` on a pane where no work is outstanding, and
nothing closes the gap. A pane's Claude state is a join of a lead turn and three
latches — the subagent roster, the background-task gate and the session-cron gate
— and each is set by a hook and cleared only by another hook. Claude Code emits
no terminating hook on `/exit`, `/clear`, Ctrl+C, crash, SIGKILL or terminal
close, so every one of those latches is a claim with no owner and no expiry. The
join is also materialised at ingest time and persisted, so a stale `working`
survives restart and blocks hibernation, which requires `done`.

Registering `SessionEnd` is not the fix: it covers roughly a third of exit paths
(measured on 2.1.231/2.1.233; upstream anthropics/claude-code#17885 and #6428 are
both closed as not planned). Nor is a TTL — `AGENT_STATUS_STALE_AFTER_MS` only
decays the sidebar dot at read time while the stored row stays non-terminal.

So the backstop is built from evidence Orca already owns.

A session id that changes means the conversation was replaced. On the first hook
of the new session — whatever that hook is — the previous session's own claims
are void: its session crons and its one-shot subagents. Deliberately not voided:
the background-task gate (a background shell is an OS process that survives
`/clear`, and the previous inventory is positive evidence it was running), and
`confirmedTeammate` rows (persistent in-process teammates a lead swap cannot
end). The lead record is left to the incoming event's own fold.

A certified process exit retires the pane. Orca already does this on every
attributable PTY exit — `clearProviderPtyState` resolves the pane key and calls
`clearPaneState` — but that resolution depends on the spawn-time `ptyPaneKey`
mapping, which a restored or reattached PTY may never rebuild. Those panes keep
their row and latches for good. `onPtyExit` knows the keys teardown could not
resolve, so it reconciles them from its own records. The certificate is
`exitCode >= 0 || hostExitConfirmed || providerExitObserved`: a synthetic `-1`
from a failed stop is not a death (the PTY can have survived it), while a real
exit can also report `-1`, so neither the code nor the SSH surface predicate is
sufficient alone. `providerExitObserved` is additive and separate from
`hostExitConfirmed`, which also drives the liveness verdict and the SSH surface
decision.

A confirmed shell foreground is the `/exit` case: the agent died, the shell
lived. That already dropped the row, but through `agentStatus:drop`, which by its
own contract preserves a live pane's caches — so every latch survived and the
next event resolved the pane back to `working`. It now routes through the
reconciler instead, gated on a per-pane accepted-status generation rather than
row identity: the confirming process read can take seconds, and `updatedAt`
cannot order two writes inside one millisecond (the store deliberately admits
equal timestamps).

Cold start generalises the same way. The startup sweep required a restored
subagent roster, so a stranded lead row, background-task gate or cron gate — the
shapes with no child event left to reap them — were never candidates.

Hibernation needs no change: with the above, those rows become genuinely `done`
and the lockout resolves through the front door. A `restoredUnconfirmed` bypass
in the planner would let it reclaim the heap of an agent that may be working.

Not included: folding `background_tasks` from a child-attributed `SubagentStop`.
Writing its test surfaced #11838's deliberate assertion that child inventories
are not authoritative for lead-owned background work, and the listener says the
same — "background_tasks is trusted only where unambiguous". An empty list on a
`SubagentStop` does not prove the lead's shell ended, so the fold would have
cleared a gate on evidence that establishes nothing.

STA-4119's live-side question — whether a genuinely live background shell should
hold the lead row after the lead turn ends — is untouched. This change extends
gate-clearing to zero new triggers.

* fix(agent-status): make the confirmed-shell reconcile survive its own drop

The /exit leg never fired. `settleDeferredCommandFinishedStatusDrop` runs the
paired drop before the reconcile, and `dropAgentStatus` cleared the per-pane
accepted-status counter the reconcile's guard then read — so the guard compared
a live anchor against a zeroed counter and skipped itself on every pane that had
a status row, which is every pane worth reconciling. The existing test passed
only because it used a pane with no row, where the drop early-returns and both
sides read 0.

Stop keying the guard on a counter a sibling teardown path can reset: the
ordinal is now stamped on the row itself, derived from the row it replaces, so
there is no side table to clear and a batched burst lands the same ordinals as
the equivalent sequential writes. A removed row means "nothing reported", which
is exactly what the paired drop leaves behind.

Also:
- Keep the `providerSessionOnly` resume identity that the paired dismissal mints
  when the shell outlived the agent; a certified PTY exit still takes it, since
  there is no pane left to resume into.
- De-vacuum two guard tests. The confirmed-teammate pin never anchored a session
  owner, so the void it claimed to survive never ran; the unavailable-inspection
  pin asserted before the confirm ladder settled. Both now fail when their guard
  is removed.
- Derive `hasLiveClaimsForPaneKey` from a predicate that lives beside
  `clearPaneCacheState`, so a new latch cannot be added to the teardown and
  silently missed by the claim check.
- Drop the unreachable compact-`trigger` clauses; SessionStart is the whole guard.
- Cover the connectionId arm of the exit certificate, where a provider-observed
  death and a preserved SSH surface are deliberately independent.

* fix(agent-status): keep agent-status-types under its line cap

main already sits exactly at the 300-line max-lines cap for this file, so the single
`acceptedStatusSeq` field this branch adds pushed it to 301 once main's observation
facet merged in.

Declared the field as a mixin beside the observation facet instead. Both are per-write
facets mixed into `AgentStatusEntry` rather than fields a reporter supplies, so they
belong together — and the capped file loses a line rather than gaining one, since it
already imports from that module. No lint suppression.

* fix(agent-status): collapse the entry facets into one intersection

The previous attempt still tripped max-lines: two mixins on one intersection wrap
across two lines under oxfmt, so removing the field line bought nothing.

Expose a single AgentStatusRowFacets that already includes the observation facet, so
the entry intersects one short name on one line. The payload keeps intersecting the
observation facet alone — it must not carry the renderer-local ordinal.

Verified by formatting first and then linting, which is the order that catches this.

* fix(agent-status): retire resume authority with dead panes
2026-08-24 11:15:11 -07:00

58 lines
3.0 KiB
TypeScript

import type {
AgentStatusClearIpcPayload,
AgentStatusIpcPayload,
MigrationUnsupportedPtyEntry
} from '../../shared/agent-status-types'
import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent'
import type { AgentQuestionAnsweredInferenceRequest } from '../../shared/agent-question-answered-intent'
import type { ComputerAwakeStatus } from '../../shared/computer-awake-mode'
export type AgentStatusApi = {
/** Listen for agent status updates forwarded from native hook receivers. */
onSet: (callback: (data: AgentStatusIpcPayload) => void) => () => void
/** Listen for main-process cleanup that evicted cached hook status. */
onClear: (callback: (data: AgentStatusClearIpcPayload) => void) => () => void
/** Return the current main-process hook cache after renderer hydration. */
getSnapshot: () => Promise<AgentStatusIpcPayload[]>
inferInterrupt: (request: AgentInterruptInferenceRequest) => Promise<boolean>
/** Guarded clear for an answered AskUserQuestion wait — the CLI emits no hook at answer time, so the renderer reports the submit keystroke. */
inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest) => Promise<boolean>
/** Listen for PTYs on a legacy numeric pane key that have registry-backed UUID pane proof. */
onMigrationUnsupported: (callback: (entry: MigrationUnsupportedPtyEntry) => void) => () => void
onMigrationUnsupportedClear: (callback: (data: { ptyId: string }) => void) => () => void
onLegacyWorkerTerminalRecovery: (
callback: (data: {
paneKey: string
resolution: 'adopted' | 'exited' | 'rolled_back'
ptyId?: string
}) => void
) => () => void
getMigrationUnsupportedSnapshot: () => Promise<MigrationUnsupportedPtyEntry[]>
/** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. */
drop: (paneKey: string) => void
/** Retire a pane whose agent process is proven gone — clears the row AND the per-pane caches a
* dismissal deliberately keeps. Not `drop`: that one is a user dismissal of a live pane's row. */
reconcileEndedProcess: (paneKey: string) => void
/** Drop every cached hook status under one terminal tab prefix. Fire-and-forget. */
dropByTabPrefix: (tabId: string) => void
/** Permanently retire one pane's hook authority while siblings stay live. */
retirePaneAuthority: (paneKey: string) => void
/** Lift one pane's retirement fence when a live PTY re-attaches to it. Closed tabs stay retired. */
restorePaneAuthority: (paneKey: string) => void
/** Move hook authority when a live pane is detached into another tab. */
transferPaneAuthority: (args: { fromPaneKey: string; toPaneKey: string; ptyId?: string }) => void
}
export type AgentTrustApi = {
markTrusted: (args: {
preset: 'cursor' | 'copilot' | 'codex'
workspacePath: string
connectionId?: string
}) => Promise<void>
}
export type AgentAwakeApi = {
getStatus: () => Promise<ComputerAwakeStatus>
onChanged: (callback: (status: ComputerAwakeStatus) => void) => () => void
}