diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 52d615090e2..8f1439c2122 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -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 | `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) | +| 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 @@ -192,13 +192,13 @@ store is now the only main-process copy of a PTY agent's row. ### The five call sites -| Call site | Before | After | -| --- | --- | --- | +| 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` | +| `...command-code-detector.ts` `clearPty()` | drops rows on pty exit | deleted; pane teardown already clears the hook row | +| `orca-runtime-get-worktree-ps.ts` `values()` | fed `retainedSnapshots` | deleted; the reader keeps only `hookSnapshots` | +| `orca-runtime-serialize-agent-prompt-submission.ts` `getFreshExplicit()` | retained row first, hook rows second | `selectFreshExplicitAgentStatus`, hook rows only | +| `orca-runtime-prune-mobile-session-tab-group-layout.ts` `getFreshForMobile()` | pane key, then pty id | `selectFreshAgentRowForMobileTab`: pane key, then `terminalHandle` | Both readers moved into `runtime-hook-agent-row-selection.ts`, which also owns `RuntimeAgentRowSnapshot` now that nothing retains one. @@ -235,8 +235,9 @@ for a row whose pane binding was nulled by a controller incarnation change. `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 -exact change set plus hook restore provenance, so the replacement was to route -the signal off the store rather than build a second comparator. +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. @@ -254,14 +255,11 @@ republish signal, alongside the snapshot and structured sink it already had. 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. -### The one consequence that was not intended - -A legacy numeric pane key (`:`, minted for a -pre-stable-id `pane:N` leaf) fails `parsePaneKey`, so `ingestTerminalStatus` -refuses it. Such a pane already produced no hook row and therefore no sidebar -row; the retained store was the last thing still listing it in `worktree ps` -and on mobile. Those rows are now absent everywhere rather than present in two -surfaces out of four. +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 @@ -271,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 @@ -302,6 +300,44 @@ 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 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 index fc2482cbdb0..5e893dee048 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts @@ -36,6 +36,14 @@ describe('createHookStatusSessionTabsInvalidator', () => { expect(changed(working())).toBe(true) }) + it('invalidates when a row acquires a terminal handle', () => { + const changed = createHookStatusSessionTabsInvalidator() + changed(working()) + + expect(changed(working({ terminalHandle: 'term_rejoined' }))).toBe(true) + expect(changed(working({ terminalHandle: 'term_rejoined' }))).toBe(false) + }) + it.each([ ['state', { state: 'waiting' as const }], ['workingMode', { workingMode: 'monitoring' as const }], diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts index 210efcc8483..3c1a8afa47b 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts @@ -5,6 +5,7 @@ type KnownStatus = { connectionId: string | null payload: ParsedAgentStatusPayload restoredUnconfirmed: boolean + terminalHandle: string | null } /** Reports whether a hook status event changed anything the `session.tabs` @@ -29,7 +30,8 @@ export function createHookStatusSessionTabsInvalidator(): { known.set(event.paneKey, { connectionId: event.connectionId, payload: next, - restoredUnconfirmed + restoredUnconfirmed, + terminalHandle: event.terminalHandle ?? null }) return ( !previous || @@ -42,7 +44,8 @@ export function createHookStatusSessionTabsInvalidator(): { (previous.payload.interrupted ?? false) !== (next.interrupted ?? false) || (previous.payload.turnCompletedAt ?? null) !== (next.turnCompletedAt ?? null) || (previous.payload.lastAssistantMessage ?? null) !== (next.lastAssistantMessage ?? null) || - previous.restoredUnconfirmed !== restoredUnconfirmed + previous.restoredUnconfirmed !== restoredUnconfirmed || + previous.terminalHandle !== (event.terminalHandle ?? null) ) } // Why: a cleared pane must re-arm, else the memo swallows the first event of the @@ -50,8 +53,8 @@ export function createHookStatusSessionTabsInvalidator(): { 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. + // Why: an explicit connection clear 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) { diff --git a/src/main/agent-hooks/hook-status-session-tabs-republish.ts b/src/main/agent-hooks/hook-status-session-tabs-republish.ts index a3f9529e9cf..d804f5a740b 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-republish.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-republish.ts @@ -28,8 +28,8 @@ export function installHookStatusSessionTabsRepublish( getRuntime()?.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. + // Teardown: certified agent exit, pane close, and explicit connection clears land here. + // Transport loss alone keeps the last remote observation as unverifiable evidence. const unsubscribeClear = statusStore.subscribePaneStatusClear((clear) => { const clearedPaneKeys = 'paneKey' in clear ? [clear.paneKey] : changedSessionTabs.forgetConnection(clear.connectionId) 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..3a66dcd4ea7 100644 --- a/src/main/agent-hooks/server-ingest-terminal-status.test.ts +++ b/src/main/agent-hooks/server-ingest-terminal-status.test.ts @@ -294,6 +294,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) 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/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index c7c346ddd12..9d3f8e216cf 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,6 +8,7 @@ import { AgentHookServerIngestNormalization } from './server-ingest-normalizatio export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization { ingestTerminalStatus(event: { + ptyId?: string paneKey: string tabId?: string worktreeId?: string @@ -18,23 +19,29 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges const physicalPaneKey = event.paneKey.trim() const 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 } 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-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 832accbc321..762d7eeb22f 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -179,10 +179,13 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA if (!identity.inheritedFromActivePane) { this.maybeTrackAgentPromptSent(effectivePayload, previous) } - // Why carried forward: the handle is pane identity, not turn state. Only main's OSC parse - // resolves one, so a hook post for the same pane would otherwise erase the row's only join - // back to its terminal — and the worktree listing rescues a cleared pane binding with it. - const terminalHandle = boundaryAwarePayload.terminalHandle ?? previous?.terminalHandle + // 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 terminalHandle = + boundaryAwarePayload.terminalHandle ?? + (boundaryAwarePayload.connectionId === previous?.connectionId + ? previous?.terminalHandle + : undefined) const enriched = { ...this.attachStatusTiming(boundaryAwarePayload, now, observedAt), ...(terminalHandle ? { terminalHandle } : {}), diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index 3ce2c4fce0a..80447e202b7 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -7,9 +7,11 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { dropStatusEntriesByTabPrefix(tabId: string): void { this.markTabClosedForAgentStatus(tabId) const paneKeysToClear = new Set() + const statusPaneKeysToClear = new Set() for (const key of this.state.lastStatusByPaneKey.keys()) { if (paneCacheKeyMatchesTab(key, tabId)) { paneKeysToClear.add(key) + statusPaneKeysToClear.add(key) } } for (const key of this.state.lastPromptByPaneKey.keys()) { @@ -72,6 +74,7 @@ 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() @@ -80,6 +83,10 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { 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 { diff --git a/src/main/agent-hooks/terminal-handle-row-identity.test.ts b/src/main/agent-hooks/terminal-handle-row-identity.test.ts index b12088faf4e..ab08c6517e6 100644 --- a/src/main/agent-hooks/terminal-handle-row-identity.test.ts +++ b/src/main/agent-hooks/terminal-handle-row-identity.test.ts @@ -38,6 +38,27 @@ describe('the terminal handle a status row is stamped with', () => { }) }) + 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) 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 891f3c6bb09..672bddc9071 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 } = @@ -152,6 +151,25 @@ async function startOrcadRuntime( const { installHookStatusSessionTabsRepublish } = await import('../agent-hooks/hook-status-session-tabs-republish') + let rpc: InstanceType | null = null + let uninstallHookStatusRepublish = (): void => {} + let daemonStarted = false + 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. + if (daemonStarted) { + await stopOrcadDaemon() + } + } finally { + uninstallHookStatusRepublish() + } + } + }) + const runtimeUserDataPath = getAppEnvironment().getPath('userData') initOrcaProfilePaths() const profile = ensureActiveOrcaProfile(runtimeUserDataPath) @@ -169,6 +187,7 @@ async function startOrcadRuntime( // 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. await startOrcadDaemon() + daemonStarted = true const runtime = new OrcaRuntimeService(store, undefined, { // Why lazy: a daemon swap replaces the provider after construction, so an eager @@ -194,15 +213,20 @@ async function startOrcadRuntime( // 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), structuredAgentStatusSink: { publish: (summary) => agentHookServer.ingestStructuredStatus(summary), forget: (sessionId) => agentHookServer.dropStructuredStatus(sessionId) - } + }, + reconcileAgentStatusForEndedProcess: (paneKeys) => + agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys) }) // 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. - const uninstallHookStatusRepublish = installHookStatusSessionTabsRepublish( + uninstallHookStatusRepublish = installHookStatusSessionTabsRepublish( agentHookServer, () => runtime ) @@ -225,7 +249,7 @@ async function startOrcadRuntime( await runtime.reconcileLegacyWorkerTerminals() const bindHost = resolveOrcadBindHost(options.bind) - const rpc = new OrcaRuntimeRpcServer({ + rpc = new OrcaRuntimeRpcServer({ runtime, userDataPath: runtimeUserDataPath, enableWebSocket: true, @@ -283,24 +307,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) - uninstallHookStatusRepublish() - 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..1e5b92434de 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,36 @@ 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('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..2b2ef5252a6 --- /dev/null +++ b/src/main/orcad/orcad-lifecycle.ts @@ -0,0 +1,30 @@ +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) { + await cleanup() + throw error + } +} diff --git a/src/main/runtime/agent-status-store-wiring.test-fixture.ts b/src/main/runtime/agent-status-store-wiring.test-fixture.ts index ef32ad8f319..524651e7bd0 100644 --- a/src/main/runtime/agent-status-store-wiring.test-fixture.ts +++ b/src/main/runtime/agent-status-store-wiring.test-fixture.ts @@ -18,6 +18,13 @@ export function makeAgentStatusStoreWiring(): { 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 @@ -28,7 +35,13 @@ export function makeAgentStatusStoreWiring(): { deps: { onTerminalAgentStatus: (event) => statusStore.ingestTerminalStatus(event), getAgentStatusSnapshot: () => - statusStore.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true) + 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/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/orca-runtime-apply-tracked-pty-title.ts b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts index d713f38d67d..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,6 +173,7 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper leaf.waitBlockedAt = null leaf.tailWaitState = undefined } + this.reconcileAgentStatusForEndedProcessFn?.(this.collectAgentStatusPaneKeysForPty(ptyId)) this.primeWaitBlockedBaselineFromSeededTail(ptyId) } 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-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 437a96d8208..3eb7a6e9401 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -96,8 +96,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent missingIds: missingRuntimeWorktreeIds, ptysById: this.ptysById, tabs: this.tabs, - getTerminalHandleForPty: (ptyId) => - this.handleByPtyId.get(ptyId) ?? this.findHandleForPtyRecord(ptyId), + getTerminalHandlesForPty: (ptyId) => this.getExistingTerminalHandlesForPtyId(ptyId), getSummary: (summaryMap, pathIndex, missingIds, worktreeId) => this.getSummaryForRuntimeWorktreeId(summaryMap, pathIndex, missingIds, worktreeId) }) 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 27590b35faa..7a11501db38 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 @@ -67,9 +67,17 @@ async function createRuntimeWithHookRows( ? (): AgentStatusIpcPayload[] => [...rows, ...statusWiring.deps.getAgentStatusSnapshot()] : (): AgentStatusIpcPayload[] => rows const runtime = new OrcaRuntimeService(null, undefined, { - ...(statusWiring ? { onTerminalAgentStatus: statusWiring.deps.onTerminalAgentStatus } : {}), + ...(statusWiring + ? { + onTerminalAgentStatus: statusWiring.deps.onTerminalAgentStatus, + reconcileAgentStatusForEndedProcess: + statusWiring.deps.reconcileAgentStatusForEndedProcess, + getAgentProviderSessionSnapshot: statusWiring.deps.getAgentProviderSessionSnapshot, + getAgentProviderSessionRowsForPane: statusWiring.deps.getAgentProviderSessionRowsForPane + } + : {}), getAgentStatusSnapshot: readRows, - getAgentProviderSessionRowsForPane: readRows + ...(statusWiring ? {} : { getAgentProviderSessionRowsForPane: readRows }) }) const internals = runtime as unknown as { resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise @@ -327,6 +335,100 @@ 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('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-exit.ts b/src/main/runtime/orca-runtime-on-pty-exit.ts index 09894356828..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, { 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 ebca2ff3f7e..8b3f5406dc7 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 @@ -94,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), @@ -129,13 +130,14 @@ 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 { - const handlePty = pty ?? (tab.ptyId ? (this.ptysById.get(tab.ptyId) ?? null) : null) + const terminalHandle = pty ? this.issuePtyHandle(pty) : null return selectFreshAgentRowForMobileTab({ paneKey, - terminalHandle: handlePty ? this.issuePtyHandle(handlePty) : null, - hookRows: this.getAgentStatusSnapshotFn?.() ?? [] + terminalHandle, + hookRows: getRows(paneKey, terminalHandle) }) } 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 38eedc1b8e0..da257921f2b 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,7 @@ export class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends OrcaRunt protected dropDisconnectedPtyRecord(ptyId: string): void { // Why: pruning can remove a PTY without the normal exit callback. + this.reconcileAgentStatusForEndedProcessFn?.(this.collectAgentStatusPaneKeysForPty(ptyId)) this.advancePtyLifecycleGeneration(ptyId) this.pairedRendererSessionOwnedPtyIds.delete(ptyId) this.ptysById.delete(ptyId) 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-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/worktree-ps-agent-row-dismissal.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-ps-agent-row-dismissal.spec.ts index cc758514293..0b388ea88c5 100644 --- 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 @@ -12,7 +12,7 @@ import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fi const LEAF_ID = '77777777-7777-4777-8777-777777777777' const PANE_KEY = `tab-dismiss:${LEAF_ID}` -function wiredRuntime(): { +function wiredRuntime(incarnationId?: string): { runtime: OrcaRuntimeService statusWiring: ReturnType } { @@ -39,6 +39,13 @@ function wiredRuntime(): { } ] }) + if (incarnationId) { + runtime.registerPty('dismiss-pty', TEST_WORKTREE_ID, null, { + tabId: 'tab-dismiss', + leafId: LEAF_ID, + incarnationId + }) + } return { runtime, statusWiring } } @@ -98,4 +105,101 @@ describe('worktree ps follows a dismissal out of the agent-status store', () => 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('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/runtime-hook-agent-row-selection.test.ts b/src/main/runtime/runtime-hook-agent-row-selection.test.ts index 96781acc667..5efa8736186 100644 --- a/src/main/runtime/runtime-hook-agent-row-selection.test.ts +++ b/src/main/runtime/runtime-hook-agent-row-selection.test.ts @@ -47,17 +47,31 @@ describe('selectFreshExplicitAgentStatus', () => { ).toBeNull() }) - it('refuses restored and stale rows', () => { + 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', () => { @@ -120,5 +134,16 @@ describe('selectFreshAgentRowForMobileTab', () => { 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 index c2766fadfca..f07f04de107 100644 --- a/src/main/runtime/runtime-hook-agent-row-selection.ts +++ b/src/main/runtime/runtime-hook-agent-row-selection.ts @@ -17,6 +17,7 @@ export type RuntimeAgentRowSnapshot = { payload: ParsedAgentStatusPayload stateStartedAt: number updatedAt: number + evidenceObservedAt?: number } function isLiveObservation(row: AgentStatusIpcPayload): boolean { @@ -42,13 +43,15 @@ export function selectFreshExplicitAgentStatus(args: { 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 || typeof updatedAt !== 'number') { + if (!state || restoredUnconfirmed || providerSessionOnly || typeof updatedAt !== 'number') { return } - if (now - updatedAt > AGENT_STATUS_STALE_AFTER_MS) { + if (now - (evidenceObservedAt ?? updatedAt) > AGENT_STATUS_STALE_AFTER_MS) { return } const status = mapExplicitAgentStateToRuntimeTerminalStatus(state) @@ -62,7 +65,14 @@ export function selectFreshExplicitAgentStatus(args: { if (row.terminalHandle !== args.handle && (!args.paneKey || row.paneKey !== args.paneKey)) { continue } - consider(row.state, row.receivedAt, row.restoredUnconfirmed, row.stateStartedAt) + consider( + row.state, + row.receivedAt, + row.evidenceObservedAt, + row.restoredUnconfirmed, + row.providerSessionOnly, + row.stateStartedAt + ) } return bestStatus ? { @@ -81,8 +91,12 @@ export function selectFreshAgentRowForMobileTab(args: { hookRows: readonly AgentStatusIpcPayload[] }): RuntimeAgentRowSnapshot | null { let match: AgentStatusIpcPayload | null = null + const now = Date.now() for (const row of args.hookRows) { - if (!isLiveObservation(row)) { + if ( + !isLiveObservation(row) || + now - (row.evidenceObservedAt ?? row.receivedAt) > AGENT_STATUS_STALE_AFTER_MS + ) { continue } if (row.paneKey === args.paneKey) { @@ -100,7 +114,7 @@ export function selectFreshAgentRowForMobileTab(args: { match = row } } - if (!match || Date.now() - match.receivedAt > AGENT_STATUS_STALE_AFTER_MS) { + if (!match) { return null } return { @@ -110,6 +124,9 @@ export function selectFreshAgentRowForMobileTab(args: { ...(match.tabId ? { tabId: match.tabId } : {}), payload: pickParsedAgentStatusPayload(match), stateStartedAt: match.stateStartedAt ?? match.receivedAt, - updatedAt: match.receivedAt + updatedAt: match.receivedAt, + ...(match.evidenceObservedAt !== undefined + ? { evidenceObservedAt: match.evidenceObservedAt } + : {}) } } diff --git a/src/main/runtime/runtime-mobile-agent-status-builder.ts b/src/main/runtime/runtime-mobile-agent-status-builder.ts index 2f8480b7580..7dfbea1e320 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) { @@ -101,6 +101,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 1b730d81c1c..227788af7e1 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -104,7 +104,7 @@ export type RuntimeTerminalAgentStatusEvent = { 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-sources.test.ts b/src/main/runtime/runtime-worktree-agent-sources.test.ts index 398a8da27dc..5da2f6e8550 100644 --- a/src/main/runtime/runtime-worktree-agent-sources.test.ts +++ b/src/main/runtime/runtime-worktree-agent-sources.test.ts @@ -65,7 +65,7 @@ describe('worktree agent source admission', () => { expect(collectRuntimeWorktreeAgentSources(base).size).toBe(0) }) - it('carries the row own working mode and drops restored rows', () => { + it('carries the row own working mode and drops non-live rows', () => { const monitoring = collectRuntimeWorktreeAgentSources({ ...connected, hookSnapshots: [{ ...hookRow, workingMode: 'monitoring' as const }] @@ -77,5 +77,11 @@ describe('worktree agent source admission', () => { 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 8d53134a67e..ae3ecdee4c3 100644 --- a/src/main/runtime/runtime-worktree-ps-activity.ts +++ b/src/main/runtime/runtime-worktree-ps-activity.ts @@ -189,7 +189,7 @@ export function applyRuntimeWorktreePsSessionActivity(args: { ptysById: ReadonlyMap tabs: ReadonlyMap /** Non-minting: a listing must not issue handles, only recognise the ones already bound. */ - getTerminalHandleForPty: (ptyId: string) => string | null + getTerminalHandlesForPty: (ptyId: string) => readonly string[] getSummary: SummaryLookup }): { mirroredWorktreeIdByTabId: Map @@ -262,8 +262,7 @@ export function applyRuntimeWorktreePsSessionActivity(args: { if (pty.paneKey) { connectedPtyEvidence.paneKeys.add(pty.paneKey) } - const terminalHandle = args.getTerminalHandleForPty(pty.ptyId) - if (terminalHandle) { + for (const terminalHandle of args.getTerminalHandlesForPty(pty.ptyId)) { connectedPtyEvidence.ptyIdByTerminalHandle.set(terminalHandle, pty.ptyId) } } diff --git a/src/main/runtime/runtime-worktree-pty-agent-sources.ts b/src/main/runtime/runtime-worktree-pty-agent-sources.ts index 438f1cef2ff..058d378df11 100644 --- a/src/main/runtime/runtime-worktree-pty-agent-sources.ts +++ b/src/main/runtime/runtime-worktree-pty-agent-sources.ts @@ -27,7 +27,7 @@ export function collectRuntimeWorktreePtyAgentSources(args: { RuntimeWorktreeAgentSource & { payload: ParsedAgentStatusPayload } >() for (const entry of args.hookSnapshots) { - if (entry.restoredUnconfirmed === true) { + if (entry.restoredUnconfirmed === true || entry.providerSessionOnly === true) { continue } const hookPayload = pickParsedAgentStatusPayload(entry) @@ -49,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 } : {}) }) } 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/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(