From da5d555259fc63cb0e39b848e677987a98cbf493 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:28:16 -0700 Subject: [PATCH] refactor(agent-status): delete the runtime's retained row store (PR 1b) (#19785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(agent-status): plan PR 1b at file level Names the five RuntimeAgentRowStore call sites and what each becomes, why terminalHandle has to be stamped before the store can go, and the one intended behavior change. * feat(agent-status): stamp the pane terminal handle on hook-server rows The runtime's retained row store carried the pty binding two readers need. Put that fact on the row that already owns the pane instead, resolved through the same lookup the renderer-facing IPC boundary runs, so the two surfaces cannot disagree about which terminal a pane is. Carried forward when a later write resolves no handle (only main's OSC parse can), and never persisted: a handle belongs to the runtime that issued it. * refactor(agent-status): route the session-tabs republish off the store `retain()` was not only a duplicate 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 change set plus hook restore provenance, so route the signal off the store rather than keep a second comparator. Adds the status-drop arm a user dismissal emits, which the pane-clear fan-out deliberately skips — now load-bearing, because a dismissed row leaves the listing at once. Installed on both hosts. orcad had neither the OSC producer nor this signal, so its runtime observed agent status and published it nowhere; deleting the retained copy without wiring it would list no PTY agents there at all. * refactor(agent-status): delete the runtime's duplicate retained row store `RuntimeAgentRowStore` held the same payload the hook server already holds, so the same pane could legitimately read differently in the sidebar, in `worktree ps`, and on the phone. Both of its readers move onto the store's snapshot in `runtime-hook-agent-row-selection.ts`, and `collectRuntimeWorktreePtyAgentSources` loses the retained-versus-hook reconciliation that only existed because two stores could disagree. `ConnectedPtyEvidence` trades its flat pty-id set for `ptyIdByTerminalHandle`, which is how a row still resolves the connected PTY behind it — the working-terminal rollup's match key, and the last rescue for a row whose pane binding a controller incarnation nulled under it. The one intended behavior change: a row the user dismisses on the desktop leaves `worktree ps` and mobile at once instead of lingering until the pty exits. One store means one dismissal. The suites written against the retained store are rewired to a real AgentHookServer rather than deleted, so each still asserts the listing behavior it named. * docs(agent-status): record what PR 1b landed Past tense, plus two corrections to the plan: `terminalHandle` is not the pty id (they are different identifiers, and the explicit-status reader was already comparing against a real handle), and the legacy numeric pane key is a consequence the plan did not name. * fix(agent-status): harden single-store lifecycle * fix(agent-status): preserve mobile terminal rejoin * fix(agent-status): preserve unverifiable remote rows * fix(agent-status): own PTY row lifecycle in hook server * fix(agent-status): preserve state and renew freshness * fix(agent-status): ignore freshness for dismissed identity rows * fix(agent-status): fence orcad observed identities * fix(orcad): always release daemon adapter on cleanup * fix(agent-status): cover remint and headless lifecycle edges * fix agent status identity recovery gaps * fix(agent-status): suppress duplicate child-only row mutation * test(runtime): preserve hook store wiring in transcript harness --------- Co-authored-by: Merge Sim --- docs/reference/agent-status-store.md | 177 +++++++--- ...-awake-service-platform-assertions.test.ts | 1 + src/main/agent-awake-service.test.ts | 30 ++ src/main/agent-awake-service.ts | 88 ++--- src/main/agent-awake-status-lease.ts | 106 ++++++ ...hook-provider-session-invalidation.test.ts | 68 ---- .../hook-provider-session-invalidation.ts | 43 --- ...k-status-session-tabs-invalidation.test.ts | 101 ------ .../hook-status-session-tabs-invalidation.ts | 65 ---- ...hook-status-session-tabs-republish.test.ts | 139 ++++++++ .../hook-status-session-tabs-republish.ts | 67 ++++ .../server-ingest-terminal-status.test.ts | 48 ++- .../server-start-failure-lifecycle.test.ts | 167 ++++++++++ .../server-status-listener-fanout.test.ts | 33 ++ src/main/agent-hooks/server.ts | 3 + .../server/server-authority-aliases.ts | 20 +- .../server/server-authority-fences.ts | 25 +- src/main/agent-hooks/server/server-cleanup.ts | 9 +- .../server/server-ingest-terminal.ts | 70 +++- .../agent-hooks/server/server-lifecycle.ts | 101 +++--- .../agent-hooks/server/server-listeners.ts | 22 ++ .../agent-hooks/server/server-persistence.ts | 3 + src/main/agent-hooks/server/server-reaping.ts | 1 + .../server/server-row-ownership.ts | 132 ++++++++ src/main/agent-hooks/server/server-state.ts | 23 +- .../server/server-status-disposition.ts | 3 +- .../server/server-status-identity.ts | 1 + .../server/server-status-inference.ts | 4 +- .../server/server-status-update.ts | 104 +++++- .../agent-hooks/server/server-tab-cleanup.ts | 28 +- src/main/agent-hooks/server/server-types.ts | 19 ++ .../terminal-handle-row-identity.test.ts | 261 +++++++++++++++ src/main/ipc/agent-hooks.test.ts | 12 +- src/main/ipc/agent-status-row-teardown-ipc.ts | 6 +- src/main/orcad/orcad-entry.ts | 118 ++++--- src/main/orcad/orcad-launch-contract.test.ts | 58 +++- src/main/orcad/orcad-lifecycle.ts | 35 ++ .../agent-status-observed-pane-identity.ts | 25 ++ .../agent-status-store-wiring.test-fixture.ts | 51 +++ .../agent-transcript-pane-test-harness.ts | 5 +- ...le-agent-status-permission-renewal.test.ts | 12 + ...ession-tabs-agent-status-heartbeat.test.ts | 15 + ...ile-session-tabs-agent-status-heartbeat.ts | 81 +++-- .../orca-runtime-apply-tracked-pty-title.ts | 2 +- ...rca-runtime-bind-pty-incarnation-handle.ts | 6 +- ...orca-runtime-build-pty-terminal-summary.ts | 2 +- ...minal-side-effect-command-code-detector.ts | 59 +--- .../orca-runtime-fit-override-listeners.ts | 6 - ...me-get-orchestration-dispatch-authority.ts | 31 +- ...rca-runtime-get-pty-record-for-pane-key.ts | 2 +- .../runtime/orca-runtime-get-worktree-ps.ts | 2 +- ...orca-runtime-has-terminals-for-worktree.ts | 6 + ...ntime-hook-agent-status-projection.test.ts | 153 ++++++++- src/main/runtime/orca-runtime-on-pty-data.ts | 11 +- src/main/runtime/orca-runtime-on-pty-exit.ts | 3 +- ...e-prune-mobile-session-tab-group-layout.ts | 31 +- ...refresh-floating-workspace-pty-liveness.ts | 9 +- ...ntime-serialize-agent-prompt-submission.ts | 3 +- ...ntime-stop-exact-terminals-for-worktree.ts | 30 +- .../orca-runtime-stop-requested-pty-ids.ts | 2 +- .../agent-status-and-waits.spec.ts | 2 + .../headless-snapshots.spec.ts | 2 + .../mobile-session-tabs-part-08.spec.ts | 66 +++- .../mobile-summaries-part-02.spec.ts | 62 ++-- .../mobile-summaries-part-03.spec.ts | 9 +- .../mobile-summaries.spec.ts | 42 ++- .../terminal-handles-part-02.spec.ts | 14 +- .../terminal-handles.spec.ts | 3 +- ...erminal-output-and-worker-recovery.spec.ts | 16 +- .../worktree-ps-agent-row-dismissal.spec.ts | 313 ++++++++++++++++++ ...-touch-mobile-session-tabs-for-worktree.ts | 8 + src/main/runtime/orca-runtime.test.ts | 1 + .../agent-status-producer-census.test.ts | 4 +- .../fleet-status-observed-identity.test.ts | 29 ++ src/main/runtime/runtime-agent-row-store.ts | 125 ------- .../runtime-hook-agent-row-selection.test.ts | 159 +++++++++ .../runtime-hook-agent-row-selection.ts | 135 ++++++++ ...untime-mobile-agent-status-builder.test.ts | 39 +++ .../runtime-mobile-agent-status-builder.ts | 13 +- .../runtime-mobile-agent-status-projection.ts | 31 +- ...time-mobile-session-projection-contract.ts | 4 +- .../runtime-mobile-session-projection.ts | 40 ++- .../runtime/runtime-terminal-contracts.ts | 5 +- ...ime-worktree-agent-rows-structured.test.ts | 7 +- .../runtime/runtime-worktree-agent-rows.ts | 2 +- .../runtime-worktree-agent-sources.test.ts | 81 +++-- .../runtime/runtime-worktree-ps-activity.ts | 14 +- .../runtime-worktree-pty-agent-sources.ts | 72 +--- ...ree-structured-agent-rows-liveness.test.ts | 7 +- ...rminal-interactive-wait-visibility.test.ts | 13 +- ...ay-session-agent-hooks.integration.test.ts | 15 +- src/main/ssh/ssh-relay-session.ts | 5 +- .../headless-pty-hydration-ordering.test.ts | 60 ++++ src/main/startup/main-process-observers.ts | 56 +--- .../startup/main-process-runtime-service.ts | 1 - src/main/startup/main-process-state.ts | 4 - .../agent-hook-listener/listener-event.ts | 4 + ...chestration-fleet-agent-status-evidence.ts | 9 +- ...-tabs-decorative-title-fanout.unit.test.ts | 7 +- ...n-tabs-rich-status-boundaries.unit.test.ts | 17 +- tests/e2e/worktree-switch-first-paint.spec.ts | 4 +- 101 files changed, 3246 insertions(+), 992 deletions(-) create mode 100644 src/main/agent-awake-status-lease.ts delete mode 100644 src/main/agent-hooks/hook-provider-session-invalidation.test.ts delete mode 100644 src/main/agent-hooks/hook-provider-session-invalidation.ts delete mode 100644 src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts delete mode 100644 src/main/agent-hooks/hook-status-session-tabs-invalidation.ts create mode 100644 src/main/agent-hooks/hook-status-session-tabs-republish.test.ts create mode 100644 src/main/agent-hooks/hook-status-session-tabs-republish.ts create mode 100644 src/main/agent-hooks/server-start-failure-lifecycle.test.ts create mode 100644 src/main/agent-hooks/server/server-row-ownership.ts create mode 100644 src/main/agent-hooks/terminal-handle-row-identity.test.ts create mode 100644 src/main/orcad/orcad-lifecycle.ts create mode 100644 src/main/runtime/agent-status-store-wiring.test-fixture.ts create mode 100644 src/main/runtime/orca-runtime-tests/worktree-ps-agent-row-dismissal.spec.ts delete mode 100644 src/main/runtime/runtime-agent-row-store.ts create mode 100644 src/main/runtime/runtime-hook-agent-row-selection.test.ts create mode 100644 src/main/runtime/runtime-hook-agent-row-selection.ts create mode 100644 src/main/runtime/runtime-mobile-agent-status-builder.test.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index f1068a0d993..8f1439c2122 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -12,7 +12,7 @@ this order, each independently shippable: 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; only PR 1a has landed. +the step that delivers them; PR 1a and PR 1b have landed. ## The problem this solves @@ -24,11 +24,11 @@ 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 | `src/main/runtime/runtime-agent-row-store.ts` | 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) | +| 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 @@ -92,14 +92,14 @@ 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(tabId, sessionId)`, the key the renderer already uses; 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 | +| Row field | From | +| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `paneKey` | `structuredAgentSessionPaneKey(tabId, sessionId)`, the key the renderer already uses; 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 @@ -151,7 +151,7 @@ sits at the file-length cap. 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` -stays until PR 1b removes the store that feeds it. What this step settles is +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 @@ -186,22 +186,80 @@ pane key two writers. Removing that filter is the first step of PR 2. ## PR 1b: the runtime's retained row store is deleted -Not yet implemented; `RuntimeAgentRowStore` and the retained-versus-hook -reconciliation it feeds are both still in place after PR 1a. +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. -`RuntimeAgentRowStore` keeps the same payload the hook server already holds. -Its only extra is the pty id, used to clear rows on exit and as a fallback key -for the mobile projection. PR 1b will stamp `terminalHandle` on OSC-ingested -rows from the runtime event's `ptyId`, and rewrite the three readers over the -hook server's snapshot: +### The five call sites -- `worktree ps` reads `getStatusSnapshot()` directly; -- `getFreshExplicit` already consults hook rows; it drops the retained input; -- `getFreshForMobile` matches on pane key, then on `terminalHandle`. +| 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` | -One behavior change will follow and is intended: a row the user dismisses on -the desktop disappears from `worktree ps` and the phone at the same time, -instead of lingering until the pty exits. +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_`) 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 2: the renderer subscribes @@ -211,14 +269,14 @@ 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 | +| 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 @@ -242,14 +300,55 @@ call it. - 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 existing `worktree ps` suites pass unchanged, which is the - characterization that will show PR 1b's deletion of the retained store - changed no listing. +- 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. diff --git a/src/main/agent-awake-service-platform-assertions.test.ts b/src/main/agent-awake-service-platform-assertions.test.ts index 7b3566b322f..231b1aaaf13 100644 --- a/src/main/agent-awake-service-platform-assertions.test.ts +++ b/src/main/agent-awake-service-platform-assertions.test.ts @@ -16,6 +16,7 @@ vi.mock('electron', () => ({ function workingStatus(): AgentAwakeStatus { return { + paneKey: 'pane-1', state: 'working', receivedAt: 1_000, observedInCurrentRuntime: true diff --git a/src/main/agent-awake-service.test.ts b/src/main/agent-awake-service.test.ts index d1792e665fd..12dc5abaa63 100644 --- a/src/main/agent-awake-service.test.ts +++ b/src/main/agent-awake-service.test.ts @@ -16,6 +16,7 @@ vi.mock('electron', () => ({ function workingStatus(overrides: Partial = {}): AgentAwakeStatus { return { + paneKey: 'pane-1', state: 'working', receivedAt: 1_000, observedInCurrentRuntime: true, @@ -279,6 +280,35 @@ describe('AgentAwakeService', () => { service.dispose() }) + it('renews a working lease across two hours without semantic status churn', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const blocker = createBlocker() + const service = createService(() => Date.now(), blocker) + const listener = vi.fn() + service.subscribe(listener) + service.setMode('auto') + service.setStatuses([workingStatus()]) + + for (let index = 0; index < 5; index += 1) { + vi.advanceTimersByTime(30 * 60 * 1000) + service.observeStatusFreshness( + workingStatus({ receivedAt: Date.now(), observedInCurrentRuntime: true }) + ) + } + + expect(Date.now()).toBeGreaterThan(1_000 + AGENT_AWAKE_STATUS_STALE_AFTER_MS) + expect(service.getStatus()).toEqual({ mode: 'auto', active: true }) + expect(blocker.stop).not.toHaveBeenCalled() + expect(listener).toHaveBeenCalledTimes(2) + + vi.advanceTimersByTime(AGENT_AWAKE_STATUS_STALE_AFTER_MS) + expect(service.getStatus()).toEqual({ mode: 'auto', active: true }) + vi.advanceTimersByTime(1) + expect(service.getStatus()).toEqual({ mode: 'auto', active: false }) + service.dispose() + }) + it('keeps the blocker id when stop fails and Electron reports it is still started', () => { const blocker = createBlocker() blocker.stop.mockImplementation(() => { diff --git a/src/main/agent-awake-service.ts b/src/main/agent-awake-service.ts index 6be27e9d0e6..45db4e8608b 100644 --- a/src/main/agent-awake-service.ts +++ b/src/main/agent-awake-service.ts @@ -1,5 +1,4 @@ import { powerMonitor, powerSaveBlocker } from 'electron' -import type { AgentStatusState } from '../shared/agent-status-types' import { normalizeComputerAwakeMode, type ComputerAwakeMode, @@ -7,14 +6,12 @@ import { } from '../shared/computer-awake-mode' import { LinuxLidSleepAssertion } from './linux-lid-sleep-assertion' import { MacosSystemSleepAssertion } from './macos-system-sleep-assertion' +import { AgentAwakeStatusLease, type AgentAwakeStatus } from './agent-awake-status-lease' -export const AGENT_AWAKE_STATUS_STALE_AFTER_MS = 2 * 60 * 60 * 1000 - -export type AgentAwakeStatus = { - state: AgentStatusState - receivedAt: number - observedInCurrentRuntime: boolean -} +export { + AGENT_AWAKE_STATUS_STALE_AFTER_MS, + type AgentAwakeStatus +} from './agent-awake-status-lease' type PowerSaveBlocker = { start: (type: 'prevent-app-suspension' | 'prevent-display-sleep') => number @@ -47,9 +44,7 @@ type AgentAwakeServiceOptions = { export class AgentAwakeService { private mode: ComputerAwakeMode = 'off' - private statuses: AgentAwakeStatus[] = [] private blockerId: number | null = null - private staleTimer: ReturnType | null = null private readonly statusListeners = new Set<(status: ComputerAwakeStatus) => void>() private lastPublishedStatus: ComputerAwakeStatus | null = null private readonly blocker: PowerSaveBlocker @@ -58,12 +53,14 @@ export class AgentAwakeService { private readonly macosAssertion: PlatformAwakeAssertion private readonly platform: NodeJS.Platform private readonly now: () => number + private readonly statusLease: AgentAwakeStatusLease private readonly unsubscribeResume: (() => void) | null constructor(options: AgentAwakeServiceOptions = {}) { this.blocker = options.blocker ?? powerSaveBlocker this.logger = options.logger ?? console this.now = options.now ?? Date.now + this.statusLease = new AgentAwakeStatusLease(this.now, () => this.refresh('stale-expiry')) // Windows lid close is intentionally not modeled as an assertion here: // keeping it awake requires mutating the user's global power plan. this.linuxAssertion = @@ -105,11 +102,20 @@ export class AgentAwakeService { } setStatuses(statuses: AgentAwakeStatus[]): void { - // Copy the array, not every row: the hook server allocates each row fresh per event. - this.statuses = [...statuses] + this.statusLease.replace(statuses) this.refresh('status-change') } + /** Renew one accepted observation without rescanning every active agent. */ + observeStatusFreshness(status: AgentAwakeStatus): void { + if (!this.statusLease.renew(status)) { + return + } + if (this.mode === 'auto' && this.lastPublishedStatus?.active !== true) { + this.applyAwakeDecision('status-freshness', 1) + } + } + getStatus(): ComputerAwakeStatus { const workingAgentCount = this.getEligibleRunningStatusCount() return { @@ -129,7 +135,7 @@ export class AgentAwakeService { } dispose(): void { - this.clearStaleTimer() + this.statusLease.dispose() this.unsubscribeResume?.() this.stopBlocker('dispose') this.macosAssertion.dispose() @@ -137,8 +143,11 @@ export class AgentAwakeService { } private refresh(reason: string): void { - this.scheduleStaleTimer() const runningStatusCount = this.getEligibleRunningStatusCount() + this.applyAwakeDecision(reason, runningStatusCount) + } + + private applyAwakeDecision(reason: string, runningStatusCount: number): void { const shouldBlock = this.mode === 'on' || (this.mode === 'auto' && runningStatusCount > 0) if (shouldBlock) { const macosAssertionActive = this.startMacosAssertion(reason) @@ -171,56 +180,7 @@ export class AgentAwakeService { } private getEligibleRunningStatusCount(): number { - const now = this.now() - // Counted in place: the filtered array was only ever measured, and this runs per hook event. - return this.statuses.reduce((count, s) => count + (this.isWakeEligible(s, now) ? 1 : 0), 0) - } - - private isWakeEligible(status: AgentAwakeStatus, now: number): boolean { - return ( - status.observedInCurrentRuntime && - status.state === 'working' && - Number.isFinite(status.receivedAt) && - now - status.receivedAt <= AGENT_AWAKE_STATUS_STALE_AFTER_MS - ) - } - - private scheduleStaleTimer(): void { - this.clearStaleTimer() - const now = this.now() - let earliestExpiry: number | null = null - for (const status of this.statuses) { - if ( - !status.observedInCurrentRuntime || - status.state !== 'working' || - !Number.isFinite(status.receivedAt) - ) { - continue - } - const expiry = status.receivedAt + AGENT_AWAKE_STATUS_STALE_AFTER_MS - if (expiry <= now) { - continue - } - earliestExpiry = earliestExpiry === null ? expiry : Math.min(earliestExpiry, expiry) - } - if (earliestExpiry === null) { - return - } - this.staleTimer = setTimeout(() => { - this.staleTimer = null - this.refresh('stale-expiry') - }, earliestExpiry - now) - if (typeof this.staleTimer.unref === 'function') { - this.staleTimer.unref() - } - } - - private clearStaleTimer(): void { - if (!this.staleTimer) { - return - } - clearTimeout(this.staleTimer) - this.staleTimer = null + return this.statusLease.countEligible() } private startBlocker(reason: string, runningStatusCount: number): void { diff --git a/src/main/agent-awake-status-lease.ts b/src/main/agent-awake-status-lease.ts new file mode 100644 index 00000000000..327bbc201ea --- /dev/null +++ b/src/main/agent-awake-status-lease.ts @@ -0,0 +1,106 @@ +import type { AgentStatusState } from '../shared/agent-status-types' + +export const AGENT_AWAKE_STATUS_STALE_AFTER_MS = 2 * 60 * 60 * 1000 + +export type AgentAwakeStatus = { + paneKey: string + state: AgentStatusState + receivedAt: number + observedInCurrentRuntime: boolean +} + +export class AgentAwakeStatusLease { + private statuses = new Map() + private timer: ReturnType | null = null + private timerExpiresAt: number | null = null + + constructor( + private readonly now: () => number, + private readonly onExpiry: () => void + ) {} + + replace(statuses: AgentAwakeStatus[]): void { + this.statuses = new Map(statuses.map((status) => [status.paneKey, status])) + this.scheduleNextExpiry() + } + + /** Returns whether the renewed row is currently wake-eligible. */ + renew(status: AgentAwakeStatus): boolean { + this.statuses.set(status.paneKey, status) + const now = this.now() + if (!this.isEligible(status, now)) { + return false + } + this.scheduleAt(status.receivedAt + AGENT_AWAKE_STATUS_STALE_AFTER_MS, now) + return true + } + + countEligible(): number { + const now = this.now() + let count = 0 + for (const status of this.statuses.values()) { + if (this.isEligible(status, now)) { + count += 1 + } + } + return count + } + + dispose(): void { + this.clearTimer() + } + + private isEligible(status: AgentAwakeStatus, now: number): boolean { + return ( + status.observedInCurrentRuntime && + status.state === 'working' && + Number.isFinite(status.receivedAt) && + now - status.receivedAt <= AGENT_AWAKE_STATUS_STALE_AFTER_MS + ) + } + + private scheduleNextExpiry(): void { + this.clearTimer() + const now = this.now() + let earliestExpiry: number | null = null + for (const status of this.statuses.values()) { + if (!this.isEligible(status, now)) { + continue + } + const expiry = status.receivedAt + AGENT_AWAKE_STATUS_STALE_AFTER_MS + const nextCheckAt = expiry === now ? now + 1 : expiry + earliestExpiry = earliestExpiry === null ? nextCheckAt : Math.min(earliestExpiry, nextCheckAt) + } + if (earliestExpiry !== null) { + this.scheduleAt(earliestExpiry, now) + } + } + + private scheduleAt(expiry: number, now: number): void { + if ( + expiry <= now || + (this.timer !== null && this.timerExpiresAt !== null && this.timerExpiresAt <= expiry) + ) { + return + } + this.clearTimer() + this.timerExpiresAt = expiry + this.timer = setTimeout(() => { + this.timer = null + this.timerExpiresAt = null + this.scheduleNextExpiry() + this.onExpiry() + }, expiry - now) + if (typeof this.timer.unref === 'function') { + this.timer.unref() + } + } + + private clearTimer(): void { + if (this.timer !== null) { + clearTimeout(this.timer) + this.timer = null + } + this.timerExpiresAt = null + } +} diff --git a/src/main/agent-hooks/hook-provider-session-invalidation.test.ts b/src/main/agent-hooks/hook-provider-session-invalidation.test.ts deleted file mode 100644 index 15338c20056..00000000000 --- a/src/main/agent-hooks/hook-provider-session-invalidation.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createHookProviderSessionInvalidator } from './hook-provider-session-invalidation' - -describe('createHookProviderSessionInvalidator', () => { - it('names the worktree the first time a pane reports a provider session', () => { - const collect = createHookProviderSessionInvalidator() - - expect(collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }])).toEqual(['w1']) - }) - - it('stays quiet while the same session keeps being reported', () => { - const collect = createHookProviderSessionInvalidator() - const rows = [{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }] - collect(rows) - - expect(collect(rows)).toEqual([]) - }) - - it('names the worktree when a pane relaunches under a new session', () => { - const collect = createHookProviderSessionInvalidator() - collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) - - expect(collect([{ paneKey: 'tab:leaf', sessionId: 's2', worktreeId: 'w1' }])).toEqual(['w1']) - }) - - it('names the worktree when a pane loses its session entirely', () => { - const collect = createHookProviderSessionInvalidator() - collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) - - expect(collect([])).toEqual(['w1']) - }) - - it('names both worktrees when a pane moves without changing session', () => { - const collect = createHookProviderSessionInvalidator() - collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) - - expect(collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w2' }])).toEqual([ - 'w1', - 'w2' - ]) - }) - - it('invalidates when Pi keeps its session id but changes transcript path', () => { - const collect = createHookProviderSessionInvalidator() - collect([ - { paneKey: 'tab:leaf', sessionId: 's1', transcriptPath: '/pi/a.jsonl', worktreeId: 'w1' } - ]) - - expect( - collect([ - { paneKey: 'tab:leaf', sessionId: 's1', transcriptPath: '/pi/b.jsonl', worktreeId: 'w1' } - ]) - ).toEqual(['w1']) - }) - - it('retains the known worktree when a later hook omits it', () => { - const collect = createHookProviderSessionInvalidator() - collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) - - expect(collect([{ paneKey: 'tab:leaf', sessionId: 's2' }])).toEqual(['w1']) - }) - - it('ignores a session with no worktree to invalidate', () => { - const collect = createHookProviderSessionInvalidator() - - expect(collect([{ paneKey: 'tab:leaf', sessionId: 's1' }])).toEqual([]) - }) -}) diff --git a/src/main/agent-hooks/hook-provider-session-invalidation.ts b/src/main/agent-hooks/hook-provider-session-invalidation.ts deleted file mode 100644 index 6ef1e6f7d63..00000000000 --- a/src/main/agent-hooks/hook-provider-session-invalidation.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { AgentHookProviderSessionIdentity } from './server' - -type KnownSession = { sessionId: string; transcriptPath?: string; worktreeId: string } - -/** Names worktrees whose hook-reported resume identity changed. */ -export function createHookProviderSessionInvalidator(): ( - identities: readonly AgentHookProviderSessionIdentity[] -) => string[] { - let known = new Map() - return (identities) => { - const next = new Map() - const changedWorktrees = new Set() - for (const identity of identities) { - const previous = known.get(identity.paneKey) - const worktreeId = identity.worktreeId ?? previous?.worktreeId - if (!worktreeId) { - continue - } - next.set(identity.paneKey, { - sessionId: identity.sessionId, - ...(identity.transcriptPath ? { transcriptPath: identity.transcriptPath } : {}), - worktreeId - }) - if ( - previous?.sessionId !== identity.sessionId || - previous?.transcriptPath !== identity.transcriptPath || - previous?.worktreeId !== worktreeId - ) { - if (previous?.worktreeId !== worktreeId) { - changedWorktrees.add(previous?.worktreeId ?? worktreeId) - } - changedWorktrees.add(worktreeId) - } - } - for (const [paneKey, previous] of known) { - if (!next.has(paneKey)) { - changedWorktrees.add(previous.worktreeId) - } - } - known = next - return [...changedWorktrees] - } -} diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts deleted file mode 100644 index fc2482cbdb0..00000000000 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' -import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' -import { createHookStatusSessionTabsInvalidator } from './hook-status-session-tabs-invalidation' - -function working( - overrides: Partial = {}, - payload: Partial = {} -): AgentHookEventPayload { - return { - paneKey: 'tab:leaf', - connectionId: null, - payload: { state: 'working', prompt: 'fix the tests', agentType: 'claude', ...payload }, - ...overrides - } -} - -describe('createHookStatusSessionTabsInvalidator', () => { - it('invalidates the first time a pane reports', () => { - const changed = createHookStatusSessionTabsInvalidator() - - expect(changed(working())).toBe(true) - }) - - it('stays quiet while the same status keeps being pinged', () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working()) - - expect(changed(working())).toBe(false) - }) - - it('invalidates when a restored row is confirmed by live activity', () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working({ restoredUnconfirmed: true })) - - expect(changed(working())).toBe(true) - }) - - it.each([ - ['state', { state: 'waiting' as const }], - ['workingMode', { workingMode: 'monitoring' as const }], - ['prompt', { prompt: 'ship it' }], - ['agentType', { agentType: 'codex' }], - ['toolName', { toolName: 'Bash' }], - ['interactivePrompt', { interactivePrompt: '{"questions":[]}' }], - ['interrupted', { interrupted: true }] - ])('invalidates when %s changes', (_field, payload) => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working()) - - expect(changed(working({}, payload))).toBe(true) - }) - - it('invalidates when the completion stamp is added, changed, or removed', () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working()) - - expect(changed(working({}, { turnCompletedAt: 100 }))).toBe(true) - expect(changed(working({}, { turnCompletedAt: 200 }))).toBe(true) - expect(changed(working())).toBe(true) - }) - - it('invalidates when the assistant body changes', () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working({}, { lastAssistantMessage: 'First answer' })) - - expect(changed(working({}, { lastAssistantMessage: 'Corrected answer' }))).toBe(true) - }) - - it('ignores resume-identity rows, which the provider-session path owns', () => { - const changed = createHookStatusSessionTabsInvalidator() - - expect(changed(working({ providerSessionOnly: true }))).toBe(false) - }) - - it('tracks panes independently', () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working()) - - expect(changed(working({ paneKey: 'tab:other' }))).toBe(true) - expect(changed(working())).toBe(false) - }) - - it('re-arms a forgotten pane so an identical relaunch still invalidates', () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working()) - changed.forgetPane('tab:leaf') - - expect(changed(working())).toBe(true) - }) - - it("names an SSH host's panes so a disconnect can republish each of them", () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working({ connectionId: 'conn-1' })) - changed(working({ paneKey: 'tab:remote', connectionId: 'conn-1' })) - changed(working({ paneKey: 'tab:local' })) - - expect(changed.forgetConnection('conn-1').sort()).toEqual(['tab:leaf', 'tab:remote']) - expect(changed(working({ paneKey: 'tab:local' }))).toBe(false) - }) -}) diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts deleted file mode 100644 index 04902579855..00000000000 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' -import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' - -type KnownStatus = { - connectionId: string | null - payload: ParsedAgentStatusPayload - restoredUnconfirmed: boolean -} - -/** Reports whether a hook status event changed anything the `session.tabs` - * projection publishes, so a repeated same-state ping costs no snapshot rebuild. - * Mirrors `retainAgentRowSnapshot`'s change set plus hook restore provenance. */ -export function createHookStatusSessionTabsInvalidator(): { - (event: AgentHookEventPayload): boolean - forgetPane: (paneKey: string) => void - forgetConnection: (connectionId: string) => string[] -} { - const known = new Map() - const invalidator = (event: AgentHookEventPayload): boolean => { - // Why: resume-identity rows carry transport placeholders, not status; the - // provider-session invalidator owns their republish. - if (event.providerSessionOnly === true) { - return false - } - const previous = known.get(event.paneKey) - const next = event.payload - const restoredUnconfirmed = event.restoredUnconfirmed === true - known.set(event.paneKey, { - connectionId: event.connectionId, - payload: next, - restoredUnconfirmed - }) - return ( - !previous || - previous.payload.state !== next.state || - previous.payload.workingMode !== next.workingMode || - previous.payload.prompt !== next.prompt || - (previous.payload.agentType ?? null) !== (next.agentType ?? null) || - (previous.payload.toolName ?? null) !== (next.toolName ?? null) || - (previous.payload.interactivePrompt ?? null) !== (next.interactivePrompt ?? null) || - (previous.payload.interrupted ?? false) !== (next.interrupted ?? false) || - (previous.payload.turnCompletedAt ?? null) !== (next.turnCompletedAt ?? null) || - (previous.payload.lastAssistantMessage ?? null) !== (next.lastAssistantMessage ?? null) || - previous.restoredUnconfirmed !== restoredUnconfirmed - ) - } - // Why: a cleared pane must re-arm, else the memo swallows the first event of the - // next agent when it happens to match the one that just went away. - invalidator.forgetPane = (paneKey: string): void => { - known.delete(paneKey) - } - // Why: an SSH disconnect clears a whole host's rows at once and names no pane, so - // the caller needs the pane list back to republish each affected workspace. - invalidator.forgetConnection = (connectionId: string): string[] => { - const forgotten: string[] = [] - for (const [paneKey, status] of known) { - if (status.connectionId === connectionId) { - known.delete(paneKey) - forgotten.push(paneKey) - } - } - return forgotten - } - return invalidator -} diff --git a/src/main/agent-hooks/hook-status-session-tabs-republish.test.ts b/src/main/agent-hooks/hook-status-session-tabs-republish.test.ts new file mode 100644 index 00000000000..150c00a0137 --- /dev/null +++ b/src/main/agent-hooks/hook-status-session-tabs-republish.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { installHookStatusSessionTabsRepublish } from './hook-status-session-tabs-republish' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' +import { + createMobileSessionTabsAgentStatusHeartbeat, + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS +} from '../runtime/mobile-session-tabs-agent-status-heartbeat' + +const PANE = 'tab-provider:11111111-1111-4111-8111-111111111111' + +function providerOnly(server: AgentHookServer, transcriptPath: string): void { + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-provider', + worktreeId: 'repo::/worktree', + providerSession: { key: 'session_id', id: 'pi-session', transcriptPath }, + providerSessionOnly: true, + payload: { state: 'done', prompt: '', agentType: 'pi' } + }, + null + ) +} + +describe('hook status session-tabs republish', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('delivers provider-only changes and authority retirement from the owner mutation stream', () => { + const server = new AgentHookServer() + const touch = vi.fn() + const uninstall = installHookStatusSessionTabsRepublish(server, () => ({ + getTerminalWorktreeIdForHandle: () => null, + getTerminalWorktreeIdForPaneKey: () => null, + scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree: vi.fn(), + touchMobileSessionTabsForWorktree: touch + })) + try { + providerOnly(server, '/sessions/first.jsonl') + expect(touch).toHaveBeenLastCalledWith('repo::/worktree') + + touch.mockClear() + providerOnly(server, '/sessions/first.jsonl') + expect(touch).not.toHaveBeenCalled() + + providerOnly(server, '/sessions/replaced.jsonl') + expect(touch).toHaveBeenCalledTimes(1) + + touch.mockClear() + server.retirePaneAuthority(PANE) + expect(touch).toHaveBeenCalledTimes(1) + expect(touch).toHaveBeenCalledWith('repo::/worktree') + } finally { + uninstall() + } + }) + + it('deduplicates the old and new ownership of one moved row', () => { + const server = new AgentHookServer() + const touch = vi.fn() + providerOnly(server, '/sessions/first.jsonl') + const uninstall = installHookStatusSessionTabsRepublish(server, () => ({ + getTerminalWorktreeIdForHandle: () => null, + getTerminalWorktreeIdForPaneKey: () => null, + scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree: vi.fn(), + touchMobileSessionTabsForWorktree: touch + })) + try { + server.transferPaneAuthority(PANE, 'tab-new:22222222-2222-4222-8222-222222222222') + expect(touch).toHaveBeenCalledTimes(1) + expect(touch).toHaveBeenCalledWith('repo::/worktree') + } finally { + uninstall() + } + }) + + it('renews mobile freshness across its lease through a bounded heartbeat cadence', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const server = new AgentHookServer() + const publications: number[] = [] + const rowMutations = vi.fn() + const enrichedStatuses = vi.fn() + const semanticStatuses = vi.fn() + let heartbeat: ReturnType + const runtime = { + getTerminalWorktreeIdForHandle: () => null, + getTerminalWorktreeIdForPaneKey: () => null, + scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree: (worktreeId: string) => + heartbeat.scheduleWorktreeHeartbeat(worktreeId), + touchMobileSessionTabsForWorktree: (worktreeId: string) => { + heartbeat.observeWorktreeRefresh(worktreeId) + publications.push(Date.now()) + } + } + heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + () => [], + (worktreeId) => runtime.touchMobileSessionTabsForWorktree(worktreeId) + ) + const uninstall = installHookStatusSessionTabsRepublish(server, () => runtime) + server.subscribeStatusRowMutations(rowMutations) + server.subscribeEnrichedStatus(enrichedStatuses) + server.subscribeStatusChanges(semanticStatuses) + const observation = { + paneKey: PANE, + tabId: 'tab-provider', + worktreeId: 'repo::/worktree', + payload: { state: 'working' as const, prompt: 'active', agentType: 'codex' as const } + } + + try { + server.ingestTerminalStatus(observation) + for (let minute = 1; minute <= 31; minute += 1) { + vi.advanceTimersByTime(60_000) + server.ingestTerminalStatus(observation) + vi.runOnlyPendingTimers() + } + + expect(Date.now()).toBeGreaterThan(1_000 + AGENT_STATUS_STALE_AFTER_MS) + const renewed = server.getStatusSnapshot()[0] + expect(renewed?.state).toBe('working') + expect(Date.now() - renewed!.receivedAt).toBeLessThan(AGENT_STATUS_STALE_AFTER_MS) + expect(publications).toEqual([ + 1_000, + 1_000 + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS, + 1_000 + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS * 2 + ]) + expect(rowMutations).toHaveBeenCalledTimes(1) + expect(enrichedStatuses).toHaveBeenCalledTimes(1) + expect(semanticStatuses).toHaveBeenCalledTimes(1) + } finally { + uninstall() + heartbeat.dispose() + server.stop() + } + }) +}) diff --git a/src/main/agent-hooks/hook-status-session-tabs-republish.ts b/src/main/agent-hooks/hook-status-session-tabs-republish.ts new file mode 100644 index 00000000000..b53e4e90501 --- /dev/null +++ b/src/main/agent-hooks/hook-status-session-tabs-republish.ts @@ -0,0 +1,67 @@ +import type { AgentHookServer } from './server' + +type SessionTabsRepublisher = { + getTerminalWorktreeIdForHandle(handle: string): string | null + getTerminalWorktreeIdForPaneKey(paneKey: string): string | null + scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree(worktreeId: string): void + touchMobileSessionTabsForWorktree(worktreeId: string): void +} + +type StatusStore = Pick + +/** + * Republish `session.tabs` whenever a pane's status row changes. + * + * Every producer — hook posts, the relay receivers, and main's own OSC parse — lands in the + * store, so this is the one signal that a pane's published projection is out of date. Nothing + * else republishes on a status-only transition, so a paired client would otherwise keep the + * pane's last projection until an unrelated PTY touch came along (#7970). + */ +export function installHookStatusSessionTabsRepublish( + statusStore: StatusStore, + getRuntime: () => SessionTabsRepublisher | null | undefined +): () => void { + const resolveWorktreeId = ( + identity: { paneKey: string; worktreeId?: string; terminalHandle?: string }, + runtime: SessionTabsRepublisher + ): string | null => + identity.worktreeId ?? + (identity.terminalHandle + ? runtime.getTerminalWorktreeIdForHandle(identity.terminalHandle) + : null) ?? + runtime.getTerminalWorktreeIdForPaneKey(identity.paneKey) + + const unsubscribeMutations = statusStore.subscribeStatusRowMutations((mutation) => { + const runtime = getRuntime() + if (!runtime) { + return + } + const worktreeIds = new Set() + for (const identity of [mutation.before, mutation.after]) { + if (!identity) { + continue + } + const worktreeId = resolveWorktreeId(identity, runtime) + if (worktreeId) { + worktreeIds.add(worktreeId) + } + } + for (const worktreeId of worktreeIds) { + runtime.touchMobileSessionTabsForWorktree(worktreeId) + } + }) + const unsubscribeFreshness = statusStore.subscribeStatusFreshness((status) => { + const runtime = getRuntime() + if (!runtime) { + return + } + const worktreeId = resolveWorktreeId(status, runtime) + if (worktreeId) { + runtime.scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree(worktreeId) + } + }) + return () => { + unsubscribeMutations() + unsubscribeFreshness() + } +} diff --git a/src/main/agent-hooks/server-ingest-terminal-status.test.ts b/src/main/agent-hooks/server-ingest-terminal-status.test.ts index 0ac729fa72e..f54a5c26802 100644 --- a/src/main/agent-hooks/server-ingest-terminal-status.test.ts +++ b/src/main/agent-hooks/server-ingest-terminal-status.test.ts @@ -267,6 +267,7 @@ describe('AgentHookServer ingestTerminalStatus', () => { worktreeId: 'wt-1', connectionId: null, receivedAt: 1_000, + evidenceObservedAt: 1_000, stateStartedAt: 1_000, payload: { state: 'working', @@ -282,6 +283,7 @@ describe('AgentHookServer ingestTerminalStatus', () => { worktreeId: 'wt-1', connectionId: null, receivedAt: 1_000, + evidenceObservedAt: 1_000, stateStartedAt: 1_000, state: 'working', prompt: 'ship it', @@ -294,6 +296,49 @@ describe('AgentHookServer ingestTerminalStatus', () => { } }) + it('accepts a runtime-owned legacy pane without opening legacy relay ingress', () => { + const server = new AgentHookServer() + const event = { + paneKey: 'legacy-tab:7', + tabId: 'legacy-tab', + ptyId: 'legacy-pty', + terminalHandle: 'term_legacy', + worktreeId: 'wt-1', + payload: { state: 'working' as const, prompt: 'legacy task', agentType: 'codex' as const } + } + + server.ingestTerminalStatus(event) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: 'legacy-tab:7', + tabId: 'legacy-tab', + terminalHandle: 'term_legacy', + prompt: 'legacy task' + }) + ]) + server.stop() + }) + + it.each([ + ['PTY id', { ptyId: undefined }], + ['terminal handle', { terminalHandle: undefined }], + ['matching tab', { tabId: 'other-tab' }] + ])('rejects a legacy terminal row without its runtime-owned %s', (_label, overrides) => { + const server = new AgentHookServer() + server.ingestTerminalStatus({ + paneKey: 'legacy-tab:7', + tabId: 'legacy-tab', + ptyId: 'legacy-pty', + terminalHandle: 'term_legacy', + payload: { state: 'working', prompt: 'legacy task', agentType: 'codex' }, + ...overrides + }) + + expect(server.getStatusSnapshot()).toEqual([]) + server.stop() + }) + it('suppresses exact duplicate runtime terminal status observations', () => { vi.useFakeTimers() vi.setSystemTime(1_000) @@ -320,7 +365,8 @@ describe('AgentHookServer ingestTerminalStatus', () => { expect(server.getStatusSnapshot()).toEqual([ expect.objectContaining({ paneKey: PANE, - receivedAt: 1_000, + receivedAt: 1_250, + evidenceObservedAt: 1_250, stateStartedAt: 1_000, state: 'working', prompt: 'same turn' diff --git a/src/main/agent-hooks/server-start-failure-lifecycle.test.ts b/src/main/agent-hooks/server-start-failure-lifecycle.test.ts new file mode 100644 index 00000000000..c4c28f71482 --- /dev/null +++ b/src/main/agent-hooks/server-start-failure-lifecycle.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as NodeHttp from 'node:http' + +const { createServerMock, getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + createServerMock: vi.fn(), + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('node:http', async (importOriginal) => { + const actual = await importOriginal() + createServerMock.mockImplementation(actual.createServer) + return { ...actual, createServer: createServerMock } +}) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) + +import { AgentHookServer, _internals } from './server' +import { makePaneKey } from '../../shared/stable-pane-id' + +const PANE = makePaneKey('tab-lifecycle', '11111111-1111-4111-8111-111111111111') + +beforeEach(() => { + _internals.resetCachesForTests() + createServerMock.mockClear() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('AgentHookServer startup failure lifecycle', () => { + it('rolls back only transport on bind failure and preserves owner state through retry', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-hook-start-failure-')) + const persisted = new AgentHookServer() + await persisted.start({ env: 'production', userDataPath }) + persisted.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-lifecycle', + worktreeId: 'wt-lifecycle', + payload: { state: 'working', prompt: 'surviving PTY', agentType: 'codex' } + }, + 'ssh-lifecycle' + ) + persisted.stop() + const server = new AgentHookServer() + const rendererListener = vi.fn() + const statusChanges = vi.fn() + const freshness = vi.fn() + const enrichedStatuses = vi.fn() + const rowMutations = vi.fn() + server.setListener(rendererListener) + server.subscribeStatusChanges(statusChanges) + server.subscribeStatusFreshness(freshness) + server.subscribeEnrichedStatus(enrichedStatuses) + server.subscribeStatusRowMutations(rowMutations) + + try { + let startupErrorListener: ((error: Error) => void) | null = null + const failedServer = { + once: vi.fn((event: string, listener: (error: Error) => void) => { + if (event === 'error') { + startupErrorListener = listener + } + return failedServer + }), + off: vi.fn(() => failedServer), + listen: vi.fn(() => { + startupErrorListener?.(new Error('listener unavailable')) + return failedServer + }), + close: vi.fn(() => failedServer) + } + createServerMock.mockImplementationOnce(() => failedServer) + + await expect(server.start({ env: 'production', userDataPath })).rejects.toThrow( + 'listener unavailable' + ) + expect(failedServer.close).toHaveBeenCalledOnce() + expect(server.buildPtyEnv()).toEqual({}) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, prompt: 'surviving PTY' }) + ]) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-lifecycle', + worktreeId: 'wt-lifecycle', + payload: { state: 'working', prompt: 'newer in-process state', agentType: 'codex' } + }, + 'ssh-lifecycle' + ) + const duplicateOsc = { + paneKey: PANE, + tabId: 'tab-lifecycle', + worktreeId: 'wt-lifecycle', + connectionId: 'ssh-lifecycle', + payload: { state: 'working' as const, prompt: 'newer in-process state', agentType: 'codex' } + } + server.ingestTerminalStatus(duplicateOsc) + + expect(rendererListener).toHaveBeenCalledTimes(1) + expect(enrichedStatuses).toHaveBeenCalledTimes(1) + expect(rowMutations).toHaveBeenCalledTimes(1) + expect(statusChanges).toHaveBeenCalledTimes(1) + expect(freshness).toHaveBeenCalledTimes(1) + expect( + JSON.parse(readFileSync(server.lastStatusPath!, 'utf8')).entries[PANE].payload.prompt + ).toBe('surviving PTY') + + await server.start({ env: 'production', userDataPath }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + worktreeId: 'wt-lifecycle', + prompt: 'newer in-process state' + }) + ]) + expect(server.buildPtyEnv()).toMatchObject({ + ORCA_AGENT_HOOK_ENV: 'production', + ORCA_AGENT_HOOK_PORT: expect.any(String), + ORCA_AGENT_HOOK_TOKEN: expect.any(String), + ORCA_AGENT_HOOK_ENDPOINT: server.endpointFilePath + }) + server.ingestTerminalStatus(duplicateOsc) + expect(freshness).toHaveBeenCalledTimes(2) + expect(rendererListener).toHaveBeenCalledTimes(1) + expect(enrichedStatuses).toHaveBeenCalledTimes(1) + expect(rowMutations).toHaveBeenCalledTimes(1) + expect(statusChanges).toHaveBeenCalledTimes(1) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-lifecycle', + worktreeId: 'wt-lifecycle', + payload: { state: 'done', prompt: 'newer in-process state', agentType: 'codex' } + }, + 'ssh-lifecycle' + ) + expect(rendererListener).toHaveBeenCalledTimes(2) + expect(enrichedStatuses).toHaveBeenCalledTimes(2) + expect(rowMutations).toHaveBeenCalledTimes(2) + expect(statusChanges).toHaveBeenCalledTimes(2) + + server.stop() + server.stop() + expect(server.buildPtyEnv()).toEqual({}) + expect(server.getStatusSnapshot()).toEqual([]) + expect(statusChanges).toHaveBeenCalledTimes(3) + expect(statusChanges).toHaveBeenLastCalledWith([]) + } finally { + server.stop() + persisted.stop() + rmSync(userDataPath, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-hooks/server-status-listener-fanout.test.ts b/src/main/agent-hooks/server-status-listener-fanout.test.ts index b6633dbf6f1..ef35674e0ed 100644 --- a/src/main/agent-hooks/server-status-listener-fanout.test.ts +++ b/src/main/agent-hooks/server-status-listener-fanout.test.ts @@ -364,6 +364,39 @@ describe('AgentHookServer listener replay', () => { expect(listener).toHaveBeenCalledWith({ paneKey: PANE }) }) + it('fans out one pane clear per status evicted by tab teardown', () => { + const server = new AgentHookServer() + const siblingPane = makePaneKey('tab-1', '22222222-2222-4222-8222-222222222222') + const otherTabPane = makePaneKey('tab-2', '33333333-3333-4333-8333-333333333333') + for (const paneKey of [PANE, siblingPane, otherTabPane]) { + server.ingestRemote( + { + paneKey, + payload: { state: 'working', agentType: 'claude' } + }, + 'conn-1' + ) + } + const clearListener = vi.fn() + const statusListener = vi.fn() + server.subscribePaneStatusClear(clearListener) + server.subscribeStatusChanges(statusListener) + const evidenceObservedAtByPaneKey = ( + server as unknown as { evidenceObservedAtByPaneKey: Map } + ).evidenceObservedAtByPaneKey + expect(evidenceObservedAtByPaneKey.size).toBe(3) + + server.dropStatusEntriesByTabPrefix('tab-1') + + expect(clearListener.mock.calls.map(([clear]) => clear)).toEqual([ + { paneKey: PANE }, + { paneKey: siblingPane } + ]) + expect(statusListener).toHaveBeenCalledOnce() + expect(server.getStatusSnapshot()).toEqual([expect.objectContaining({ paneKey: otherTabPane })]) + expect([...evidenceObservedAtByPaneKey.keys()]).toEqual([otherTabPane]) + }) + it('batches connection cleanup and retains sibling and local statuses', () => { const server = new AgentHookServer() const paneKeyAt = (prefix: string, index: number): string => diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index f03484f14f9..3fb3f51f5f1 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -11,7 +11,9 @@ export type { AgentHookAuthorityAttestation, AgentHookAuthorityEvidence, AgentHookProviderSessionIdentity, + AgentHookStatusRowMutation, AgentHookStatusChangeEntry, + AgentHookStatusFreshnessObservation, EnrichedAgentHookEventPayload } from './server/server-types' export type { AgentHookSource } @@ -40,6 +42,7 @@ export const _internals = { parseFormEncodedBody, resetCachesForTests: (): void => { clearAllListenerCaches(agentHookServer._getStateForTests()) + agentHookServer._resetRowOwnershipForTests() agentHookServer._resetPromptSentDedupeForTests() agentHookServer._resetConnectionTimestampWatermarksForTests() } diff --git a/src/main/agent-hooks/server/server-authority-aliases.ts b/src/main/agent-hooks/server/server-authority-aliases.ts index 18756cb459c..b3debc397d2 100644 --- a/src/main/agent-hooks/server/server-authority-aliases.ts +++ b/src/main/agent-hooks/server/server-authority-aliases.ts @@ -133,7 +133,7 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut toPaneKey: string, ptyId?: string, updatedAt = Date.now(), - options?: { authorityVerified?: boolean } + options?: { authorityVerified?: boolean; emitStatusRowMutation?: boolean } ): void { if (!isValidPaneKey(fromPaneKey) || !isValidPaneKey(toPaneKey)) { return @@ -142,7 +142,10 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) const existing = this.legacyPaneKeyAliases.get(physicalPaneKey) const normalizedPtyId = ptyId?.trim() || existing?.ptyId || null - const hadStatus = this.state.lastStatusByPaneKey.has(previousOwnerPaneKey) + const previousStatus = this.state.lastStatusByPaneKey.get(previousOwnerPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + const hadStatus = previousStatus !== undefined movePaneCacheState(this.state, previousOwnerPaneKey, toPaneKey) const movedStatus = this.state.lastStatusByPaneKey.get(toPaneKey) as | EnrichedAgentHookEventPayload @@ -155,6 +158,9 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut tabId: owner?.tabId }) } + const transferredStatus = this.state.lastStatusByPaneKey.get(toPaneKey) as + | EnrichedAgentHookEventPayload + | undefined const hydratedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) if (hydratedLaunchTokenHash) { this.hydratedLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) @@ -188,6 +194,11 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey) this.activeHookTurnCompletedAtByPaneKey.set(toPaneKey, activeTurnCompletedAt) } + const evidenceObservedAt = this.evidenceObservedAtByPaneKey.get(previousOwnerPaneKey) + if (evidenceObservedAt !== undefined) { + this.evidenceObservedAtByPaneKey.delete(previousOwnerPaneKey) + this.evidenceObservedAtByPaneKey.set(toPaneKey, evidenceObservedAt) + } const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey) if (authorityObservation) { const owner = parsePaneKey(toPaneKey) @@ -214,6 +225,11 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut this.boundPaneKeyAliases() this.closedAgentStatusPaneKeys.delete(toPaneKey) this.notifyPaneKeyAliasPersistenceListener() + this.commitStatusRowMutation( + previousStatus, + transferredStatus, + options?.emitStatusRowMutation !== false + ) if (hadStatus || persistedAuthority) { this.scheduleStatusPersist() this.notifyStatusChangeListeners() diff --git a/src/main/agent-hooks/server/server-authority-fences.ts b/src/main/agent-hooks/server/server-authority-fences.ts index 0ad1bdeba62..fdbc16d7012 100644 --- a/src/main/agent-hooks/server/server-authority-fences.ts +++ b/src/main/agent-hooks/server/server-authority-fences.ts @@ -1,7 +1,11 @@ import { clearPaneCacheState } from '../../../shared/agent-hook-listener/listener-state' import { parsePaneKey } from '../../../shared/stable-pane-id' import { AgentHookServerAuthorityAliases } from './server-authority-aliases' -import type { RetiredPaneAlias, RetiredPaneFence } from './server-types' +import type { + EnrichedAgentHookEventPayload, + RetiredPaneAlias, + RetiredPaneFence +} from './server-types' export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuthorityAliases { // Why: retirement fences a pane and every alias of it, then deletes those aliases. @@ -21,7 +25,13 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth } this.recordRetiredPaneFence(paneKeys, retiredAliases) const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) - const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key)) + const retiredRows = [...paneKeys].flatMap((key) => { + const row = this.state.lastStatusByPaneKey.get(key) as + | EnrichedAgentHookEventPayload + | undefined + return row ? [row] : [] + }) + const hadStatus = retiredRows.length > 0 for (const key of paneKeys) { this.markPaneClosedForAgentStatus(key) this.restartedStatusLaunchTokenHashByPaneKey.delete(key) @@ -37,6 +47,9 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth if (aliasChanged) { this.notifyPaneKeyAliasPersistenceListener() } + for (const row of retiredRows) { + this.commitStatusRowMutation(row, undefined) + } if (hadStatus || authorityChanged) { this.scheduleStatusPersist() this.notifyStatusChangeListeners() @@ -108,6 +121,7 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth let aliasChanged = false let statusChanged = false const clearedStatusPaneKeys = new Set() + const clearedStatusRows = new Map() for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { if (entry.ptyId !== ptyId) { continue @@ -129,6 +143,10 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) { statusChanged = true clearedStatusPaneKeys.add(entry.stablePaneKey) + clearedStatusRows.set( + entry.stablePaneKey, + this.state.lastStatusByPaneKey.get(entry.stablePaneKey) as EnrichedAgentHookEventPayload + ) } if (shouldClearStablePaneKey) { // Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor. @@ -143,6 +161,9 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth if (aliasChanged) { this.notifyPaneKeyAliasPersistenceListener() } + for (const row of clearedStatusRows.values()) { + this.commitStatusRowMutation(row, undefined) + } if (statusChanged) { this.scheduleStatusPersist() this.notifyStatusChangeListeners() diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index d7669c7449f..9f36d676143 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -37,6 +37,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen if (retained) { this.state.lastStatusByPaneKey.set(deleted.paneKey, retained) } + this.commitStatusRowMutation(deleted, retained) this.scheduleStatusPersist() this.notifyStatusChangeListeners() this.emitStatusDropped(deleted.paneKey) @@ -74,6 +75,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen if (retained) { this.state.lastStatusByPaneKey.set(deleted.paneKey, retained) } + this.commitStatusRowMutation(deleted, retained) evicted.push(deleted.paneKey) } if (evicted.length === 0) { @@ -119,12 +121,16 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen | undefined ) : null - this.clearPaneState(resolvedPaneKey) + const previous = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + this.clearPaneState(resolvedPaneKey, { emitStatusRowMutation: false }) if (retained) { this.state.lastStatusByPaneKey.set(resolvedPaneKey, retained) this.scheduleStatusPersist() this.notifyStatusChangeListeners() } + this.commitStatusRowMutation(previous, retained) cleared += 1 } return cleared @@ -159,6 +165,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) if (deleted) { statusChanged = true + this.commitStatusRowMutation(deleted, undefined) if (deleted.payload.agentType === 'codex') { // Why: a replacement remote process may reuse the pane; don't merge it with the lost connection's children. this.state.codexSubagentRosterByPaneKey.delete(paneKey) diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index 822c7e76f02..e7e68115659 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -1,6 +1,6 @@ import { track } from '../../telemetry/client' import { MAX_PANE_KEY_LEN } from '../../../shared/agent-hook-listener/listener-limits' -import { parsePaneKey } from '../../../shared/stable-pane-id' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { terminalStatusPayloadMatchesHook } from '../../../shared/agent-terminal-status-equivalence' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import type { EnrichedAgentHookEventPayload } from './server-types' @@ -8,32 +8,40 @@ import { AgentHookServerIngestNormalization } from './server-ingest-normalizatio export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization { ingestTerminalStatus(event: { + ptyId?: string paneKey: string tabId?: string worktreeId?: string connectionId?: string | null + terminalHandle?: string payload: ParsedAgentStatusPayload }): void { const physicalPaneKey = event.paneKey.trim() - const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) + let paneKey = this.resolvePaneKeyAlias(physicalPaneKey) const parsedPaneKey = parsePaneKey(paneKey) + const legacyPaneKey = parseLegacyNumericPaneKey(paneKey) if (paneKey.length === 0) { track('agent_hook_unattributed', { reason: 'empty_pane_key' }) return } - if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) { - return - } const reportedTabId = event.tabId !== undefined && event.tabId.trim().length > 0 ? event.tabId.trim() : undefined - if ( - paneKey === physicalPaneKey && - reportedTabId !== undefined && - reportedTabId !== parsedPaneKey.tabId - ) { + const runtimeOwnedLegacyPane = Boolean( + legacyPaneKey && + event.ptyId?.trim() && + event.terminalHandle?.trim() && + reportedTabId === legacyPaneKey.tabId + ) + // Legacy rows are accepted only from the in-process PTY ingress with both runtime identities; + // HTTP and relay paths still require a stable pane key or a registered alias. + if (paneKey.length > MAX_PANE_KEY_LEN || (!parsedPaneKey && !runtimeOwnedLegacyPane)) { return } - const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId + const paneTabId = parsedPaneKey?.tabId ?? legacyPaneKey?.tabId + if (paneKey === physicalPaneKey && reportedTabId !== undefined && reportedTabId !== paneTabId) { + return + } + const tabId = paneKey !== physicalPaneKey ? parsedPaneKey?.tabId : reportedTabId if (this.getAgentStatusDisposition(paneKey) !== 'accept') { return } @@ -45,6 +53,31 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges typeof event.connectionId === 'string' && event.connectionId.trim().length > 0 ? event.connectionId.trim() : null + const terminalHandle = + typeof event.terminalHandle === 'string' && event.terminalHandle.trim().length > 0 + ? event.terminalHandle.trim() + : undefined + let mutationBefore: EnrichedAgentHookEventPayload | undefined + const indexedPaneKey = terminalHandle + ? this.getStatusPaneKeyForTerminalHandle(terminalHandle) + : undefined + if (indexedPaneKey && indexedPaneKey !== paneKey) { + const indexedStatus = this.state.lastStatusByPaneKey.get(indexedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + if ( + indexedStatus && + indexedStatus.terminalHandle === terminalHandle && + this.sameTerminalOwner(indexedStatus, { connectionId, worktreeId }) + ) { + mutationBefore = indexedStatus + this.transferPaneAuthority(indexedPaneKey, paneKey, event.ptyId, Date.now(), { + authorityVerified: true, + emitStatusRowMutation: false + }) + paneKey = this.resolvePaneKeyAlias(paneKey) + } + } const previous = this.state.lastStatusByPaneKey.get(paneKey) as | EnrichedAgentHookEventPayload | undefined @@ -54,6 +87,10 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges event.payload.agentType === 'claude' ) { // Why: OSC has no child identity or lead boundary, so it cannot replace a persisted child-only proof before the lifecycle hook arrives. + if (mutationBefore !== undefined) { + this.commitStatusRowMutation(mutationBefore, previous) + this.emitEnrichedStatus(previous) + } return } // Why: preserve the hook-completed turn stamp while OSC repaints the current state. @@ -65,8 +102,14 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges previous?.connectionId === connectionId && previous.tabId === tabId && previous.worktreeId === worktreeId && + // Why in the unchanged gate: the handle is a join key readers match on, so a pane that + // only just acquired one (or moved to another) must still refresh the row it is stamped on. + previous.terminalHandle === (terminalHandle ?? previous.terminalHandle) && terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp) ) { + // A handle-authority transfer is a new pane observation even when its payload is a + // duplicate; enriched subscribers must capture the replacement pane identity. + this.refreshTerminalStatusEvidence(previous, mutationBefore, mutationBefore !== undefined) return } // Why: the OSC 9999 wire payload has no providerSession field at all, so an OSC observation is @@ -95,10 +138,13 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges worktreeId, connectionId, ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), + ...(terminalHandle ? { terminalHandle } : {}), payload: event.payload }, undefined, - 'osc' + 'osc', + undefined, + mutationBefore ) } } diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 9beb0ad0bbb..e7f68829f3b 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -36,19 +36,22 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.token = randomUUID() this.endpointFileWritten = false this.lastWrittenJson = null - // Why: hydrate before binding the listener so an early hook POST runs against a populated map. - if (this.lastStatusFilePath) { - this.hydrateLastStatusFromDisk() - } - this.captureHydratedAuthorityCommitments() - // Drain before binding the listener so replay cannot race a live hook during startup. - if (this.endpointDir) { - drainAgentHookSpool({ - endpointDir: this.endpointDir, - getPersistedLaunchTokenHash: (paneKey) => - this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), - ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) - }) + if (!this.ownerStateInitialized) { + // Why: hydrate before binding the listener so an early hook POST runs against a populated map. + if (this.lastStatusFilePath) { + this.hydrateLastStatusFromDisk() + } + this.captureHydratedAuthorityCommitments() + // Drain before binding the listener so replay cannot race a live hook during startup. + if (this.endpointDir) { + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: (paneKey) => + this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), + ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) + }) + } + this.ownerStateInitialized = true } const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { if (req.method !== 'POST') { @@ -134,39 +137,51 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.server = createServer((req, res) => { void handleRequest(req, res) }) - await new Promise((resolve, reject) => { - const onStartupError = (err: Error): void => { - // Why: swap the startup reject-handler for a logging one so a later runtime 'error' can't crash main as an unhandled event. - this.server?.off('listening', onListening) - reject(err) - } - const onListening = (): void => { - this.server?.off('error', onStartupError) - this.server?.on('error', (err) => { - console.error('[agent-hooks] server error', err) - }) - const address = this.server!.address() - if (address && typeof address === 'object') { - this.port = address.port + try { + await new Promise((resolve, reject) => { + const onStartupError = (err: Error): void => { + this.server?.off('listening', onListening) + reject(err) } - this.maybeWriteEndpointFile() - resolve() - } - this.server!.once('error', onStartupError) - this.server!.listen(0, '127.0.0.1', onListening) - }) + const onListening = (): void => { + this.server?.off('error', onStartupError) + this.server?.on('error', (err) => { + console.error('[agent-hooks] server error', err) + }) + const address = this.server!.address() + if (address && typeof address === 'object') { + this.port = address.port + } + this.maybeWriteEndpointFile() + resolve() + } + this.server!.once('error', onStartupError) + this.server!.listen(0, '127.0.0.1', onListening) + }) + } catch (error) { + this.rollbackTransportStart() + throw error + } + } + + private rollbackTransportStart(): void { + this.server?.close() + this.server = null + this.port = 0 + this.token = '' + this.endpointFileWritten = false } stop(): void { // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. this.flushStatusPersistSync() - this.server?.close() - this.server = null - this.port = 0 - this.token = '' + this.rollbackTransportStart() this.env = 'production' this.onAgentStatus = null + this.onClaudeStatusLine = null this.onPaneStatusCleared = null + this.onTransportInterference = null + this.transportInterference.reset() for (const timer of this.assistantMessageRetryTimers.values()) { clearTimeout(timer) } @@ -178,6 +193,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.lastStatusFilePath = null this.lastWrittenJson = null this.runtimeObservedStatusPaneKeys.clear() + this.paneKeyByTerminalHandle.clear() this.hydratedAuthorityCommitments = Object.freeze([]) this.hydratedLaunchTokenHashByPaneKey.clear() this.persistedAuthorityCommitmentsByPaneKey.clear() @@ -189,9 +205,20 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.restartedStatusLaunchTokenHashByPaneKey.clear() this.retiredPaneFencesByKey.clear() this.connectionTimestampWatermarkById.clear() + this.evidenceObservedAtByPaneKey.clear() + this.activeHookTurnCompletedAtByPaneKey.clear() this.legacyPaneKeyAliases.clear() + this.paneKeyAliasPersistenceListener = null + this.ownerStateInitialized = false // Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca. clearAllListenerCaches(this.state) this.notifyStatusChangeListeners() + this.paneStatusClearListeners.clear() + this.statusDropListeners.clear() + this.statusChangeListeners.clear() + this.statusFreshnessListeners.clear() + this.providerSessionChangeListeners.clear() + this.enrichedStatusListeners.clear() + this.statusRowMutationListeners.clear() } } diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index 08d2ef21a70..44e2c940753 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -9,6 +9,7 @@ import type { AgentHookAuthorityEvidence, AgentHookProviderSessionIdentity, AgentHookStatusChangeEntry, + AgentHookStatusFreshnessObservation, EnrichedAgentHookEventPayload, StatusDropListener } from './server-types' @@ -57,6 +58,26 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { } } + /** Accepted duplicate evidence renews leases without becoming a semantic row mutation. */ + subscribeStatusFreshness( + listener: (status: AgentHookStatusFreshnessObservation) => void + ): () => void { + this.statusFreshnessListeners.add(listener) + return () => { + this.statusFreshnessListeners.delete(listener) + } + } + + protected emitStatusFreshnessObservation(status: AgentHookStatusFreshnessObservation): void { + for (const listener of this.statusFreshnessListeners) { + try { + listener(status) + } catch (err) { + console.error('[agent-hooks] status-freshness listener threw', err) + } + } + } + subscribeProviderSessionChanges( listener: (providerSessions: AgentHookProviderSessionIdentity[]) => void ): () => void { @@ -177,6 +198,7 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { } if (!enriched.providerSessionOnly) { statuses.push({ + paneKey, state: enriched.payload.state, receivedAt: enriched.receivedAt, observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey) diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts index f5b66222d6d..6d811216f9f 100644 --- a/src/main/agent-hooks/server/server-persistence.ts +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -42,6 +42,9 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio observation: _observation, // Replay provenance is runtime-only and must not survive another restart. isReplay: _isReplay, + // A terminal handle belongs to the runtime that issued it; a hydrated one could only + // rejoin a row to somebody else's terminal. + terminalHandle: _terminalHandle, launchToken, ...persistedPayload } = enrichedPayload diff --git a/src/main/agent-hooks/server/server-reaping.ts b/src/main/agent-hooks/server/server-reaping.ts index 7805303ced1..55a6addbc45 100644 --- a/src/main/agent-hooks/server/server-reaping.ts +++ b/src/main/agent-hooks/server/server-reaping.ts @@ -114,6 +114,7 @@ export abstract class AgentHookServerReaping extends AgentHookServerTabCleanup { } } this.state.lastStatusByPaneKey.set(paneKey, reconciled) + this.commitStatusRowMutation(enriched, reconciled) } if (changedPanes > 0) { this.scheduleStatusPersist() diff --git a/src/main/agent-hooks/server/server-row-ownership.ts b/src/main/agent-hooks/server/server-row-ownership.ts new file mode 100644 index 00000000000..2895eb463e4 --- /dev/null +++ b/src/main/agent-hooks/server/server-row-ownership.ts @@ -0,0 +1,132 @@ +import { + isWslHookRelayConnectionId, + wslHookRelayConnectionId +} from '../../../shared/wsl-hook-relay-contract' +import { splitWorktreeIdForFilesystem, worktreeIdsEqual } from '../../../shared/worktree/id' +import { parseWslUncPath } from '../../../shared/wsl-paths' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { + AgentHookStatusRowIdentity, + AgentHookStatusRowMutation, + EnrichedAgentHookEventPayload, + StatusRowMutationListener +} from './server-types' +import { toAgentStatusIpcPayload } from './server-status-identity' +import { AgentHookServerListeners } from './server-listeners' + +function toMutationIdentity( + row: EnrichedAgentHookEventPayload | null | undefined +): AgentHookStatusRowIdentity | null { + if (!row) { + return null + } + return { + paneKey: row.paneKey, + ...(row.worktreeId ? { worktreeId: row.worktreeId } : {}), + ...(row.terminalHandle ? { terminalHandle: row.terminalHandle } : {}) + } +} + +function semanticRowJson(row: EnrichedAgentHookEventPayload | null | undefined): string | null { + if (!row) { + return null + } + const { + receivedAt: _receivedAt, + evidenceObservedAt: _evidenceObservedAt, + observation: _observation, + launchToken: _launchToken, + promptInteractionKey: _promptInteractionKey, + ...semantic + } = toAgentStatusIpcPayload(row) + return JSON.stringify(semantic) +} + +function wslDistroForWorktree(worktreeId: string | undefined): string | null { + const worktreePath = worktreeId + ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + : undefined + return worktreePath ? (parseWslUncPath(worktreePath)?.distro ?? null) : null +} + +export abstract class AgentHookServerRowOwnership extends AgentHookServerListeners { + _resetRowOwnershipForTests(): void { + this.paneKeyByTerminalHandle.clear() + } + + subscribeStatusRowMutations(listener: StatusRowMutationListener): () => void { + this.statusRowMutationListeners.add(listener) + return () => { + this.statusRowMutationListeners.delete(listener) + } + } + + protected getStatusPaneKeyForTerminalHandle(terminalHandle: string): string | undefined { + return this.paneKeyByTerminalHandle.get(terminalHandle) + } + + protected sameTerminalOwner( + previous: EnrichedAgentHookEventPayload, + incoming: Pick + ): boolean { + if ( + previous.worktreeId && + incoming.worktreeId && + !worktreeIdsEqual(previous.worktreeId, incoming.worktreeId) + ) { + return false + } + if (previous.connectionId === incoming.connectionId) { + return true + } + const relayConnection = isWslHookRelayConnectionId(previous.connectionId) + ? previous.connectionId + : isWslHookRelayConnectionId(incoming.connectionId) + ? incoming.connectionId + : null + const localConnection = previous.connectionId === null || incoming.connectionId === null + if (!relayConnection || !localConnection || !previous.worktreeId || !incoming.worktreeId) { + return false + } + const previousDistro = wslDistroForWorktree(previous.worktreeId) + const incomingDistro = wslDistroForWorktree(incoming.worktreeId) + return ( + previousDistro !== null && + incomingDistro !== null && + previousDistro === incomingDistro && + relayConnection === wslHookRelayConnectionId(previousDistro) && + worktreeIdsEqual(previous.worktreeId, incoming.worktreeId) + ) + } + + protected commitStatusRowMutation( + before: EnrichedAgentHookEventPayload | null | undefined, + after: EnrichedAgentHookEventPayload | null | undefined, + emit = true + ): boolean { + if ( + before?.terminalHandle && + this.paneKeyByTerminalHandle.get(before.terminalHandle) === before.paneKey + ) { + this.paneKeyByTerminalHandle.delete(before.terminalHandle) + } + if (after?.terminalHandle) { + this.paneKeyByTerminalHandle.set(after.terminalHandle, after.paneKey) + } + if (!emit || semanticRowJson(before) === semanticRowJson(after)) { + return false + } + const mutation: AgentHookStatusRowMutation = { + before: toMutationIdentity(before), + after: toMutationIdentity(after) + } + for (const listener of this.statusRowMutationListeners) { + try { + listener(mutation) + } catch (error) { + console.error('[agent-hooks] status-row mutation listener threw', error) + } + } + return true + } +} diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index b18677689d0..0df8ff70445 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -25,6 +25,7 @@ import type { AgentHookAuthorityEvidence, AgentHookProviderSessionIdentity, AgentHookStatusChangeEntry, + AgentHookStatusFreshnessObservation, AgentPromptSentDedupeEntry, EnrichedAgentHookEventPayload, NormalizedLocalHook, @@ -37,7 +38,9 @@ import type { ServerAgentStatusListener, ServerStatusLineListener, StatusChangeListener, - StatusDropListener + StatusDropListener, + StatusFreshnessListener, + StatusRowMutationListener } from './server-types' /** Shared mutable state for the layered hook-server implementation. */ @@ -53,7 +56,14 @@ export abstract class AgentHookServerState { protected paneStatusClearListeners = new Set() protected statusDropListeners = new Set() protected statusChangeListeners = new Set() + protected statusFreshnessListeners = new Set() protected providerSessionChangeListeners = new Set() + protected statusRowMutationListeners = new Set() + // Hydration and spool replay belong to the owner lifetime, not each transport bind attempt. + protected ownerStateInitialized = false + // Runtime terminal handles are stable across pane remints, unlike tab/leaf keys. This index is + // deliberately in-memory only and contains no rows of its own. + protected paneKeyByTerminalHandle = new Map() // Why: setListener is a single slot owned by the main-window fanout; the // plugin event bus (and future consumers) need an additive subscription // that also works in headless serve, where no window listener exists. @@ -117,6 +127,9 @@ export abstract class AgentHookServerState { providerSessions: AgentHookProviderSessionIdentity[] } protected abstract notifyStatusChangeListeners(): void + protected abstract emitStatusFreshnessObservation( + status: AgentHookStatusFreshnessObservation + ): void protected abstract markTabClosedForAgentStatus(tabId: string): void protected abstract getAgentStatusDisposition( paneKey: string, @@ -154,7 +167,8 @@ export abstract class AgentHookServerState { payload: AgentHookEventPayload, onAccepted?: () => void, origin?: AgentStatusObservationOrigin, - observedAt?: number + observedAt?: number, + mutationBefore?: EnrichedAgentHookEventPayload ): EnrichedAgentHookEventPayload protected abstract emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void protected abstract clearAssistantMessageRetry(paneKey: string): void @@ -200,7 +214,10 @@ export abstract class AgentHookServerState { entry: EnrichedAgentHookEventPayload | null | undefined ): EnrichedAgentHookEventPayload | null protected abstract hasLiveClaimsForPaneKey(paneKey: string): boolean - protected abstract clearPaneState(paneKey: string): void + protected abstract clearPaneState( + paneKey: string, + options?: { emitStatusRowMutation?: boolean } + ): void protected abstract deleteStatusEntry( paneKey: string, options?: { preserveAuthority?: boolean } diff --git a/src/main/agent-hooks/server/server-status-disposition.ts b/src/main/agent-hooks/server/server-status-disposition.ts index b6c69967280..c4b7230bc2b 100644 --- a/src/main/agent-hooks/server/server-status-disposition.ts +++ b/src/main/agent-hooks/server/server-status-disposition.ts @@ -41,7 +41,8 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt const paneRetired = this.closedAgentStatusPaneKeys.has(paneKey) || this.closedAgentStatusPaneKeys.has(ownerPaneKey) - const tabId = parsePaneKey(ownerPaneKey)?.tabId + const tabId = + parsePaneKey(ownerPaneKey)?.tabId ?? parseLegacyNumericPaneKey(ownerPaneKey)?.tabId if (tabId && this.closedAgentStatusTabIds.has(tabId)) { return 'suppress' } diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts index 4f6e920d86e..1694c4b1b67 100644 --- a/src/main/agent-hooks/server/server-status-identity.ts +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -69,6 +69,7 @@ export function toAgentStatusIpcPayload( ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), ...(entry.observation ? { observation: entry.observation } : {}), ...(entry.structuredHost ? { structuredHost: entry.structuredHost } : {}), + ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}), ...entry.payload } } diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index ec651691982..2bbe5508651 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -14,9 +14,9 @@ import { import { AGENT_STATUS_STALE_AFTER_MS, type AgentType } from '../../../shared/agent-status-types' import type { EnrichedAgentHookEventPayload } from './server-types' import { equivalentInterruptAgentType, isValidPaneKey } from './server-status-identity' -import { AgentHookServerListeners } from './server-listeners' +import { AgentHookServerRowOwnership } from './server-row-ownership' -export abstract class AgentHookServerStatusInference extends AgentHookServerListeners { +export abstract class AgentHookServerStatusInference extends AgentHookServerRowOwnership { inferInterrupt(request: AgentInterruptInferenceRequest): boolean { if (!isValidPaneKey(request.paneKey)) { return false diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 1a3798efe47..5ea3b07739b 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -24,7 +24,8 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA payload: AgentHookEventPayload, onAccepted?: () => void, origin: AgentStatusObservationOrigin = 'hook', - observedAt?: number + observedAt?: number, + mutationBefore?: EnrichedAgentHookEventPayload ): EnrichedAgentHookEventPayload { if (payload.hookEventName === 'UserPromptSubmit') { // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. @@ -33,8 +34,16 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA let previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as | EnrichedAgentHookEventPayload | undefined - const connectionClearWatermark = payload.connectionId - ? this.connectionTimestampWatermarkById.get(payload.connectionId) + const rowBefore = mutationBefore ?? previous + const terminalHandle = + payload.terminalHandle ?? + (previous?.terminalHandle && this.sameTerminalOwner(previous, payload) + ? previous.terminalHandle + : undefined) + const terminalOwnedPayload = + terminalHandle === payload.terminalHandle ? payload : { ...payload, terminalHandle } + const connectionClearWatermark = terminalOwnedPayload.connectionId + ? this.connectionTimestampWatermarkById.get(terminalOwnedPayload.connectionId) : undefined // Why: renderer ordering rejects older rows; live evidence must sort after reconnect clears and restored rows across clock rollback. const restoredStatusWatermark = previous?.restoredUnconfirmed ? previous.receivedAt : undefined @@ -43,38 +52,41 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA (connectionClearWatermark ?? -1) + 1, (restoredStatusWatermark ?? -1) + 1 ) - if (payload.connectionId) { - this.connectionTimestampWatermarkById.set(payload.connectionId, now) + if (terminalOwnedPayload.connectionId) { + this.connectionTimestampWatermarkById.set(terminalOwnedPayload.connectionId, now) } - if (payload.providerSessionOnly) { + if (terminalOwnedPayload.providerSessionOnly) { // Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status. onAccepted?.() const enriched = { - ...this.attachStatusTiming(payload, now), - observation: this.stampObservation(payload, origin, now) + ...this.attachStatusTiming(terminalOwnedPayload, now), + observation: this.stampObservation(terminalOwnedPayload, origin, now) } this.clearAssistantMessageRetry(enriched.paneKey) this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) + this.commitStatusRowMutation(rowBefore, enriched) this.scheduleStatusPersist() this.notifyStatusChangeListeners() this.emitEnrichedStatus(enriched) return enriched } const stateReconciledPayload = - payload.connectionId && payload.payload.agentType === 'codex' && payload.hookEventName + terminalOwnedPayload.connectionId && + terminalOwnedPayload.payload.agentType === 'codex' && + terminalOwnedPayload.hookEventName ? { - ...payload, + ...terminalOwnedPayload, payload: reconcileRemoteCodexState( this.state, - payload.paneKey, - payload.hookEventName, - payload.toolAgentId, - payload.payload, + terminalOwnedPayload.paneKey, + terminalOwnedPayload.hookEventName, + terminalOwnedPayload.toolAgentId, + terminalOwnedPayload.payload, previous?.payload ) } - : payload + : terminalOwnedPayload const previousCodexRoot = stateReconciledPayload.payload.agentType === 'codex' && stateReconciledPayload.toolAgentId && @@ -128,6 +140,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA incomingState: rootContextPreservingPayload.payload.state }) ) { + this.commitStatusRowMutation(rowBefore, previous) return previous } const identityResolvedPayload = @@ -140,6 +153,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) const boundaryAwarePayload = attachClaudeChildOnlyBoundary(previous, effectivePayload) if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { + this.commitStatusRowMutation(rowBefore, previous) return previous } // Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row. @@ -151,6 +165,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA previous.payload.prompt === effectivePayload.payload.prompt && Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS ) { + this.commitStatusRowMutation(rowBefore, previous) return previous } if ( @@ -167,6 +182,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA if (effectivePayload.payload.agentType === 'codex') { markCodexLeadTurnInterrupted(this.state, effectivePayload.paneKey) } + this.commitStatusRowMutation(rowBefore, previous) return previous } if ( @@ -179,6 +195,8 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA if (!identity.inheritedFromActivePane) { this.maybeTrackAgentPromptSent(effectivePayload, previous) } + // Why carried forward only within one host: main's OSC parse resolves the handle, so a later + // hook must not erase its terminal join; a connection change must not inherit another host's. const enriched = { ...this.attachStatusTiming(boundaryAwarePayload, now, observedAt), observation: this.stampObservation(boundaryAwarePayload, origin, observedAt ?? now) @@ -199,6 +217,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) } this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) + this.commitStatusRowMutation(rowBefore, enriched) // Why skipped for structured rows: the serializer drops them, so the whole walk and stringify // can only ever reproduce the last file — once per debounce window for a streaming chat. if (!enriched.structuredHost) { @@ -209,6 +228,61 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA return enriched } + protected refreshTerminalStatusEvidence( + previous: EnrichedAgentHookEventPayload, + mutationBefore?: EnrichedAgentHookEventPayload, + emitEnrichedStatus = false + ): void { + const connectionClearWatermark = previous.connectionId + ? this.connectionTimestampWatermarkById.get(previous.connectionId) + : undefined + const now = Math.max(Date.now(), (connectionClearWatermark ?? -1) + 1) + if (previous.connectionId) { + this.connectionTimestampWatermarkById.set(previous.connectionId, now) + } + const { + receivedAt: _receivedAt, + evidenceObservedAt: _evidenceObservedAt, + stateStartedAt, + observation: _observation, + restoredUnconfirmed: _restoredUnconfirmed, + isReplay: _isReplay, + ...payload + } = previous + const refreshed: EnrichedAgentHookEventPayload = { + ...payload, + receivedAt: now, + evidenceObservedAt: now, + stateStartedAt, + observation: this.stampObservation(payload, 'osc', now) + } + const firstRuntimeObservation = !this.runtimeObservedStatusPaneKeys.has(refreshed.paneKey) + this.runtimeObservedStatusPaneKeys.add(refreshed.paneKey) + this.state.lastStatusByPaneKey.set(refreshed.paneKey, refreshed) + this.commitStatusRowMutation(mutationBefore ?? previous, refreshed) + this.scheduleStatusPersist() + // A dismissed row may retain only provider resume identity. Its preserved payload can still + // read `working`, but it is deliberately hidden from live readers and must not renew awake or + // mobile freshness leases. + if (refreshed.providerSessionOnly === true) { + return + } + if (firstRuntimeObservation) { + this.notifyStatusChangeListeners() + } + this.emitStatusFreshnessObservation({ + paneKey: refreshed.paneKey, + state: refreshed.payload.state, + receivedAt: refreshed.receivedAt, + observedInCurrentRuntime: true, + ...(refreshed.worktreeId ? { worktreeId: refreshed.worktreeId } : {}), + ...(refreshed.terminalHandle ? { terminalHandle: refreshed.terminalHandle } : {}) + }) + if (emitEnrichedStatus) { + this.emitEnrichedStatus(refreshed) + } + } + // Why: every status emit must reach plugins too, so a new early-return path // upstream cannot silently leave the plugin tap behind the main-window fanout. protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index 3ce2c4fce0a..a108bdbbd22 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -1,15 +1,25 @@ import { clearPaneCacheState } from '../../../shared/agent-hook-listener/listener-state' import { paneCacheKeyMatchesTab } from './server-status-identity' import { AgentHookServerCleanup } from './server-cleanup' +import type { EnrichedAgentHookEventPayload } from './server-types' export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { /** Drop every status/cache claim attributable to a closed tab prefix. */ dropStatusEntriesByTabPrefix(tabId: string): void { this.markTabClosedForAgentStatus(tabId) const paneKeysToClear = new Set() + const statusPaneKeysToClear = new Set() + const statusRowsToClear: EnrichedAgentHookEventPayload[] = [] for (const key of this.state.lastStatusByPaneKey.keys()) { if (paneCacheKeyMatchesTab(key, tabId)) { paneKeysToClear.add(key) + statusPaneKeysToClear.add(key) + const row = this.state.lastStatusByPaneKey.get(key) as + | EnrichedAgentHookEventPayload + | undefined + if (row) { + statusRowsToClear.push(row) + } } } for (const key of this.state.lastPromptByPaneKey.keys()) { @@ -72,21 +82,32 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { this.currentAuthorityObservations.delete(paneKey) this.promptSentDedupeByPaneKey.delete(paneKey) this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey) + this.evidenceObservedAtByPaneKey.delete(paneKey) } if (aliasChanged) { this.notifyPaneKeyAliasPersistenceListener() } + for (const row of statusRowsToClear) { + this.commitStatusRowMutation(row, undefined) + } if (statusChanged || authorityChanged) { this.scheduleStatusPersist() this.notifyStatusChangeListeners() } + // Why: tab teardown must retire status subscribers' pane-scoped memo state too. + for (const paneKey of statusPaneKeysToClear) { + this.emitPaneStatusCleared({ paneKey }) + } } - clearPaneState(paneKey: string): void { + clearPaneState(paneKey: string, options?: { emitStatusRowMutation?: boolean }): void { const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) const paneKeys = new Set([paneKey, resolvedPaneKey]) // Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file. - const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey) + const previousStatus = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + const hadStatus = previousStatus !== undefined this.clearAssistantMessageRetry(resolvedPaneKey) this.clearCodexSubagentPoll(resolvedPaneKey) clearPaneCacheState(this.state, resolvedPaneKey) @@ -115,6 +136,9 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { if (clearedAlias) { this.notifyPaneKeyAliasPersistenceListener() } + if (options?.emitStatusRowMutation !== false) { + this.commitStatusRowMutation(previousStatus, undefined) + } if (hadStatus || authorityChanged) { this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) this.scheduleStatusPersist() diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index c151c70d34b..b4cf176b176 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -36,6 +36,8 @@ export type PersistedAgentHookEventPayload = Omit< // Why: revision counters are in-memory and the authority id is regenerated per process, so // a stored observation could only rehydrate as a stale ordering claim from a dead authority. | 'observation' + // Same: a terminal handle is issued by one runtime and means nothing to the next. + | 'terminalHandle' > & { launchTokenHash?: string } @@ -50,11 +52,17 @@ export type PersistedAgentHookAuthorityCommitment = { } export type AgentHookStatusChangeEntry = { + paneKey: string state: AgentStatusState receivedAt: number observedInCurrentRuntime: boolean } +export type AgentHookStatusFreshnessObservation = AgentHookStatusChangeEntry & { + worktreeId?: string + terminalHandle?: string +} + export type AgentHookProviderSessionIdentity = { paneKey: string sessionId: string @@ -77,9 +85,20 @@ export type AgentHookAuthorityAttestation = Readonly<{ }> export type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void +export type StatusFreshnessListener = (status: AgentHookStatusFreshnessObservation) => void export type ProviderSessionChangeListener = ( providerSessions: AgentHookProviderSessionIdentity[] ) => void +export type AgentHookStatusRowIdentity = { + paneKey: string + worktreeId?: string + terminalHandle?: string +} +export type AgentHookStatusRowMutation = { + before: AgentHookStatusRowIdentity | null + after: AgentHookStatusRowIdentity | null +} +export type StatusRowMutationListener = (mutation: AgentHookStatusRowMutation) => void export type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void export type StatusDropListener = (paneKey: string) => void export type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void diff --git a/src/main/agent-hooks/terminal-handle-row-identity.test.ts b/src/main/agent-hooks/terminal-handle-row-identity.test.ts new file mode 100644 index 00000000000..329ffd19958 --- /dev/null +++ b/src/main/agent-hooks/terminal-handle-row-identity.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' +import { selectFreshExplicitAgentStatus } from '../runtime/runtime-hook-agent-row-selection' +import { wslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' + +const PANE_KEY = 'tab-handle:33333333-3333-4333-8333-333333333333' +const HANDLE = 'term_identity' +const NEW_PANE_KEY = 'tab-reminted:44444444-4444-4444-8444-444444444444' + +function ingest(server: AgentHookServer, overrides: Record = {}): void { + server.ingestTerminalStatus({ + paneKey: PANE_KEY, + tabId: 'tab-handle', + worktreeId: 'worktree', + connectionId: null, + terminalHandle: HANDLE, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' }, + ...overrides + }) +} + +describe('the terminal handle a status row is stamped with', () => { + it('reaches the published row', () => { + const server = new AgentHookServer() + ingest(server) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + paneKey: PANE_KEY, + terminalHandle: HANDLE + }) + }) + + it('survives a later write that resolved no handle', () => { + // Only main's OSC parse resolves one; an HTTP hook post for the same pane carries none and + // must not erase the row's only join back to its terminal. + const server = new AgentHookServer() + ingest(server) + ingest(server, { terminalHandle: undefined, payload: { state: 'done', prompt: 'ship it' } }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + terminalHandle: HANDLE + }) + }) + + it('does not cross a connection ownership change on a colliding pane key', () => { + const server = new AgentHookServer() + ingest(server, { connectionId: 'ssh-a' }) + + server.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-handle', + worktreeId: 'other-worktree', + payload: { state: 'done', prompt: 'other host', agentType: 'codex' } + }, + 'ssh-b' + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + connectionId: 'ssh-b', + worktreeId: 'other-worktree' + }) + expect(server.getStatusSnapshot()[0]).not.toHaveProperty('terminalHandle') + }) + + it('is never persisted, because it belongs to the runtime that issued it', () => { + const server = new AgentHookServer() + ingest(server) + const serialized = ( + server as unknown as { serializeStatusFile(): string } + ).serializeStatusFile() + expect(serialized).toContain(PANE_KEY) + expect(serialized).not.toContain(HANDLE) + }) + + it('moves one PTY row and all of its resume identity across a pane remint', () => { + const server = new AgentHookServer() + ingest(server) + server.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-handle', + worktreeId: 'worktree', + providerSession: { key: 'session_id', id: 'session-1' }, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' } + }, + null + ) + const mutations: Parameters[0]>[0][] = [] + server.subscribeStatusRowMutations((mutation) => mutations.push(mutation)) + + ingest(server, { paneKey: NEW_PANE_KEY, tabId: 'tab-reminted' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: NEW_PANE_KEY, + terminalHandle: HANDLE, + providerSession: { key: 'session_id', id: 'session-1' } + }) + ]) + expect(mutations).toEqual([ + { + before: { paneKey: PANE_KEY, worktreeId: 'worktree', terminalHandle: HANDLE }, + after: { paneKey: NEW_PANE_KEY, worktreeId: 'worktree', terminalHandle: HANDLE } + } + ]) + + server.dropStatusEntry(NEW_PANE_KEY) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: NEW_PANE_KEY, + providerSessionOnly: true, + providerSession: { key: 'session_id', id: 'session-1' } + }) + ]) + expect(server.reconcileEndedProcessForPaneKeys([NEW_PANE_KEY])).toBe(1) + expect(server.getStatusSnapshot()).toEqual([]) + expect(mutations).toHaveLength(3) + expect( + (server as unknown as { paneKeyByTerminalHandle: Map }) + .paneKeyByTerminalHandle + ).toEqual(new Map()) + }) + + it('preserves a local WSL terminal join only for its exact relay distro', () => { + const server = new AgentHookServer() + const worktreeId = String.raw`repo::\\wsl.localhost\Ubuntu\home\user\repo` + ingest(server, { worktreeId }) + + server.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-handle', + worktreeId, + providerSession: { key: 'session_id', id: 'wsl-session' }, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' } + }, + wslHookRelayConnectionId('Ubuntu') + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ terminalHandle: HANDLE }) + + server.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-handle', + worktreeId, + payload: { state: 'done', prompt: 'wrong distro', agentType: 'codex' } + }, + wslHookRelayConnectionId('Debian') + ) + expect(server.getStatusSnapshot()[0]).not.toHaveProperty('terminalHandle') + }) + + it('renews duplicate OSC evidence without publishing another semantic row', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const enriched = vi.fn() + const mutated = vi.fn() + const statusChanges = vi.fn() + server.subscribeEnrichedStatus(enriched) + server.subscribeStatusRowMutations(mutated) + server.subscribeStatusChanges(statusChanges) + ingest(server) + enriched.mockClear() + mutated.mockClear() + statusChanges.mockClear() + + vi.setSystemTime(1_000 + AGENT_STATUS_STALE_AFTER_MS + 1) + ingest(server) + + const [row] = server.getStatusSnapshot() + expect(row.evidenceObservedAt).toBe(Date.now()) + expect( + selectFreshExplicitAgentStatus({ handle: HANDLE, paneKey: PANE_KEY, hookRows: [row] }) + ).toMatchObject({ status: 'working', updatedAt: Date.now() }) + expect(enriched).not.toHaveBeenCalled() + expect(mutated).not.toHaveBeenCalled() + expect(statusChanges).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('publishes an enriched observation when duplicate OSC transfers pane authority', () => { + const server = new AgentHookServer() + const enriched = vi.fn() + server.subscribeEnrichedStatus(enriched) + ingest(server) + enriched.mockClear() + + ingest(server, { paneKey: NEW_PANE_KEY, tabId: 'tab-reminted' }) + + expect(enriched).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: NEW_PANE_KEY, terminalHandle: HANDLE }) + ) + }) + + it('publishes only the remint observation for a Claude child-only row', () => { + const server = new AgentHookServer() + const enriched = vi.fn() + const mutations = vi.fn() + server.subscribeEnrichedStatus(enriched) + server.subscribeStatusRowMutations(mutations) + const payload = { state: 'working' as const, prompt: 'ship it', agentType: 'claude' as const } + ingest(server, { payload }) + const row = server._getStateForTests().lastStatusByPaneKey.get(PANE_KEY) as + | { claudeLeadBoundaryChildOnly?: true } + | undefined + if (!row) { + throw new Error('expected seeded status row') + } + row.claudeLeadBoundaryChildOnly = true + enriched.mockClear() + mutations.mockClear() + + ingest(server, { payload }) + expect(enriched).not.toHaveBeenCalled() + expect(mutations).not.toHaveBeenCalled() + + ingest(server, { paneKey: NEW_PANE_KEY, tabId: 'tab-reminted', payload }) + expect(enriched).toHaveBeenCalledOnce() + expect(mutations).toHaveBeenCalledOnce() + expect(mutations).toHaveBeenCalledWith({ + before: { paneKey: PANE_KEY, worktreeId: 'worktree', terminalHandle: HANDLE }, + after: { paneKey: NEW_PANE_KEY, worktreeId: 'worktree', terminalHandle: HANDLE } + }) + expect(enriched).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: NEW_PANE_KEY, terminalHandle: HANDLE }) + ) + }) + + it('does not renew freshness from a provider-session-only dismissal remnant', () => { + const server = new AgentHookServer() + const freshness = vi.fn() + server.subscribeStatusFreshness(freshness) + ingest(server) + server.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-handle', + worktreeId: 'worktree', + providerSession: { key: 'session_id', id: 'resume-me' }, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' } + }, + null + ) + server.dropStatusEntry(PANE_KEY) + freshness.mockClear() + + ingest(server) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + paneKey: PANE_KEY, + providerSessionOnly: true, + providerSession: { key: 'session_id', id: 'resume-me' } + }) + expect(freshness).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index 001380e934d..f8e3420720a 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -293,6 +293,17 @@ describe('agentStatus:drop IPC', () => { expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY) }) + it('forwards a runtime-owned legacy numeric row dismissal', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = onHandlers.get('agentStatus:drop')! + handler!({}, 'tab-1:0') + + expect(dropStatusEntry).toHaveBeenCalledWith('tab-1:0') + expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith('tab-1:0') + }) + it('rejects non-string paneKey (defensive against a malformed renderer message)', async () => { const { registerAgentHookHandlers } = await import('./agent-hooks') registerAgentHookHandlers() @@ -305,7 +316,6 @@ describe('agentStatus:drop IPC', () => { null, {}, [], - 'tab-1:0', // legacy numeric pane-key suffix 'no-colon', // missing colon — rejected by isValidPaneKey ':leading', // empty tabId half 'trailing:', // empty leafId half diff --git a/src/main/ipc/agent-status-row-teardown-ipc.ts b/src/main/ipc/agent-status-row-teardown-ipc.ts index 020cfa7259d..5e42312ec20 100644 --- a/src/main/ipc/agent-status-row-teardown-ipc.ts +++ b/src/main/ipc/agent-status-row-teardown-ipc.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron' import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' import type { AgentStatusCacheIdentity } from '../../shared/agent-status-types' +import { parseLegacyNumericPaneKey } from '../../shared/stable-pane-id' import { clearMigrationUnsupportedPtysByTabPrefix, clearMigrationUnsupportedPtysForPaneKey @@ -27,7 +28,10 @@ export function registerAgentStatusRowTeardownIpcHandlers(): void { ipcMain.removeAllListeners('agentStatus:dropByTabPrefix') ipcMain.on('agentStatus:drop', (_event, paneKey: unknown) => { - if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) { + if ( + typeof paneKey !== 'string' || + (!isValidPaneKey(paneKey) && parseLegacyNumericPaneKey(paneKey) === null) + ) { return } try { diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 79e01a13163..137894f87b8 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -16,18 +16,15 @@ import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environ import { setSecretStore, type SecretStore } from '../../shared/secret-store' import type { ServeReadiness } from '../server/serve-readiness' import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' -import { resolveOrcadBrowserProvider, type OrcadBrowserProvider } from './orcad-browser-provider' +import { resolveOrcadBrowserProvider } from './orcad-browser-provider' import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths' import { describeOrcadBindExposure, OrcadBindAddressError, resolveOrcadBindHost } from './orcad-bind-address' -import { - acquireOrcadInstanceLock, - OrcadInstanceLockError, - type OrcadInstanceLock -} from './orcad-instance-lock' +import { acquireOrcadInstanceLock, OrcadInstanceLockError } from './orcad-instance-lock' +import { startOrcadWithLifecycle } from './orcad-lifecycle' let runOrcadQuitHandlers = (): void => {} @@ -116,22 +113,24 @@ export async function startOrcad(options: OrcadOptions = {}): Promise browserProvider.isAvailable() } : {}) }) - try { - return await startOrcadRuntime(options, browserProvider, instanceLock) - } catch (error) { - await browserProvider?.stop() - setRuntimeBrowserCommandsFactory(null) - runOrcadQuitHandlers() - instanceLock.release() - throw error - } + return startOrcadWithLifecycle( + (registerCleanup) => startOrcadRuntime(options, registerCleanup), + async () => { + try { + await browserProvider?.stop() + } finally { + setRuntimeBrowserCommandsFactory(null) + runOrcadQuitHandlers() + instanceLock.release() + } + } + ) } async function startOrcadRuntime( options: OrcadOptions, - browserProvider: OrcadBrowserProvider | null, - instanceLock: OrcadInstanceLock -): Promise { + registerCleanup: (cleanup: () => Promise) => void +): Promise> { const { OrcaRuntimeService } = await import('../runtime/orca-runtime') const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc') const { registerHeadlessPtyRuntime, getLocalPtyProvider, getSshPtyProvider } = @@ -146,15 +145,41 @@ async function startOrcadRuntime( const { startOrcadDaemon, stopOrcadDaemon } = await import('./orcad-daemon-supervision') const { daemonOwnsFreshPersistentPtys } = await import('../daemon/daemon-init') const { collectOrcadHealth } = await import('./orcad-health') - // Why importable here: the store is an in-memory singleton whose module tree never reaches - // Electron, and its file paths come from `start()`, which orcad never calls. + // Why importable here: the singleton's module tree never reaches Electron, and orcad supplies + // its persistence and endpoint paths explicitly below. const { agentHookServer } = await import('../agent-hooks/server') + const { isAgentStatusHooksEnabled } = await import('../agent-hooks/managed-agent-hook-controls') + const { installHookStatusSessionTabsRepublish } = + await import('../agent-hooks/hook-status-session-tabs-republish') + const { AgentStatusObservedPaneIdentities, AgentStatusObservedPaneIdentityCapture } = + await import('../runtime/agent-status-observed-pane-identity') + + let rpc: InstanceType | null = null + let uninstallHookStatusRepublish = (): void => {} + let uninstallObservedStatusIdentity = (): void => {} + registerCleanup(async () => { + try { + await rpc?.stop() + } finally { + try { + // Why disconnect and not shut down: the daemon must outlive this process, or an + // orcad restart goes back to killing every running terminal. + await stopOrcadDaemon() + } finally { + uninstallObservedStatusIdentity() + uninstallHookStatusRepublish() + agentHookServer.stop() + } + } + }) const { DesktopPushService } = await import('../runtime/push/desktop-push-service') const { resolvePushGatewayOrigin } = await import('../runtime/push/push-gateway-origin') const runtimeUserDataPath = getAppEnvironment().getPath('userData') initOrcaProfilePaths() const profile = ensureActiveOrcaProfile(runtimeUserDataPath) + const observedPaneIdentities = new AgentStatusObservedPaneIdentities() + const observedStatusCapture = new AgentStatusObservedPaneIdentityCapture(observedPaneIdentities) // Why a real Store: without one every persistence-backed RPC throws `runtime_unavailable` // and the read paths that use `this.store?.x ?? []` quietly answer "empty" instead — // a server that pairs and lists nothing looks healthy and is not. @@ -165,6 +190,13 @@ async function startOrcadRuntime( // which is safe but silently discards accept records on every launch. initSshHostKeyStoreFile(profile.dataFile) + uninstallObservedStatusIdentity = agentHookServer.subscribeEnrichedStatus((enriched) => + observedStatusCapture.observe(enriched) + ) + if (isAgentStatusHooksEnabled(store.getSettings())) { + await agentHookServer.start({ env: 'production', userDataPath: runtimeUserDataPath }) + } + // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon // adapter as THE local provider, and the registry's contract is that it lands before // registerPtyHandlers so the IPC layer routes through the daemon from the first call. @@ -186,16 +218,37 @@ async function startOrcadRuntime( // what powers serve→desktop promotion. A Node host can never do that, and the // constructor's default would advertise it. getDesktopWindowStatus: () => 'blocked', + // Why here too and not only on the desktop: main's OSC parse is the only producer for a + // PTY agent on this host, and the store is the only place `worktree.ps` and the mobile + // projection read from — unwired, orcad lists no PTY agents at all. + onTerminalAgentStatus: (event) => agentHookServer.ingestTerminalStatus(event), // Why here too and not only on the desktop: orcad serves `worktree.ps` and `agentSession.*`, // so without these a headless host publishes its structured chats nowhere and lists no agents. getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(), + getAgentProviderSessionRowsForPane: (paneKey) => + agentHookServer.getStatusSnapshotForPane(paneKey), + // Why captured rather than resolved at read: the fleet snapshot remints cached rows on every + // read, so a row observed under one process otherwise acquires whatever process owns the pane now. + readObservedAgentStatusPaneIdentity: (paneKey) => observedPaneIdentities.read(paneKey), structuredAgentStatusSink: { publish: (summary) => agentHookServer.ingestStructuredStatus(summary), forget: (sessionId) => agentHookServer.dropStructuredStatus(sessionId) - } + }, + reconcileAgentStatusForEndedProcess: (paneKeys) => + agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), + buildAgentHookPtyEnv: () => + isAgentStatusHooksEnabled(store.getSettings()) ? agentHookServer.buildPtyEnv() : {} }) + // Why here too and not only on the desktop: nothing else republishes `session.tabs` when a + // pane's status row changes, and orcad's whole job is serving paired clients. + uninstallHookStatusRepublish = installHookStatusSessionTabsRepublish( + agentHookServer, + () => runtime + ) + // Why the headless entry point rather than registerPtyHandlers directly: this is the // same call `--serve` makes, and it threads the store through. Without the store the // handlers install fine and every terminal.create then fails at persistence time. @@ -213,8 +266,11 @@ async function startOrcadRuntime( await runtime.refreshRestoredOrchestrationAuthority() await runtime.reconcileLegacyWorkerTerminals() + // Recovery binds terminal and dispatch identities; only now can startup observations be fenced. + observedStatusCapture.attach(runtime) + const bindHost = resolveOrcadBindHost(options.bind) - const rpc = new OrcaRuntimeRpcServer({ + rpc = new OrcaRuntimeRpcServer({ runtime, userDataPath: runtimeUserDataPath, enableWebSocket: true, @@ -279,23 +335,7 @@ async function startOrcadRuntime( mode: options.json ? 'json' : 'human' }) - return { - readiness, - stop: async () => { - try { - await rpc.stop() - } finally { - // Why disconnect and not shut down: the daemon must outlive this process, or an - // orcad restart goes back to killing every running terminal. See - // orcad-daemon-supervision.ts. - await stopOrcadDaemon() - await browserProvider?.stop() - setRuntimeBrowserCommandsFactory(null) - runOrcadQuitHandlers() - instanceLock.release() - } - } - } + return { readiness } } export function parseArgs(argv: string[]): OrcadOptions { diff --git a/src/main/orcad/orcad-launch-contract.test.ts b/src/main/orcad/orcad-launch-contract.test.ts index b22dc74f0e4..2d2e4e6157e 100644 --- a/src/main/orcad/orcad-launch-contract.test.ts +++ b/src/main/orcad/orcad-launch-contract.test.ts @@ -2,13 +2,14 @@ * The two things a supervisor reads off a launch: what the arguments mean, and what an exit * code means. Both are part of the ops contract in docs/reference/orcad-operations.md. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { ORCAD_EXIT_CONFIGURATION, ORCAD_EXIT_FAILED, parseArgs, resolveOrcadExitCode } from './orcad-entry' +import { startOrcadWithLifecycle } from './orcad-lifecycle' import { OrcadBindAddressError } from './orcad-bind-address' import { OrcadInstanceLockError } from './orcad-instance-lock' @@ -41,3 +42,58 @@ describe('resolveOrcadExitCode', () => { expect(ORCAD_EXIT_CONFIGURATION).not.toBe(ORCAD_EXIT_FAILED) }) }) + +describe('orcad lifecycle cleanup', () => { + it('uninstalls registered runtime resources when startup fails', async () => { + const cleanupRuntime = vi.fn(async () => {}) + const cleanupHost = vi.fn(async () => {}) + + await expect( + startOrcadWithLifecycle(async (registerCleanup) => { + registerCleanup(cleanupRuntime) + await Promise.resolve() + throw new Error('startup failed') + }, cleanupHost) + ).rejects.toThrow('startup failed') + + expect(cleanupRuntime).toHaveBeenCalledOnce() + expect(cleanupHost).toHaveBeenCalledOnce() + }) + + it('preserves the startup error when rollback also fails', async () => { + const startupError = new Error('bind failed') + const cleanupError = new Error('daemon stop failed') + const cleanupRuntime = vi.fn(async () => {}) + const cleanupHost = vi.fn(async () => { + throw cleanupError + }) + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + await expect( + startOrcadWithLifecycle(async (registerCleanup) => { + registerCleanup(cleanupRuntime) + throw startupError + }, cleanupHost) + ).rejects.toBe(startupError) + expect(report).toHaveBeenCalledWith('[orcad] startup cleanup failed:', cleanupError) + } finally { + report.mockRestore() + } + }) + + it('coalesces concurrent and repeated normal stops', async () => { + const cleanupRuntime = vi.fn(async () => {}) + const cleanupHost = vi.fn(async () => {}) + const handle = await startOrcadWithLifecycle(async (registerCleanup) => { + registerCleanup(cleanupRuntime) + return { readiness: 'ready' } + }, cleanupHost) + + await Promise.all([handle.stop(), handle.stop()]) + await handle.stop() + + expect(cleanupRuntime).toHaveBeenCalledOnce() + expect(cleanupHost).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/orcad/orcad-lifecycle.ts b/src/main/orcad/orcad-lifecycle.ts new file mode 100644 index 00000000000..913a21c4874 --- /dev/null +++ b/src/main/orcad/orcad-lifecycle.ts @@ -0,0 +1,35 @@ +function createIdempotentOrcadCleanup(cleanup: () => Promise): () => Promise { + let completion: Promise | null = null + return () => { + completion ??= Promise.resolve().then(cleanup) + return completion + } +} + +export async function startOrcadWithLifecycle( + start: (registerRuntimeCleanup: (cleanup: () => Promise) => void) => Promise, + cleanupHost: () => Promise +): Promise }> { + let cleanupRuntime = async (): Promise => {} + const cleanup = createIdempotentOrcadCleanup(async () => { + try { + await cleanupRuntime() + } finally { + await cleanupHost() + } + }) + try { + const handle = await start((nextCleanup) => { + cleanupRuntime = nextCleanup + }) + return { ...handle, stop: cleanup } + } catch (error) { + try { + await cleanup() + } catch (cleanupError) { + // Keep the launch failure as the supervisor-facing verdict; cleanup still needs a breadcrumb. + console.error('[orcad] startup cleanup failed:', cleanupError) + } + throw error + } +} diff --git a/src/main/runtime/agent-status-observed-pane-identity.ts b/src/main/runtime/agent-status-observed-pane-identity.ts index 773bddc7f3c..e26929f2090 100644 --- a/src/main/runtime/agent-status-observed-pane-identity.ts +++ b/src/main/runtime/agent-status-observed-pane-identity.ts @@ -3,6 +3,7 @@ import { type AgentStatusRuntimeEnrichment, type ObservedAgentStatusPaneIdentity } from '../ipc/agent-status-ipc-boundary' +import type { EnrichedAgentHookEventPayload } from '../agent-hooks/server/server-types' /** Bounded like the hook server's own per-pane maps; eviction only degrades a row to `unobserved`. */ const MAX_OBSERVED_PANES = 1024 @@ -44,6 +45,30 @@ export class AgentStatusObservedPaneIdentities { } } +/** Buffers startup replay until PTY recovery has restored the runtime identities it fences. */ +export class AgentStatusObservedPaneIdentityCapture { + private readonly pending = new Map() + private runtime: AgentStatusRuntimeEnrichment | null = null + + constructor(private readonly identities: AgentStatusObservedPaneIdentities) {} + + observe(enriched: EnrichedAgentHookEventPayload): void { + if (this.runtime) { + recordObservedAgentStatusPaneIdentity(this.identities, enriched.paneKey, this.runtime) + return + } + this.pending.set(enriched.paneKey, enriched) + } + + attach(runtime: AgentStatusRuntimeEnrichment): void { + this.runtime = runtime + for (const enriched of this.pending.values()) { + recordObservedAgentStatusPaneIdentity(this.identities, enriched.paneKey, runtime) + } + this.pending.clear() + } +} + /** Ingest-time capture: resolve the pane once, as the status arrives, and keep that answer. */ export function recordObservedAgentStatusPaneIdentity( identities: AgentStatusObservedPaneIdentities, diff --git a/src/main/runtime/agent-status-store-wiring.test-fixture.ts b/src/main/runtime/agent-status-store-wiring.test-fixture.ts new file mode 100644 index 00000000000..1f399e0464f --- /dev/null +++ b/src/main/runtime/agent-status-store-wiring.test-fixture.ts @@ -0,0 +1,51 @@ +import { AgentHookServer } from '../agent-hooks/server' +import { installHookStatusSessionTabsRepublish } from '../agent-hooks/hook-status-session-tabs-republish' + +type WiredRuntime = { + getTerminalWorktreeIdForHandle(handle: string): string | null + getTerminalWorktreeIdForPaneKey(paneKey: string): string | null + scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree(worktreeId: string): void + touchMobileSessionTabsForWorktree(worktreeId: string): void +} + +/** + * The agent-status wiring every real host performs, in one place for the runtime specs. + * + * `main-process-runtime-service.ts` and `orcad-entry.ts` both hand the runtime's OSC parse to + * the store, read the listing back out of it, and install the republish signal. A runtime + * constructed without these observes agent status and publishes it nowhere, so a spec that + * exercises OSC 9999 has to compose the same three parts. + */ +export function makeAgentStatusStoreWiring(): { + statusStore: AgentHookServer + deps: { + onTerminalAgentStatus: (event: Parameters[0]) => void + getAgentStatusSnapshot: () => ReturnType + getAgentProviderSessionSnapshot: () => ReturnType + getAgentProviderSessionRowsForPane: ( + paneKey: string + ) => ReturnType + reconcileAgentStatusForEndedProcess: ( + paneKeys: Parameters[0] + ) => void + } + /** Call once the runtime exists; returns the republish teardown. */ + attach: (runtime: WiredRuntime) => () => void +} { + const statusStore = new AgentHookServer() + return { + statusStore, + deps: { + onTerminalAgentStatus: (event) => statusStore.ingestTerminalStatus(event), + getAgentStatusSnapshot: () => + statusStore.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + getAgentProviderSessionSnapshot: () => statusStore.getStatusSnapshot(), + getAgentProviderSessionRowsForPane: (paneKey) => + statusStore.getStatusSnapshotForPane(paneKey), + reconcileAgentStatusForEndedProcess: (paneKeys) => { + statusStore.reconcileEndedProcessForPaneKeys(paneKeys) + } + }, + attach: (runtime) => installHookStatusSessionTabsRepublish(statusStore, () => runtime) + } +} diff --git a/src/main/runtime/agent-transcript-pane-test-harness.ts b/src/main/runtime/agent-transcript-pane-test-harness.ts index f3a9a64793c..4345e98fd93 100644 --- a/src/main/runtime/agent-transcript-pane-test-harness.ts +++ b/src/main/runtime/agent-transcript-pane-test-harness.ts @@ -19,9 +19,10 @@ export type TranscriptPaneOptions = { } export async function createTranscriptPane( - options: TranscriptPaneOptions + options: TranscriptPaneOptions, + runtimeDeps?: ConstructorParameters[2] ): Promise<{ runtime: OrcaRuntimeService; handle: string }> { - const runtime = new OrcaRuntimeService(null) + const runtime = new OrcaRuntimeService(null, undefined, runtimeDeps) const internals = runtime as unknown as { resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise } diff --git a/src/main/runtime/mobile-agent-status-permission-renewal.test.ts b/src/main/runtime/mobile-agent-status-permission-renewal.test.ts index 9b7f0ef457d..a802844d826 100644 --- a/src/main/runtime/mobile-agent-status-permission-renewal.test.ts +++ b/src/main/runtime/mobile-agent-status-permission-renewal.test.ts @@ -92,6 +92,18 @@ describe('mobile/paired projection for a pane pending a human answer', () => { expect(out?.state).toBe('done') }) + it('does not let replay delivery time make old working evidence outrank a newer title', () => { + const hookAt = Date.now() - 1_000 + const replayedAt = Date.now() + const out = renewFromPtyTitle()( + { ...claudeStatus('working', replayedAt), evidenceObservedAt: hookAt }, + parkedOnPromptPty(hookAt), + { preserveQuestionUnderShellTitle: true } + ) + + expect(out?.state).toBe('done') + }) + // Why: an idle title is the ABSENCE of activity evidence, so it cannot outrank the hook. // A `working` title is positive evidence the agent resumed, which does — otherwise a // finished turn's question card would linger into the next working interval (#11761). diff --git a/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts index 582837826ac..416cb2fdf46 100644 --- a/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts +++ b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts @@ -189,4 +189,19 @@ describe('mobile session-tabs agent-status heartbeat', () => { expect(emitted).toEqual([]) expect(vi.getTimerCount()).toBe(0) }) + + it('keeps a direct status heartbeat queued when an unrelated PTY is removed', () => { + const emitted: string[] = [] + const heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + () => [], + (worktreeId) => emitted.push(worktreeId) + ) + + heartbeat.scheduleWorktreeHeartbeat('worktree-1') + heartbeat.removePty('unrelated-pty') + vi.runAllTimers() + + expect(emitted).toEqual(['worktree-1']) + heartbeat.dispose() + }) }) diff --git a/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts index 6c80cf4a35d..df457c6bb27 100644 --- a/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts +++ b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts @@ -6,7 +6,9 @@ export const SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS = 50 export type MobileSessionTabsAgentStatusHeartbeat = { observeSemanticTitle: (ptyId: string) => void + observeWorktreeRefresh: (worktreeId: string) => void scheduleDecorativeHeartbeat: (ptyId: string) => void + scheduleWorktreeHeartbeat: (worktreeId: string) => void removePty: (ptyId: string) => void removeWorktree: (worktreeId: string) => void cancelPending: () => void @@ -19,7 +21,7 @@ export function createMobileSessionTabsAgentStatusHeartbeat( ): MobileSessionTabsAgentStatusHeartbeat { const lastEligibilityCheckAtByPtyId = new Map() const lastRefreshAtByWorktreeId = new Map() - const pendingPtyIdsByWorktreeId = new Map>() + const pendingByWorktreeId = new Map }>() let lastGlobalHeartbeatAt: number | null = null let timer: ReturnType | null = null @@ -30,8 +32,16 @@ export function createMobileSessionTabsAgentStatusHeartbeat( } } + const observeWorktreeRefresh = (worktreeId: string, observedAt = Date.now()): void => { + lastRefreshAtByWorktreeId.set(worktreeId, observedAt) + pendingByWorktreeId.delete(worktreeId) + if (pendingByWorktreeId.size === 0) { + clearTimer() + } + } + const arm = (): void => { - if (timer !== null || pendingPtyIdsByWorktreeId.size === 0) { + if (timer !== null || pendingByWorktreeId.size === 0) { return } const now = Date.now() @@ -44,15 +54,15 @@ export function createMobileSessionTabsAgentStatusHeartbeat( ) timer = setTimeout(() => { timer = null - const worktreeId = pendingPtyIdsByWorktreeId.keys().next().value + const worktreeId = pendingByWorktreeId.keys().next().value if (typeof worktreeId !== 'string') { return } - const pendingPtyIds = pendingPtyIdsByWorktreeId.get(worktreeId) - pendingPtyIdsByWorktreeId.delete(worktreeId) + const pending = pendingByWorktreeId.get(worktreeId) + pendingByWorktreeId.delete(worktreeId) const emittedAt = Date.now() lastRefreshAtByWorktreeId.set(worktreeId, emittedAt) - for (const ptyId of pendingPtyIds ?? []) { + for (const ptyId of pending?.ptyIds ?? []) { lastEligibilityCheckAtByPtyId.set(ptyId, emittedAt) } lastGlobalHeartbeatAt = emittedAt @@ -64,18 +74,37 @@ export function createMobileSessionTabsAgentStatusHeartbeat( } } + const scheduleWorktreeHeartbeat = (worktreeId: string, ptyId?: string): void => { + const now = Date.now() + const lastRefreshAt = lastRefreshAtByWorktreeId.get(worktreeId) + if ( + lastRefreshAt !== undefined && + now - lastRefreshAt < SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS + ) { + return + } + const pending = pendingByWorktreeId.get(worktreeId) ?? { + directObservation: false, + ptyIds: new Set() + } + if (ptyId) { + pending.ptyIds.add(ptyId) + } else { + pending.directObservation = true + } + pendingByWorktreeId.set(worktreeId, pending) + arm() + } + return { observeSemanticTitle(ptyId: string): void { const observedAt = Date.now() lastEligibilityCheckAtByPtyId.set(ptyId, observedAt) for (const worktreeId of resolveWorktreeIds(ptyId)) { - lastRefreshAtByWorktreeId.set(worktreeId, observedAt) - pendingPtyIdsByWorktreeId.delete(worktreeId) - } - if (pendingPtyIdsByWorktreeId.size === 0) { - clearTimer() + observeWorktreeRefresh(worktreeId, observedAt) } }, + observeWorktreeRefresh, scheduleDecorativeHeartbeat(ptyId: string): void { const now = Date.now() const lastEligibilityCheckAt = lastEligibilityCheckAtByPtyId.get(ptyId) @@ -87,44 +116,36 @@ export function createMobileSessionTabsAgentStatusHeartbeat( } lastEligibilityCheckAtByPtyId.set(ptyId, now) for (const worktreeId of resolveWorktreeIds(ptyId)) { - const lastRefreshAt = lastRefreshAtByWorktreeId.get(worktreeId) - if ( - lastRefreshAt === undefined || - now - lastRefreshAt >= SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS - ) { - const pendingPtyIds = pendingPtyIdsByWorktreeId.get(worktreeId) ?? new Set() - pendingPtyIds.add(ptyId) - pendingPtyIdsByWorktreeId.set(worktreeId, pendingPtyIds) - } + scheduleWorktreeHeartbeat(worktreeId, ptyId) } - arm() }, + scheduleWorktreeHeartbeat, removePty(ptyId: string): void { lastEligibilityCheckAtByPtyId.delete(ptyId) - for (const [worktreeId, pendingPtyIds] of pendingPtyIdsByWorktreeId) { - pendingPtyIds.delete(ptyId) - if (pendingPtyIds.size === 0) { - pendingPtyIdsByWorktreeId.delete(worktreeId) + for (const [worktreeId, pending] of pendingByWorktreeId) { + pending.ptyIds.delete(ptyId) + if (pending.ptyIds.size === 0 && !pending.directObservation) { + pendingByWorktreeId.delete(worktreeId) } } - if (pendingPtyIdsByWorktreeId.size === 0) { + if (pendingByWorktreeId.size === 0) { clearTimer() } }, removeWorktree(worktreeId: string): void { lastRefreshAtByWorktreeId.delete(worktreeId) - pendingPtyIdsByWorktreeId.delete(worktreeId) - if (pendingPtyIdsByWorktreeId.size === 0) { + pendingByWorktreeId.delete(worktreeId) + if (pendingByWorktreeId.size === 0) { clearTimer() } }, cancelPending(): void { clearTimer() - pendingPtyIdsByWorktreeId.clear() + pendingByWorktreeId.clear() }, dispose(): void { clearTimer() - pendingPtyIdsByWorktreeId.clear() + pendingByWorktreeId.clear() lastEligibilityCheckAtByPtyId.clear() lastRefreshAtByWorktreeId.clear() lastGlobalHeartbeatAt = null diff --git a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts index 605eacdbe29..6d7e59c383f 100644 --- a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts +++ b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts @@ -173,8 +173,8 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper leaf.waitBlockedAt = null leaf.tailWaitState = undefined } + this.reconcileAgentStatusForEndedProcessFn?.(this.collectAgentStatusPaneKeysForPty(ptyId)) this.primeWaitBlockedBaselineFromSeededTail(ptyId) - this.clearAgentRowSnapshotsForPty(ptyId) } protected setTerminalSideEffectConsumerAvailable(available: boolean): void { diff --git a/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts b/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts index 69f9be4ba85..3f1a79beb77 100644 --- a/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts +++ b/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts @@ -55,7 +55,7 @@ export class OrcaRuntimeWithBindPtyIncarnationHandle extends OrcaRuntimeWithBuil const pty = this.ptysById.get(ptyId) const leaves = this.getLeavesForPty(ptyId) if ( - !pty?.incarnationId || + !pty || pty.incarnationId !== retained.incarnationId || leaves.length !== 1 || this.handleByPtyId.has(ptyId) @@ -91,6 +91,10 @@ export class OrcaRuntimeWithBindPtyIncarnationHandle extends OrcaRuntimeWithBuil } protected issuePtyHandle(pty: RuntimePtyWorktreeRecord): string { + const retained = this.handleByPtyIncarnation.get(pty.ptyId) + if (retained?.incarnationId === pty.incarnationId) { + return retained.handle + } const existingHandle = this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId) if (existingHandle) { diff --git a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts index d781bd2ae90..615353bbc13 100644 --- a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts +++ b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts @@ -173,7 +173,7 @@ export class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntimeWithGetPt ptyGeneration: leaf.ptyGeneration }) this.handleByLeafKey.set(leafKey, handle) - if (leaf.ptyId && incarnationId) { + if (leaf.ptyId) { this.handleByPtyIncarnation.set(leaf.ptyId, { handle, incarnationId, leafKey }) } return handle diff --git a/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts b/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts index 9a9a6c1c146..5486e953989 100644 --- a/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts +++ b/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts @@ -11,7 +11,6 @@ import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import type { ProcessedAgentStatusChunk } from '../../shared/agent-status-osc' import { mapExplicitAgentStateToRuntimeTerminalStatus } from './runtime-worktree-status-projection' -import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends OrcaRuntimeWithApplyTrackedPtyTitle { protected createTerminalSideEffectCommandCodeDetector( @@ -86,17 +85,9 @@ export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'posix' } - /** Returns true when any retained agent-row snapshot changed in a - * client-visible way, so the caller can republish session snapshots. */ - protected emitTerminalAgentStatusEvents( - ptyId: string, - chunk: ProcessedAgentStatusChunk - ): boolean { - // Why: snapshot retention (for mobile worktree.ps) must run even when no - // renderer listener is attached, so we don't early-return on a missing - // onTerminalAgentStatus — only the per-target emit below is gated on it. + protected emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): void { if (chunk.payloads.length === 0) { - return false + return } const targets = new Map< string, @@ -106,6 +97,7 @@ export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends tabId?: string worktreeId?: string connectionId?: string | null + terminalHandle?: string } >() const pty = this.ptysById.get(ptyId) @@ -129,22 +121,24 @@ export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends connectionId }) } - let retainedChanged = false + // Why once per chunk and not per payload: the same lookup the renderer-facing IPC boundary + // runs, and it is the pane's only durable join back to its terminal once the pane key moves. + if (this.onTerminalAgentStatus) { + for (const target of targets.values()) { + const terminalHandle = this.getAgentStatusTerminalHandleForPaneKey(target.paneKey) + if (terminalHandle) { + target.terminalHandle = terminalHandle + } + } + } for (const payload of chunk.payloads) { + // Why not gated on a listener: the prompt lifecycle is main's own state, read by + // terminal waits that run with no status consumer attached. this.recordAgentPromptLifecycleState( ptyId, mapExplicitAgentStateToRuntimeTerminalStatus(payload.state) ) for (const target of targets.values()) { - retainedChanged = - this.retainAgentRowSnapshot( - ptyId, - target.paneKey, - target.worktreeId, - target.tabId, - target.connectionId ?? null, - payload - ) || retainedChanged if (!this.onTerminalAgentStatus) { continue } @@ -165,28 +159,5 @@ export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends } } } - return retainedChanged - } - - protected retainAgentRowSnapshot( - ptyId: string, - paneKey: string, - worktreeId: string | undefined, - tabId: string | undefined, - connectionId: string | null, - payload: ParsedAgentStatusPayload - ): boolean { - return this.agentRows.retain({ - ptyId, - paneKey, - worktreeId, - tabId, - connectionId, - payload - }) - } - - protected clearAgentRowSnapshotsForPty(ptyId: string): void { - this.agentRows.clearPty(ptyId) } } diff --git a/src/main/runtime/orca-runtime-fit-override-listeners.ts b/src/main/runtime/orca-runtime-fit-override-listeners.ts index 63adb867d55..5ef17ce4c2e 100644 --- a/src/main/runtime/orca-runtime-fit-override-listeners.ts +++ b/src/main/runtime/orca-runtime-fit-override-listeners.ts @@ -13,7 +13,6 @@ import type { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kit import type { PtyProviderBufferSnapshot } from '../providers/types' import type { WaitBlockedCheckState } from './wait-blocked-check-state' import type { createAgentStatusOscProcessor } from '../../shared/agent-status-osc' -import { RuntimeAgentRowStore } from './runtime-agent-row-store' import { RuntimeTerminalViewSubscribers } from './runtime-terminal-view-subscribers' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' @@ -125,11 +124,6 @@ export class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWithStopRequ protected terminalFileUriHostnameByPtyId = new Map() - // Why: latest agent-status payload per pane, retained so worktree.ps can serve - // mobile the same inline agent rows the desktop sidebar renders. Cleared on pty - // teardown so dead agents don't linger. See RuntimeAgentRowSnapshot. - protected readonly agentRows = new RuntimeAgentRowStore() - // Why: per-PTY hydration state guards against double-hydration. Keys: // 'pending' → maybeHydrateHeadlessFromRenderer is in flight // 'done' → hydration completed (success or skip); never run again diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index 14345d10a77..d61374c3466 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -2,7 +2,12 @@ import { OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller } from './orca-runtime-verify-orchestration-compatibility-caller' import type { OrchestrationCompatibilityTerminalAuthority } from './runtime-terminal-contracts' import { createHash } from 'node:crypto' -import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { + isTerminalLeafId, + makePaneKey, + parseLegacyNumericPaneKey, + parsePaneKey +} from '../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../shared/terminal-tab-id' import { RECENT_PTY_OUTPUT_LIMIT, RecentPtyOutputBuffer } from './recent-pty-output-buffer' import { appendRecentPtyPathCandidates } from './terminal-output-path-candidates' @@ -38,6 +43,30 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim return paneKeys } + /** Status cleanup also owns runtime-admitted legacy OSC rows; orchestration authority does not. */ + protected collectAgentStatusPaneKeysForPty(ptyId: string): Set { + const paneKeys = this.collectPaneKeysForPty(ptyId) + const terminalHandles = new Set(this.getExistingTerminalHandlesForPtyId(ptyId)) + // The provider-session snapshot is the unfiltered store view, so certified exit can also + // retire a dismissed row's identity-only remnant after its pane binding moved. + for (const row of this.getAgentProviderSessionSnapshotFn?.() ?? []) { + if (row.terminalHandle && terminalHandles.has(row.terminalHandle)) { + paneKeys.add(row.paneKey) + } + } + const ptyPaneKey = this.ptysById.get(ptyId)?.paneKey + if (ptyPaneKey && parseLegacyNumericPaneKey(ptyPaneKey)) { + paneKeys.add(ptyPaneKey) + } + for (const leaf of this.getLeavesForPty(ptyId)) { + const paneKey = this.makeRuntimePaneKey(leaf) + if (parseLegacyNumericPaneKey(paneKey)) { + paneKeys.add(paneKey) + } + } + return paneKeys + } + getOrchestrationDispatchAuthority( terminalHandle: string ): OrchestrationCompatibilityTerminalAuthority | null { diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index 0c41b176d34..27012bdf8e9 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -64,7 +64,7 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM return makePaneKey(record.tabId, record.leafId) } - protected getWorktreeIdForTerminalHandle(handle: string): string | null { + getTerminalWorktreeIdForHandle(handle: string): string | null { const livePty = this.getLivePtyForHandle(handle) if (livePty?.pty.worktreeId) { return livePty.pty.worktreeId diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index ade25ad1975..3eb7a6e9401 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -96,6 +96,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent missingIds: missingRuntimeWorktreeIds, ptysById: this.ptysById, tabs: this.tabs, + getTerminalHandlesForPty: (ptyId) => this.getExistingTerminalHandlesForPtyId(ptyId), getSummary: (summaryMap, pathIndex, missingIds, worktreeId) => this.getSummaryForRuntimeWorktreeId(summaryMap, pathIndex, missingIds, worktreeId) }) @@ -107,7 +108,6 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent rowSources: collectRuntimeWorktreeAgentSources({ mirroredWorktreeIdByTabId, connectedPtyEvidence, - retainedSnapshots: this.agentRows.values(), // Structured sessions are in here too: the host publishes them into the same store. hookSnapshots: this.getAgentStatusSnapshotFn?.() ?? [] }), diff --git a/src/main/runtime/orca-runtime-has-terminals-for-worktree.ts b/src/main/runtime/orca-runtime-has-terminals-for-worktree.ts index 77082530ed2..ebedfb362fc 100644 --- a/src/main/runtime/orca-runtime-has-terminals-for-worktree.ts +++ b/src/main/runtime/orca-runtime-has-terminals-for-worktree.ts @@ -55,6 +55,12 @@ export class OrcaRuntimeWithHasTerminalsForWorktree extends OrcaRuntimeWithStopE const revision = this.graphReloadLifecycle.begin(windowId) this.setTerminalSideEffectConsumerAvailable(false) this.rememberDetachedPreAllocatedLeaves() + // A null incarnation is safe within one graph diff, but cannot prove a same-id PTY survived a renderer reload. + for (const [ptyId, retained] of this.handleByPtyIncarnation) { + if (retained.incarnationId === null) { + this.invalidatePtyIncarnationHandle(ptyId) + } + } const retainedHandles = new Set([ ...this.handleByPtyId.values(), ...[...this.handleByPtyIncarnation.values()].map((record) => record.handle) diff --git a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts index f8171862728..9fab2267d25 100644 --- a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts +++ b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts @@ -2,6 +2,7 @@ // live agent state, so `session.tabs` must project the hook row's status fields — not // just its identity — while still refusing rows that only prove an agent once existed. import { describe, expect, it, vi } from 'vitest' +import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture' import { OrcaRuntimeService } from './orca-runtime' import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' @@ -58,11 +59,25 @@ function hookRow(overrides: Partial = {}): AgentStatusIpc } async function createRuntimeWithHookRows( - rows: AgentStatusIpcPayload[] + rows: AgentStatusIpcPayload[], + /** Pass a store to exercise the OSC producer; otherwise the rows stand in for it. */ + statusWiring?: ReturnType ): Promise { + const readRows = statusWiring + ? (): AgentStatusIpcPayload[] => [...rows, ...statusWiring.deps.getAgentStatusSnapshot()] + : (): AgentStatusIpcPayload[] => rows const runtime = new OrcaRuntimeService(null, undefined, { - getAgentStatusSnapshot: () => rows, - getAgentProviderSessionRowsForPane: () => rows + ...(statusWiring + ? { + onTerminalAgentStatus: statusWiring.deps.onTerminalAgentStatus, + reconcileAgentStatusForEndedProcess: + statusWiring.deps.reconcileAgentStatusForEndedProcess, + getAgentProviderSessionSnapshot: statusWiring.deps.getAgentProviderSessionSnapshot, + getAgentProviderSessionRowsForPane: statusWiring.deps.getAgentProviderSessionRowsForPane + } + : {}), + getAgentStatusSnapshot: readRows, + ...(statusWiring ? {} : { getAgentProviderSessionRowsForPane: readRows }) }) const internals = runtime as unknown as { resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise @@ -224,8 +239,18 @@ describe('headless hook agent-status projection (#11761)', () => { }) // #7970: a retained OSC 9999 row is the pane's own report and keeps precedence. - it('prefers a retained OSC 9999 row over the hook row', async () => { - const runtime = await createRuntimeWithHookRows([hookRow()]) + it('projects the OSC turn that replaced the hook row in the store', async () => { + // One store: an OSC turn is a write, not a competing copy, so the pane projects whatever + // the store holds now rather than a reader-side preference between two rows. + const statusWiring = makeAgentStatusStoreWiring() + statusWiring.statusStore.ingestTerminalStatus({ + paneKey: PANE_KEY, + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + connectionId: null, + payload: { state: 'waiting', prompt: 'Tabs or spaces?', agentType: 'claude' } + }) + const runtime = await createRuntimeWithHookRows([], statusWiring) runtime.onPtyData( PTY_ID, '\x1b]9999;{"state":"working","prompt":"fix the tests","agentType":"claude"}\x07', @@ -310,6 +335,124 @@ describe('headless hook agent-status projection (#11761)', () => { expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('interactivePrompt') }) + it('evicts the predecessor row at a certified provider generation reset', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = await createRuntimeWithHookRows([], statusWiring) + runtime.onPtyData( + PTY_ID, + '\x1b]9999;{"state":"working","prompt":"predecessor","agentType":"claude"}\x07', + 1 + ) + expect(statusWiring.statusStore.getStatusSnapshot()).toHaveLength(1) + + const internals = runtime as unknown as { + resetTrackedTerminalStateForProviderGeneration: (ptyId: string) => void + } + internals.resetTrackedTerminalStateForProviderGeneration(PTY_ID) + + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + statusWiring.statusStore.stop() + }) + + it('evicts a row joined only through the terminal handle on certified PTY exit', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = await createRuntimeWithHookRows([], statusWiring) + const terminal = (await runtime.listTerminals()).terminals[0] + if (!terminal) { + throw new Error('expected a live terminal') + } + const priorPaneKey = makePaneKey('prior-tab', UNKNOWN_LEAF_ID) + statusWiring.statusStore.ingestTerminalStatus({ + paneKey: priorPaneKey, + tabId: 'prior-tab', + terminalHandle: terminal.handle, + payload: { state: 'working', prompt: 'prior pane', agentType: 'claude' } + }) + expect(statusWiring.statusStore.getStatusSnapshot()).toHaveLength(1) + + runtime.onPtyExit(PTY_ID, 0) + + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + statusWiring.statusStore.stop() + }) + + it('evicts the central status row when a disconnected PTY record is pruned', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = await createRuntimeWithHookRows([], statusWiring) + runtime.onPtyData( + PTY_ID, + '\x1b]9999;{"state":"working","prompt":"before prune","agentType":"claude"}\x07', + 1 + ) + expect(statusWiring.statusStore.getStatusSnapshot()).toHaveLength(1) + + const internals = runtime as unknown as { + dropDisconnectedPtyRecord: (ptyId: string) => void + } + internals.dropDisconnectedPtyRecord(PTY_ID) + + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + statusWiring.statusStore.stop() + }) + + it('keeps an unverifiable remote row when its disconnected PTY record is pruned', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = await createRuntimeWithHookRows([], statusWiring) + const internals = runtime as unknown as { + ptysById: Map + dropDisconnectedPtyRecord: (ptyId: string) => void + } + const pty = internals.ptysById.get(PTY_ID)! + pty.connectionId = 'ssh-target' + runtime.onPtyData( + PTY_ID, + '\x1b]9999;{"state":"working","prompt":"remote work","agentType":"claude"}\x07', + 1 + ) + pty.connected = false + + internals.dropDisconnectedPtyRecord(PTY_ID) + + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([ + expect.objectContaining({ connectionId: 'ssh-target', prompt: 'remote work' }) + ]) + statusWiring.statusStore.stop() + }) + + it('evicts a dismissed handle-joined remnant on certified PTY exit', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = await createRuntimeWithHookRows([], statusWiring) + const terminal = (await runtime.listTerminals()).terminals[0] + if (!terminal) { + throw new Error('expected a live terminal') + } + const priorPaneKey = makePaneKey('prior-tab', UNKNOWN_LEAF_ID) + statusWiring.statusStore.ingestTerminalStatus({ + paneKey: priorPaneKey, + tabId: 'prior-tab', + terminalHandle: terminal.handle, + payload: { state: 'working', prompt: 'dismissed pane', agentType: 'claude' } + }) + statusWiring.statusStore.ingestRemote( + { + paneKey: priorPaneKey, + tabId: 'prior-tab', + providerSession: PROVIDER_SESSION, + payload: { state: 'working', prompt: 'dismissed pane', agentType: 'claude' } + }, + null + ) + statusWiring.statusStore.dropStatusEntry(priorPaneKey) + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: priorPaneKey, providerSessionOnly: true }) + ]) + + runtime.onPtyExit(PTY_ID, 0) + + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + statusWiring.statusStore.stop() + }) + it('does not carry a hook question across an identity-only owner title', async () => { const runtime = await createRuntimeWithHookRows([hookRow()]) const internals = runtime as unknown as { diff --git a/src/main/runtime/orca-runtime-on-pty-data.ts b/src/main/runtime/orca-runtime-on-pty-data.ts index 5d27da94c14..df384980fef 100644 --- a/src/main/runtime/orca-runtime-on-pty-data.ts +++ b/src/main/runtime/orca-runtime-on-pty-data.ts @@ -211,7 +211,6 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution } titleTrackerEntry.applyingChunk = true titleTrackerEntry.chunkTouchedSessionTabs = false - let retainedAgentStatusChanged = false try { for (const payload of agentStatusChunk.payloads) { titleTrackerEntry.pendingFacts.push({ kind: 'agent-status', payload }) @@ -230,7 +229,7 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution // Why: per-chunk cross-channel contract order is status → titles → // bell — the chunk's agentStatus:set events must reach the renderer // before its pty:sideEffect batch. - retainedAgentStatusChanged = this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) + this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) const lastPayloadTitleOffset = agentStatusChunk.lastPayloadCleanOffset === null ? null @@ -242,10 +241,10 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution this.flushPendingTerminalSideEffectFacts(ptyId, titleTrackerEntry) } } - // Why: hook (OSC 9999) transitions often arrive without a title change, so - // headless-serve snapshots would never republish and paired remote clients - // kept the stale agent state until the next title change (#7970). - if (titleTrackerEntry.chunkTouchedSessionTabs || retainedAgentStatusChanged) { + // Why only the title arm here: an OSC 9999 transition republishes off the store's own + // change signal (installHookStatusSessionTabsRepublish), which sees hook and OSC rows + // alike — a second per-chunk republish would only re-emit the same snapshot version. + if (titleTrackerEntry.chunkTouchedSessionTabs) { this.touchMobileSessionSnapshotsForPty(ptyId) } diff --git a/src/main/runtime/orca-runtime-on-pty-exit.ts b/src/main/runtime/orca-runtime-on-pty-exit.ts index 7d3626d4ef0..6ddf877f87c 100644 --- a/src/main/runtime/orca-runtime-on-pty-exit.ts +++ b/src/main/runtime/orca-runtime-on-pty-exit.ts @@ -47,7 +47,7 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte options.hostExitConfirmed !== true // Why: collect before retirePtyAgentLaunchAuthority, which deletes the restored-authority // receipt a receipt-only pane's key comes from. - const exitPaneKeys = this.collectPaneKeysForPty(ptyId) + const exitPaneKeys = this.collectAgentStatusPaneKeysForPty(ptyId) if (preservesAbnormalSshSurface) { const prior = this.ptyLivenessVerdictByPtyId.get(ptyId)?.verdict this.rememberPtyLivenessVerdict(ptyId, { @@ -153,7 +153,6 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte this.terminalCwdByPtyId.delete(ptyId) this.terminalFileUriHostnameByPtyId.delete(ptyId) this.wslDistroByPtyId.delete(ptyId) - this.clearAgentRowSnapshotsForPty(ptyId) // Why: a Claude agent-team leader whose PTY exits naturally (agent finished, // process died, renderer reload) must release its team + nested panes map. // Previously only explicit closeTerminal evicted it, so natural exits leaked diff --git a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts index 2c704205b93..4a910f02451 100644 --- a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { selectFreshAgentRowForMobileTab } from './runtime-hook-agent-row-selection' import { OrcaRuntimeWithScheduleMobileSessionTabsChanged } from './orca-runtime-schedule-mobile-session-tabs-changed' import type { TabGroupLayoutNode } from '../../shared/tab-types' import type { @@ -93,11 +94,12 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime getLiveBrowserTabs: (worktreeId) => this.getLiveBrowserTabsByPageId(worktreeId), getProviderSessionRows: (paneKey) => this.getAgentProviderSessionRowsForPaneFn?.(paneKey), getProviderSessionSnapshot: () => this.getAgentProviderSessionSnapshotFn?.() ?? [], + getStatusSnapshot: () => this.getAgentStatusSnapshotFn?.() ?? [], getLeafKey: (tabId, leafId) => this.getLeafKey(tabId, leafId), findPty: (worktreeId, tab, options) => this.findPtyForMobileTerminalTab(worktreeId, tab, options), - getRetainedStatus: (paneKey, pty, tab) => - this.getFreshRetainedAgentStatusForMobileTab(paneKey, pty, tab), + getRetainedStatus: (paneKey, pty, tab, getRows) => + this.getFreshRetainedAgentStatusForMobileTab(paneKey, pty, tab, getRows), getTrackedTitle: (ptyId) => this.getUnpersistedTrackedTitleForPty(ptyId), issuePtyHandle: (pty) => this.issuePtyHandle(pty), recordPty: (ptyId, worktreeId, state) => this.recordPtyWorktree(ptyId, worktreeId, state), @@ -128,9 +130,30 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime protected getFreshRetainedAgentStatusForMobileTab( paneKey: string, pty: RuntimePtyWorktreeRecord | null, - tab: RuntimeMobileSessionTerminalTab + _tab: RuntimeMobileSessionTerminalTab, + getRows: (paneKey: string, terminalHandle: string | null) => AgentStatusIpcPayload[] ): RuntimeAgentRowSnapshot | null { - return this.agentRows.getFreshForMobile(paneKey, pty, tab) + const paneMatch = selectFreshAgentRowForMobileTab({ + paneKey, + terminalHandle: null, + hookRows: getRows(paneKey, null) + }) + if (paneMatch || !pty) { + return paneMatch + } + // Why: the OSC producer can stamp a leaf or incarnation handle; use the same non-minting + // inventory as worktree.ps so a tab-id remint can rejoin the still-live central row. + for (const terminalHandle of this.getExistingTerminalHandlesForPtyId(pty.ptyId)) { + const handleMatch = selectFreshAgentRowForMobileTab({ + paneKey, + terminalHandle, + hookRows: getRows(paneKey, terminalHandle) + }) + if (handleMatch) { + return handleMatch + } + } + return null } protected findPtyForMobileTerminalTab( diff --git a/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts b/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts index e9871f24c75..de98831cdd5 100644 --- a/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts +++ b/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts @@ -119,6 +119,14 @@ export class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends OrcaRunt protected dropDisconnectedPtyRecord(ptyId: string): void { // Why: pruning can remove a PTY without the normal exit callback. + const pty = this.ptysById.get(ptyId) + // Remote disconnect is unverifiable; its host-owned status survives until certified exit. + const processDeathCertified = + pty?.connectionId === null || + this.ptyLivenessVerdictByPtyId.get(ptyId)?.verdict.status === 'exited' + if (processDeathCertified) { + this.reconcileAgentStatusForEndedProcessFn?.(this.collectAgentStatusPaneKeysForPty(ptyId)) + } this.advancePtyLifecycleGeneration(ptyId) this.pairedRendererSessionOwnedPtyIds.delete(ptyId) this.ptysById.delete(ptyId) @@ -145,7 +153,6 @@ export class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends OrcaRunt this.terminalCwdByPtyId.delete(ptyId) this.terminalFileUriHostnameByPtyId.delete(ptyId) this.wslDistroByPtyId.delete(ptyId) - this.clearAgentRowSnapshotsForPty(ptyId) const handle = this.handleByPtyId.get(ptyId) if (handle) { // Why: pruning can remove a PTY without onPtyExit firing; release this leader's agent team so it doesn't leak. diff --git a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts index 81696fe4739..1e321bc7584 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { selectFreshExplicitAgentStatus } from './runtime-hook-agent-row-selection' import { OrcaRuntimeWithControllerKnowsPtyIsLive } from './orca-runtime-controller-knows-pty-is-live' import type { RuntimeTerminalAgentStatus } from '../../shared/runtime-types' import type { RuntimeTerminalAgentStatusSnapshot } from './runtime-terminal-agent-status-query' @@ -181,7 +182,7 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi updatedAt: number stateStartedAt: number } | null { - return this.agentRows.getFreshExplicit({ + return selectFreshExplicitAgentStatus({ handle, paneKey: paneKeyOverride ?? this.getPaneKeyForTerminalHandle(handle), hookRows: this.getAgentStatusSnapshotFn?.() ?? [] diff --git a/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts b/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts index be54620fde3..a96ae64e596 100644 --- a/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts +++ b/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts @@ -123,14 +123,11 @@ export class OrcaRuntimeWithStopExactTerminalsForWorktree extends OrcaRuntimeWit } protected getTerminalHandlesForPtyId(ptyId: string): string[] { - const handles = new Set( - this.getLeavesForPty(ptyId) - .filter((candidate) => candidate.connected) - .map((leaf) => this.issueHandle(leaf)) - ) - const runtimeHandle = this.handleByPtyId.get(ptyId) - if (runtimeHandle) { - handles.add(runtimeHandle) + const handles = new Set(this.getExistingTerminalHandlesForPtyId(ptyId)) + for (const handle of this.getLeavesForPty(ptyId) + .filter((candidate) => candidate.connected) + .map((leaf) => this.issueHandle(leaf))) { + handles.add(handle) } const pty = this.getOrCreatePtyWorktreeRecord(ptyId) if (!pty) { @@ -142,6 +139,23 @@ export class OrcaRuntimeWithStopExactTerminalsForWorktree extends OrcaRuntimeWit return [...handles].sort() } + protected getExistingTerminalHandlesForPtyId(ptyId: string): string[] { + const handles = new Set( + this.getLeavesForPty(ptyId) + .map((leaf) => this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId))) + .filter((handle): handle is string => handle !== undefined) + ) + const runtimeHandle = this.handleByPtyId.get(ptyId) + if (runtimeHandle) { + handles.add(runtimeHandle) + } + const incarnationHandle = this.handleByPtyIncarnation.get(ptyId)?.handle + if (incarnationHandle) { + handles.add(incarnationHandle) + } + return [...handles].sort() + } + protected getRecordedTerminalSleepHandles( ptyIds: Iterable, terminalHandlesByPtyId: Readonly> diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index a3785adb2bb..c7f883fcfcb 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -107,7 +107,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId issueLeafHandle: (leaf) => this.issueHandle(leaf), issuePtyHandle: (pty) => this.issuePtyHandle(pty), makePaneKey: (leaf) => this.makeRuntimePaneKey(leaf), - getWorktreeId: (handle) => this.getWorktreeIdForTerminalHandle(handle), + getWorktreeId: (handle) => this.getTerminalWorktreeIdForHandle(handle), getHandleForPaneKey: (paneKey) => this.getTerminalHandleForPaneKey(paneKey), getPaneKey: (handle) => this.getPaneKeyForTerminalHandle(handle), getDispatchAuthority: (handle) => this.getOrchestrationDispatchAuthority(handle), diff --git a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts index 7c0310554ef..23312f49420 100644 --- a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts +++ b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts @@ -41,6 +41,8 @@ describe('OrcaRuntimeService', () => { tabId: spawnedEnv.ORCA_TAB_ID, worktreeId: TEST_WORKTREE_ID, connectionId: null, + // The pane's handle rides the event so the store's row can rejoin its terminal. + terminalHandle: expect.stringMatching(/^term_/), payload: { state: 'done', prompt: 'ok' diff --git a/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts b/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts index 4de568618cc..b9bb63fcea8 100644 --- a/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts +++ b/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts @@ -598,6 +598,8 @@ describe('OrcaRuntimeService', () => { tabId: 'tab-1', worktreeId: TEST_WORKTREE_ID, connectionId: null, + // The pane's handle rides the event so the store's row can rejoin its terminal. + terminalHandle: expect.stringMatching(/^term_/), payload: { state: 'working', prompt: 'ship it', diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts index 65de777139f..518d6805c5b 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' import { OrcaRuntimeService, electronMocks } from '../orca-runtime-test-mocks.spec' import { HEADLESS_LEAF_ID, @@ -284,7 +285,11 @@ describe('OrcaRuntimeService', () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal() ) - const runtime = new OrcaRuntimeService(runtimeStore as never) + let rows: AgentStatusIpcPayload[] = [] + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => rows, + getAgentProviderSessionRowsForPane: () => [] + }) runtime.setPtyController({ write: () => true, kill: () => true, @@ -293,7 +298,27 @@ describe('OrcaRuntimeService', () => { { id: 'persisted-pty', cwd: TEST_WORKTREE_PATH, title: 'Unrelated PTY' } ] }) + runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, { + tabId: 'other-tab', + leafId: '99999999-9999-4999-8999-999999999999' + }) runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const unrelatedPty = runtime['ptysById'].get('persisted-pty')! + const unrelatedHandle = runtime['issuePtyHandle'](unrelatedPty) + rows = [ + { + paneKey: 'other-tab:99999999-9999-4999-8999-999999999999', + tabId: 'other-tab', + worktreeId: TEST_WORKTREE_ID, + terminalHandle: unrelatedHandle, + connectionId: null, + state: 'working', + prompt: 'unrelated task', + agentType: 'codex', + receivedAt: Date.now(), + stateStartedAt: Date.now() + } + ] const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) @@ -304,6 +329,45 @@ describe('OrcaRuntimeService', () => { status: 'pending-handle', terminal: null }) + expect(listed.tabs[0]).not.toHaveProperty('agentStatus') + }) + + it('reads and indexes the full agent-status snapshot once per mobile projection', async () => { + const tabCount = 20 + const session = makeWorkspaceSessionWithHeadlessTerminal() + const tabs = Array.from({ length: tabCount }, (_, index) => ({ + ...session.tabsByWorktree[TEST_WORKTREE_ID]![0]!, + id: `host-tab-${index}`, + ptyId: `missing-pty-${index}` + })) + const terminalLayoutsByTabId = Object.fromEntries( + tabs.map((tab, index) => [ + tab.id, + makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: `missing-pty-${index}` }) + ]) + ) + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...session, + tabsByWorktree: { [TEST_WORKTREE_ID]: tabs }, + terminalLayoutsByTabId + }) + const getAgentStatusSnapshot = vi.fn(() => []) + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot, + getAgentProviderSessionRowsForPane: () => [] + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(listed.tabs).toHaveLength(tabCount) + expect(getAgentStatusSnapshot).toHaveBeenCalledOnce() }) it('kills persisted SSH PTYs when closing hydrated headless tabs before pane metadata is restored', async () => { diff --git a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-02.spec.ts index bd39e91e460..58fa4ae750b 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-02.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture' import { AGENT_STATUS_STALE_AFTER_MS, MOCK_GIT_WORKTREES, @@ -254,23 +255,18 @@ describe('OrcaRuntimeService', () => { }) it('keeps a fresh OSC row when the cached hook row for the same pane is older', async () => { - const now = Date.now() const leafId = '44444444-4444-4444-8444-444444444444' const paneKey = `tab-1:${leafId}` - const runtime = new OrcaRuntimeService(store, undefined, { - getAgentStatusSnapshot: () => [ - { - paneKey, - worktreeId: TEST_WORKTREE_ID, - tabId: 'tab-1', - state: 'working', - prompt: 'stale hook row', - agentType: 'claude', - connectionId: null, - receivedAt: now - AGENT_STATUS_STALE_AFTER_MS - 1, - stateStartedAt: now - AGENT_STATUS_STALE_AFTER_MS - 100 - } - ] + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + statusWiring.statusStore.ingestTerminalStatus({ + paneKey, + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + connectionId: null, + // Same agent as the OSC turn below: the store resolves pane identity itself, and a + // cross-agent flip inside the inheritance window is a different rule's subject. + payload: { state: 'working', prompt: 'earlier hook row', agentType: 'codex' } }) runtime.attachWindow(1) runtime.syncWindowGraph(1, { @@ -567,15 +563,19 @@ describe('OrcaRuntimeService', () => { ]) }) - it('keeps a retained OSC row via its connected PTY after the pane binding is cleared', async () => { + it('keeps an OSC row via its connected PTY after the pane binding is cleared', async () => { // A controller incarnation change nulls pty.tabId/paneKey while the PTY - // stays connected (adoptControllerTerminalHandle); the ptyId conjunct is - // then the only rescue for the retained OSC row. + // stays connected (adoptControllerTerminalHandle); the terminal handle the row was + // stamped with is then the only rescue left for it. const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ ...getDefaultWorkspaceSession(), tabsByWorktree: {} }) - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = new OrcaRuntimeService( + runtimeStore as never, + undefined, + makeAgentStatusStoreWiring().deps + ) runtime['recordPtyWorktree']('osc-pty', TEST_WORKTREE_ID, { connected: true, tabId: 'osc-tab', @@ -604,21 +604,8 @@ describe('OrcaRuntimeService', () => { ...getDefaultWorkspaceSession(), tabsByWorktree: {} }) - const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { - getAgentStatusSnapshot: () => [ - { - paneKey, - worktreeId: TEST_WORKTREE_ID, - tabId: 'race-tab', - state: 'working', - prompt: 'hook-fresh agent', - agentType: 'codex', - connectionId: null, - receivedAt: Date.now() + 60_000, - stateStartedAt: Date.now() - 100 - } - ] - }) + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, statusWiring.deps) runtime['recordPtyWorktree']('race-pty', TEST_WORKTREE_ID, { connected: true, tabId: 'race-tab', @@ -629,6 +616,13 @@ describe('OrcaRuntimeService', () => { '\x1b]9999;{"state":"working","prompt":"osc ping","agentType":"codex"}\x07', 1 ) + statusWiring.statusStore.ingestTerminalStatus({ + paneKey, + tabId: 'race-tab', + worktreeId: TEST_WORKTREE_ID, + connectionId: null, + payload: { state: 'working', prompt: 'hook-fresh agent', agentType: 'codex' } + }) const pty = runtime['ptysById'].get('race-pty')! pty.tabId = null pty.paneKey = null diff --git a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts index d1f60e8cda7..b82e5863c2e 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture' import { OrcaRuntimeService, getDefaultWorkspaceSession, @@ -17,14 +18,18 @@ import { } from '../orca-runtime-test-fixtures.spec' describe('OrcaRuntimeService', () => { - it('keeps a retained OSC row from an SSH pane after its PTY disconnects', async () => { + it('keeps an OSC row from an SSH pane after its PTY disconnects', async () => { // Why: OSC snapshots must carry the pane transport; hardcoding local would // strip the SSH exemption off rows whose freshest update arrived via OSC. const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ ...getDefaultWorkspaceSession(), tabsByWorktree: {} }) - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = new OrcaRuntimeService( + runtimeStore as never, + undefined, + makeAgentStatusStoreWiring().deps + ) runtime['recordPtyWorktree']('ssh-osc-pty', TEST_WORKTREE_ID, { connected: true, connectionId: 'ssh-osc-1', diff --git a/src/main/runtime/orca-runtime-tests/mobile-summaries.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-summaries.spec.ts index 60bd9086e56..64ad592209b 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-summaries.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-summaries.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture' import { OrcaRuntimeService, listWorktrees } from '../orca-runtime-test-mocks.spec' import { HEADLESS_LEAF_ID, @@ -386,7 +387,7 @@ describe('OrcaRuntimeService', () => { }) it('attaches inline agent rows from the latest OSC 9999 status', async () => { - const runtime = new OrcaRuntimeService(store) + const runtime = new OrcaRuntimeService(store, undefined, makeAgentStatusStoreWiring().deps) const leafId = '22222222-2222-4222-8222-222222222222' runtime.attachWindow(1) runtime.syncWindowGraph(1, { @@ -611,24 +612,37 @@ describe('OrcaRuntimeService', () => { ]) }) it('does not carry hook monitoring mode into a newer OSC turn', async () => { - const now = Date.now() - const runtime = new OrcaRuntimeService(store, undefined, { - getAgentStatusSnapshot: () => [ + // One store, so the newer turn simply replaces the monitoring row; nothing reconciles them. + const leafId = '55555555-5555-4555-8555-555555555555' + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ { - paneKey: 'tab-1:1', - worktreeId: TEST_WORKTREE_ID, tabId: 'tab-1', - state: 'working', - workingMode: 'monitoring', - prompt: 'watch tests', - agentType: 'claude', - connectionId: null, - receivedAt: now - 100, - stateStartedAt: now - 200 + worktreeId: TEST_WORKTREE_ID, + title: 'Claude', + activeLeafId: leafId, + layout: null } + ], + leaves: [ + { tabId: 'tab-1', worktreeId: TEST_WORKTREE_ID, leafId, paneRuntimeId: 1, ptyId: 'pty-1' } ] }) - syncSinglePty(runtime) + statusWiring.statusStore.ingestTerminalStatus({ + paneKey: `tab-1:${leafId}`, + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + connectionId: null, + payload: { + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude' + } + }) runtime.onPtyData( 'pty-1', '\x1b]9999;{"state":"working","prompt":"fix tests","agentType":"claude"}\x07', diff --git a/src/main/runtime/orca-runtime-tests/terminal-handles-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-handles-part-02.spec.ts index 438ec34118e..170a20b278c 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-handles-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-handles-part-02.spec.ts @@ -9,10 +9,10 @@ import { import { makePaneKey } from '../orca-runtime-test-mocks.spec' describe('OrcaRuntimeService', () => { - it('invalidates a re-keyed leaf-unique handle so in-flight waiters fail fast', async () => { + it('keeps a no-incarnation handle across an in-graph pane remint', async () => { const runtime = createRuntime() const tabId = 'tab-1' - // No preAllocateHandleForPty: a plain terminal's handle is leaf-unique, so a re-key leaves it with no next owner and it goes stale immediately. + // No preallocated handle or incarnation id: the live PTY itself is the continuity proof within this graph. runtime.attachWindow(TEST_WINDOW_ID) runtime.syncWindowGraph(TEST_WINDOW_ID, { tabs: [ @@ -36,8 +36,8 @@ describe('OrcaRuntimeService', () => { }) const before = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) expect(before.terminals).toHaveLength(1) - const staleHandle = before.terminals[0].handle - const waiting = runtime.waitForTerminal(staleHandle, { condition: 'exit', timeoutMs: 30_000 }) + const stableHandle = before.terminals[0].handle + const waiting = runtime.waitForTerminal(stableHandle, { condition: 'exit', timeoutMs: 30_000 }) // Re-key WITHOUT a renderer reload (e.g. a pane moved across tabs) while the same PTY stays live under a new leaf. runtime.syncWindowGraph(TEST_WINDOW_ID, { @@ -61,11 +61,11 @@ describe('OrcaRuntimeService', () => { ] }) - // The waiter must fail fast, not hang until timeout on a dead leaf. - await expect(waiting).rejects.toThrow('terminal_handle_stale') const after = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) expect(after.terminals).toHaveLength(1) - expect(after.terminals[0].handle).not.toBe(staleHandle) + expect(after.terminals[0].handle).toBe(stableHandle) + runtime.onPtyExit('pty-plain', 0) + await expect(waiting).resolves.toMatchObject({ handle: stableHandle, status: 'exited' }) }) it('keeps a live CLI waiter pending when a re-keyed shared handle transfers to the live leaf', async () => { diff --git a/src/main/runtime/orca-runtime-tests/terminal-handles.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-handles.spec.ts index 4ae933afef4..8433dc5a71e 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-handles.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-handles.spec.ts @@ -82,6 +82,7 @@ describe('OrcaRuntimeService', () => { if (!mobileHandle) { throw new Error('expected mobile terminal handle') } + expect(mobileHandle).toBe(terminals.terminals[0].handle) const processLists = [[{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Claude' }], []] runtime.setPtyController({ @@ -101,7 +102,7 @@ describe('OrcaRuntimeService', () => { (event) => event.type === 'worktreeTerminalSleepState' && event.phase === 'started' ) ).toMatchObject({ - terminalHandles: [terminals.terminals[0].handle, mobileHandle].sort() + terminalHandles: [...new Set([terminals.terminals[0].handle, mobileHandle])].sort() }) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery.spec.ts index 4c17cc9df18..21d1450b641 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery.spec.ts @@ -1,4 +1,5 @@ import { settledWriteStub } from '../../providers/settled-pty-write-stub' +import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -139,7 +140,9 @@ describe('OrcaRuntimeService', () => { // #7970: headless serve has no renderer syncing tab.agentStatus, so hook-only transitions must republish the snapshot carrying the retained hook payload. it('republishes mobile session tabs with hook payloads for title-less OSC 9999 transitions', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'hook-only-pty' }) - const runtime = new OrcaRuntimeService(store) + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + const uninstallRepublish = statusWiring.attach(runtime) runtime.setPtyController({ spawn, write: () => true, @@ -188,12 +191,15 @@ describe('OrcaRuntimeService', () => { ) unsubscribe() + uninstallRepublish() }) // Why: restored OMP panes can retain the hook while the wrapped Pi owns foreground (#6364). it('keeps an OMP hook labeled OMP when the wrapped pi child owns the foreground', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'omp-flicker-pty' }) - const runtime = new OrcaRuntimeService(store) + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + const uninstallRepublish = statusWiring.attach(runtime) runtime.setPtyController({ spawn, write: () => true, @@ -240,11 +246,14 @@ describe('OrcaRuntimeService', () => { ) unsubscribe() + uninstallRepublish() }) it('does not republish mobile session tabs for repeated identical OSC 9999 payloads', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'hook-ping-pty' }) - const runtime = new OrcaRuntimeService(store) + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + const uninstallRepublish = statusWiring.attach(runtime) runtime.setPtyController({ spawn, write: () => true, @@ -270,6 +279,7 @@ describe('OrcaRuntimeService', () => { expect(events).toHaveLength(1) unsubscribe() + uninstallRepublish() }) it('suppresses a retained hook working status once the shell owns the pane title again', async () => { diff --git a/src/main/runtime/orca-runtime-tests/worktree-ps-agent-row-dismissal.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-ps-agent-row-dismissal.spec.ts new file mode 100644 index 00000000000..351996166c2 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/worktree-ps-agent-row-dismissal.spec.ts @@ -0,0 +1,313 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import { TEST_WORKTREE_ID, store } from '../orca-runtime-test-fixtures.spec' +import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture' + +/** + * One store means one dismissal. Before PR 1b the runtime kept its own copy of the OSC row, so a + * row the user dismissed on the desktop stayed in `orca worktree ps` and on the phone until the + * PTY exited. These drive the real OSC byte path so the producer under test is the runtime's own + * parse, not a hand-built snapshot. + */ +const LEAF_ID = '77777777-7777-4777-8777-777777777777' +const REMINTED_LEAF_ID = '88888888-8888-4888-8888-888888888888' +const PANE_KEY = `tab-dismiss:${LEAF_ID}` + +function wiredRuntime(incarnationId?: string): { + runtime: OrcaRuntimeService + statusWiring: ReturnType +} { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-dismiss', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-dismiss', + worktreeId: TEST_WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: 'dismiss-pty' + } + ] + }) + if (incarnationId) { + runtime.registerPty('dismiss-pty', TEST_WORKTREE_ID, null, { + tabId: 'tab-dismiss', + leafId: LEAF_ID, + incarnationId + }) + } + return { runtime, statusWiring } +} + +function emitWorkingStatus(runtime: OrcaRuntimeService, sequence: number): void { + runtime.onPtyData( + 'dismiss-pty', + '\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex"}\x07', + sequence + ) +} + +describe('worktree ps follows a dismissal out of the agent-status store', () => { + it('drops the row as soon as the user dismisses it, without waiting for the PTY to exit', async () => { + const { runtime, statusWiring } = wiredRuntime() + emitWorkingStatus(runtime, 1) + + const listed = await runtime.getWorktreePs() + expect( + listed.worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents + ).toEqual([expect.objectContaining({ paneKey: PANE_KEY, prompt: 'ship it' })]) + + statusWiring.statusStore.dropStatusEntry(PANE_KEY) + + // The PTY is untouched and still connected; only the store was told. + expect(runtime['ptysById'].get('dismiss-pty')?.connected).toBe(true) + const afterDismissal = await runtime.getWorktreePs() + expect( + afterDismissal.worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents + ).toEqual([]) + }) + + it('tells paired clients to republish on the transition and on the dismissal', async () => { + const { runtime, statusWiring } = wiredRuntime() + const republish = vi.spyOn(runtime, 'touchMobileSessionTabsForWorktree') + const uninstall = statusWiring.attach(runtime) + try { + emitWorkingStatus(runtime, 1) + expect(republish).toHaveBeenCalledWith(TEST_WORKTREE_ID) + + // The same payload again changes nothing a client would render. + republish.mockClear() + emitWorkingStatus(runtime, 2) + expect(republish).not.toHaveBeenCalled() + + runtime.onPtyData( + 'dismiss-pty', + '\x1b]9999;{"state":"done","prompt":"ship it","agentType":"codex"}\x07', + 3 + ) + expect(republish).toHaveBeenCalledWith(TEST_WORKTREE_ID) + + republish.mockClear() + statusWiring.statusStore.dropStatusEntry(PANE_KEY) + expect(republish).toHaveBeenCalledWith(TEST_WORKTREE_ID) + } finally { + uninstall() + republish.mockRestore() + } + }) + + it.each([ + ['leaf binding', undefined, false], + ['controller incarnation', 'incarnation-1', true] + ] as const)( + 'rejoins a row through its %s handle after pane ownership clears', + async (_, incarnationId, clearLeafBinding) => { + const { runtime, statusWiring } = wiredRuntime(incarnationId) + emitWorkingStatus(runtime, 1) + const row = statusWiring.statusStore.getStatusSnapshot()[0]! + const internals = runtime as unknown as { + handleByLeafKey: Map + handleByPtyIncarnation: Map + ptysById: Map + } + const pty = internals.ptysById.get('dismiss-pty')! + pty.paneKey = null + pty.tabId = null + if (clearLeafBinding) { + expect(internals.handleByPtyIncarnation.get('dismiss-pty')?.handle).toBe(row.terminalHandle) + internals.handleByLeafKey.clear() + } + + const listed = await runtime.getWorktreePs() + + expect( + listed.worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents + ).toEqual([expect.objectContaining({ prompt: 'ship it' })]) + statusWiring.statusStore.stop() + } + ) + + it('publishes one provider-addressable row through remint, dismissal, and exit', async () => { + const { runtime, statusWiring } = wiredRuntime('incarnation-1') + emitWorkingStatus(runtime, 1) + const row = statusWiring.statusStore.getStatusSnapshot()[0]! + expect(row.terminalHandle).toMatch(/^term_/) + statusWiring.statusStore.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-dismiss', + worktreeId: TEST_WORKTREE_ID, + providerSession: { key: 'session_id', id: 'provider-session-1' }, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' } + }, + null + ) + + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-reminted', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: REMINTED_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-reminted', + worktreeId: TEST_WORKTREE_ID, + leafId: REMINTED_LEAF_ID, + paneRuntimeId: 1, + ptyId: 'dismiss-pty' + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'reminted-epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `tab-reminted::${REMINTED_LEAF_ID}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab-reminted::${REMINTED_LEAF_ID}`, + parentTabId: 'tab-reminted', + leafId: REMINTED_LEAF_ID, + ptyId: 'dismiss-pty', + title: 'Codex', + isActive: true + } + ] + } + ] + }) + + const before = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const events: Awaited>[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + const uninstall = statusWiring.attach(runtime) + try { + emitWorkingStatus(runtime, 2) + await vi.waitFor(() => expect(events).toHaveLength(1)) + const remintedPaneKey = `tab-reminted:${REMINTED_LEAF_ID}` + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: remintedPaneKey, + terminalHandle: row.terminalHandle, + providerSession: { key: 'session_id', id: 'provider-session-1' } + }) + ]) + expect(events[0]).toMatchObject({ + snapshotVersion: before.snapshotVersion + 1, + tabs: [ + expect.objectContaining({ + agentStatus: expect.objectContaining({ + state: 'working', + providerSession: { key: 'session_id', id: 'provider-session-1' } + }) + }) + ] + }) + + statusWiring.statusStore.dropStatusEntry(remintedPaneKey) + await vi.waitFor(() => expect(events).toHaveLength(2)) + expect(events[1]).toMatchObject({ + snapshotVersion: before.snapshotVersion + 2, + tabs: [expect.objectContaining({ agentStatus: expect.objectContaining({ state: 'done' }) })] + }) + expect((await runtime.getWorktreePs()).worktrees[0]?.agents).toEqual([]) + + runtime.onPtyExit('dismiss-pty', 0) + await vi.waitFor(() => expect(events).toHaveLength(3)) + expect(events[2]).toMatchObject({ snapshotVersion: before.snapshotVersion + 4 }) + expect( + events[2]?.tabs.every((tab) => tab.type !== 'terminal' || tab.agentStatus === undefined) + ).toBe(true) + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + } finally { + uninstall() + unsubscribe() + statusWiring.statusStore.stop() + } + }) + + it('keeps runtime-owned legacy OSC rows in worktree.ps and mobile projections', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'legacy-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: 'pane:7', + layout: null + } + ], + leaves: [ + { + tabId: 'legacy-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:7', + paneRuntimeId: 7, + ptyId: 'legacy-pty' + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'legacy-epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: 'legacy-tab::pane:7', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'legacy-tab::pane:7', + parentTabId: 'legacy-tab', + leafId: 'pane:7', + ptyId: 'legacy-pty', + title: 'Codex', + isActive: true + } + ] + } + ] + }) + runtime.onPtyData( + 'legacy-pty', + '\x1b]9999;{"state":"working","prompt":"legacy task","agentType":"codex"}\x07', + 1 + ) + + const listed = await runtime.getWorktreePs() + const mobile = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect( + listed.worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents + ).toEqual([expect.objectContaining({ paneKey: 'legacy-tab:7', prompt: 'legacy task' })]) + expect(mobile.tabs[0]).toMatchObject({ + type: 'terminal', + agentStatus: { paneKey: 'legacy-tab:7', prompt: 'legacy task' } + }) + runtime.onPtyExit('legacy-pty', 0) + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + statusWiring.statusStore.stop() + }) +}) diff --git a/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts b/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts index b7ca09af705..7a08f8f3bac 100644 --- a/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts +++ b/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts @@ -21,6 +21,7 @@ export class OrcaRuntimeWithTouchMobileSessionTabsForWorktree extends OrcaRuntim if (!snapshot) { return } + this.mobileSessionTabsAgentStatusHeartbeat.observeWorktreeRefresh(worktreeId) this.storeMobileSessionSnapshot(worktreeId, { ...snapshot, snapshotVersion: snapshot.snapshotVersion + 1 @@ -36,6 +37,13 @@ export class OrcaRuntimeWithTouchMobileSessionTabsForWorktree extends OrcaRuntim this.scheduleMobileSessionTabsChanged(worktreeId) } + scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree(worktreeId: string): void { + if (this.mobileSessionTabListeners.size === 0) { + return + } + this.mobileSessionTabsAgentStatusHeartbeat.scheduleWorktreeHeartbeat(worktreeId) + } + /** Republish the workspace snapshot after a pane's hook status changed. * Hook rows feed the headless `agentStatus` projection, which nothing else touches. */ touchMobileSessionTabsForPane(paneKey: string, worktreeId?: string | null): void { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 74098040c27..7ec1fd5fc35 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -86,6 +86,7 @@ await import('./orca-runtime-tests/mobile-summaries-part-02.spec') await import('./orca-runtime-tests/mobile-summaries-part-03.spec') await import('./orca-runtime-tests/mobile-summaries-part-04.spec') await import('./orca-runtime-tests/worktree-ps-structured-host.spec') +await import('./orca-runtime-tests/worktree-ps-agent-row-dismissal.spec') await import('./orca-runtime-tests/terminal-sleep-and-teardown.spec') await import('./orca-runtime-tests/terminal-sleep-and-teardown-part-02.spec') await import('./orca-runtime-tests/terminal-sleep-and-teardown-part-03.spec') diff --git a/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts index 61a28f4612c..afed92ec8aa 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts @@ -97,7 +97,7 @@ const CENSUS: readonly CensusRow[] = [ { path: 'main/orcad/orcad-entry.ts', kind: 'wiring', - role: 'binds the same snapshot and structured sink into the headless orcad runtime deps' + role: 'binds the same snapshot, OSC producer and structured sink into the headless orcad runtime deps' }, { path: 'main/runtime/orca-runtime-state-fields.ts', @@ -157,7 +157,7 @@ const CENSUS: readonly CensusRow[] = [ { path: 'main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts', kind: 'consumes', - role: 'mobile tab-group pruning from provider-session rows, and the pane identity accessors' + role: 'mobile tab-group pruning and its live agent row, plus the pane identity accessors' } ] diff --git a/src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts index 0e5addf1ba3..8aa6d03ed84 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts @@ -5,9 +5,11 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' import type { OrchestrationDb } from '../../../../orchestration/db' import { OrcaRuntimeWithGetOrchestrationDispatchAuthority } from '../../../../orca-runtime-get-orchestration-dispatch-authority' import { + AgentStatusObservedPaneIdentityCapture, AgentStatusObservedPaneIdentities, recordObservedAgentStatusPaneIdentity } from '../../../../agent-status-observed-pane-identity' +import type { EnrichedAgentHookEventPayload } from '../../../../../agent-hooks/server/server-types' import { projectFleetWorkerPage } from './worker-observation' /** @@ -135,6 +137,33 @@ function livenessOf(world: ObservedWorld, db: OrchestrationDb, dispatchId: strin } describe('fleet evidence keeps the identity it was observed under', () => { + it('buffers startup observations until terminal recovery is ready', () => { + const identities = new AgentStatusObservedPaneIdentities() + const capture = new AgentStatusObservedPaneIdentityCapture(identities) + const runtime = { + getAgentStatusTerminalHandleForPaneKey: () => TERMINAL_HANDLE, + getTerminalProcessIncarnation: () => INCARNATION_ONE, + getAgentStatusOrchestrationContextForPaneKey: () => undefined + } + const entry = { + paneKey: PANE_KEY, + payload: { state: 'working', prompt: 'startup', agentType: 'claude' }, + receivedAt: 1, + stateStartedAt: 1 + } as EnrichedAgentHookEventPayload + + capture.observe(entry) + expect(identities.read(PANE_KEY)).toEqual({ kind: 'unobserved' }) + + capture.attach(runtime) + expect(identities.read(PANE_KEY)).toEqual({ + kind: 'observed', + terminalHandle: TERMINAL_HANDLE, + processIncarnation: INCARNATION_ONE, + dispatchId: null + }) + }) + it('reads live while the pane still runs the process the row was observed on', () => { const world = createWorld() world.bindPane(PANE_KEY, TERMINAL_HANDLE) diff --git a/src/main/runtime/runtime-agent-row-store.ts b/src/main/runtime/runtime-agent-row-store.ts deleted file mode 100644 index c0c58d7ca82..00000000000 --- a/src/main/runtime/runtime-agent-row-store.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusEntry, - type AgentStatusIpcPayload, - type ParsedAgentStatusPayload -} from '../../shared/agent-status-types' -import type { - RuntimeTerminalAgentStatus, - RuntimeMobileSessionTerminalTab -} from '../../shared/runtime-types' -import { mapExplicitAgentStateToRuntimeTerminalStatus } from './runtime-worktree-status-projection' -import type { RuntimeAgentRowSnapshot } from './runtime-worktree-agent-rows' -import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' - -export class RuntimeAgentRowStore { - private readonly byPaneKey = new Map() - - values(): IterableIterator { - return this.byPaneKey.values() - } - - retain(args: { - ptyId: string - paneKey: string - worktreeId?: string - tabId?: string - connectionId: string | null - payload: ParsedAgentStatusPayload - }): boolean { - const now = Date.now() - const previous = this.byPaneKey.get(args.paneKey) - const stateStartedAt = - previous?.payload.state === args.payload.state ? previous.stateStartedAt : now - this.byPaneKey.set(args.paneKey, { ...args, stateStartedAt, updatedAt: now }) - return ( - !previous || - previous.payload.state !== args.payload.state || - previous.payload.workingMode !== args.payload.workingMode || - previous.payload.prompt !== args.payload.prompt || - (previous.payload.agentType ?? null) !== (args.payload.agentType ?? null) || - (previous.payload.toolName ?? null) !== (args.payload.toolName ?? null) || - (previous.payload.interactivePrompt ?? null) !== (args.payload.interactivePrompt ?? null) || - (previous.payload.interrupted ?? false) !== (args.payload.interrupted ?? false) || - (previous.payload.turnCompletedAt ?? null) !== (args.payload.turnCompletedAt ?? null) || - (previous.payload.lastAssistantMessage ?? null) !== - (args.payload.lastAssistantMessage ?? null) - ) - } - - clearPty(ptyId: string): void { - for (const [paneKey, snapshot] of this.byPaneKey) { - if (snapshot.ptyId === ptyId) { - this.byPaneKey.delete(paneKey) - } - } - } - - getFreshForMobile( - paneKey: string, - pty: RuntimePtyWorktreeRecord | null, - tab: RuntimeMobileSessionTerminalTab - ): RuntimeAgentRowSnapshot | null { - let retained = this.byPaneKey.get(paneKey) ?? null - if (!retained) { - const ptyId = pty?.ptyId ?? tab.ptyId ?? null - if (ptyId) { - for (const snapshot of this.byPaneKey.values()) { - if (snapshot.ptyId === ptyId && (!retained || snapshot.updatedAt > retained.updatedAt)) { - retained = snapshot - } - } - } - } - return retained && Date.now() - retained.updatedAt <= AGENT_STATUS_STALE_AFTER_MS - ? retained - : null - } - - getFreshExplicit(args: { - handle: string - paneKey: string | null - hookRows: readonly AgentStatusIpcPayload[] - }): { - status: NonNullable - updatedAt: number - stateStartedAt: number - } | null { - const now = Date.now() - let bestStatus: NonNullable | null = null - let bestUpdatedAt = -1 - let bestStateStartedAt = -1 - const consider = ( - state: AgentStatusEntry['state'] | undefined, - updatedAt: number | null | undefined, - restoredUnconfirmed = false, - stateStartedAt?: number | null - ): void => { - if (!state || restoredUnconfirmed || typeof updatedAt !== 'number') { - return - } - if (now - updatedAt > AGENT_STATUS_STALE_AFTER_MS) { - return - } - const status = mapExplicitAgentStateToRuntimeTerminalStatus(state) - if (updatedAt > bestUpdatedAt || (updatedAt === bestUpdatedAt && status === 'permission')) { - bestStatus = status - bestUpdatedAt = updatedAt - bestStateStartedAt = typeof stateStartedAt === 'number' ? stateStartedAt : updatedAt - } - } - if (args.paneKey) { - const retained = this.byPaneKey.get(args.paneKey) - consider(retained?.payload.state, retained?.updatedAt, false, retained?.stateStartedAt) - } - for (const row of args.hookRows) { - if (row.terminalHandle !== args.handle && (!args.paneKey || row.paneKey !== args.paneKey)) { - continue - } - consider(row.state, row.receivedAt, row.restoredUnconfirmed, row.stateStartedAt) - } - return bestStatus - ? { status: bestStatus, updatedAt: bestUpdatedAt, stateStartedAt: bestStateStartedAt } - : null - } -} diff --git a/src/main/runtime/runtime-hook-agent-row-selection.test.ts b/src/main/runtime/runtime-hook-agent-row-selection.test.ts new file mode 100644 index 00000000000..662fc5c160f --- /dev/null +++ b/src/main/runtime/runtime-hook-agent-row-selection.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import { + selectFreshAgentRowForMobileTab, + selectFreshExplicitAgentStatus +} from './runtime-hook-agent-row-selection' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' + +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' +const OTHER_PANE_KEY = 'tab-1:22222222-2222-4222-8222-222222222222' +const HANDLE = 'term_selection' +const PROVIDER_SESSION = { key: 'session_id' as const, id: 'session-1' } + +function row(overrides: Partial = {}): AgentStatusIpcPayload { + const now = Date.now() + return { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'worktree', + connectionId: null, + terminalHandle: HANDLE, + state: 'working', + prompt: 'ship it', + agentType: 'codex', + receivedAt: now, + stateStartedAt: now - 500, + ...overrides + } +} + +describe('selectFreshExplicitAgentStatus', () => { + it('matches on the terminal handle when the pane key has moved', () => { + const selected = selectFreshExplicitAgentStatus({ + handle: HANDLE, + paneKey: OTHER_PANE_KEY, + hookRows: [row()] + }) + expect(selected).toMatchObject({ status: 'working' }) + }) + + it('ignores a row belonging to neither the handle nor the pane', () => { + expect( + selectFreshExplicitAgentStatus({ + handle: 'term_other', + paneKey: OTHER_PANE_KEY, + hookRows: [row()] + }) + ).toBeNull() + }) + + it('refuses restored, identity-only and stale evidence rows', () => { + const args = { handle: HANDLE, paneKey: PANE_KEY } + expect( + selectFreshExplicitAgentStatus({ ...args, hookRows: [row({ restoredUnconfirmed: true })] }) + ).toBeNull() + expect( + selectFreshExplicitAgentStatus({ ...args, hookRows: [row({ providerSessionOnly: true })] }) + ).toBeNull() + expect( + selectFreshExplicitAgentStatus({ + ...args, + hookRows: [row({ receivedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 })] + }) + ).toBeNull() + expect( + selectFreshExplicitAgentStatus({ + ...args, + hookRows: [ + row({ + receivedAt: Date.now(), + evidenceObservedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 + }) + ] + }) + ).toBeNull() + }) + + it('prefers a permission row over a working row stamped at the same instant', () => { + const at = Date.now() + const selected = selectFreshExplicitAgentStatus({ + handle: HANDLE, + paneKey: PANE_KEY, + hookRows: [ + row({ receivedAt: at }), + row({ paneKey: OTHER_PANE_KEY, state: 'blocked', receivedAt: at }) + ] + }) + expect(selected?.status).toBe('permission') + }) +}) + +describe('selectFreshAgentRowForMobileTab', () => { + it('prefers the pane own row over one that only shares its terminal', () => { + const selected = selectFreshAgentRowForMobileTab({ + paneKey: PANE_KEY, + terminalHandle: HANDLE, + hookRows: [ + row({ paneKey: OTHER_PANE_KEY, prompt: 'sibling pane', receivedAt: Date.now() }), + row({ prompt: 'this pane', receivedAt: Date.now() - 50 }) + ] + }) + expect(selected?.payload.prompt).toBe('this pane') + }) + + it('falls back to the terminal handle once the pane key no longer matches', () => { + const selected = selectFreshAgentRowForMobileTab({ + paneKey: OTHER_PANE_KEY, + terminalHandle: HANDLE, + hookRows: [row()] + }) + expect(selected).toMatchObject({ paneKey: PANE_KEY, payload: { prompt: 'ship it' } }) + }) + + it('carries provider-session identity through a terminal-handle rejoin', () => { + const selected = selectFreshAgentRowForMobileTab({ + paneKey: OTHER_PANE_KEY, + terminalHandle: HANDLE, + hookRows: [row({ providerSession: PROVIDER_SESSION })] + }) + expect(selected?.providerSession).toEqual(PROVIDER_SESSION) + }) + + it('has no fallback when the tab is bound to no terminal', () => { + expect( + selectFreshAgentRowForMobileTab({ + paneKey: OTHER_PANE_KEY, + terminalHandle: null, + hookRows: [row()] + }) + ).toBeNull() + }) + + it('refuses restored, resume-identity and stale rows', () => { + const args = { paneKey: PANE_KEY, terminalHandle: HANDLE } + expect( + selectFreshAgentRowForMobileTab({ ...args, hookRows: [row({ restoredUnconfirmed: true })] }) + ).toBeNull() + expect( + selectFreshAgentRowForMobileTab({ ...args, hookRows: [row({ providerSessionOnly: true })] }) + ).toBeNull() + expect( + selectFreshAgentRowForMobileTab({ + ...args, + hookRows: [row({ receivedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 })] + }) + ).toBeNull() + expect( + selectFreshAgentRowForMobileTab({ + ...args, + hookRows: [ + row({ + receivedAt: Date.now(), + evidenceObservedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 + }) + ] + }) + ).toBeNull() + }) +}) diff --git a/src/main/runtime/runtime-hook-agent-row-selection.ts b/src/main/runtime/runtime-hook-agent-row-selection.ts new file mode 100644 index 00000000000..67c1b698a1d --- /dev/null +++ b/src/main/runtime/runtime-hook-agent-row-selection.ts @@ -0,0 +1,135 @@ +import { + AGENT_STATUS_STALE_AFTER_MS, + pickParsedAgentStatusPayload, + type AgentStatusEntry, + type AgentStatusIpcPayload, + type ParsedAgentStatusPayload +} from '../../shared/agent-status-types' +import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' +import type { RuntimeTerminalAgentStatus } from '../../shared/runtime-types' +import { mapExplicitAgentStateToRuntimeTerminalStatus } from './runtime-worktree-status-projection' + +/** One hook-server row projected into the shape the runtime's own readers consume. */ +export type RuntimeAgentRowSnapshot = { + paneKey: string + worktreeId?: string + tabId?: string + connectionId: string | null + payload: ParsedAgentStatusPayload + stateStartedAt: number + updatedAt: number + evidenceObservedAt?: number + providerSession?: AgentProviderSessionMetadata +} + +function isLiveObservation(row: AgentStatusIpcPayload): boolean { + // A restored row cannot prove liveness (the turn may have ended while offline), and a + // resume-identity row carries no status at all. + return row.restoredUnconfirmed !== true && row.providerSessionOnly !== true +} + +/** The freshest explicit state for a terminal, matched on its handle or its pane key. */ +export function selectFreshExplicitAgentStatus(args: { + handle: string + paneKey: string | null + hookRows: readonly AgentStatusIpcPayload[] +}): { + status: NonNullable + updatedAt: number + stateStartedAt: number +} | null { + const now = Date.now() + let bestStatus: NonNullable | null = null + let bestUpdatedAt = -1 + let bestStateStartedAt = -1 + const consider = ( + state: AgentStatusEntry['state'] | undefined, + updatedAt: number | null | undefined, + evidenceObservedAt: number | null | undefined, + restoredUnconfirmed = false, + providerSessionOnly = false, + stateStartedAt?: number | null + ): void => { + if (!state || restoredUnconfirmed || providerSessionOnly || typeof updatedAt !== 'number') { + return + } + if (now - (evidenceObservedAt ?? updatedAt) > AGENT_STATUS_STALE_AFTER_MS) { + return + } + const status = mapExplicitAgentStateToRuntimeTerminalStatus(state) + if (updatedAt > bestUpdatedAt || (updatedAt === bestUpdatedAt && status === 'permission')) { + bestStatus = status + bestUpdatedAt = updatedAt + bestStateStartedAt = typeof stateStartedAt === 'number' ? stateStartedAt : updatedAt + } + } + for (const row of args.hookRows) { + if (row.terminalHandle !== args.handle && (!args.paneKey || row.paneKey !== args.paneKey)) { + continue + } + consider( + row.state, + row.receivedAt, + row.evidenceObservedAt, + row.restoredUnconfirmed, + row.providerSessionOnly, + row.stateStartedAt + ) + } + return bestStatus + ? { + status: bestStatus, + updatedAt: bestUpdatedAt, + stateStartedAt: bestStateStartedAt + } + : null +} + +/** The pane's live row for the mobile projection: its own key first, then the terminal it is + * bound to, which is the only join left once a pane key has moved. */ +export function selectFreshAgentRowForMobileTab(args: { + paneKey: string + terminalHandle: string | null + hookRows: readonly AgentStatusIpcPayload[] +}): RuntimeAgentRowSnapshot | null { + let match: AgentStatusIpcPayload | null = null + const now = Date.now() + for (const row of args.hookRows) { + if ( + !isLiveObservation(row) || + now - (row.evidenceObservedAt ?? row.receivedAt) > AGENT_STATUS_STALE_AFTER_MS + ) { + continue + } + if (row.paneKey === args.paneKey) { + if (!match || match.paneKey !== args.paneKey || row.receivedAt > match.receivedAt) { + match = row + } + continue + } + if ( + match?.paneKey !== args.paneKey && + args.terminalHandle !== null && + row.terminalHandle === args.terminalHandle && + (!match || row.receivedAt > match.receivedAt) + ) { + match = row + } + } + if (!match) { + return null + } + return { + paneKey: match.paneKey, + connectionId: match.connectionId ?? null, + ...(match.worktreeId ? { worktreeId: match.worktreeId } : {}), + ...(match.tabId ? { tabId: match.tabId } : {}), + payload: pickParsedAgentStatusPayload(match), + stateStartedAt: match.stateStartedAt ?? match.receivedAt, + updatedAt: match.receivedAt, + ...(match.providerSession ? { providerSession: match.providerSession } : {}), + ...(match.evidenceObservedAt !== undefined + ? { evidenceObservedAt: match.evidenceObservedAt } + : {}) + } +} diff --git a/src/main/runtime/runtime-mobile-agent-status-builder.test.ts b/src/main/runtime/runtime-mobile-agent-status-builder.test.ts new file mode 100644 index 00000000000..f9e21d7e323 --- /dev/null +++ b/src/main/runtime/runtime-mobile-agent-status-builder.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTerminalTab } from '../../shared/runtime-types' +import type { RuntimeAgentRowSnapshot } from './runtime-hook-agent-row-selection' +import { buildRuntimeMobileAgentStatus } from './runtime-mobile-agent-status-builder' + +const PROVIDER_SESSION = { key: 'session_id' as const, id: 'session-1' } +const TAB: RuntimeMobileSessionTerminalTab = { + type: 'terminal', + id: 'tab::leaf', + parentTabId: 'tab', + leafId: 'leaf', + title: 'Terminal', + isActive: true +} + +describe('mobile agent status builder', () => { + it('keeps provider-session identity from a terminal-handle row rejoin', () => { + const retained: RuntimeAgentRowSnapshot = { + paneKey: 'old-tab:old-leaf', + connectionId: null, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' }, + stateStartedAt: 10, + updatedAt: 10, + providerSession: PROVIDER_SESSION + } + + const result = buildRuntimeMobileAgentStatus(null, TAB, 'term-1', retained, () => [], { + getPaneKey: () => 'new-tab:new-leaf', + getLeaf: () => null, + getTrackedTitle: () => null + }) + + expect(result).toEqual( + expect.objectContaining({ + agentStatus: expect.objectContaining({ providerSession: PROVIDER_SESSION }) + }) + ) + }) +}) diff --git a/src/main/runtime/runtime-mobile-agent-status-builder.ts b/src/main/runtime/runtime-mobile-agent-status-builder.ts index 2f8480b7580..5ce39d49d6a 100644 --- a/src/main/runtime/runtime-mobile-agent-status-builder.ts +++ b/src/main/runtime/runtime-mobile-agent-status-builder.ts @@ -33,13 +33,13 @@ export function buildRuntimeMobileAgentStatus( host: RuntimeMobileAgentStatusHost ): { agentStatus: AgentStatusEntry } | Record { const paneKey = host.getPaneKey(tab) - // Why: neither the OSC-retained row nor a title-derived status can carry a - // provider session — only the hook payload does, and headless serve has no + // Why: neither the live-status projection nor a title-derived status carries a + // provider session — only the full hook payload does, and headless serve has no // renderer to publish `tab.agentStatus`. Without it mobile native chat has no // transcript to address and sits on the empty state forever. const hookRow = selectRuntimeHookAgentRowForPane(getHookRowsForPane(paneKey)) // Why: the hook row is evidence in its own right. Returning early on a missing - // PTY status/retained row put this check ahead of the only headless carrier, so + // PTY status/projected row put this check ahead of the only headless carrier, so // an agent that reported its session but never emitted a recognized title got no // `agentStatus` at all — exactly the hook-only case the fallback exists for. if (!pty?.lastAgentStatus && !retained && !hookRow.agentType && !hookRow.providerSession) { @@ -47,7 +47,9 @@ export function buildRuntimeMobileAgentStatus( } const providerSession = hookRow.providerSession ? { providerSession: hookRow.providerSession } - : {} + : retained?.providerSession + ? { providerSession: retained.providerSession } + : {} const leaf = host.getLeaf(tab) const trackerOnlyTitle = host.getTrackedTitle(pty?.ptyId ?? leaf?.ptyId ?? null) const ptyTitle = pty @@ -101,6 +103,9 @@ export function buildRuntimeMobileAgentStatus( ...liveRow.payload, paneKey, updatedAt: liveRow.updatedAt, + ...(liveRow.evidenceObservedAt !== undefined + ? { evidenceObservedAt: liveRow.evidenceObservedAt } + : {}), stateStartedAt: liveRow.stateStartedAt, stateHistory: [], ...(terminalHandle ? { terminalHandle } : {}), diff --git a/src/main/runtime/runtime-mobile-agent-status-projection.ts b/src/main/runtime/runtime-mobile-agent-status-projection.ts index 7c7749c76ab..b21fd8bb3ff 100644 --- a/src/main/runtime/runtime-mobile-agent-status-projection.ts +++ b/src/main/runtime/runtime-mobile-agent-status-projection.ts @@ -1,5 +1,6 @@ import { AGENT_STATUS_STALE_AFTER_MS, + agentStatusAuthorityObservedAt, pickParsedAgentStatusPayload, type AgentStatusEntry, type AgentStatusIpcPayload @@ -22,7 +23,7 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle( if ( (status.state === 'waiting' || status.state === 'blocked') && pty.lastAgentStatus === 'idle' && - Date.now() - status.updatedAt <= AGENT_STATUS_STALE_AFTER_MS + Date.now() - agentStatusAuthorityObservedAt(status) <= AGENT_STATUS_STALE_AFTER_MS ) { return status } @@ -35,7 +36,7 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle( } const richStatusCanOwnTitleInterval = pty.lastAgentStatusRichInvalidatedAtEpochMs === null || - status.updatedAt > pty.lastAgentStatusRichInvalidatedAtEpochMs + agentStatusAuthorityObservedAt(status) > pty.lastAgentStatusRichInvalidatedAtEpochMs const titleEvidenceAt = pty.lastOscTitleEpochMs if (titleEvidenceAt === null) { return richStatusCanOwnTitleInterval ? status : null @@ -63,7 +64,10 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle( (pty.lastAgentStatus === 'permission' && (status.state === 'blocked' || status.state === 'waiting')) if (!titleConfirmsState) { - if (richStatusCanOwnTitleInterval && status.updatedAt >= titleEvidenceAt) { + if ( + richStatusCanOwnTitleInterval && + agentStatusAuthorityObservedAt(status) >= titleEvidenceAt + ) { return status } if (pty.lastAgentStatus === null && !terminalTitleBlocksExplicitAgentStatus(pty.lastOscTitle)) { @@ -82,7 +86,8 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle( ) } const richStatusOwnsCurrentState = - Date.now() - status.updatedAt <= AGENT_STATUS_STALE_AFTER_MS && richStatusCanOwnTitleInterval + Date.now() - agentStatusAuthorityObservedAt(status) <= AGENT_STATUS_STALE_AFTER_MS && + richStatusCanOwnTitleInterval // Fresh explicit evidence from this title interval owns acknowledgement identity. const stateStartedAt = richStatusOwnsCurrentState ? status.stateStartedAt @@ -124,7 +129,7 @@ export function selectRuntimeHookAgentRowForPane( entry.agentType && (entry.providerSessionOnly !== true || (entry.agentType === 'pi' && entry.providerSession != null)) && - entry.receivedAt >= freshAfter && + (entry.evidenceObservedAt ?? entry.receivedAt) >= freshAfter && (!agent || entry.receivedAt > agent.receivedAt) ) { agent = entry @@ -133,7 +138,7 @@ export function selectRuntimeHookAgentRowForPane( entry.providerSessionOnly !== true && // Restored rows cannot prove liveness because the turn may have ended while offline (#12346). entry.restoredUnconfirmed !== true && - entry.receivedAt >= freshAfter && + (entry.evidenceObservedAt ?? entry.receivedAt) >= freshAfter && (!live || entry.receivedAt > live.receivedAt) ) { live = entry @@ -149,6 +154,9 @@ export function selectRuntimeHookAgentRowForPane( ? { payload: pickParsedAgentStatusPayload(live), updatedAt: live.receivedAt, + ...(live.evidenceObservedAt !== undefined + ? { evidenceObservedAt: live.evidenceObservedAt } + : {}), stateStartedAt: live.stateStartedAt ?? live.receivedAt, ...(live.worktreeId ? { worktreeId: live.worktreeId } : {}) } @@ -167,6 +175,13 @@ export function resolveRuntimeHookLiveAgentRow( if (live.payload.interactivePrompt != null) { return live } - // This is the pane's only wall-clock title timestamp comparable to hook `receivedAt`. - return !nonAgentTitle && live.updatedAt >= (pty?.lastOscTitleEpochMs ?? 0) ? live : null + // This is the pane's only wall-clock title timestamp comparable to when the hook evidence + // was observed; replay delivery order must not make old evidence outrank a newer title. + return !nonAgentTitle && + agentStatusAuthorityObservedAt({ + updatedAt: live.updatedAt, + evidenceObservedAt: live.evidenceObservedAt + }) >= (pty?.lastOscTitleEpochMs ?? 0) + ? live + : null } diff --git a/src/main/runtime/runtime-mobile-session-projection-contract.ts b/src/main/runtime/runtime-mobile-session-projection-contract.ts index 6aaed42764c..f4174b7715a 100644 --- a/src/main/runtime/runtime-mobile-session-projection-contract.ts +++ b/src/main/runtime/runtime-mobile-session-projection-contract.ts @@ -18,6 +18,7 @@ export type RuntimeMobileSessionProjectionHost = { getLiveBrowserTabs(worktreeId: string): Map getProviderSessionRows(paneKey: string): AgentStatusIpcPayload[] | undefined getProviderSessionSnapshot(): AgentStatusIpcPayload[] + getStatusSnapshot(): AgentStatusIpcPayload[] getLeafKey(tabId: string, leafId: string): string findPty( worktreeId: string, @@ -27,7 +28,8 @@ export type RuntimeMobileSessionProjectionHost = { getRetainedStatus( paneKey: string, pty: RuntimePtyWorktreeRecord | null, - tab: RuntimeMobileSessionTerminalTab + tab: RuntimeMobileSessionTerminalTab, + getRows: (paneKey: string, terminalHandle: string | null) => AgentStatusIpcPayload[] ): RuntimeAgentRowSnapshot | null getTrackedTitle(ptyId: string | null): string | null issuePtyHandle(pty: RuntimePtyWorktreeRecord): string diff --git a/src/main/runtime/runtime-mobile-session-projection.ts b/src/main/runtime/runtime-mobile-session-projection.ts index 8fa9bb954dc..db1ef0619ca 100644 --- a/src/main/runtime/runtime-mobile-session-projection.ts +++ b/src/main/runtime/runtime-mobile-session-projection.ts @@ -48,6 +48,42 @@ export function projectRuntimeMobileSessionTabs( hookRowsForPane.set(paneKey, rows) return rows } + let statusRowsByPaneKey: Map | null = null + let statusRowsByTerminalHandle: Map | null = null + const getStatusRows = ( + paneKey: string, + terminalHandle: string | null + ): AgentStatusIpcPayload[] => { + if (!statusRowsByPaneKey || !statusRowsByTerminalHandle) { + statusRowsByPaneKey = new Map() + statusRowsByTerminalHandle = new Map() + for (const row of host.getStatusSnapshot()) { + const paneRows = statusRowsByPaneKey.get(row.paneKey) + if (paneRows) { + paneRows.push(row) + } else { + statusRowsByPaneKey.set(row.paneKey, [row]) + } + if (row.terminalHandle) { + const handleRows = statusRowsByTerminalHandle.get(row.terminalHandle) + if (handleRows) { + handleRows.push(row) + } else { + statusRowsByTerminalHandle.set(row.terminalHandle, [row]) + } + } + } + } + const paneRows = statusRowsByPaneKey.get(paneKey) ?? [] + if (!terminalHandle) { + return paneRows + } + const handleRows = statusRowsByTerminalHandle.get(terminalHandle) ?? [] + if (paneRows.length === 0) { + return handleRows + } + return [...paneRows, ...handleRows.filter((row) => !paneRows.includes(row))] + } // Why: a live PTY backs one surface; claim each once so two leaves resolving to it can't emit duplicate React keys and crash the client. const claimedLivePtyIds = new Set() for (const tab of snapshot.tabs) { @@ -98,11 +134,11 @@ export function projectRuntimeMobileSessionTabs( ? makePaneKey(tab.parentTabId, tab.leafId) : `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}` const mobileStatusPty = livePty ?? pty - // Why: headless hooks live only in main's retained rows; reuse this lookup + // Why: headless hooks live in main's status store; reuse this lookup // for both title ownership and status publication so the two cannot diverge. const retainedAgentStatus = tab.agentStatus ? null - : host.getRetainedStatus(paneKey, liveLeafPty ?? mobileStatusPty, tab) + : host.getRetainedStatus(paneKey, liveLeafPty ?? mobileStatusPty, tab, getStatusRows) const hookAgentStatus = tab.agentStatus ? selectRuntimeHookAgentRowForPane(getHookRowsForPane(paneKey)) : null diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 875eef03600..227788af7e1 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -96,12 +96,15 @@ export type RuntimeTerminalAgentStatusEvent = { tabId?: string worktreeId?: string connectionId?: string | null + /** The pane's terminal handle, when it is bound to one. Stamped on the stored row so a + * reader can rejoin it to the terminal after the pane key moved. */ + terminalHandle?: string payload: ParsedAgentStatusPayload } export type HookLiveAgentRow = Pick< RuntimeAgentRowSnapshot, - 'payload' | 'updatedAt' | 'stateStartedAt' | 'worktreeId' + 'payload' | 'updatedAt' | 'evidenceObservedAt' | 'stateStartedAt' | 'worktreeId' > export type RuntimePtyDataAdmission = Readonly<{ diff --git a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts index 83b3d651642..5b517f1bfc8 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts @@ -54,8 +54,11 @@ function attach(summaries: AgentSessionStatusSummary[]): RuntimeWorktreePsSummar workingTerminalEvidenceByWorktreeId: new Map(), rowSources: collectRuntimeWorktreeAgentSources({ mirroredWorktreeIdByTabId: new Map(), - connectedPtyEvidence: { tabIds: new Set(), paneKeys: new Set(), ptyIds: new Set() }, - retainedSnapshots: [], + connectedPtyEvidence: { + tabIds: new Set(), + paneKeys: new Set(), + ptyIdByTerminalHandle: new Map() + }, hookSnapshots: store.getStatusSnapshot() }), orchestrationByPaneKey: null, diff --git a/src/main/runtime/runtime-worktree-agent-rows.ts b/src/main/runtime/runtime-worktree-agent-rows.ts index da145b67091..20c17f9b01a 100644 --- a/src/main/runtime/runtime-worktree-agent-rows.ts +++ b/src/main/runtime/runtime-worktree-agent-rows.ts @@ -4,7 +4,7 @@ import { mergeWorktreeSummaryStatus } from './runtime-worktree-status-projection import type { RuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary-paths' import type { RuntimeWorkingTerminalEvidence } from './runtime-worktree-ps-activity' import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' -export type { RuntimeAgentRowSnapshot } from './runtime-worktree-pty-agent-sources' +export type { RuntimeAgentRowSnapshot } from './runtime-hook-agent-row-selection' type OrchestrationDisplay = { taskTitle?: string | null diff --git a/src/main/runtime/runtime-worktree-agent-sources.test.ts b/src/main/runtime/runtime-worktree-agent-sources.test.ts index c0395cd6831..5da2f6e8550 100644 --- a/src/main/runtime/runtime-worktree-agent-sources.test.ts +++ b/src/main/runtime/runtime-worktree-agent-sources.test.ts @@ -1,47 +1,48 @@ import { describe, expect, it } from 'vitest' import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' -import type { RuntimeAgentRowSnapshot } from './runtime-worktree-pty-agent-sources' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' const paneKey = 'worktree:tab:0' const now = Date.now() -const retained: RuntimeAgentRowSnapshot = { +const hookRow: AgentStatusIpcPayload = { paneKey, - ptyId: 'pty', tabId: 'tab', + terminalHandle: 'term_row', worktreeId: 'worktree', connectionId: null, - payload: { state: 'working', prompt: 'implement', agentType: 'codex' }, + state: 'working', + prompt: 'implement', + agentType: 'codex', stateStartedAt: now, - updatedAt: now + receivedAt: now } const base = { - retainedSnapshots: [retained], - hookSnapshots: [] as AgentStatusIpcPayload[], - structuredSummaries: [], + hookSnapshots: [hookRow], mirroredWorktreeIdByTabId: new Map(), connectedPtyEvidence: { tabIds: new Set(), paneKeys: new Set(), - ptyIds: new Set() + ptyIdByTerminalHandle: new Map() + } +} +const connected = { + ...base, + connectedPtyEvidence: { + tabIds: new Set(['tab']), + paneKeys: new Set([paneKey]), + ptyIdByTerminalHandle: new Map([['term_row', 'pty']]) } } describe('worktree agent source admission', () => { it('rejects a disconnected local terminal before row assembly', () => { expect(collectRuntimeWorktreeAgentSources(base).size).toBe(0) - const connected = { - ...base, - connectedPtyEvidence: { ...base.connectedPtyEvidence, ptyIds: new Set(['pty']) } - } expect(collectRuntimeWorktreeAgentSources(connected).get(paneKey)?.state).toBe('working') }) it('keeps remote evidence and resolves mirrored workspace ownership', () => { - const remote = { ...retained, connectionId: 'ssh-connection' } - expect(collectRuntimeWorktreeAgentSources({ ...base, retainedSnapshots: [remote] }).size).toBe( - 1 - ) + const remote = { ...hookRow, connectionId: 'ssh-connection' } + expect(collectRuntimeWorktreeAgentSources({ ...base, hookSnapshots: [remote] }).size).toBe(1) const sources = collectRuntimeWorktreeAgentSources({ ...base, mirroredWorktreeIdByTabId: new Map([['tab', 'remote-worktree']]) @@ -49,22 +50,38 @@ describe('worktree agent source admission', () => { expect(sources.get(paneKey)?.worktreeId).toBe('remote-worktree') }) - it('preserves fresh monitoring enrichment on a newer retained report', () => { - const hook: AgentStatusIpcPayload = { - ...retained.payload, - paneKey, - tabId: 'tab', - worktreeId: 'worktree', - connectionId: null, - stateStartedAt: now - 1, - receivedAt: now - 1, - workingMode: 'monitoring' - } - const sources = collectRuntimeWorktreeAgentSources({ + it('rejoins the row to the connected PTY behind its terminal handle', () => { + expect(collectRuntimeWorktreeAgentSources(connected).get(paneKey)?.ptyId).toBe('pty') + // The handle is the last rescue once a controller incarnation nulls the pane binding. + const bindingCleared = collectRuntimeWorktreeAgentSources({ ...base, - hookSnapshots: [hook], - connectedPtyEvidence: { ...base.connectedPtyEvidence, ptyIds: new Set(['pty']) } + connectedPtyEvidence: { + ...base.connectedPtyEvidence, + ptyIdByTerminalHandle: new Map([['term_row', 'pty']]) + } }) - expect(sources.get(paneKey)).toMatchObject({ updatedAt: now, workingMode: 'monitoring' }) + expect(bindingCleared.get(paneKey)?.ptyId).toBe('pty') + // No connected PTY answers to the handle and no pane evidence: the row is not admitted. + expect(collectRuntimeWorktreeAgentSources(base).size).toBe(0) + }) + + it('carries the row own working mode and drops non-live rows', () => { + const monitoring = collectRuntimeWorktreeAgentSources({ + ...connected, + hookSnapshots: [{ ...hookRow, workingMode: 'monitoring' as const }] + }) + expect(monitoring.get(paneKey)).toMatchObject({ updatedAt: now, workingMode: 'monitoring' }) + + const restored = collectRuntimeWorktreeAgentSources({ + ...connected, + hookSnapshots: [{ ...hookRow, restoredUnconfirmed: true as const }] + }) + expect(restored.size).toBe(0) + + const providerSessionOnly = collectRuntimeWorktreeAgentSources({ + ...connected, + hookSnapshots: [{ ...hookRow, providerSessionOnly: true }] + }) + expect(providerSessionOnly.size).toBe(0) }) }) diff --git a/src/main/runtime/runtime-worktree-ps-activity.ts b/src/main/runtime/runtime-worktree-ps-activity.ts index c6c8d7fafc3..ae3ecdee4c3 100644 --- a/src/main/runtime/runtime-worktree-ps-activity.ts +++ b/src/main/runtime/runtime-worktree-ps-activity.ts @@ -188,10 +188,16 @@ export function applyRuntimeWorktreePsSessionActivity(args: { missingIds: Set ptysById: ReadonlyMap tabs: ReadonlyMap + /** Non-minting: a listing must not issue handles, only recognise the ones already bound. */ + getTerminalHandlesForPty: (ptyId: string) => readonly string[] getSummary: SummaryLookup }): { mirroredWorktreeIdByTabId: Map - connectedPtyEvidence: { tabIds: Set; paneKeys: Set; ptyIds: Set } + connectedPtyEvidence: { + tabIds: Set + paneKeys: Set + ptyIdByTerminalHandle: Map + } } { const mirroredWorktreeIdByTabId = new Map() const sessionsByHostId = new Map() @@ -244,19 +250,21 @@ export function applyRuntimeWorktreePsSessionActivity(args: { const connectedPtyEvidence = { tabIds: new Set(), paneKeys: new Set(), - ptyIds: new Set() + ptyIdByTerminalHandle: new Map() } for (const pty of args.ptysById.values()) { if (!pty.connected) { continue } - connectedPtyEvidence.ptyIds.add(pty.ptyId) if (pty.tabId) { connectedPtyEvidence.tabIds.add(pty.tabId) } if (pty.paneKey) { connectedPtyEvidence.paneKeys.add(pty.paneKey) } + for (const terminalHandle of args.getTerminalHandlesForPty(pty.ptyId)) { + connectedPtyEvidence.ptyIdByTerminalHandle.set(terminalHandle, pty.ptyId) + } } return { mirroredWorktreeIdByTabId, connectedPtyEvidence } } diff --git a/src/main/runtime/runtime-worktree-pty-agent-sources.ts b/src/main/runtime/runtime-worktree-pty-agent-sources.ts index 9f297d7edf7..058d378df11 100644 --- a/src/main/runtime/runtime-worktree-pty-agent-sources.ts +++ b/src/main/runtime/runtime-worktree-pty-agent-sources.ts @@ -1,34 +1,23 @@ import { - AGENT_STATUS_STALE_AFTER_MS, pickParsedAgentStatusPayload, type AgentStatusIpcPayload, type ParsedAgentStatusPayload } from '../../shared/agent-status-types' -import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' -export type RuntimeAgentRowSnapshot = { - paneKey: string - ptyId: string - worktreeId?: string - tabId?: string - connectionId: string | null - payload: ParsedAgentStatusPayload - stateStartedAt: number - updatedAt: number -} - export type ConnectedPtyEvidence = { tabIds: ReadonlySet paneKeys: ReadonlySet - ptyIds: ReadonlySet + /** The connected PTY behind each issued terminal handle. A status row names a pane and the + * handle it was observed under, never a process, so this is where it rejoins its terminal — + * and it is the only rescue left for a row whose pane binding was cleared under it. */ + ptyIdByTerminalHandle: ReadonlyMap } -/** Reconcile terminal status, then admit rows using their execution-host evidence. */ +/** Admit hook-server rows using their execution-host evidence. */ export function collectRuntimeWorktreePtyAgentSources(args: { - retainedSnapshots: Iterable hookSnapshots: readonly AgentStatusIpcPayload[] mirroredWorktreeIdByTabId: ReadonlyMap connectedPtyEvidence: ConnectedPtyEvidence @@ -37,50 +26,16 @@ export function collectRuntimeWorktreePtyAgentSources(args: { string, RuntimeWorktreeAgentSource & { payload: ParsedAgentStatusPayload } >() - const now = Date.now() - for (const snapshot of args.retainedSnapshots) { - const { payload } = snapshot - rowSources.set(snapshot.paneKey, { - paneKey: snapshot.paneKey, - ptyId: snapshot.ptyId, - tabId: snapshot.tabId, - worktreeId: snapshot.worktreeId, - connectionId: snapshot.connectionId, - payload, - state: payload.state, - ...(payload.workingMode ? { workingMode: payload.workingMode } : {}), - agentType: payload.agentType ?? null, - prompt: payload.prompt, - lastAssistantMessage: payload.lastAssistantMessage ?? null, - toolName: payload.toolName ?? null, - toolInput: payload.toolInput ?? null, - interrupted: payload.interrupted ?? false, - stateStartedAt: snapshot.stateStartedAt, - updatedAt: snapshot.updatedAt - }) - } for (const entry of args.hookSnapshots) { - if (entry.restoredUnconfirmed === true) { + if (entry.restoredUnconfirmed === true || entry.providerSessionOnly === true) { continue } - const existing = rowSources.get(entry.paneKey) const hookPayload = pickParsedAgentStatusPayload(entry) - if (existing && existing.updatedAt > entry.receivedAt) { - if ( - entry.workingMode === 'monitoring' && - now - entry.receivedAt <= AGENT_STATUS_STALE_AFTER_MS && - terminalStatusPayloadMatchesHook(hookPayload, existing.payload) - ) { - existing.workingMode = 'monitoring' - if (existing.payload.workingMode === undefined) { - existing.payload = { ...existing.payload, workingMode: 'monitoring' } - } - } - continue - } rowSources.set(entry.paneKey, { paneKey: entry.paneKey, - ptyId: existing?.ptyId, + ptyId: entry.terminalHandle + ? args.connectedPtyEvidence.ptyIdByTerminalHandle.get(entry.terminalHandle) + : undefined, tabId: entry.tabId, worktreeId: entry.worktreeId, connectionId: entry.connectionId, @@ -94,10 +49,8 @@ export function collectRuntimeWorktreePtyAgentSources(args: { toolInput: entry.toolInput ?? null, interrupted: entry.interrupted ?? false, stateStartedAt: entry.stateStartedAt, - // A structured row's clock is its journal, so a restart's republish does not read as new. - updatedAt: entry.structuredHost - ? (entry.evidenceObservedAt ?? entry.receivedAt) - : entry.receivedAt, + // A replay advances delivery order, not the age of the evidence shown by worktree.ps. + updatedAt: entry.evidenceObservedAt ?? entry.receivedAt, ...(entry.structuredHost ? { structuredHost: entry.structuredHost } : {}) }) } @@ -117,7 +70,8 @@ export function collectRuntimeWorktreePtyAgentSources(args: { (source.connectionId === null || isWslHookRelayConnectionId(source.connectionId)) && !args.connectedPtyEvidence.tabIds.has(tabId) && !args.connectedPtyEvidence.paneKeys.has(source.paneKey) && - (source.ptyId === undefined || !args.connectedPtyEvidence.ptyIds.has(source.ptyId)) + // Resolved only from a connected PTY's handle, so its presence is the liveness evidence. + source.ptyId === undefined ) { continue } diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts index 519b9026f17..afe5a3a2a71 100644 --- a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -115,8 +115,11 @@ function worktreeFor(store: AgentHookServer): RuntimeWorktreePsSummary { workingTerminalEvidenceByWorktreeId: new Map(), rowSources: collectRuntimeWorktreeAgentSources({ mirroredWorktreeIdByTabId: new Map(), - connectedPtyEvidence: { tabIds: new Set(), paneKeys: new Set(), ptyIds: new Set() }, - retainedSnapshots: [], + connectedPtyEvidence: { + tabIds: new Set(), + paneKeys: new Set(), + ptyIdByTerminalHandle: new Map() + }, hookSnapshots: store.getStatusSnapshot() }), orchestrationByPaneKey: null, diff --git a/src/main/runtime/terminal-interactive-wait-visibility.test.ts b/src/main/runtime/terminal-interactive-wait-visibility.test.ts index 5e652af41f6..3a6470b41f9 100644 --- a/src/main/runtime/terminal-interactive-wait-visibility.test.ts +++ b/src/main/runtime/terminal-interactive-wait-visibility.test.ts @@ -1,10 +1,12 @@ // A worker parked on an interactive prompt must be distinguishable from one that is thinking // or inside a long tool call (STA-4513, STA-3714). import { readFileSync } from 'node:fs' +import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { - createTranscriptPane as createPane, + createTranscriptPane, + type TranscriptPaneOptions, TRANSCRIPT_PANE_PTY_ID as PTY_ID } from './agent-transcript-pane-test-harness' import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' @@ -41,6 +43,15 @@ function agentStatusOsc(state: string): string { return `]9999;${JSON.stringify({ state, prompt: 'ship it', agentType: 'claude' })}` } +async function createPane( + options: TranscriptPaneOptions +): Promise>> { + // Compose the same central hook-store wiring as desktop and orcad so OSC rows exercise the + // production status path rather than silently disappearing in a bare runtime fixture. + const statusWiring = makeAgentStatusStoreWiring() + return createTranscriptPane(options, statusWiring.deps) +} + // cursor-agent renders a braille spinner in its OSC title while it works, and Orca reads // that as `working`; the title is identical whether it is running a command or waiting. const CURSOR_TITLE = '⠇ Cursor Agent' diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 76d92e77e9a..87d5c77f205 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -412,7 +412,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { expect(events).toHaveLength(2) }) - it('clears stamped status on reconnect loss but not final shutdown', async () => { + it('keeps stamped status unverifiable across reconnect loss and final shutdown', async () => { const initialRelay = createFakeRelay() relay = createFakeRelay() vi.mocked(deployAndLaunchRelay) @@ -436,16 +436,13 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { await session.reconnect({} as SshConnection) initialRelay.dispose() - expect(agentHookServer.getStatusSnapshot()).toEqual([]) - expect(clearListener).toHaveBeenCalledOnce() - expect(clearListener).toHaveBeenCalledWith({ - transient: true, - connectionId: 'conn-clear', - clearedAt: expect.any(Number) - }) + expect(agentHookServer.getStatusSnapshot()).toEqual([ + expect.objectContaining({ connectionId: 'conn-clear', state: 'working' }) + ]) + expect(clearListener).not.toHaveBeenCalled() session.dispose() session = null - expect(clearListener).toHaveBeenCalledOnce() + expect(clearListener).not.toHaveBeenCalled() }) it('asks the fake relay for cached hook replay after the session wires its listener', async () => { diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index a4fdb0f0fa7..08754ca76e1 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1679,10 +1679,9 @@ export class SshRelaySession { if (reason === 'shutdown') { clearPtyOwnershipForConnection(this.targetId) - } else { - // Why: handlers detached above, so no late event can re-stamp status between this clear and reconnect replay. - agentHookServer.clearStatusEntriesForConnection(this.targetId) } + // Connection loss makes remote status unverifiable, not exited. Keep the last observation; + // replay or certified process teardown will update or remove it on the execution host. const ptyProvider = getSshPtyProvider(this.targetId) if (ptyProvider && 'dispose' in ptyProvider) { diff --git a/src/main/startup/headless-pty-hydration-ordering.test.ts b/src/main/startup/headless-pty-hydration-ordering.test.ts index e866a5d1926..3b661dede99 100644 --- a/src/main/startup/headless-pty-hydration-ordering.test.ts +++ b/src/main/startup/headless-pty-hydration-ordering.test.ts @@ -52,4 +52,64 @@ describe('headless PTY registry hydration ordering', () => { expect(rpc).toBeGreaterThan(handlersAndHydration) expect(readiness).toBeGreaterThan(rpc) }) + + it('starts the orcad hook owner after Store hydration and before daemon PTY recovery', () => { + const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') + const cleanup = source.indexOf('registerCleanup(async () => {') + const hookStop = source.indexOf('agentHookServer.stop()', cleanup) + const store = source.indexOf('const store = new Store(') + const hookStart = source.indexOf('await agentHookServer.start(', store) + const daemon = source.indexOf('await startOrcadDaemon()', hookStart) + const hookEnv = source.indexOf('buildAgentHookPtyEnv:', daemon) + const handlersAndHydration = source.indexOf('await registerHeadlessPtyRuntime(', hookEnv) + + expect(cleanup).toBeGreaterThanOrEqual(0) + expect(hookStop).toBeGreaterThan(cleanup) + expect(store).toBeGreaterThan(hookStop) + expect(hookStart).toBeGreaterThan(store) + expect(daemon).toBeGreaterThan(hookStart) + expect(hookEnv).toBeGreaterThan(daemon) + expect(source.slice(hookEnv, handlersAndHydration)).toContain('agentHookServer.buildPtyEnv()') + expect(handlersAndHydration).toBeGreaterThan(hookEnv) + }) + + it('captures orcad status identity at ingest for fleet stale-row fencing', () => { + const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') + const runtime = source.indexOf('const runtime = new OrcaRuntimeService(') + const identityReader = source.indexOf('readObservedAgentStatusPaneIdentity:', runtime) + const identitySubscription = source.indexOf('agentHookServer.subscribeEnrichedStatus(') + const hooksEnabled = source.indexOf('if (isAgentStatusHooksEnabled(', identitySubscription) + const identityFlush = source.indexOf('observedStatusCapture.attach(runtime)', runtime) + + expect(runtime).toBeGreaterThanOrEqual(0) + expect(identityReader).toBeGreaterThan(runtime) + expect(identitySubscription).toBeGreaterThanOrEqual(0) + expect(identitySubscription).toBeLessThan(runtime) + expect(hooksEnabled).toBeGreaterThan(identitySubscription) + expect(identityFlush).toBeGreaterThan(runtime) + expect(source.slice(identitySubscription, runtime)).toContain( + 'observedStatusCapture.observe(enriched)' + ) + }) + + it('captures spool-replayed identity after the orcad runtime is ready', () => { + const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') + const subscription = source.indexOf('agentHookServer.subscribeEnrichedStatus(') + const hookStart = source.indexOf('await agentHookServer.start(', subscription) + const runtime = source.indexOf('const runtime = new OrcaRuntimeService(') + const handlers = source.indexOf('await registerHeadlessPtyRuntime(', runtime) + const identityRecovery = source.indexOf('await runtime.refreshRestoredOrchestrationAuthority()') + const workerRecovery = source.indexOf('await runtime.reconcileLegacyWorkerTerminals()') + const replay = source.indexOf('observedStatusCapture.attach(runtime)', runtime) + + expect(subscription).toBeGreaterThanOrEqual(0) + expect(hookStart).toBeGreaterThan(subscription) + expect(runtime).toBeGreaterThan(hookStart) + expect(handlers).toBeGreaterThan(runtime) + expect(identityRecovery).toBeGreaterThan(handlers) + expect(workerRecovery).toBeGreaterThan(identityRecovery) + expect(replay).toBeGreaterThan(workerRecovery) + expect(source.slice(subscription, runtime)).toContain('observedStatusCapture.observe(enriched)') + expect(source.slice(replay)).toContain('observedStatusCapture.attach(runtime)') + }) }) diff --git a/src/main/startup/main-process-observers.ts b/src/main/startup/main-process-observers.ts index ba37b0a312f..86f7992e10d 100644 --- a/src/main/startup/main-process-observers.ts +++ b/src/main/startup/main-process-observers.ts @@ -3,9 +3,8 @@ import { join } from 'node:path' import { AgentAwakeService } from '../agent-awake-service' import { normalizeComputerAwakeMode } from '../../shared/computer-awake-mode' import { registerSystemResumeBroadcast } from '../system-resume-broadcast' -import { agentHookServer, type AgentHookProviderSessionIdentity } from '../agent-hooks/server' -import { createHookProviderSessionInvalidator } from '../agent-hooks/hook-provider-session-invalidation' -import { createHookStatusSessionTabsInvalidator } from '../agent-hooks/hook-status-session-tabs-invalidation' +import { agentHookServer } from '../agent-hooks/server' +import { installHookStatusSessionTabsRepublish } from '../agent-hooks/hook-status-session-tabs-republish' import { initTelemetry, track } from '../telemetry/client' import { setCodexTrustGrantTelemetry } from '../codex/codex-trust-grant-telemetry' import { initObservability } from '../observability' @@ -40,55 +39,20 @@ export function initializeMainProcessObservers(): void { isQuitting: () => state.isQuitting, getWorkingAgentCount: () => state.agentAwakeService?.getWorkingAgentCount() ?? 0 }) - const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() - const publishProviderSessionChanges = (identities: AgentHookProviderSessionIdentity[]): void => { - const ownedIdentities = identities.map((identity) => ({ - ...identity, - worktreeId: - identity.worktreeId ?? - state.runtime?.getTerminalWorktreeIdForPaneKey(identity.paneKey) ?? - undefined - })) - for (const worktreeId of collectChangedProviderSessionWorktrees(ownedIdentities)) { - // Why not `notifyMobileSessionTabsChanged` alone: it re-emits at the unchanged - // `snapshotVersion`, which every client drops on its monotonic gate. - state.runtime?.touchMobileSessionTabsForWorktree(worktreeId, { immediate: true }) - } - } - state.publishProviderSessionChanges = publishProviderSessionChanges const unsubscribeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => { state.agentAwakeService?.setStatuses(statuses) }) - // Healthy session.tabs streams need a push when transcript identity changes. - const unsubscribeProviderSessionChanges = agentHookServer.subscribeProviderSessionChanges( - (sessions) => publishProviderSessionChanges(sessions) + const unsubscribeStatusFreshness = agentHookServer.subscribeStatusFreshness((status) => { + state.agentAwakeService?.observeStatusFreshness(status) + }) + const uninstallHookStatusRepublish = installHookStatusSessionTabsRepublish( + agentHookServer, + () => state.runtime ) - // Why: hook rows are the only carrier of live agent state on a headless host, and - // nothing else republishes `session.tabs` when one changes — so a paired client - // would keep the pane's last projection until an unrelated PTY touch came along. - const hookStatusChangedSessionTabs = createHookStatusSessionTabsInvalidator() - const unsubscribeHookStatusSessionTabs = agentHookServer.subscribeEnrichedStatus((enriched) => { - if (hookStatusChangedSessionTabs(enriched)) { - state.runtime?.touchMobileSessionTabsForPane(enriched.paneKey, enriched.worktreeId ?? null) - } - }) - // Teardown: agent exit, pane close, and the SSH transient-disconnect batch all land - // here. Without it the live state published above becomes a zombie question card. - const unsubscribeHookStatusClear = agentHookServer.subscribePaneStatusClear((clear) => { - const clearedPaneKeys = - 'paneKey' in clear - ? [clear.paneKey] - : hookStatusChangedSessionTabs.forgetConnection(clear.connectionId) - for (const paneKey of clearedPaneKeys) { - hookStatusChangedSessionTabs.forgetPane(paneKey) - state.runtime?.touchMobileSessionTabsForPane(paneKey) - } - }) state.unsubscribeAgentAwakeStatusChanges = () => { unsubscribeStatusChanges() - unsubscribeProviderSessionChanges() - unsubscribeHookStatusSessionTabs() - unsubscribeHookStatusClear() + unsubscribeStatusFreshness() + uninstallHookStatusRepublish() } // Why: telemetry must init before any IPC handler/renderer can call track(); it's a no-op in dev and while TELEMETRY_ENABLED is false, so it's safe early. initTelemetry(store) diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 8af5630e02b..3aac4a03b3c 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -138,7 +138,6 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { // Why before anything can attach: a client host that reattaches to a restarted runtime is only // handed its pages back if the runtime found them first. runtime.rehydrateClientHostedBrowserPages() - state.publishProviderSessionChanges?.(agentHookServer.getProviderSessionIdentities()) browserManager.setBrowserGuestStateChangedListener((worktreeId) => { runtime.notifyMobileSessionTabsChanged(worktreeId) }) diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index 17a5eb427c6..2b9361a6d51 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -25,7 +25,6 @@ import type { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-i import type { KeybindingService } from '../keybindings/keybinding-service' import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' -import type { AgentHookProviderSessionIdentity } from '../agent-hooks/server' import type { EmulatorBridge } from '../emulator/emulator-bridge' import type { GpuFallbackMarker, GpuFallbackEnvironment } from './gpu-fallback-marker' import type { createCodexSessionMigrationScheduler } from '../codex/codex-session-migration-scheduler' @@ -78,9 +77,6 @@ export const mainProcessState = { repoMaintenanceShutdown: Promise.resolve() as Promise, crashReports: null as CrashReportStore | null, unsubscribeAgentAwakeStatusChanges: null as (() => void) | null, - publishProviderSessionChanges: null as - | ((identities: AgentHookProviderSessionIdentity[]) => void) - | null, unsubscribeSystemResumeBroadcast: null as (() => void) | null, watcherShutdownPromise: null as Promise | null, watcherShutdownDone: false, diff --git a/src/shared/agent-hook-listener/listener-event.ts b/src/shared/agent-hook-listener/listener-event.ts index 9bca14cc857..e31222d0bb6 100644 --- a/src/shared/agent-hook-listener/listener-event.ts +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -44,6 +44,10 @@ export type AgentHookEventPayload = { /** Row projected from a structured session the host holds: `owned` while its provider child * runs here, `held` once the child is gone but the session is still open. Never persisted. */ structuredHost?: StructuredHostStatus + /** Runtime terminal handle the pane resolved to when main parsed this status off the PTY. + * Lets a reader rejoin the row to its terminal after the pane key moved. Never persisted: + * a handle belongs to the runtime that issued it. */ + terminalHandle?: string payload: ParsedAgentStatusPayload } diff --git a/src/shared/orchestration-fleet-agent-status-evidence.ts b/src/shared/orchestration-fleet-agent-status-evidence.ts index f03b1d9cbfa..43ff2c40cf4 100644 --- a/src/shared/orchestration-fleet-agent-status-evidence.ts +++ b/src/shared/orchestration-fleet-agent-status-evidence.ts @@ -1,7 +1,8 @@ // ─── The one identity/clock contract the fleet path reads ──────────────────── // A hook row carries a pane key, a delivery timestamp and, from newer hosts, an -// observation timestamp. Terminal identity lives on the runtime, not on the row. -// The fleet matcher needs both, and every fact it needs used to be an OPTIONAL +// observation timestamp. A row may carry the runtime handle observed with OSC, but +// fleet authority still resolves terminal identity from the runtime. The matcher needs both, +// and every fact it needs used to be an OPTIONAL // field on `AgentStatusIpcPayload` — so an unenriched producer published a row the // matcher silently failed to identify (failure table L-1) and a missing observation // clock silently degraded to the delivery clock (W1-14 / RR-W-P1A). @@ -10,8 +11,8 @@ // deliberately exposes no `terminalHandle?`, no `evidenceObservedAt?` and no raw // payload, so a consumer cannot read an absent identity or clock by accident. // -// This type never crosses IPC or the wire. `AgentStatusIpcPayload` is unchanged and -// remains what `agentStatus:set` / `agentStatus:getSnapshot` publish. +// This type never crosses IPC or the wire. `AgentStatusIpcPayload` remains what +// `agentStatus:set` / `agentStatus:getSnapshot` publish. import type { AgentStatusIpcPayload } from './agent-status-ipc-payload' import type { AgentStatusState, AgentType } from './agent-status-types' diff --git a/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts b/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts index 09cfa6d6d7c..9a5528938be 100644 --- a/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts +++ b/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts @@ -16,6 +16,7 @@ import { resetWebSessionTabsSnapshotFreshnessForTests, type WebSessionTabsSyncState } from '../../src/renderer/src/runtime/web-session-tabs-sync' +import { makeAgentStatusStoreWiring } from '../../src/main/runtime/agent-status-store-wiring.test-fixture' vi.mock('../../src/renderer/src/store', () => ({ useAppStore: { @@ -689,7 +690,9 @@ describe('real PTY decorative session-tabs fanout', () => { }) it('renews retained hook status without resetting its state start', () => { - const runtime = new OrcaRuntimeService() + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(null, undefined, statusWiring.deps) + const uninstallStatusRepublish = statusWiring.attach(runtime) const ptyId = seedWorktree(runtime, 0) const internals = runtime as unknown as RuntimeInternals const seededTab = internals.mobileSessionTabsByWorktree.get('workspace-0')?.tabs[0] @@ -769,5 +772,7 @@ describe('real PTY decorative session-tabs fanout', () => { true ) unsubscribe() + uninstallStatusRepublish() + statusWiring.statusStore.stop() }) }) diff --git a/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts b/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts index 2c60fe19082..d15a6513cee 100644 --- a/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts +++ b/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' +import { makeAgentStatusStoreWiring } from '../../src/main/runtime/agent-status-store-wiring.test-fixture' import type { RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot @@ -27,7 +28,9 @@ type Harness = { } function createHarness(): Harness { - const runtime = new OrcaRuntimeService() + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(null, undefined, statusWiring.deps) + const uninstallStatusRepublish = statusWiring.attach(runtime) runtime.registerPty(PTY_ID, WORKTREE_ID) const tab: TerminalTab = { type: 'terminal', @@ -58,7 +61,17 @@ function createHarness(): Harness { const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { publications.push(structuredClone(snapshot)) }) - return { internals, publications, runtime, tab, unsubscribe } + return { + internals, + publications, + runtime, + tab, + unsubscribe: () => { + unsubscribe() + uninstallStatusRepublish() + statusWiring.statusStore.stop() + } + } } function setRichStatus( diff --git a/tests/e2e/worktree-switch-first-paint.spec.ts b/tests/e2e/worktree-switch-first-paint.spec.ts index aaaea9aaac8..f062b6e246a 100644 --- a/tests/e2e/worktree-switch-first-paint.spec.ts +++ b/tests/e2e/worktree-switch-first-paint.spec.ts @@ -471,7 +471,9 @@ test.describe('Worktree switch first paint', () => { // runners cannot hold a latency threshold, but "the switch mounted one pane" // and "the warm set came back" are exact and are the real regression guards. if (process.env.CI) { - console.log(`[switch-budget] CI run, latency budget not enforced (median ${median(restored).toFixed(1)}ms)`) + console.log( + `[switch-budget] CI run, latency budget not enforced (median ${median(restored).toFixed(1)}ms)` + ) return } expect(median(restored)).toBeLessThanOrEqual(FIRST_PAINT_BUDGET_MS)