refactor(agent-status): delete the runtime's retained row store (PR 1b) (#19785)

* docs(agent-status): plan PR 1b at file level

Names the five RuntimeAgentRowStore call sites and what each becomes, why
terminalHandle has to be stamped before the store can go, and the one
intended behavior change.

* feat(agent-status): stamp the pane terminal handle on hook-server rows

The runtime's retained row store carried the pty binding two readers need. Put
that fact on the row that already owns the pane instead, resolved through the
same lookup the renderer-facing IPC boundary runs, so the two surfaces cannot
disagree about which terminal a pane is.

Carried forward when a later write resolves no handle (only main's OSC parse
can), and never persisted: a handle belongs to the runtime that issued it.

* refactor(agent-status): route the session-tabs republish off the store

`retain()` was not only a duplicate store: its boolean return was the signal
that republished `session.tabs` for a status-only transition, which no title
change covers (#7970). `hook-status-session-tabs-invalidation.ts` already
mirrors that change set plus hook restore provenance, so route the signal off
the store rather than keep a second comparator.

Adds the status-drop arm a user dismissal emits, which the pane-clear fan-out
deliberately skips — now load-bearing, because a dismissed row leaves the
listing at once.

Installed on both hosts. orcad had neither the OSC producer nor this signal, so
its runtime observed agent status and published it nowhere; deleting the
retained copy without wiring it would list no PTY agents there at all.

* refactor(agent-status): delete the runtime's duplicate retained row store

`RuntimeAgentRowStore` held the same payload the hook server already holds, so
the same pane could legitimately read differently in the sidebar, in
`worktree ps`, and on the phone. Both of its readers move onto the store's
snapshot in `runtime-hook-agent-row-selection.ts`, and
`collectRuntimeWorktreePtyAgentSources` loses the retained-versus-hook
reconciliation that only existed because two stores could disagree.

`ConnectedPtyEvidence` trades its flat pty-id set for `ptyIdByTerminalHandle`,
which is how a row still resolves the connected PTY behind it — the
working-terminal rollup's match key, and the last rescue for a row whose pane
binding a controller incarnation nulled under it.

The one intended behavior change: a row the user dismisses on the desktop
leaves `worktree ps` and mobile at once instead of lingering until the pty
exits. One store means one dismissal.

The suites written against the retained store are rewired to a real
AgentHookServer rather than deleted, so each still asserts the listing
behavior it named.

* docs(agent-status): record what PR 1b landed

Past tense, plus two corrections to the plan: `terminalHandle` is not the pty
id (they are different identifiers, and the explicit-status reader was already
comparing against a real handle), and the legacy numeric pane key is a
consequence the plan did not name.

* fix(agent-status): harden single-store lifecycle

* fix(agent-status): preserve mobile terminal rejoin

* fix(agent-status): preserve unverifiable remote rows

* fix(agent-status): own PTY row lifecycle in hook server

* fix(agent-status): preserve state and renew freshness

* fix(agent-status): ignore freshness for dismissed identity rows

* fix(agent-status): fence orcad observed identities

* fix(orcad): always release daemon adapter on cleanup

* fix(agent-status): cover remint and headless lifecycle edges

* fix agent status identity recovery gaps

* fix(agent-status): suppress duplicate child-only row mutation

* test(runtime): preserve hook store wiring in transcript harness

---------

Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
Brennan Benson
2026-09-11 15:28:16 -07:00
committed by GitHub
co-authored by Merge Sim
parent 729491597f
commit da5d555259
101 changed files with 3246 additions and 992 deletions
+138 -39
View File
@@ -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_<uuid>`) and a pty id are different
identifiers, and `getFreshExplicit` was already comparing hook rows against a
real handle. What landed instead:
- `AgentHookEventPayload` and the runtime's terminal-status event gained an
optional `terminalHandle`. The detector resolves it once per chunk through
`getAgentStatusTerminalHandleForPaneKey` — the same lookup the renderer-facing
IPC boundary already runs for every row, so the two surfaces cannot disagree
about which terminal a pane is.
- `applyNormalizedStatus` carries the handle forward when an incoming event
resolves none. Only main's OSC parse can resolve one, so an HTTP hook post for
the same pane would otherwise erase it.
- It is never persisted. A handle belongs to the runtime that issued it, and a
hydrated one could only rejoin a row to somebody else's terminal.
- `toAgentStatusIpcPayload` publishes it, which also makes `getFreshExplicit`'s
long-dead handle comparison live: the runtime reads raw snapshot rows, and
before this nothing ever stamped the field on them.
`worktree ps` uses it too. `ConnectedPtyEvidence` traded its flat `ptyIds` set
for `ptyIdByTerminalHandle`, so a row still resolves the connected PTY behind
it — which is both the working-terminal rollup's match key and the last rescue
for a row whose pane binding was nulled by a controller incarnation change.
### The change detector had to move with the store
`retain()` was not only a store: its boolean return was the signal that
republished `session.tabs` for a status-only transition, which no title change
covers (#7970). `hook-status-session-tabs-invalidation.ts` already mirrors that
projection change set, including restore provenance and terminal-handle joins,
so the replacement was to route the signal off the store rather than build a
second comparator.
`installHookStatusSessionTabsRepublish` now owns all three arms — enriched
status, pane clear, and the status-drop tap a dismissal emits — and both hosts
install it.
### Both hosts, not just the desktop one
`orcad` constructed its runtime with no `onTerminalAgentStatus`, so main's OSC
parse never reached the store there and the retained copy was the only carrier.
Deleting it without wiring orcad would have made a headless host list no PTY
agents at all. `orcad-entry.ts` now binds the producer and installs the
republish signal, alongside the snapshot and structured sink it already had.
### The intended behavior change
A row the user dismisses on the desktop leaves `worktree ps` and the phone at
once, instead of lingering until the pty exits. One store means one dismissal.
Legacy numeric pane keys remain a bounded compatibility case. Persisted layouts
register aliases to their stable leaf owners; an in-process OSC observation may
also retain a numeric key only when the runtime supplies the matching tab, PTY,
and terminal handle. HTTP and relay ingress still require a stable key or a
registered alias, and numeric rows are never persisted.
## PR 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.
@@ -16,6 +16,7 @@ vi.mock('electron', () => ({
function workingStatus(): AgentAwakeStatus {
return {
paneKey: 'pane-1',
state: 'working',
receivedAt: 1_000,
observedInCurrentRuntime: true
+30
View File
@@ -16,6 +16,7 @@ vi.mock('electron', () => ({
function workingStatus(overrides: Partial<AgentAwakeStatus> = {}): 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(() => {
+24 -64
View File
@@ -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<typeof setTimeout> | 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 {
+106
View File
@@ -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<string, AgentAwakeStatus>()
private timer: ReturnType<typeof setTimeout> | 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
}
}
@@ -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([])
})
})
@@ -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<string, KnownSession>()
return (identities) => {
const next = new Map<string, KnownSession>()
const changedWorktrees = new Set<string>()
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]
}
}
@@ -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<AgentHookEventPayload> = {},
payload: Partial<ParsedAgentStatusPayload> = {}
): 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)
})
})
@@ -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<string, KnownStatus>()
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
}
@@ -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<typeof createMobileSessionTabsAgentStatusHeartbeat>
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()
}
})
})
@@ -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<AgentHookServer, 'subscribeStatusFreshness' | 'subscribeStatusRowMutations'>
/**
* 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<string>()
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()
}
}
@@ -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'
@@ -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<typeof NodeHttp>()
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 })
}
})
})
@@ -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<string, number> }
).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 =>
+3
View File
@@ -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()
}
@@ -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()
@@ -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<string>()
const clearedStatusRows = new Map<string, EnrichedAgentHookEventPayload>()
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()
@@ -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)
@@ -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
)
}
}
+64 -37
View File
@@ -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<void> => {
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<void>((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<void>((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()
}
}
@@ -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)
@@ -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
@@ -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()
@@ -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<AgentHookEventPayload, 'connectionId' | 'worktreeId'>
): 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
}
}
+20 -3
View File
@@ -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<PaneStatusClearListener>()
protected statusDropListeners = new Set<StatusDropListener>()
protected statusChangeListeners = new Set<StatusChangeListener>()
protected statusFreshnessListeners = new Set<StatusFreshnessListener>()
protected providerSessionChangeListeners = new Set<ProviderSessionChangeListener>()
protected statusRowMutationListeners = new Set<StatusRowMutationListener>()
// 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<string, string>()
// 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 }
@@ -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'
}
@@ -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
}
}
@@ -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
@@ -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 {
@@ -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<string>()
const statusPaneKeysToClear = new Set<string>()
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()
@@ -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
@@ -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<string, unknown> = {}): 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<Parameters<typeof server.subscribeStatusRowMutations>[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<string, string> })
.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()
})
})
+11 -1
View File
@@ -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
@@ -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 {
+79 -39
View File
@@ -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<OrcadHandl
headless: browserProvider !== null,
...(browserProvider ? { isAvailable: () => 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<OrcadHandle> {
registerCleanup: (cleanup: () => Promise<void>) => void
): Promise<Pick<OrcadHandle, 'readiness'>> {
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<typeof OrcaRuntimeRpcServer> | 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 {
+57 -1
View File
@@ -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()
})
})
+35
View File
@@ -0,0 +1,35 @@
function createIdempotentOrcadCleanup(cleanup: () => Promise<void>): () => Promise<void> {
let completion: Promise<void> | null = null
return () => {
completion ??= Promise.resolve().then(cleanup)
return completion
}
}
export async function startOrcadWithLifecycle<T extends object>(
start: (registerRuntimeCleanup: (cleanup: () => Promise<void>) => void) => Promise<T>,
cleanupHost: () => Promise<void>
): Promise<T & { stop(): Promise<void> }> {
let cleanupRuntime = async (): Promise<void> => {}
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
}
}
@@ -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<string, EnrichedAgentHookEventPayload>()
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,
@@ -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<AgentHookServer['ingestTerminalStatus']>[0]) => void
getAgentStatusSnapshot: () => ReturnType<AgentHookServer['getStatusSnapshot']>
getAgentProviderSessionSnapshot: () => ReturnType<AgentHookServer['getStatusSnapshot']>
getAgentProviderSessionRowsForPane: (
paneKey: string
) => ReturnType<AgentHookServer['getStatusSnapshotForPane']>
reconcileAgentStatusForEndedProcess: (
paneKeys: Parameters<AgentHookServer['reconcileEndedProcessForPaneKeys']>[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)
}
}
@@ -19,9 +19,10 @@ export type TranscriptPaneOptions = {
}
export async function createTranscriptPane(
options: TranscriptPaneOptions
options: TranscriptPaneOptions,
runtimeDeps?: ConstructorParameters<typeof OrcaRuntimeService>[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<unknown>
}
@@ -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).
@@ -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()
})
})
@@ -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<string, number>()
const lastRefreshAtByWorktreeId = new Map<string, number>()
const pendingPtyIdsByWorktreeId = new Map<string, Set<string>>()
const pendingByWorktreeId = new Map<string, { directObservation: boolean; ptyIds: Set<string> }>()
let lastGlobalHeartbeatAt: number | null = null
let timer: ReturnType<typeof setTimeout> | 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<string>()
}
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<string>()
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
@@ -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 {
@@ -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) {
@@ -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
@@ -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)
}
}
@@ -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<string, string>()
// 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
@@ -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<string> {
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 {
@@ -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
@@ -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?.() ?? []
}),
@@ -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)
@@ -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<AgentStatusIpcPayload> = {}): 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<typeof makeAgentStatusStoreWiring>
): Promise<OrcaRuntimeService> {
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<unknown>
@@ -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<string, { connected: boolean; connectionId: string | null }>
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 {
+5 -6
View File
@@ -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)
}
+1 -2
View File
@@ -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
@@ -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(
@@ -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.
@@ -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?.() ?? []
@@ -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<string>,
terminalHandlesByPtyId: Readonly<Record<string, readonly string[]>>
@@ -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),
@@ -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'
@@ -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',
@@ -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 () => {
@@ -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
@@ -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',
@@ -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',
@@ -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 () => {
@@ -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()
})
})
@@ -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 () => {
@@ -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<typeof makeAgentStatusStoreWiring>
} {
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<string, string>
handleByPtyIncarnation: Map<string, { handle: string }>
ptysById: Map<string, { paneKey: string | null; tabId: string | null }>
}
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<ReturnType<typeof runtime.listMobileSessionTabs>>[] = []
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()
})
})
@@ -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 {
+1
View File
@@ -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')
@@ -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'
}
]
@@ -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)
-125
View File
@@ -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<string, RuntimeAgentRowSnapshot>()
values(): IterableIterator<RuntimeAgentRowSnapshot> {
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<RuntimeTerminalAgentStatus['status']>
updatedAt: number
stateStartedAt: number
} | null {
const now = Date.now()
let bestStatus: NonNullable<RuntimeTerminalAgentStatus['status']> | 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
}
}
@@ -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> = {}): 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()
})
})
@@ -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<RuntimeTerminalAgentStatus['status']>
updatedAt: number
stateStartedAt: number
} | null {
const now = Date.now()
let bestStatus: NonNullable<RuntimeTerminalAgentStatus['status']> | 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 }
: {})
}
}
@@ -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 })
})
)
})
})
@@ -33,13 +33,13 @@ export function buildRuntimeMobileAgentStatus(
host: RuntimeMobileAgentStatusHost
): { agentStatus: AgentStatusEntry } | Record<string, never> {
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 } : {}),
@@ -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
}
@@ -18,6 +18,7 @@ export type RuntimeMobileSessionProjectionHost = {
getLiveBrowserTabs(worktreeId: string): Map<string, BrowserTabInfo>
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
@@ -48,6 +48,42 @@ export function projectRuntimeMobileSessionTabs(
hookRowsForPane.set(paneKey, rows)
return rows
}
let statusRowsByPaneKey: Map<string, AgentStatusIpcPayload[]> | null = null
let statusRowsByTerminalHandle: Map<string, AgentStatusIpcPayload[]> | 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<string>()
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
@@ -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<{
@@ -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,
@@ -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
@@ -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<string, string>(),
connectedPtyEvidence: {
tabIds: new Set<string>(),
paneKeys: new Set<string>(),
ptyIds: new Set<string>()
ptyIdByTerminalHandle: new Map<string, string>()
}
}
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)
})
})
@@ -188,10 +188,16 @@ export function applyRuntimeWorktreePsSessionActivity(args: {
missingIds: Set<string>
ptysById: ReadonlyMap<string, RuntimePtyWorktreeRecord>
tabs: ReadonlyMap<string, RuntimeSyncedTab>
/** Non-minting: a listing must not issue handles, only recognise the ones already bound. */
getTerminalHandlesForPty: (ptyId: string) => readonly string[]
getSummary: SummaryLookup
}): {
mirroredWorktreeIdByTabId: Map<string, string>
connectedPtyEvidence: { tabIds: Set<string>; paneKeys: Set<string>; ptyIds: Set<string> }
connectedPtyEvidence: {
tabIds: Set<string>
paneKeys: Set<string>
ptyIdByTerminalHandle: Map<string, string>
}
} {
const mirroredWorktreeIdByTabId = new Map<string, string>()
const sessionsByHostId = new Map<ExecutionHostId, WorkspaceSessionState>()
@@ -244,19 +250,21 @@ export function applyRuntimeWorktreePsSessionActivity(args: {
const connectedPtyEvidence = {
tabIds: new Set<string>(),
paneKeys: new Set<string>(),
ptyIds: new Set<string>()
ptyIdByTerminalHandle: new Map<string, string>()
}
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 }
}
@@ -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<string>
paneKeys: ReadonlySet<string>
ptyIds: ReadonlySet<string>
/** 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<string, string>
}
/** 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<RuntimeAgentRowSnapshot>
hookSnapshots: readonly AgentStatusIpcPayload[]
mirroredWorktreeIdByTabId: ReadonlyMap<string, string>
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
}
@@ -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,
@@ -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<Awaited<ReturnType<typeof createTranscriptPane>>> {
// 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'
@@ -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 () => {
+2 -3
View File
@@ -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) {
@@ -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)')
})
})
+10 -46
View File
@@ -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)
@@ -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)
})
-4
View File
@@ -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<void>,
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<void> | null,
watcherShutdownDone: false,
@@ -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
}
@@ -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'
@@ -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()
})
})
@@ -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(

Some files were not shown because too many files have changed in this diff Show More