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)