Files
orca/docs/reference/agent-status-store.md
T
Merge Sim cf2ff6659a fix(agent-status): resolve a structured row's surface by session, not by its key
The pane key's tab-id half is now derived from the session id, so it stops
naming the surface whenever `terminal-surfaces.ts` reuses a tab: a conversation
that replaces another keeps the superseded tab, whose local id still spells the
old session while `entityId` becomes the new one. Two readers were using the key
as surface routing and broke on that.

`WorktreeCardAgents`'s row click matched the tab by exact id, found nothing, and
returned silently — the row stayed and did nothing. It now falls back to the
session the key encodes, via a new inverse of `structuredAgentSessionTabId`.

The live-entry worktree index maps tab id to worktree, and a `done` row is
bucketed only through it (a live row still has `entry.worktreeId`). A replaced
conversation's settled row therefore left the sidebar entirely. The index now
registers each agent-session tab under its derived session tab id too.

`stateStartedAt` needed no shared module. The renderer store's own default
already holds the state clock across republished evidence, and carries the
Command Code new-turn clause besides; the divergence was that the bridge passed
a `timing.stateStartedAt` that overrode it. The bridge now passes none, so the
store applies its own rule, and `resolveAgentStatusStateStartedAt` is gone — it
would have had one caller, and `server-reaping.ts` keeps its own inline copy
regardless.
2026-09-11 16:37:06 -07:00

455 lines
27 KiB
Markdown

