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

This commit is contained in:
Merge Sim
2026-09-10 14:11:47 -07:00
parent 0f2bc5d7cf
commit c3cc86c6da
43 changed files with 877 additions and 173 deletions
+73 -37
View File
@@ -24,11 +24,11 @@ the structured-session mapping and nothing else.
An audit on 2026-09-09 found six producers and three consumers, and three
separate copies of the same row inside the main process alone:
| Main-process copy | Keyed by | Owned by | Persisted | Evicted |
| -------------------------------------- | --------- | ---------------------------------------------------------- | ------------------- | ----------------------------- |
| hook server `lastStatusByPaneKey` | paneKey | `src/main/agent-hooks/server.ts` | `last-status.json` | tab close, pty exit, hydrate |
| runtime `RuntimeAgentRowStore` | paneKey | `runtime-agent-row-store.ts` (deleted in PR 1b) | no | pty exit only |
| structured feed `published` | sessionId | `src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts` | no | never (a broadcast cache) |
| Main-process copy | Keyed by | Owned by | Persisted | Evicted |
| --------------------------------- | --------- | --------------------------------------------------------------------------------- | ------------------ | ---------------------------- |
| hook server `lastStatusByPaneKey` | paneKey | `src/main/agent-hooks/server.ts` | `last-status.json` | tab close, pty exit, hydrate |
| runtime `RuntimeAgentRowStore` | paneKey | `runtime-agent-row-store.ts` (deleted in PR 1b) | no | pty exit only |
| structured feed `published` | sessionId | `src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts` | no | never (a broadcast cache) |
The second copy is a duplicate write: the OSC status parsed in main is
forwarded to the hook server _and_ retained in the runtime store from the same
@@ -92,14 +92,14 @@ The structured feed keeps its job of projecting a session's journal into a
summary and streaming it to subscribers. On every publish it additionally
ingests the summary into the hook server as a status row:
| Row field | From |
| ----------------- | ------------------------------------------------------------- |
| `paneKey` | `structuredAgentSessionPaneKey(tabId, sessionId)`, the key the renderer already uses; its leaf is UUID-shaped so pane-key validation accepts it |
| `tabId` | `structuredAgentSessionTabId(sessionId)` |
| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) |
| `state` | `structuredAgentSessionStatusState(summary.status)`, the mapping #19217 shared |
| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it |
| prompt, tool, last message, model, provider session | the summary's fields |
| Row field | From |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `paneKey` | `structuredAgentSessionPaneKey(tabId, sessionId)`, the key the renderer already uses; its leaf is UUID-shaped so pane-key validation accepts it |
| `tabId` | `structuredAgentSessionTabId(sessionId)` |
| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) |
| `state` | `structuredAgentSessionStatusState(summary.status)`, the mapping #19217 shared |
| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it |
| prompt, tool, last message, model, provider session | the summary's fields |
Sessions with no persisted turn (`status === null`) produce no row, matching
what the chat shows. When the host revokes live ownership the row is re-set
@@ -192,13 +192,13 @@ store is now the only main-process copy of a PTY agent's row.
### The five call sites
| Call site | Before | After |
| --- | --- | --- |
| Call site | Before | After |
| ------------------------------------------------------------------------------ | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `orca-runtime-create-terminal-side-effect-command-code-detector.ts` `retain()` | second write of the OSC payload already sent to the hook server | deleted; the event now carries the pane's `terminalHandle` and the hook ingest keeps the only copy |
| `...command-code-detector.ts` `clearPty()` | drops rows on pty exit | deleted; pane teardown already clears the hook row |
| `orca-runtime-get-worktree-ps.ts` `values()` | fed `retainedSnapshots` | deleted; the reader keeps only `hookSnapshots` |
| `orca-runtime-serialize-agent-prompt-submission.ts` `getFreshExplicit()` | retained row first, hook rows second | `selectFreshExplicitAgentStatus`, hook rows only |
| `orca-runtime-prune-mobile-session-tab-group-layout.ts` `getFreshForMobile()` | pane key, then pty id | `selectFreshAgentRowForMobileTab`: pane key, then `terminalHandle` |
| `...command-code-detector.ts` `clearPty()` | drops rows on pty exit | deleted; pane teardown already clears the hook row |
| `orca-runtime-get-worktree-ps.ts` `values()` | fed `retainedSnapshots` | deleted; the reader keeps only `hookSnapshots` |
| `orca-runtime-serialize-agent-prompt-submission.ts` `getFreshExplicit()` | retained row first, hook rows second | `selectFreshExplicitAgentStatus`, hook rows only |
| `orca-runtime-prune-mobile-session-tab-group-layout.ts` `getFreshForMobile()` | pane key, then pty id | `selectFreshAgentRowForMobileTab`: pane key, then `terminalHandle` |
Both readers moved into `runtime-hook-agent-row-selection.ts`, which also owns
`RuntimeAgentRowSnapshot` now that nothing retains one.
@@ -235,8 +235,9 @@ for a row whose pane binding was nulled by a controller incarnation change.
`retain()` was not only a store: its boolean return was the signal that
republished `session.tabs` for a status-only transition, which no title change
covers (#7970). `hook-status-session-tabs-invalidation.ts` already mirrors that
exact change set plus hook restore provenance, so the replacement was to route
the signal off the store rather than build a second comparator.
projection change set, including restore provenance and terminal-handle joins,
so the replacement was to route the signal off the store rather than build a
second comparator.
`installHookStatusSessionTabsRepublish` now owns all three arms — enriched
status, pane clear, and the status-drop tap a dismissal emits — and both hosts
install it.
@@ -254,14 +255,11 @@ republish signal, alongside the snapshot and structured sink it already had.
A row the user dismisses on the desktop leaves `worktree ps` and the phone at
once, instead of lingering until the pty exits. One store means one dismissal.
### The one consequence that was not intended
A legacy numeric pane key (`<tabId>:<paneRuntimeId>`, minted for a
pre-stable-id `pane:N` leaf) fails `parsePaneKey`, so `ingestTerminalStatus`
refuses it. Such a pane already produced no hook row and therefore no sidebar
row; the retained store was the last thing still listing it in `worktree ps`
and on mobile. Those rows are now absent everywhere rather than present in two
surfaces out of four.
Legacy numeric pane keys remain a bounded compatibility case. Persisted layouts
register aliases to their stable leaf owners; an in-process OSC observation may
also retain a numeric key only when the runtime supplies the matching tab, PTY,
and terminal handle. HTTP and relay ingress still require a stable key or a
registered alias, and numeric rows are never persisted.
## PR 2: the renderer subscribes
@@ -271,14 +269,14 @@ unmount cleanup becomes a tab-close signal to the host. The IPC applicator is
the single writer for observed status. The 2026-09-09 audit sorted the other
writers:
| Writer | Disposition |
| --------------------------------------------------------------- | -------------------------------------------------- |
| Command Code output seeds, parked-pane seeds, pty-exit removal | delete; main already emits the same facts |
| structured bridge status writes | delete; main now publishes the row |
| launch placeholder seeds (a user launched an agent with a prompt) | keep for now; main holds the launch config and can seed later |
| dismissal, acknowledgement, unmount | keep; user facts and component lifecycle |
| remote-runtime OSC parse (bytes never transit local main) | keep, fenced behind the host's published row once the host is new enough; rule 3 of the wire doc applies |
| web-session mirror receipt clock | keep; the decay rule needs both clocks from one machine |
| Writer | Disposition |
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Command Code output seeds, parked-pane seeds, pty-exit removal | delete; main already emits the same facts |
| structured bridge status writes | delete; main now publishes the row |
| launch placeholder seeds (a user launched an agent with a prompt) | keep for now; main holds the launch config and can seed later |
| dismissal, acknowledgement, unmount | keep; user facts and component lifecycle |
| remote-runtime OSC parse (bytes never transit local main) | keep, fenced behind the host's published row once the host is new enough; rule 3 of the wire doc applies |
| web-session mirror receipt clock | keep; the decay rule needs both clocks from one machine |
The Command Code done-settle window is renderer policy with no main
equivalent. PR 2 either moves it into main's detector or leaves it, and says
@@ -302,6 +300,44 @@ call it.
- Hydration honesty: a restored non-done row is `restoredUnconfirmed` and is
never fresh.
## PR 1b reliability contract
- **Invariant (`agent-session.status-host-ownership`):** each execution host has
one agent-status store; OSC, hooks, and structured sessions write it, while
desktop, `worktree ps`, and mobile only project it. Dismissal, certified PTY
exit, and provider-generation replacement remove the same row everywhere;
transport loss alone removes nothing.
- **Failure source:** the deleted runtime row store duplicated OSC observations,
keyed them by a different terminal identity, and outlived a dismissal from the
hook store. Relay replay could also make old evidence look fresh when readers
used its new delivery timestamp.
- **Oracle:** one OSC observation appears through the hook snapshot in
`worktree ps` and mobile, and one store dismissal removes it from both without
stopping the PTY. Focused tests also require leaf/incarnation-handle rejoin,
legacy numeric-pane compatibility, certified-exit and provider-generation
cleanup, evidence-age freshness, and exactly-once startup/stop teardown.
- **Gate:** `terminal-performance.osc-status-scan-budget` covers the unchanged
bounded OSC parser and the runtime projection. There is not yet a dedicated
blocking multi-surface status-store gate; the focused suites below are the
accepted gap until they accumulate reliability-gate soak evidence.
- **Provider/platform coverage:** local and daemon-backed PTYs are covered by
runtime tests, and SSH relay loss/replay semantics by relay integration tests.
The projection is shared by git worktrees and folder workspaces. WSL uses the
same store and admission code but has no live run here; Linux and Windows
runtime execution, native mobile clients, and mixed-version paired clients
remain validation gaps.
- **Performance budget:** publication stays event-driven with no new polling or
subprocesses. One mobile projection clones the status snapshot once, builds
pane/handle indexes once, and has a deterministic call-count test; lifecycle
cleanup is bounded by the existing status and handle inventories, and orcad
tests prove listeners clean up once on failed startup and repeated stop.
- **Diagnostics:** existing hook-listener errors name the pane and PTY, while
status-store tests pin delivery versus evidence clocks. No new telemetry or
raw terminal data is emitted.
- **Residual gaps:** rendered Electron/mobile behavior, live SSH reconnect, and
Linux/Windows/WSL execution require the platform QA pass. The current
cross-version gate does not cover `session.tabs` content.
## Verification
- Unit: ingest a structured summary and read it back through
@@ -36,6 +36,14 @@ describe('createHookStatusSessionTabsInvalidator', () => {
expect(changed(working())).toBe(true)
})
it('invalidates when a row acquires a terminal handle', () => {
const changed = createHookStatusSessionTabsInvalidator()
changed(working())
expect(changed(working({ terminalHandle: 'term_rejoined' }))).toBe(true)
expect(changed(working({ terminalHandle: 'term_rejoined' }))).toBe(false)
})
it.each([
['state', { state: 'waiting' as const }],
['workingMode', { workingMode: 'monitoring' as const }],
@@ -5,6 +5,7 @@ type KnownStatus = {
connectionId: string | null
payload: ParsedAgentStatusPayload
restoredUnconfirmed: boolean
terminalHandle: string | null
}
/** Reports whether a hook status event changed anything the `session.tabs`
@@ -29,7 +30,8 @@ export function createHookStatusSessionTabsInvalidator(): {
known.set(event.paneKey, {
connectionId: event.connectionId,
payload: next,
restoredUnconfirmed
restoredUnconfirmed,
terminalHandle: event.terminalHandle ?? null
})
return (
!previous ||
@@ -42,7 +44,8 @@ export function createHookStatusSessionTabsInvalidator(): {
(previous.payload.interrupted ?? false) !== (next.interrupted ?? false) ||
(previous.payload.turnCompletedAt ?? null) !== (next.turnCompletedAt ?? null) ||
(previous.payload.lastAssistantMessage ?? null) !== (next.lastAssistantMessage ?? null) ||
previous.restoredUnconfirmed !== restoredUnconfirmed
previous.restoredUnconfirmed !== restoredUnconfirmed ||
previous.terminalHandle !== (event.terminalHandle ?? null)
)
}
// Why: a cleared pane must re-arm, else the memo swallows the first event of the
@@ -50,8 +53,8 @@ export function createHookStatusSessionTabsInvalidator(): {
invalidator.forgetPane = (paneKey: string): void => {
known.delete(paneKey)
}
// Why: an SSH disconnect clears a whole host's rows at once and names no pane, so
// the caller needs the pane list back to republish each affected workspace.
// Why: an explicit connection clear names no pane, so the caller needs the pane list
// back to republish each affected workspace.
invalidator.forgetConnection = (connectionId: string): string[] => {
const forgotten: string[] = []
for (const [paneKey, status] of known) {
@@ -28,8 +28,8 @@ export function installHookStatusSessionTabsRepublish(
getRuntime()?.touchMobileSessionTabsForPane(enriched.paneKey, enriched.worktreeId ?? null)
}
})
// Teardown: agent exit, pane close, and the SSH transient-disconnect batch all land here.
// Without it the live state published above becomes a zombie question card.
// Teardown: certified agent exit, pane close, and explicit connection clears land here.
// Transport loss alone keeps the last remote observation as unverifiable evidence.
const unsubscribeClear = statusStore.subscribePaneStatusClear((clear) => {
const clearedPaneKeys =
'paneKey' in clear ? [clear.paneKey] : changedSessionTabs.forgetConnection(clear.connectionId)
@@ -294,6 +294,49 @@ describe('AgentHookServer ingestTerminalStatus', () => {
}
})
it('accepts a runtime-owned legacy pane without opening legacy relay ingress', () => {
const server = new AgentHookServer()
const event = {
paneKey: 'legacy-tab:7',
tabId: 'legacy-tab',
ptyId: 'legacy-pty',
terminalHandle: 'term_legacy',
worktreeId: 'wt-1',
payload: { state: 'working' as const, prompt: 'legacy task', agentType: 'codex' as const }
}
server.ingestTerminalStatus(event)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: 'legacy-tab:7',
tabId: 'legacy-tab',
terminalHandle: 'term_legacy',
prompt: 'legacy task'
})
])
server.stop()
})
it.each([
['PTY id', { ptyId: undefined }],
['terminal handle', { terminalHandle: undefined }],
['matching tab', { tabId: 'other-tab' }]
])('rejects a legacy terminal row without its runtime-owned %s', (_label, overrides) => {
const server = new AgentHookServer()
server.ingestTerminalStatus({
paneKey: 'legacy-tab:7',
tabId: 'legacy-tab',
ptyId: 'legacy-pty',
terminalHandle: 'term_legacy',
payload: { state: 'working', prompt: 'legacy task', agentType: 'codex' },
...overrides
})
expect(server.getStatusSnapshot()).toEqual([])
server.stop()
})
it('suppresses exact duplicate runtime terminal status observations', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
@@ -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 =>
@@ -1,6 +1,6 @@
import { track } from '../../telemetry/client'
import { MAX_PANE_KEY_LEN } from '../../../shared/agent-hook-listener/listener-limits'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
import { terminalStatusPayloadMatchesHook } from '../../../shared/agent-terminal-status-equivalence'
import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types'
import type { EnrichedAgentHookEventPayload } from './server-types'
@@ -8,6 +8,7 @@ import { AgentHookServerIngestNormalization } from './server-ingest-normalizatio
export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization {
ingestTerminalStatus(event: {
ptyId?: string
paneKey: string
tabId?: string
worktreeId?: string
@@ -18,23 +19,29 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges
const physicalPaneKey = event.paneKey.trim()
const paneKey = this.resolvePaneKeyAlias(physicalPaneKey)
const parsedPaneKey = parsePaneKey(paneKey)
const legacyPaneKey = parseLegacyNumericPaneKey(paneKey)
if (paneKey.length === 0) {
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
return
}
if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) {
return
}
const reportedTabId =
event.tabId !== undefined && event.tabId.trim().length > 0 ? event.tabId.trim() : undefined
if (
paneKey === physicalPaneKey &&
reportedTabId !== undefined &&
reportedTabId !== parsedPaneKey.tabId
) {
const runtimeOwnedLegacyPane = Boolean(
legacyPaneKey &&
event.ptyId?.trim() &&
event.terminalHandle?.trim() &&
reportedTabId === legacyPaneKey.tabId
)
// Legacy rows are accepted only from the in-process PTY ingress with both runtime identities;
// HTTP and relay paths still require a stable pane key or a registered alias.
if (paneKey.length > MAX_PANE_KEY_LEN || (!parsedPaneKey && !runtimeOwnedLegacyPane)) {
return
}
const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId
const paneTabId = parsedPaneKey?.tabId ?? legacyPaneKey?.tabId
if (paneKey === physicalPaneKey && reportedTabId !== undefined && reportedTabId !== paneTabId) {
return
}
const tabId = paneKey !== physicalPaneKey ? parsedPaneKey?.tabId : reportedTabId
if (this.getAgentStatusDisposition(paneKey) !== 'accept') {
return
}
@@ -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'
}
@@ -179,10 +179,13 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
if (!identity.inheritedFromActivePane) {
this.maybeTrackAgentPromptSent(effectivePayload, previous)
}
// Why carried forward: the handle is pane identity, not turn state. Only main's OSC parse
// resolves one, so a hook post for the same pane would otherwise erase the row's only join
// back to its terminal — and the worktree listing rescues a cleared pane binding with it.
const terminalHandle = boundaryAwarePayload.terminalHandle ?? previous?.terminalHandle
// Why carried forward only within one host: main's OSC parse resolves the handle, so a later
// hook must not erase its terminal join; a connection change must not inherit another host's.
const terminalHandle =
boundaryAwarePayload.terminalHandle ??
(boundaryAwarePayload.connectionId === previous?.connectionId
? previous?.terminalHandle
: undefined)
const enriched = {
...this.attachStatusTiming(boundaryAwarePayload, now, observedAt),
...(terminalHandle ? { terminalHandle } : {}),
@@ -7,9 +7,11 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup {
dropStatusEntriesByTabPrefix(tabId: string): void {
this.markTabClosedForAgentStatus(tabId)
const paneKeysToClear = new Set<string>()
const statusPaneKeysToClear = new Set<string>()
for (const key of this.state.lastStatusByPaneKey.keys()) {
if (paneCacheKeyMatchesTab(key, tabId)) {
paneKeysToClear.add(key)
statusPaneKeysToClear.add(key)
}
}
for (const key of this.state.lastPromptByPaneKey.keys()) {
@@ -72,6 +74,7 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup {
this.currentAuthorityObservations.delete(paneKey)
this.promptSentDedupeByPaneKey.delete(paneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey)
this.evidenceObservedAtByPaneKey.delete(paneKey)
}
if (aliasChanged) {
this.notifyPaneKeyAliasPersistenceListener()
@@ -80,6 +83,10 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup {
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
// Why: tab teardown must retire status subscribers' pane-scoped memo state too.
for (const paneKey of statusPaneKeysToClear) {
this.emitPaneStatusCleared({ paneKey })
}
}
clearPaneState(paneKey: string): void {
@@ -38,6 +38,27 @@ describe('the terminal handle a status row is stamped with', () => {
})
})
it('does not cross a connection ownership change on a colliding pane key', () => {
const server = new AgentHookServer()
ingest(server, { connectionId: 'ssh-a' })
server.ingestRemote(
{
paneKey: PANE_KEY,
tabId: 'tab-handle',
worktreeId: 'other-worktree',
payload: { state: 'done', prompt: 'other host', agentType: 'codex' }
},
'ssh-b'
)
expect(server.getStatusSnapshot()[0]).toMatchObject({
connectionId: 'ssh-b',
worktreeId: 'other-worktree'
})
expect(server.getStatusSnapshot()[0]).not.toHaveProperty('terminalHandle')
})
it('is never persisted, because it belongs to the runtime that issued it', () => {
const server = new AgentHookServer()
ingest(server)
+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 {
+46 -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 } =
@@ -152,6 +151,25 @@ async function startOrcadRuntime(
const { installHookStatusSessionTabsRepublish } =
await import('../agent-hooks/hook-status-session-tabs-republish')
let rpc: InstanceType<typeof OrcaRuntimeRpcServer> | null = null
let uninstallHookStatusRepublish = (): void => {}
let daemonStarted = false
registerCleanup(async () => {
try {
await rpc?.stop()
} finally {
try {
// Why disconnect and not shut down: the daemon must outlive this process, or an
// orcad restart goes back to killing every running terminal.
if (daemonStarted) {
await stopOrcadDaemon()
}
} finally {
uninstallHookStatusRepublish()
}
}
})
const runtimeUserDataPath = getAppEnvironment().getPath('userData')
initOrcaProfilePaths()
const profile = ensureActiveOrcaProfile(runtimeUserDataPath)
@@ -169,6 +187,7 @@ async function startOrcadRuntime(
// adapter as THE local provider, and the registry's contract is that it lands before
// registerPtyHandlers so the IPC layer routes through the daemon from the first call.
await startOrcadDaemon()
daemonStarted = true
const runtime = new OrcaRuntimeService(store, undefined, {
// Why lazy: a daemon swap replaces the provider after construction, so an eager
@@ -194,15 +213,20 @@ async function startOrcadRuntime(
// so without these a headless host publishes its structured chats nowhere and lists no agents.
getAgentStatusSnapshot: () =>
agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true),
getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(),
getAgentProviderSessionRowsForPane: (paneKey) =>
agentHookServer.getStatusSnapshotForPane(paneKey),
structuredAgentStatusSink: {
publish: (summary) => agentHookServer.ingestStructuredStatus(summary),
forget: (sessionId) => agentHookServer.dropStructuredStatus(sessionId)
}
},
reconcileAgentStatusForEndedProcess: (paneKeys) =>
agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys)
})
// Why here too and not only on the desktop: nothing else republishes `session.tabs` when a
// pane's status row changes, and orcad's whole job is serving paired clients.
const uninstallHookStatusRepublish = installHookStatusSessionTabsRepublish(
uninstallHookStatusRepublish = installHookStatusSessionTabsRepublish(
agentHookServer,
() => runtime
)
@@ -225,7 +249,7 @@ async function startOrcadRuntime(
await runtime.reconcileLegacyWorkerTerminals()
const bindHost = resolveOrcadBindHost(options.bind)
const rpc = new OrcaRuntimeRpcServer({
rpc = new OrcaRuntimeRpcServer({
runtime,
userDataPath: runtimeUserDataPath,
enableWebSocket: true,
@@ -283,24 +307,7 @@ async function startOrcadRuntime(
mode: options.json ? 'json' : 'human'
})
return {
readiness,
stop: async () => {
try {
await rpc.stop()
} finally {
// Why disconnect and not shut down: the daemon must outlive this process, or an
// orcad restart goes back to killing every running terminal. See
// orcad-daemon-supervision.ts.
await stopOrcadDaemon()
await browserProvider?.stop()
setRuntimeBrowserCommandsFactory(null)
uninstallHookStatusRepublish()
runOrcadQuitHandlers()
instanceLock.release()
}
}
}
return { readiness }
}
export function parseArgs(argv: string[]): OrcadOptions {
+35 -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,36 @@ describe('resolveOrcadExitCode', () => {
expect(ORCAD_EXIT_CONFIGURATION).not.toBe(ORCAD_EXIT_FAILED)
})
})
describe('orcad lifecycle cleanup', () => {
it('uninstalls registered runtime resources when startup fails', async () => {
const cleanupRuntime = vi.fn(async () => {})
const cleanupHost = vi.fn(async () => {})
await expect(
startOrcadWithLifecycle(async (registerCleanup) => {
registerCleanup(cleanupRuntime)
await Promise.resolve()
throw new Error('startup failed')
}, cleanupHost)
).rejects.toThrow('startup failed')
expect(cleanupRuntime).toHaveBeenCalledOnce()
expect(cleanupHost).toHaveBeenCalledOnce()
})
it('coalesces concurrent and repeated normal stops', async () => {
const cleanupRuntime = vi.fn(async () => {})
const cleanupHost = vi.fn(async () => {})
const handle = await startOrcadWithLifecycle(async (registerCleanup) => {
registerCleanup(cleanupRuntime)
return { readiness: 'ready' }
}, cleanupHost)
await Promise.all([handle.stop(), handle.stop()])
await handle.stop()
expect(cleanupRuntime).toHaveBeenCalledOnce()
expect(cleanupHost).toHaveBeenCalledOnce()
})
})
+30
View File
@@ -0,0 +1,30 @@
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) {
await cleanup()
throw error
}
}
@@ -18,6 +18,13 @@ export function makeAgentStatusStoreWiring(): {
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
@@ -28,7 +35,13 @@ export function makeAgentStatusStoreWiring(): {
deps: {
onTerminalAgentStatus: (event) => statusStore.ingestTerminalStatus(event),
getAgentStatusSnapshot: () =>
statusStore.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true)
statusStore.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true),
getAgentProviderSessionSnapshot: () => statusStore.getStatusSnapshot(),
getAgentProviderSessionRowsForPane: (paneKey) =>
statusStore.getStatusSnapshotForPane(paneKey),
reconcileAgentStatusForEndedProcess: (paneKeys) => {
statusStore.reconcileEndedProcessForPaneKeys(paneKeys)
}
},
attach: (runtime) => installHookStatusSessionTabsRepublish(statusStore, () => runtime)
}
@@ -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).
@@ -173,6 +173,7 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper
leaf.waitBlockedAt = null
leaf.tailWaitState = undefined
}
this.reconcileAgentStatusForEndedProcessFn?.(this.collectAgentStatusPaneKeysForPty(ptyId))
this.primeWaitBlockedBaselineFromSeededTail(ptyId)
}
@@ -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 {
@@ -96,8 +96,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent
missingIds: missingRuntimeWorktreeIds,
ptysById: this.ptysById,
tabs: this.tabs,
getTerminalHandleForPty: (ptyId) =>
this.handleByPtyId.get(ptyId) ?? this.findHandleForPtyRecord(ptyId),
getTerminalHandlesForPty: (ptyId) => this.getExistingTerminalHandlesForPtyId(ptyId),
getSummary: (summaryMap, pathIndex, missingIds, worktreeId) =>
this.getSummaryForRuntimeWorktreeId(summaryMap, pathIndex, missingIds, worktreeId)
})
@@ -67,9 +67,17 @@ async function createRuntimeWithHookRows(
? (): AgentStatusIpcPayload[] => [...rows, ...statusWiring.deps.getAgentStatusSnapshot()]
: (): AgentStatusIpcPayload[] => rows
const runtime = new OrcaRuntimeService(null, undefined, {
...(statusWiring ? { onTerminalAgentStatus: statusWiring.deps.onTerminalAgentStatus } : {}),
...(statusWiring
? {
onTerminalAgentStatus: statusWiring.deps.onTerminalAgentStatus,
reconcileAgentStatusForEndedProcess:
statusWiring.deps.reconcileAgentStatusForEndedProcess,
getAgentProviderSessionSnapshot: statusWiring.deps.getAgentProviderSessionSnapshot,
getAgentProviderSessionRowsForPane: statusWiring.deps.getAgentProviderSessionRowsForPane
}
: {}),
getAgentStatusSnapshot: readRows,
getAgentProviderSessionRowsForPane: readRows
...(statusWiring ? {} : { getAgentProviderSessionRowsForPane: readRows })
})
const internals = runtime as unknown as {
resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<unknown>
@@ -327,6 +335,100 @@ describe('headless hook agent-status projection (#11761)', () => {
expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('interactivePrompt')
})
it('evicts the predecessor row at a certified provider generation reset', async () => {
const statusWiring = makeAgentStatusStoreWiring()
const runtime = await createRuntimeWithHookRows([], statusWiring)
runtime.onPtyData(
PTY_ID,
'\x1b]9999;{"state":"working","prompt":"predecessor","agentType":"claude"}\x07',
1
)
expect(statusWiring.statusStore.getStatusSnapshot()).toHaveLength(1)
const internals = runtime as unknown as {
resetTrackedTerminalStateForProviderGeneration: (ptyId: string) => void
}
internals.resetTrackedTerminalStateForProviderGeneration(PTY_ID)
expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([])
statusWiring.statusStore.stop()
})
it('evicts a row joined only through the terminal handle on certified PTY exit', async () => {
const statusWiring = makeAgentStatusStoreWiring()
const runtime = await createRuntimeWithHookRows([], statusWiring)
const terminal = (await runtime.listTerminals()).terminals[0]
if (!terminal) {
throw new Error('expected a live terminal')
}
const priorPaneKey = makePaneKey('prior-tab', UNKNOWN_LEAF_ID)
statusWiring.statusStore.ingestTerminalStatus({
paneKey: priorPaneKey,
tabId: 'prior-tab',
terminalHandle: terminal.handle,
payload: { state: 'working', prompt: 'prior pane', agentType: 'claude' }
})
expect(statusWiring.statusStore.getStatusSnapshot()).toHaveLength(1)
runtime.onPtyExit(PTY_ID, 0)
expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([])
statusWiring.statusStore.stop()
})
it('evicts the central status row when a disconnected PTY record is pruned', async () => {
const statusWiring = makeAgentStatusStoreWiring()
const runtime = await createRuntimeWithHookRows([], statusWiring)
runtime.onPtyData(
PTY_ID,
'\x1b]9999;{"state":"working","prompt":"before prune","agentType":"claude"}\x07',
1
)
expect(statusWiring.statusStore.getStatusSnapshot()).toHaveLength(1)
const internals = runtime as unknown as {
dropDisconnectedPtyRecord: (ptyId: string) => void
}
internals.dropDisconnectedPtyRecord(PTY_ID)
expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([])
statusWiring.statusStore.stop()
})
it('evicts a dismissed handle-joined remnant on certified PTY exit', async () => {
const statusWiring = makeAgentStatusStoreWiring()
const runtime = await createRuntimeWithHookRows([], statusWiring)
const terminal = (await runtime.listTerminals()).terminals[0]
if (!terminal) {
throw new Error('expected a live terminal')
}
const priorPaneKey = makePaneKey('prior-tab', UNKNOWN_LEAF_ID)
statusWiring.statusStore.ingestTerminalStatus({
paneKey: priorPaneKey,
tabId: 'prior-tab',
terminalHandle: terminal.handle,
payload: { state: 'working', prompt: 'dismissed pane', agentType: 'claude' }
})
statusWiring.statusStore.ingestRemote(
{
paneKey: priorPaneKey,
tabId: 'prior-tab',
providerSession: PROVIDER_SESSION,
payload: { state: 'working', prompt: 'dismissed pane', agentType: 'claude' }
},
null
)
statusWiring.statusStore.dropStatusEntry(priorPaneKey)
expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: priorPaneKey, providerSessionOnly: true })
])
runtime.onPtyExit(PTY_ID, 0)
expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([])
statusWiring.statusStore.stop()
})
it('does not carry a hook question across an identity-only owner title', async () => {
const runtime = await createRuntimeWithHookRows([hookRow()])
const internals = runtime as unknown as {
+1 -1
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, {
@@ -94,11 +94,12 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime
getLiveBrowserTabs: (worktreeId) => this.getLiveBrowserTabsByPageId(worktreeId),
getProviderSessionRows: (paneKey) => this.getAgentProviderSessionRowsForPaneFn?.(paneKey),
getProviderSessionSnapshot: () => this.getAgentProviderSessionSnapshotFn?.() ?? [],
getStatusSnapshot: () => this.getAgentStatusSnapshotFn?.() ?? [],
getLeafKey: (tabId, leafId) => this.getLeafKey(tabId, leafId),
findPty: (worktreeId, tab, options) =>
this.findPtyForMobileTerminalTab(worktreeId, tab, options),
getRetainedStatus: (paneKey, pty, tab) =>
this.getFreshRetainedAgentStatusForMobileTab(paneKey, pty, tab),
getRetainedStatus: (paneKey, pty, tab, getRows) =>
this.getFreshRetainedAgentStatusForMobileTab(paneKey, pty, tab, getRows),
getTrackedTitle: (ptyId) => this.getUnpersistedTrackedTitleForPty(ptyId),
issuePtyHandle: (pty) => this.issuePtyHandle(pty),
recordPty: (ptyId, worktreeId, state) => this.recordPtyWorktree(ptyId, worktreeId, state),
@@ -129,13 +130,14 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime
protected getFreshRetainedAgentStatusForMobileTab(
paneKey: string,
pty: RuntimePtyWorktreeRecord | null,
tab: RuntimeMobileSessionTerminalTab
_tab: RuntimeMobileSessionTerminalTab,
getRows: (paneKey: string, terminalHandle: string | null) => AgentStatusIpcPayload[]
): RuntimeAgentRowSnapshot | null {
const handlePty = pty ?? (tab.ptyId ? (this.ptysById.get(tab.ptyId) ?? null) : null)
const terminalHandle = pty ? this.issuePtyHandle(pty) : null
return selectFreshAgentRowForMobileTab({
paneKey,
terminalHandle: handlePty ? this.issuePtyHandle(handlePty) : null,
hookRows: this.getAgentStatusSnapshotFn?.() ?? []
terminalHandle,
hookRows: getRows(paneKey, terminalHandle)
})
}
@@ -119,6 +119,7 @@ export class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends OrcaRunt
protected dropDisconnectedPtyRecord(ptyId: string): void {
// Why: pruning can remove a PTY without the normal exit callback.
this.reconcileAgentStatusForEndedProcessFn?.(this.collectAgentStatusPaneKeysForPty(ptyId))
this.advancePtyLifecycleGeneration(ptyId)
this.pairedRendererSessionOwnedPtyIds.delete(ptyId)
this.ptysById.delete(ptyId)
@@ -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[]>>
@@ -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 () => {
@@ -12,7 +12,7 @@ import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fi
const LEAF_ID = '77777777-7777-4777-8777-777777777777'
const PANE_KEY = `tab-dismiss:${LEAF_ID}`
function wiredRuntime(): {
function wiredRuntime(incarnationId?: string): {
runtime: OrcaRuntimeService
statusWiring: ReturnType<typeof makeAgentStatusStoreWiring>
} {
@@ -39,6 +39,13 @@ function wiredRuntime(): {
}
]
})
if (incarnationId) {
runtime.registerPty('dismiss-pty', TEST_WORKTREE_ID, null, {
tabId: 'tab-dismiss',
leafId: LEAF_ID,
incarnationId
})
}
return { runtime, statusWiring }
}
@@ -98,4 +105,101 @@ describe('worktree ps follows a dismissal out of the agent-status store', () =>
republish.mockRestore()
}
})
it.each([
['leaf binding', undefined, false],
['controller incarnation', 'incarnation-1', true]
] as const)(
'rejoins a row through its %s handle after pane ownership clears',
async (_, incarnationId, clearLeafBinding) => {
const { runtime, statusWiring } = wiredRuntime(incarnationId)
emitWorkingStatus(runtime, 1)
const row = statusWiring.statusStore.getStatusSnapshot()[0]!
const internals = runtime as unknown as {
handleByLeafKey: Map<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('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()
})
})
@@ -47,17 +47,31 @@ describe('selectFreshExplicitAgentStatus', () => {
).toBeNull()
})
it('refuses restored and stale rows', () => {
it('refuses restored, identity-only and stale evidence rows', () => {
const args = { handle: HANDLE, paneKey: PANE_KEY }
expect(
selectFreshExplicitAgentStatus({ ...args, hookRows: [row({ restoredUnconfirmed: true })] })
).toBeNull()
expect(
selectFreshExplicitAgentStatus({ ...args, hookRows: [row({ providerSessionOnly: true })] })
).toBeNull()
expect(
selectFreshExplicitAgentStatus({
...args,
hookRows: [row({ receivedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 })]
})
).toBeNull()
expect(
selectFreshExplicitAgentStatus({
...args,
hookRows: [
row({
receivedAt: Date.now(),
evidenceObservedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1
})
]
})
).toBeNull()
})
it('prefers a permission row over a working row stamped at the same instant', () => {
@@ -120,5 +134,16 @@ describe('selectFreshAgentRowForMobileTab', () => {
hookRows: [row({ receivedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 })]
})
).toBeNull()
expect(
selectFreshAgentRowForMobileTab({
...args,
hookRows: [
row({
receivedAt: Date.now(),
evidenceObservedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1
})
]
})
).toBeNull()
})
})
@@ -17,6 +17,7 @@ export type RuntimeAgentRowSnapshot = {
payload: ParsedAgentStatusPayload
stateStartedAt: number
updatedAt: number
evidenceObservedAt?: number
}
function isLiveObservation(row: AgentStatusIpcPayload): boolean {
@@ -42,13 +43,15 @@ export function selectFreshExplicitAgentStatus(args: {
const consider = (
state: AgentStatusEntry['state'] | undefined,
updatedAt: number | null | undefined,
evidenceObservedAt: number | null | undefined,
restoredUnconfirmed = false,
providerSessionOnly = false,
stateStartedAt?: number | null
): void => {
if (!state || restoredUnconfirmed || typeof updatedAt !== 'number') {
if (!state || restoredUnconfirmed || providerSessionOnly || typeof updatedAt !== 'number') {
return
}
if (now - updatedAt > AGENT_STATUS_STALE_AFTER_MS) {
if (now - (evidenceObservedAt ?? updatedAt) > AGENT_STATUS_STALE_AFTER_MS) {
return
}
const status = mapExplicitAgentStateToRuntimeTerminalStatus(state)
@@ -62,7 +65,14 @@ export function selectFreshExplicitAgentStatus(args: {
if (row.terminalHandle !== args.handle && (!args.paneKey || row.paneKey !== args.paneKey)) {
continue
}
consider(row.state, row.receivedAt, row.restoredUnconfirmed, row.stateStartedAt)
consider(
row.state,
row.receivedAt,
row.evidenceObservedAt,
row.restoredUnconfirmed,
row.providerSessionOnly,
row.stateStartedAt
)
}
return bestStatus
? {
@@ -81,8 +91,12 @@ export function selectFreshAgentRowForMobileTab(args: {
hookRows: readonly AgentStatusIpcPayload[]
}): RuntimeAgentRowSnapshot | null {
let match: AgentStatusIpcPayload | null = null
const now = Date.now()
for (const row of args.hookRows) {
if (!isLiveObservation(row)) {
if (
!isLiveObservation(row) ||
now - (row.evidenceObservedAt ?? row.receivedAt) > AGENT_STATUS_STALE_AFTER_MS
) {
continue
}
if (row.paneKey === args.paneKey) {
@@ -100,7 +114,7 @@ export function selectFreshAgentRowForMobileTab(args: {
match = row
}
}
if (!match || Date.now() - match.receivedAt > AGENT_STATUS_STALE_AFTER_MS) {
if (!match) {
return null
}
return {
@@ -110,6 +124,9 @@ export function selectFreshAgentRowForMobileTab(args: {
...(match.tabId ? { tabId: match.tabId } : {}),
payload: pickParsedAgentStatusPayload(match),
stateStartedAt: match.stateStartedAt ?? match.receivedAt,
updatedAt: match.receivedAt
updatedAt: match.receivedAt,
...(match.evidenceObservedAt !== undefined
? { evidenceObservedAt: match.evidenceObservedAt }
: {})
}
}
@@ -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) {
@@ -101,6 +101,9 @@ export function buildRuntimeMobileAgentStatus(
...liveRow.payload,
paneKey,
updatedAt: liveRow.updatedAt,
...(liveRow.evidenceObservedAt !== undefined
? { evidenceObservedAt: liveRow.evidenceObservedAt }
: {}),
stateStartedAt: liveRow.stateStartedAt,
stateHistory: [],
...(terminalHandle ? { terminalHandle } : {}),
@@ -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
@@ -104,7 +104,7 @@ export type RuntimeTerminalAgentStatusEvent = {
export type HookLiveAgentRow = Pick<
RuntimeAgentRowSnapshot,
'payload' | 'updatedAt' | 'stateStartedAt' | 'worktreeId'
'payload' | 'updatedAt' | 'evidenceObservedAt' | 'stateStartedAt' | 'worktreeId'
>
export type RuntimePtyDataAdmission = Readonly<{
@@ -65,7 +65,7 @@ describe('worktree agent source admission', () => {
expect(collectRuntimeWorktreeAgentSources(base).size).toBe(0)
})
it('carries the row own working mode and drops restored rows', () => {
it('carries the row own working mode and drops non-live rows', () => {
const monitoring = collectRuntimeWorktreeAgentSources({
...connected,
hookSnapshots: [{ ...hookRow, workingMode: 'monitoring' as const }]
@@ -77,5 +77,11 @@ describe('worktree agent source admission', () => {
hookSnapshots: [{ ...hookRow, restoredUnconfirmed: true as const }]
})
expect(restored.size).toBe(0)
const providerSessionOnly = collectRuntimeWorktreeAgentSources({
...connected,
hookSnapshots: [{ ...hookRow, providerSessionOnly: true }]
})
expect(providerSessionOnly.size).toBe(0)
})
})
@@ -189,7 +189,7 @@ export function applyRuntimeWorktreePsSessionActivity(args: {
ptysById: ReadonlyMap<string, RuntimePtyWorktreeRecord>
tabs: ReadonlyMap<string, RuntimeSyncedTab>
/** Non-minting: a listing must not issue handles, only recognise the ones already bound. */
getTerminalHandleForPty: (ptyId: string) => string | null
getTerminalHandlesForPty: (ptyId: string) => readonly string[]
getSummary: SummaryLookup
}): {
mirroredWorktreeIdByTabId: Map<string, string>
@@ -262,8 +262,7 @@ export function applyRuntimeWorktreePsSessionActivity(args: {
if (pty.paneKey) {
connectedPtyEvidence.paneKeys.add(pty.paneKey)
}
const terminalHandle = args.getTerminalHandleForPty(pty.ptyId)
if (terminalHandle) {
for (const terminalHandle of args.getTerminalHandlesForPty(pty.ptyId)) {
connectedPtyEvidence.ptyIdByTerminalHandle.set(terminalHandle, pty.ptyId)
}
}
@@ -27,7 +27,7 @@ export function collectRuntimeWorktreePtyAgentSources(args: {
RuntimeWorktreeAgentSource & { payload: ParsedAgentStatusPayload }
>()
for (const entry of args.hookSnapshots) {
if (entry.restoredUnconfirmed === true) {
if (entry.restoredUnconfirmed === true || entry.providerSessionOnly === true) {
continue
}
const hookPayload = pickParsedAgentStatusPayload(entry)
@@ -49,10 +49,8 @@ export function collectRuntimeWorktreePtyAgentSources(args: {
toolInput: entry.toolInput ?? null,
interrupted: entry.interrupted ?? false,
stateStartedAt: entry.stateStartedAt,
// A structured row's clock is its journal, so a restart's republish does not read as new.
updatedAt: entry.structuredHost
? (entry.evidenceObservedAt ?? entry.receivedAt)
: entry.receivedAt,
// A replay advances delivery order, not the age of the evidence shown by worktree.ps.
updatedAt: entry.evidenceObservedAt ?? entry.receivedAt,
...(entry.structuredHost ? { structuredHost: entry.structuredHost } : {})
})
}
@@ -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) {
@@ -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(