# Agent status store
## Status
Proposed on 2026-09-09 as the follow-up to #19217. It lands in four steps, in
this order, each independently shippable:
1. main-only: every producer writes into one store and `worktree ps` reads it,
split into 1a (structured sessions join the store) and 1b (the runtime's
duplicate retained store is deleted);
2. renderer: the sidebar becomes a subscriber and stops re-deriving rows;
3. shared: one worktree-status rollup and one freshness rule for every reader.
The PR that carries this document is PR 1a. Sections below are grouped under
the step that delivers them; PR 1a, PR 1b and PR 2a have landed.
## The problem this solves
Orca shows "what is this agent doing" in four places: the desktop sidebar, the
`orca worktree ps` command, the mobile app, and the agent dashboard. Before
#19217 those readers did not even share their inputs. After #19217 they share
the structured-session mapping and nothing else.
An audit on 2026-09-09 found six producers and three consumers, and three
separate copies of the same row inside the main process alone:
| Main-process copy | Keyed by | Owned by | Persisted | Evicted |
| --------------------------------- | --------- | --------------------------------------------------------------------------------- | ------------------ | ---------------------------- |
| hook server `lastStatusByPaneKey` | paneKey | `src/main/agent-hooks/server.ts` | `last-status.json` | tab close, pty exit, hydrate |
| runtime `RuntimeAgentRowStore` | paneKey | `runtime-agent-row-store.ts` (deleted in PR 1b) | no | pty exit only |
| structured feed `published` | sessionId | `src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts` | no | never (a broadcast cache) |
The second copy is a duplicate write: the OSC status parsed in main is
forwarded to the hook server _and_ retained in the runtime store from the same
call (`orca-runtime-create-terminal-side-effect-command-code-detector.ts`).
The third copy is keyed differently and never reaches the hook server at all,
which is why `worktree ps` grew its own adapter for it in #19217.
Each reader then applies its own precedence and freshness rules, so the same
pane can legitimately read differently on the desktop, on the phone, and in
the CLI.
## The rule
**The execution host owns agent status, in one store, and every reader
subscribes to it.** This follows the boundary in
[`ssh-execution-boundary.md`](./ssh-execution-boundary.md): the host that runs
the process is the only party that can observe it, and the client is never
authoritative for execution state.
Three consequences:
- One store per execution host. A remote host keeps its own store and the
client mirrors it down, as the web-session mirror already does. Mirroring is
not merging: a client never writes its observations back to a host.
- Precedence is decided once, at write time, with provenance recorded on the
row. Readers never re-adjudicate hook versus terminal versus structured.
- Readers keep only presentation policy and user facts: the 30-minute display
decay, acknowledgements, dismissals, unread. Those stay reader-side but
become one shared implementation (PR 3).
## The store already exists
The hook server's state is that store today for every PTY-based agent. The
audit established:
- hook HTTP posts, the WSL and SSH relay receivers, and main's own OSC parse
all converge on the same `applyNormalizedStatus` path, stamped with the
authority id `main-agent-hooks`;
- it alone holds pane authority: launch tokens and their hashed commitments,
retired-pane fences, pane-key aliases, per-connection ordering watermarks,
and the evidence-age map that must outlive a transport clear;
- it alone persists, with a seven-day hydrate window and the
`restoredUnconfirmed` stamp that keeps a hydrated row from ever reading as
live truth;
- it already fans out to both renderer windows over `agentStatus:set` and
`agentStatus:clear`, and serves `agentStatus:getSnapshot`.
Nothing else in main carries those guarantees, and building a second store
with them would be the wrong direction. So the design is not "add a store". It
is: **route the two producers that bypass the hook server through it, then
delete the copies.**
## PR 1a: structured sessions publish into the store
No renderer behavior changes. The sidebar keeps receiving the same IPC events
it receives today, plus structured-session rows it currently derives itself.
### Structured sessions publish into the hook server
The structured feed keeps its job of projecting a session's journal into a
summary and streaming it to subscribers. On every publish it additionally
ingests the summary into the hook server as a status row:
| Row field | From |
| ----------------- | ------------------------------------------------------------- |
| `paneKey` | `structuredAgentSessionPaneKey(sessionId)`, the key the renderer also derives (PR 2a made it take the session id alone); its leaf is UUID-shaped so pane-key validation accepts it |
| `tabId` | `structuredAgentSessionTabId(sessionId)` |
| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) |
| `state` | `structuredAgentSessionStatusState(summary.status)`, the mapping #19217 shared |
| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it |
| prompt, tool, last message, model, provider session | the summary's fields |
Sessions with no persisted turn (`status === null`) produce no row, matching
what the chat shows. When the host revokes live ownership the row is re-set
without the flag; when the host closes or evicts the session the row is
dropped. Both already exist as feed events (`revokeLive` and the roster
filter in `liveSessionSummaries`); PR 1 turns them into store writes.
Dropping the session from the host's map and dropping its row are one
operation, `forgetStructuredAgentSession`. The store keeps a row until told,
and a host-owned row bypasses the staleness check, so a deletion path that
forgot the row would strand a permanently working-looking agent.
Two rules the ingest must keep:
- **Never persist a structured row.** The journal is the durable truth for a
structured session and the host republishes on restore. A structured row in
`last-status.json` would hydrate as `restoredUnconfirmed` and then fight the
live republish. The serializer skips rows carrying `structuredHost`, and
hydrate drops any such row found on disk. Applying one therefore also skips
the persist schedule: the walk and stringify could only reproduce the file
that is already on disk, once per debounce window for every streaming chat.
- **Never let it fight a hook row.** A structured session has no PTY, so no
hook or OSC event carries its pane key. The ingest still goes through the
disposition gate so a retired pane key is refused like any other.
Applying one does still run both status fan-outs, and that is intended rather
than incidental. `notifyStatusChangeListeners` is what feeds
`agentAwakeService`'s power-save blocker, and `subscribeEnrichedStatus` is what
feeds `AgentSessionTransitionRecorder`'s stats, so joining the store enrolls
native chats in both. A working native chat is real work and should hold the
machine awake exactly like a PTY agent does.
The drop side routes through `dropStatusEntry`, not `clearPaneState`: a
pane-status-clear reaches the renderer, and until PR 2 the renderer's own feed
bridge is that pane key's writer. It also passes `preserveResumeIdentity:
false` — the `providerSessionOnly` remnant a dismissed pane keeps exists so the
agent can be resumed in that pane, and a structured session has no pane and
keeps its resume identity in the record store. Like every other
`dropStatusEntry` caller, it emits no pane clear, so a session dropped
mid-`working` leaves `AgentSessionTransitionRecorder` holding an open stats
session until its LRU evicts it; that gap is shared with the user-dismissal
path and is not specific to structured rows.
The ingest lives in the feed, not in `structured-agent-session-host.ts`, which
sits at the file-length cap.
### `worktree ps` becomes a reader
The structured adapter added in #19217 is deleted, and structured rows reach
`worktree ps` through the same snapshot as every other row. The
retained-versus-hook reconciliation in `collectRuntimeWorktreePtyAgentSources`
stayed until PR 1b removed the store that fed it. What this step settles is
the admission gate that decides which rows a worktree listing may show:
- a hook or OSC row needs its tab mirrored or a connected pty, as today, and
SSH rows stay exempt because their tabs may exist only remotely;
- a row carrying `structuredHost` is admitted while the host holds the session, and
the host's drop on close is what removes it. No tab-mirror requirement: a
structured session's tab lives in the renderer's own tab state, and a
headless host has no renderer to mirror it from. That argument only holds if
the headless host is itself wired to the store, which is a separate
obligation per entry point: the Electron hosts (desktop and `orca serve`)
share `main-process-runtime-service.ts`, and `orcad` constructs its own
runtime in `src/main/orcad/orcad-entry.ts`. A host missing that wiring lists
no agents at all, not just no structured ones, because `worktree ps` reads
the same snapshot for every row.
The freshness bypass for host-owned structured rows already exists in
`isFreshNonDoneAgentStatus`; with the flag now on the row it becomes the only
path, and the hand-rolled check in `runtime-worktree-agent-rows.ts` goes.
### Wire compatibility
`AgentStatusIpcPayload` gains one optional field, `structuredHost`, and the
`worktree ps` row gains `structuredHostOwned`. Under rule 1 of
[`remote-wire-compatibility.md`](./remote-wire-compatibility.md) both are safe:
an old client ignores them. `worktree ps` rows keep their shape and vocabulary,
so the mobile app sees no change.
Until PR 2 the main process does not forward structured rows to the renderer
over `agentStatus:set` or `agentStatus:getSnapshot`. The renderer's feed
bridge still writes those rows itself, and forwarding them too would give one
pane key two writers. Removing that filter is the first step of PR 2.
## PR 1b: the runtime's retained row store is deleted
Landed. `RuntimeAgentRowStore` is gone, and with it the retained-versus-hook
reconciliation in `collectRuntimeWorktreePtyAgentSources`. The hook server's
store is now the only main-process copy of a PTY agent's row.
### The five call sites
| Call site | Before | After |
| ------------------------------------------------------------------------------ | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `orca-runtime-create-terminal-side-effect-command-code-detector.ts` `retain()` | second write of the OSC payload already sent to the hook server | deleted; the event now carries the pane's `terminalHandle` and the hook ingest keeps the only copy |
| `...command-code-detector.ts` `clearPty()` | drops rows on pty exit | deleted; pane teardown already clears the hook row |
| `orca-runtime-get-worktree-ps.ts` `values()` | fed `retainedSnapshots` | deleted; the reader keeps only `hookSnapshots` |
| `orca-runtime-serialize-agent-prompt-submission.ts` `getFreshExplicit()` | retained row first, hook rows second | `selectFreshExplicitAgentStatus`, hook rows only |
| `orca-runtime-prune-mobile-session-tab-group-layout.ts` `getFreshForMobile()` | pane key, then pty id | `selectFreshAgentRowForMobileTab`: pane key, then `terminalHandle` |
Both readers moved into `runtime-hook-agent-row-selection.ts`, which also owns
`RuntimeAgentRowSnapshot` now that nothing retains one.
### `terminalHandle` is the row's join back to its terminal
The retained store's only real extra was the pty id, and two readers used it.
The plan said to stamp the event's `ptyId` into `terminalHandle`; that was
wrong. A terminal handle (`term_<uuid>`) and a pty id are different
identifiers, and `getFreshExplicit` was already comparing hook rows against a
real handle. What landed instead:
- `AgentHookEventPayload` and the runtime's terminal-status event gained an
optional `terminalHandle`. The detector resolves it once per chunk through
`getAgentStatusTerminalHandleForPaneKey` — the same lookup the renderer-facing
IPC boundary already runs for every row, so the two surfaces cannot disagree
about which terminal a pane is.
- `applyNormalizedStatus` carries the handle forward when an incoming event
resolves none. Only main's OSC parse can resolve one, so an HTTP hook post for
the same pane would otherwise erase it.
- It is never persisted. A handle belongs to the runtime that issued it, and a
hydrated one could only rejoin a row to somebody else's terminal.
- `toAgentStatusIpcPayload` publishes it, which also makes `getFreshExplicit`'s
long-dead handle comparison live: the runtime reads raw snapshot rows, and
before this nothing ever stamped the field on them.
`worktree ps` uses it too. `ConnectedPtyEvidence` traded its flat `ptyIds` set
for `ptyIdByTerminalHandle`, so a row still resolves the connected PTY behind
it — which is both the working-terminal rollup's match key and the last rescue
for a row whose pane binding was nulled by a controller incarnation change.
### The change detector had to move with the store
`retain()` was not only a store: its boolean return was the signal that
republished `session.tabs` for a status-only transition, which no title change
covers (#7970). `hook-status-session-tabs-invalidation.ts` already mirrors that
projection change set, including restore provenance and terminal-handle joins,
so the replacement was to route the signal off the store rather than build a
second comparator.
`installHookStatusSessionTabsRepublish` now owns all three arms — enriched
status, pane clear, and the status-drop tap a dismissal emits — and both hosts
install it.
### Both hosts, not just the desktop one
`orcad` constructed its runtime with no `onTerminalAgentStatus`, so main's OSC
parse never reached the store there and the retained copy was the only carrier.
Deleting it without wiring orcad would have made a headless host list no PTY
agents at all. `orcad-entry.ts` now binds the producer and installs the
republish signal, alongside the snapshot and structured sink it already had.
### The intended behavior change
A row the user dismisses on the desktop leaves `worktree ps` and the phone at
once, instead of lingering until the pty exits. One store means one dismissal.
Legacy numeric pane keys remain a bounded compatibility case. Persisted layouts
register aliases to their stable leaf owners; an in-process OSC observation may
also retain a numeric key only when the runtime supplies the matching tab, PTY,
and terminal handle. HTTP and relay ingress still require a stable key or a
registered alias, and numeric rows are never persisted.
## PR 2a: converge the two derivations before the filter comes off
PR 1a left main and the renderer each deriving a structured row from the same
host summary, which is safe only while the IPC filter keeps them apart. This
step closed both divergences so PR 2b can remove the filter without one session
rendering twice or its clock jumping. No filter came off here and no writer was
retired.
### The pane key is derived from the session id alone
`structuredAgentSessionPaneKey` now takes the session id and nothing else, and
builds `structuredAgentSessionTabId(sessionId)` itself. Main's key is unchanged;
the renderer's three call sites (the status bridge's write and its unmount
cleanup, and `NativeChatStructuredSession`'s read) stopped passing `tab.id`.
The tab id was the wrong input. `web-session-tabs-sync/terminal-surfaces.ts`
re-hosts a mirrored session at `${baseId}:history-N` when its derived id is
already occupied, and the host never sees that suffix. Two things followed from
keying on it, and both are fixed:
- the renderer and main wrote different keys for one session, so removing the
filter would have produced two rows for one chat;
- the key held a second `:`, which `parsePaneKey` rejects. An unparseable key is
dropped by `buildWorktreeAgentRows` (both the `entriesByTabId` bucket and the
worktree-attributed fallback), so a surface re-hosted at `:history-N` published
a key no reader could bucket. It now publishes the session's key like any other
surface.
That is narrower than a session gaining a row it did not have. The suffix is
only assigned when the base id is already occupied, and the occupant is
normally the same session's other surface — which was already publishing the
base key, and whose row the suffixed surface now shares rather than adds to.
The bridge test for a disambiguated mirrored surface pins the derivation, not
a reachable end state: its fixture has a suffixed surface with nothing at the
base id, which `buildMirroredAgentTabs` does not produce. Read it as a guard
on the key, not as evidence of a row the user gains.
Nothing was stranded under an old key: main never wrote a suffixed key, and the
renderer's `agentStatusByPaneKey` is in-memory, so a `:history-N` row only ever
lived as long as the window that wrote it — and it was invisible while it did.
One surface behavior needed handling. Two tabs can transiently mirror one
session (the collision that produces the suffix), and they now share one key, so
the status bridge's unmount cleanup no longer clears the row while another
surface still mirrors that session.
### The key names the session, so readers resolve the surface by session
A structured pane key's tab-id half is `structuredAgentSessionTabId(sessionId)`,
not the id of the surface hosting the chat. Two readers were using it as surface
routing, and `terminal-surfaces.ts` breaks that agreement in both of its
id-collision paths — a conversation that _replaces_ another reuses the superseded
tab, so the local id keeps spelling the old session while `entityId` becomes the
new one:
- `WorktreeCardAgents`'s row click resolved the tab by exact id, so a replaced
conversation's row did nothing at all. It now falls back to
`activateStructuredAgentSessionForRow`, which reads the session id back out of
the tab id (`structuredAgentSessionIdFromTabId`) and resolves the surface by
`entityId`.
- the live-entry worktree index in `worktree-agent-row-selectors.ts` maps tab id
to worktree, and a `done` row is bucketed only through that index (a live row
still has `entry.worktreeId` to fall back on). A replaced conversation's
settled row therefore vanished from the sidebar. The index now registers each
agent-session tab under its derived session tab id as well as its local id.
`structured-agent-session-projection.ts` owns both directions of the derivation,
so no reader re-spells the prefix.
### `stateStartedAt`: the renderer stops overriding the store's rule
A row's `stateStartedAt` is the start of the state the row is in, so republished
evidence never moves it and only a state change (or Command Code's same-state
new turn) resets it. `attachStatusTiming` had this rule already, and so did the
renderer store's own default in `agent-status-live-entry-builder.ts` — including
the Command Code clause, which it computes internally.
The divergence was that the bridge's `projectStatus` _passed_ a
`timing.stateStartedAt`, which is exactly the override that displaces that
default, and the value it passed carried an extra `desired.state !== 'done'`
clause that restamped a settled row on every republish. The bridge now passes no
`stateStartedAt` at all, so the store applies its own rule. There is no shared
helper: one would have had a single caller, and it could not express the store's
Command Code clause from the bridge's call site. `server-reaping.ts` still holds
its own inline copy of the same shape, so "one rule for every writer" would not
have been true either.
`done` is not an exception, because `agentEntryCompletionAt` reads a settled
row's `stateStartedAt` as its completion time. A moving one re-dates a turn that
already finished, and `smart-attention` already documents the opposite
expectation for hook rows: same-state `done` writes advance `updatedAt` without
moving the completion.
The visible effect is narrower than this document first claimed. `stateStartedAt`
reaches `NativeChatResolvedView`'s `hookWorkingEpoch` and
`NativeChatWorkingStatus`'s elapsed time, but both are read only while the row is
`working`, where the two rules already agreed. What changes is Class 2 ordering
in the sidebar and dashboard: a settled structured session whose journal keeps
moving now sorts by when it finished rather than by its latest journal write.
## PR 2b: the renderer subscribes
With structured rows arriving over `agentStatus:set`, the renderer's
`StructuredAgentSessionStatusBridge` no longer needs to write status; its
unmount cleanup becomes a tab-close signal to the host. The IPC applicator is
the single writer for observed status. The 2026-09-09 audit sorted the other
writers:
| Writer | Disposition |
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Command Code output seeds, parked-pane seeds, pty-exit removal | delete; main already emits the same facts |
| structured bridge status writes | delete; main now publishes the row |
| launch placeholder seeds (a user launched an agent with a prompt) | keep for now; main holds the launch config and can seed later |
| dismissal, acknowledgement, unmount | keep; user facts and component lifecycle |
| remote-runtime OSC parse (bytes never transit local main) | keep, fenced behind the host's published row once the host is new enough; rule 3 of the wire doc applies |
| web-session mirror receipt clock | keep; the decay rule needs both clocks from one machine |
The Command Code done-settle window is renderer policy with no main
equivalent. PR 2 either moves it into main's detector or leaves it, and says
which.
## PR 3: one rollup, one clock
The worktree card status is derived three times: `lib/worktree-status.ts` in
the renderer, `runtime-worktree-status-projection.ts` in main, and
`agent-row-display.ts` in mobile, which hand-copies the 30-minute constant.
PR 3 moves the rollup and the decay into `src/shared` and makes all three
call it.
## What does not change
- The hook scripts, the OSC 9999 wire format, and the relay protocol.
- The status vocabulary. `working / blocked / done` for rows,
`working / attention / idle` for structured summaries, mapped once.
- The `live / unverifiable / exited` verdicts for remote work. Loss of contact
clears nothing; the SSH exemptions in the admission gate stay.
- Hydration honesty: a restored non-done row is `restoredUnconfirmed` and is
never fresh.
## PR 1b reliability contract
- **Invariant (`agent-session.status-host-ownership`):** each execution host has
one agent-status store; OSC, hooks, and structured sessions write it, while
desktop, `worktree ps`, and mobile only project it. Dismissal, certified PTY
exit, and provider-generation replacement remove the same row everywhere;
transport loss alone removes nothing.
- **Failure source:** the deleted runtime row store duplicated OSC observations,
keyed them by a different terminal identity, and outlived a dismissal from the
hook store. Relay replay could also make old evidence look fresh when readers
used its new delivery timestamp.
- **Oracle:** one OSC observation appears through the hook snapshot in
`worktree ps` and mobile, and one store dismissal removes it from both without
stopping the PTY. Focused tests also require leaf/incarnation-handle rejoin,
legacy numeric-pane compatibility, certified-exit and provider-generation
cleanup, evidence-age freshness, and exactly-once startup/stop teardown.
- **Gate:** `terminal-performance.osc-status-scan-budget` covers the unchanged
bounded OSC parser and the runtime projection. There is not yet a dedicated
blocking multi-surface status-store gate; the focused suites below are the
accepted gap until they accumulate reliability-gate soak evidence.
- **Provider/platform coverage:** local and daemon-backed PTYs are covered by
runtime tests, and SSH relay loss/replay semantics by relay integration tests.
The projection is shared by git worktrees and folder workspaces. WSL uses the
same store and admission code but has no live run here; Linux and Windows
runtime execution, native mobile clients, and mixed-version paired clients
remain validation gaps.
- **Performance budget:** publication stays event-driven with no new polling or
subprocesses. One mobile projection clones the status snapshot once, builds
pane/handle indexes once, and has a deterministic call-count test; lifecycle
cleanup is bounded by the existing status and handle inventories, and orcad
tests prove listeners clean up once on failed startup and repeated stop.
- **Diagnostics:** existing hook-listener errors name the pane and PTY, while
status-store tests pin delivery versus evidence clocks. No new telemetry or
raw terminal data is emitted.
- **Residual gaps:** rendered Electron/mobile behavior, live SSH reconnect, and
Linux/Windows/WSL execution require the platform QA pass. The current
cross-version gate does not cover `session.tabs` content.
## Verification
- Unit: ingest a structured summary and read it back through
`getStatusSnapshot`, `worktree ps`, and the mobile projection; assert the
serializer never writes a row carrying `structuredHost`; assert a hydrated
file that somehow contains one is dropped.
- Unit: the `worktree ps` suites written against the retained store are rewired
to a real `AgentHookServer` (`agent-status-store-wiring.test-fixture.ts`)
rather than deleted, so each still asserts the listing behavior it named. The
dismissal change is pinned end to end in
`orca-runtime-tests/worktree-ps-agent-row-dismissal.spec.ts`, which fails with
the retained store restored.
- Live: the parity check from #19217 (working, done, close, reload) repeated
against the merged store, with both surfaces read from the one row.