From 571e4e1e2408f1bfb82f482ca3466944aac4b0e1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:37:18 -0700 Subject: [PATCH 01/19] fix(agent-status): retire a pane's rows only after its agent's exit is verified, and tell every reader A command end (OSC 133;D) in a pane holding launch authority deleted the pane's host status rows without a pane clear, so the desktop window, dashboard popout, tab-title spinner and session stats kept a row worktree ps and mobile had dropped; and it did so on a mark a full-screen agent's nested shell can leak while the agent still runs. Launch authority still ends at the mark (the token lives on in the shell's env): the runtime drops its token/receipt/launch agent and the hook server revokes commitments, forgets token observations and strips the token from kept rows. Ingest derives whether an event's token is still honoured from the runtime's live launch token for the pane or a surviving commitment. Rows, the pane fence and the reader clear wait for a verified exit (foreground shell, else the hook's own pid); unverifiable keeps the rows. retirePaneAuthority now emits the pane clear like every other host removal, and keeps the resume remnant on a command end. The renderer no longer ignores a host clear for a Done row. --- docs/reference/agent-status-store.md | 9 + ...erver-row-removal-notifies-readers.test.ts | 223 +++++++++ .../server/server-authority-evidence.ts | 37 ++ .../server/server-authority-fences.ts | 32 +- src/main/agent-hooks/server/server-cleanup.ts | 43 ++ .../server/server-ingest-normalization.ts | 5 +- .../server/server-ingest-remote.ts | 5 +- .../agent-hooks/server/server-lifecycle.ts | 2 +- .../runtime/command-end-agent-exit-verdict.ts | 44 ++ ...-authority-retirement-reader-clear.test.ts | 454 ++++++++++++++++++ ...-runtime-emit-daemon-pty-transient-fact.ts | 2 +- ...me-get-orchestration-dispatch-authority.ts | 56 ++- ...e-get-unpersisted-tracked-title-for-pty.ts | 2 +- src/main/runtime/orca-runtime-on-pty-exit.ts | 2 +- .../orca-runtime-preserved-branch-cleanup.ts | 7 +- src/main/runtime/orca-runtime-state-fields.ts | 5 +- .../terminal-creation-and-readiness.spec.ts | 5 +- .../startup/main-process-runtime-service.ts | 8 +- .../ipc-events/agent-status-listeners.ts | 8 +- ...cEvents-agent-status-pane-teardown.test.ts | 6 +- ...Events-agent-status-queue-ordering.test.ts | 8 +- 21 files changed, 935 insertions(+), 28 deletions(-) create mode 100644 src/main/agent-hooks/server-row-removal-notifies-readers.test.ts create mode 100644 src/main/runtime/command-end-agent-exit-verdict.ts create mode 100644 src/main/runtime/launch-authority-retirement-reader-clear.test.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index b962df41edc5..c5ef1609dd25 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -83,6 +83,15 @@ with them would be the wrong direction. So the design is not "add a store". It is: **route the two producers that bypass the hook server through it, then delete the copies.** +## Removing a row + +Every host row removal notifies every reader in the same step: a teardown or an ended process +emits the pane clear, a dismissal emits the status drop. Launch-authority retirement is no +exception. A command end (OSC 133;D) ends a pane's launch authority at once, because every later +process in that shell inherits the token, but it removes the pane's rows only after the agent's +exit is verified (a shell in the foreground, or the hook's own process gone). `unverifiable` +keeps the rows. + ## PR 1a: structured sessions publish into the store No renderer behavior changes. The sidebar keeps receiving the same IPC events diff --git a/src/main/agent-hooks/server-row-removal-notifies-readers.test.ts b/src/main/agent-hooks/server-row-removal-notifies-readers.test.ts new file mode 100644 index 000000000000..d4b48f9ef566 --- /dev/null +++ b/src/main/agent-hooks/server-row-removal-notifies-readers.test.ts @@ -0,0 +1,223 @@ +import { createHash } from 'node:crypto' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentStatusClearIpcPayload } from '../../shared/agent-status-types' +import { makePaneKey } from '../../shared/stable-pane-id' +import { AgentHookServer } from './server' + +const LEAF = '11111111-1111-4111-8111-111111111111' +const PANE = makePaneKey('tab-1', LEAF) +const CONNECTION = 'conn-a' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-row-removal-')) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +async function startServer(): Promise { + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath: dir }) + return server +} + +function claudeEvent(hookEventName: string, prompt = 'review the PR') { + return { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + source: 'claude', + hookEventName, + launchToken: 'launch-token', + providerSession: { key: 'session_id', id: 'resume-me' }, + payload: { state: 'working', prompt, agentType: 'claude' } + } +} + +function seedClaudeRow(server: AgentHookServer): void { + server.ingestRemote(claudeEvent('UserPromptSubmit'), CONNECTION) +} + +function liveRow(server: AgentHookServer) { + return server.getStatusSnapshotForPane(PANE).find((row) => row.providerSessionOnly !== true) +} + +/** Every channel a main-process reader can hear a removal on. */ +function recordReaderNotifications(server: AgentHookServer): { + windowClears: AgentStatusClearIpcPayload[] + subscriberClears: AgentStatusClearIpcPayload[] + drops: string[] +} { + const seen: { + windowClears: AgentStatusClearIpcPayload[] + subscriberClears: AgentStatusClearIpcPayload[] + drops: string[] + } = { windowClears: [], subscriberClears: [], drops: [] } + server.setPaneStatusClearListener((clear) => seen.windowClears.push(clear)) + server.subscribePaneStatusClear((clear) => seen.subscriberClears.push(clear)) + server.subscribeStatusDrop((paneKey) => seen.drops.push(paneKey)) + return seen +} + +function clearNamesPane(clear: AgentStatusClearIpcPayload): boolean { + return 'paneKey' in clear + ? clear.paneKey === PANE + : clear.transient === true && clear.connectionId === CONNECTION +} + +// Why: a reader that keeps a row the host deleted is the bug class. Every host operation that +// removes a live row must tell readers in the same step, whoever started it. +const REMOVALS: Record void> = { + dropStatusEntry: (server) => server.dropStatusEntry(PANE), + dropPersistedStatusEntry: (server) => { + const row = liveRow(server)! + server.dropPersistedStatusEntry({ + paneKey: PANE, + stateStartedAt: row.stateStartedAt, + receivedAt: row.receivedAt + }) + }, + clearPaneState: (server) => server.clearPaneState(PANE), + reconcileEndedProcessForPaneKeys: (server) => { + server.reconcileEndedProcessForPaneKeys([PANE], { preserveResumeIdentity: true }) + }, + dropStatusEntriesByTabPrefix: (server) => server.dropStatusEntriesByTabPrefix('tab-1'), + clearStatusEntriesForConnection: (server) => server.clearStatusEntriesForConnection(CONNECTION), + 'retirePaneAuthority (renderer pane close)': (server) => server.retirePaneAuthority(PANE), + 'retirePaneAuthority (runtime command end)': (server) => + server.retirePaneAuthority(PANE, undefined, { preserveResumeIdentity: true }) +} + +describe('every host row removal notifies readers', () => { + for (const [name, remove] of Object.entries(REMOVALS)) { + it(name, async () => { + const server = await startServer() + try { + seedClaudeRow(server) + expect(liveRow(server)?.state).toBe('working') + const seen = recordReaderNotifications(server) + + remove(server) + + expect(liveRow(server)).toBeUndefined() + const subscribersHeard = + seen.subscriberClears.some(clearNamesPane) || seen.drops.includes(PANE) + expect(subscribersHeard).toBe(true) + } finally { + server.stop() + } + }) + } +}) + +describe('retirePaneAuthority', () => { + it('tells the desktop window and every pane-clear subscriber about each row it removes', async () => { + const server = await startServer() + try { + seedClaudeRow(server) + const seen = recordReaderNotifications(server) + + server.retirePaneAuthority(PANE, undefined, { preserveResumeIdentity: true }) + + expect(seen.windowClears).toEqual([{ paneKey: PANE }]) + expect(seen.subscriberClears).toEqual([{ paneKey: PANE }]) + } finally { + server.stop() + } + }) + + it('stays silent for a pane that held no row', async () => { + const server = await startServer() + try { + const seen = recordReaderNotifications(server) + + server.retirePaneAuthority(PANE, undefined, { preserveResumeIdentity: true }) + + expect(seen.windowClears).toEqual([]) + expect(seen.subscriberClears).toEqual([]) + } finally { + server.stop() + } + }) + + it('keeps the resume identity on a command end, as the ended-process clear does', async () => { + const server = await startServer() + try { + seedClaudeRow(server) + + server.retirePaneAuthority(PANE, undefined, { preserveResumeIdentity: true }) + + const rows = server.getStatusSnapshotForPane(PANE) + expect(rows).toHaveLength(1) + expect(rows[0]?.providerSessionOnly).toBe(true) + expect(rows[0]?.providerSession?.id).toBe('resume-me') + expect(rows[0]?.launchToken).toBeUndefined() + + // The fence still holds: a late event from the exited process cannot repaint the pane. + server.ingestRemote(claudeEvent('PostToolUse'), CONNECTION) + expect(liveRow(server)).toBeUndefined() + // A new turn proves a new process owns the pane and replaces the remnant. + server.ingestRemote(claudeEvent('UserPromptSubmit', 'next task'), CONNECTION) + expect(liveRow(server)?.prompt).toBe('next task') + + // The remnant must not bring back retired launch authority after a restart. + server.retirePaneAuthority(PANE, undefined, { preserveResumeIdentity: true }) + server.flushStatusPersistSync() + server.stop() + const restarted = await startServer() + expect(restarted.getStatusSnapshotForPane(PANE)[0]?.launchToken).toBeUndefined() + expect(restarted.getHydratedAuthorityCommitments()).toHaveLength(0) + restarted.stop() + } finally { + server.stop() + } + }) + + it('on a command end ends only launch authority: the row, its fence and its readers stay', async () => { + const server = await startServer() + try { + seedClaudeRow(server) + const seen = recordReaderNotifications(server) + const attest = () => + server.attestCompatibilityAuthority({ + paneKey: PANE, + launchTokenHash: createHash('sha256').update('launch-token').digest('hex'), + connectionId: CONNECTION, + terminalProvenance: 'current_runtime' + }) + expect(attest()).not.toBeNull() + + server.retirePaneAuthority(PANE, undefined, { authorityOnly: true }) + + expect(liveRow(server)?.state).toBe('working') + expect(liveRow(server)?.launchToken).toBeUndefined() + expect(attest()).toBeNull() + expect(seen.windowClears).toEqual([]) + expect(seen.subscriberClears).toEqual([]) + // Not fenced: the live agent's next event still lands. + server.ingestRemote(claudeEvent('PostToolUse', 'still going'), CONNECTION) + expect(liveRow(server)?.prompt).toBe('still going') + } finally { + server.stop() + } + }) + + it('takes the resume identity too when no shell is left to resume into', async () => { + const server = await startServer() + try { + seedClaudeRow(server) + + server.retirePaneAuthority(PANE) + + expect(server.getStatusSnapshotForPane(PANE)).toEqual([]) + } finally { + server.stop() + } + }) +}) diff --git a/src/main/agent-hooks/server/server-authority-evidence.ts b/src/main/agent-hooks/server/server-authority-evidence.ts index 8cda3426629f..c456c4bba56a 100644 --- a/src/main/agent-hooks/server/server-authority-evidence.ts +++ b/src/main/agent-hooks/server/server-authority-evidence.ts @@ -8,7 +8,44 @@ import type { } from './server-types' import { AgentHookServerStatusRetries } from './server-status-retries' +/** What the runtime holds for a pane's PTY: the hash of the launch token it still honours, or null + * once that authority ended. Null overall when the runtime has no PTY for the pane. */ +export type PaneLaunchAuthorityReader = ( + paneKey: string +) => { launchTokenHash: string | null } | null + export abstract class AgentHookServerAuthorityEvidence extends AgentHookServerStatusRetries { + private paneLaunchAuthorityReader: PaneLaunchAuthorityReader | null = null + + setPaneLaunchAuthorityReader(reader: PaneLaunchAuthorityReader | null): void { + this.paneLaunchAuthorityReader = reader + } + + protected withLiveLaunchToken(event: T): T { + const launchToken = this.liveLaunchToken(event.paneKey, event.launchToken) + return launchToken === event.launchToken ? event : { ...event, launchToken } + } + + /** The event's launch token, or undefined once its authority ended. Every process a shell starts + * inherits the token, so after a command end it proves nothing; a token is honoured while the + * runtime still holds it for the pane or a commitment of this host still vouches for it. + * Derived from that state, never stored: a pane the runtime does not know keeps today's rule. */ + protected liveLaunchToken(paneKey: string, launchToken: string | undefined): string | undefined { + const token = launchToken?.trim() + const runtime = token ? this.paneLaunchAuthorityReader?.(paneKey) : null + if (!token || !runtime) { + return launchToken + } + const launchTokenHash = createHash('sha256').update(token).digest('hex') + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + return runtime.launchTokenHash === launchTokenHash || + this.persistedAuthorityCommitmentsByPaneKey.get(ownerPaneKey)?.launchTokenHash === + launchTokenHash || + this.hydratedLaunchTokenHashByPaneKey.get(ownerPaneKey) === launchTokenHash + ? launchToken + : undefined + } + attestCompatibilityAuthority(candidate: { paneKey: string launchTokenHash: string diff --git a/src/main/agent-hooks/server/server-authority-fences.ts b/src/main/agent-hooks/server/server-authority-fences.ts index 3bd289d9c551..e438fc6719d8 100644 --- a/src/main/agent-hooks/server/server-authority-fences.ts +++ b/src/main/agent-hooks/server/server-authority-fences.ts @@ -8,8 +8,29 @@ import type { } from './server-types' export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuthorityAliases { + /** Re-admits a dropped row's resume identity; null when it carried no resumable session. */ + protected abstract admitResumeIdentityRemnant( + row: EnrichedAgentHookEventPayload + ): EnrichedAgentHookEventPayload | null + + /** Ends only the launch authority a pane's rows vouch for; rows and status fences stay. */ + protected abstract revokePaneLaunchAuthority(paneKey: string): void + // Why: retirement fences a pane and every alias of it, then deletes those aliases. - retirePaneAuthority(paneKey: string, retirementId?: string): void { + retirePaneAuthority( + paneKey: string, + retirementId?: string, + options?: { + /** A command end: the agent may still be alive, so only its launch authority ends now. */ + authorityOnly?: boolean + /** The pane's shell outlived its agent; keep the remnant as the ended-process clear does. */ + preserveResumeIdentity?: boolean + } + ): void { + if (options?.authorityOnly) { + this.revokePaneLaunchAuthority(paneKey) + return + } const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) const previousFence = this.retiredPaneFencesByKey.get(ownerPaneKey) const paneKeys = new Set([paneKey, ownerPaneKey]) @@ -64,12 +85,19 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth this.notifyPaneKeyAliasPersistenceListener() } for (const row of retiredRows) { - this.commitStatusRowMutation(row, undefined) + const retained = options?.preserveResumeIdentity ? this.admitResumeIdentityRemnant(row) : null + this.commitStatusRowMutation(row, retained) } if (hadStatus || authorityChanged) { this.scheduleStatusPersist() this.notifyStatusChangeListeners() } + // Why: a removal every reader must hear, like any other pane clear; readers never saw a remnant. + for (const row of retiredRows) { + if (row.providerSessionOnly !== true) { + this.emitPaneStatusCleared({ paneKey: row.paneKey }) + } + } } // Why: retirement fences a pane and every alias of it, then deletes those aliases. diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index 71be07e61703..883192c6910f 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -27,6 +27,49 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen return { ...resumeIdentity, providerSessionOnly: true, retainedForLiveness: true } } + protected admitResumeIdentityRemnant( + row: EnrichedAgentHookEventPayload + ): EnrichedAgentHookEventPayload | null { + const retained = this.toRetainedProviderSessionRow(row) + if (retained) { + admitLegacyAgentStatus( + this.state, + 'main-status-cleanup', + retained, + AGENT_STATUS_2A_CURRENT_PRODUCER_MODE + ) + } + return retained + } + + /** The launch token lives on in the shell's environment after its command ends, so its authority + * must end now even while the agent may still run: forget what vouched for it, and strip it from + * kept rows so a restart cannot rehydrate it. Ingest then derives that the token is dead. */ + protected revokePaneLaunchAuthority(paneKey: string): void { + const paneKeys = new Set([paneKey, this.resolvePaneKeyAlias(paneKey)]) + let changed = this.revokeHydratedAuthorityForPaneKeys(paneKeys) + for (const key of paneKeys) { + changed = this.currentAuthorityObservations.delete(key) || changed + const row = this.state.lastStatusByPaneKey.get(key) as + | EnrichedAgentHookEventPayload + | undefined + if (row?.launchToken) { + const { launchToken: _launchToken, ...withoutToken } = row + admitLegacyAgentStatus( + this.state, + 'main-status-cleanup', + withoutToken, + AGENT_STATUS_2A_CURRENT_PRODUCER_MODE + ) + changed = true + } + } + if (changed) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + } + /** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */ dropStatusEntry( paneKey: string, diff --git a/src/main/agent-hooks/server/server-ingest-normalization.ts b/src/main/agent-hooks/server/server-ingest-normalization.ts index 5e71e6ef033f..82cdf7ec94a2 100644 --- a/src/main/agent-hooks/server/server-ingest-normalization.ts +++ b/src/main/agent-hooks/server/server-ingest-normalization.ts @@ -78,7 +78,10 @@ export abstract class AgentHookServerIngestNormalization extends AgentHookServer if (statusDisposition === 'suppress') { return } - const event = statusDisposition === 'restart' ? { ...replay, launchToken: undefined } : replay + const event = + statusDisposition === 'restart' + ? { ...replay, launchToken: undefined } + : this.withLiveLaunchToken(replay) if (statusDisposition === 'restart') { this.observations.rebind(event.paneKey) } diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index 28eac6e56dc1..56c7340ec7a0 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -264,7 +264,10 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS ...(restartedAuthority?.authorityRestartId ? { authorityRestartId: restartedAuthority.authorityRestartId } : {}), - launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken, + launchToken: + statusDisposition === 'restart' + ? undefined + : this.liveLaunchToken(paneKey, envelope.launchToken), tabId, worktreeId, connectionId: trimmedConnectionId, diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index e6e1bf80061d..a8a8c9060fb6 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -130,7 +130,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv } : {}) } - : normalized.event + : this.withLiveLaunchToken(normalized.event) if (statusDisposition === 'restart') { // Why: a retired pane accepting a new turn is a different agent session behind the // same key — later observations must not be ordered against the retired one. diff --git a/src/main/runtime/command-end-agent-exit-verdict.ts b/src/main/runtime/command-end-agent-exit-verdict.ts new file mode 100644 index 000000000000..9acd5a1928ad --- /dev/null +++ b/src/main/runtime/command-end-agent-exit-verdict.ts @@ -0,0 +1,44 @@ +import type { AgentProcessVerdict } from '../../shared/agent-process-presence' +import { recognizeAgentProcess } from '../../shared/agent-process-recognition' +import { isShellProcess } from '../../shared/shell-process-detection' + +type CommandEndAgentExitDeps = { + /** A fresh foreground read on the PTY's execution host; throws or answers null when it cannot. */ + readForegroundProcess(): Promise + /** The hook presence check: a Claude row's own pid and start time; null when no hook identified it. */ + checkHookAgentPresence(paneKey: string): Promise +} + +/** + * Whether the agent behind a command end (OSC 133;D) really exited. A full-screen agent's nested + * shells leak their own 133;D onto the pane, so the mark alone never proves it. The foreground + * answers first, as the renderer's command-finished confirmation does: an agent there is `live`, + * a shell there is `exited`. Anything else (no answer, a wrapper, another command) falls back to + * the hook presence check, and without proof either way the verdict is `unverifiable`. + */ +export async function verifyCommandEndAgentExit( + paneKeys: Iterable, + deps: CommandEndAgentExitDeps +): Promise { + let foreground: string | null = null + try { + foreground = await deps.readForegroundProcess() + } catch { + // Loss of contact with the execution host is never evidence the agent exited. + } + if (foreground && recognizeAgentProcess(foreground)) { + return 'live' + } + if (foreground && isShellProcess(foreground)) { + return 'exited' + } + const verdicts = await Promise.all( + Array.from(paneKeys, (paneKey) => deps.checkHookAgentPresence(paneKey).catch(() => null)) + ) + if (verdicts.includes('live')) { + return 'live' + } + return verdicts.includes('exited') && !verdicts.includes('unverifiable') + ? 'exited' + : 'unverifiable' +} diff --git a/src/main/runtime/launch-authority-retirement-reader-clear.test.ts b/src/main/runtime/launch-authority-retirement-reader-clear.test.ts new file mode 100644 index 000000000000..a4c1df9c29f3 --- /dev/null +++ b/src/main/runtime/launch-authority-retirement-reader-clear.test.ts @@ -0,0 +1,454 @@ +import { createHash } from 'node:crypto' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusClearIpcPayload } from '../../shared/agent-status-types' +import { AgentHookServer } from '../agent-hooks/server' +import { installHookStatusSessionTabsRepublish } from '../agent-hooks/hook-status-session-tabs-republish' +import { OrcaRuntimeService } from './orca-runtime' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' + +// A command end (OSC 133;D) in a pane holding launch authority retires that authority. The pane's +// rows go only once the agent's exit is verified, and then every reader hears it in the same step: +// before, the host deleted them on the bare mark and told only `worktree ps` and mobile, so the +// desktop window, dashboard popout, tab-title spinner and stats recorder kept a row the host had +// deleted, and a nested shell's leaked 133;D erased a live agent's row. + +const WORKTREE_PATH = '/tmp/worktree-a' +const TEST_WORKTREE_ID = `repo-1::${WORKTREE_PATH}` + +const probe = vi.hoisted(() => vi.fn()) +vi.mock('../../shared/agent-process-presence-probe', () => ({ probeAgentProcessPresence: probe })) + +vi.mock('../git/worktree', () => { + const worktrees = [ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/retirement-clear', + isBare: false, + isMainWorktree: false + } + ] + return { + listWorktrees: vi.fn().mockResolvedValue(worktrees), + listWorktreesStrict: vi.fn().mockResolvedValue(worktrees) + } +}) + +const servers: AgentHookServer[] = [] +const teardowns: (() => void)[] = [] + +afterEach(() => { + probe.mockReset() + for (const teardown of teardowns.splice(0)) { + teardown() + } + for (const server of servers.splice(0)) { + server.stop() + } + vi.restoreAllMocks() +}) + +type Readers = { + windowClears: AgentStatusClearIpcPayload[] + subscriberClears: AgentStatusClearIpcPayload[] + republishedWorktrees: string[] +} + +/** The host wiring `main-process-runtime-service.ts` performs, plus every pane-clear reader. */ +async function wireHost( + foreground: () => Promise = async () => 'zsh', + userDataPath?: string +): Promise<{ + server: AgentHookServer + runtime: OrcaRuntimeService + readers: Readers + spawn: ReturnType +}> { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production', ...(userDataPath ? { userDataPath } : {}) }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared fixture store implements only the members these runtime paths read. + const runtime = new OrcaRuntimeService(makeStore() as never, undefined, { + getAgentStatusSnapshot: () => + server.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + getAgentProviderSessionSnapshot: () => server.getStatusSnapshot(), + getAgentProviderSessionRowsForPane: (paneKey) => server.getStatusSnapshotForPane(paneKey), + attestAgentHookCompatibilityAuthority: (candidate) => + server.attestCompatibilityAuthority(candidate), + retireAgentHookCompatibilityAuthority: (paneKey, options) => + server.retirePaneAuthority(paneKey, undefined, options), + checkHookAgentPresence: (paneKey) => server.checkAgentPresence(paneKey), + reconcileAgentStatusForEndedProcess: (paneKeys) => + server.reconcileEndedProcessForPaneKeys(paneKeys) + }) + server.setPaneLaunchAuthorityReader((paneKey) => runtime.readPaneLaunchAuthority(paneKey)) + const readers: Readers = { windowClears: [], subscriberClears: [], republishedWorktrees: [] } + server.setPaneStatusClearListener((clear) => readers.windowClears.push(clear)) + teardowns.push(server.subscribePaneStatusClear((clear) => readers.subscriberClears.push(clear))) + vi.spyOn(runtime, 'touchMobileSessionTabsForWorktree').mockImplementation((worktreeId) => { + readers.republishedWorktrees.push(worktreeId) + }) + teardowns.push(installHookStatusSessionTabsRepublish(server, () => runtime)) + const spawn = vi.fn() + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: foreground, + confirmForegroundProcess: foreground + }) + return { server, runtime, readers, spawn } +} + +async function postClaudeHook( + server: AgentHookServer, + pane: { paneKey: string; launchToken?: string }, + payload: Record +): Promise { + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ + paneKey: pane.paneKey, + ...(pane.launchToken ? { launchToken: pane.launchToken } : {}), + tabId: pane.paneKey.split(':')[0], + worktreeId: TEST_WORKTREE_ID, + env: 'production', + payload + }) + }) + expect(response.status).toBe(204) +} + +async function claudeIsWorking( + server: AgentHookServer, + pane: { paneKey: string; launchToken?: string } +): Promise { + await postClaudeHook(server, pane, { + hook_event_name: 'UserPromptSubmit', + session_id: 'claude-session', + prompt: 'review the PR' + }) + expect(liveRow(server, pane.paneKey)?.state).toBe('working') +} + +async function claudeIsWorkingWithProcess( + server: AgentHookServer, + pane: { paneKey: string; launchToken?: string } +): Promise { + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ + paneKey: pane.paneKey, + ...(pane.launchToken ? { launchToken: pane.launchToken } : {}), + tabId: pane.paneKey.split(':')[0], + worktreeId: TEST_WORKTREE_ID, + env: 'production', + agentProcess: JSON.stringify({ pid: 4001, platform: process.platform, startTime: 'birth' }), + payload: { + hook_event_name: 'UserPromptSubmit', + session_id: 'claude-session', + prompt: 'review the PR' + } + }) + }) + expect(response.status).toBe(204) + expect(server.hasVerifiableAgentProcess(pane.paneKey)).toBe(true) +} + +function liveRow(server: AgentHookServer, paneKey: string) { + return server + .getStatusSnapshotForPane(paneKey) + .find((row) => row.providerSessionOnly !== true && row.paneKey === paneKey) +} + +/** An agent Orca launched into a fresh pane: it holds a launch token and a launch agent. */ +async function launchClaudePane( + host: { runtime: OrcaRuntimeService; spawn: ReturnType }, + ptyId: string +) { + const { runtime, spawn } = host + spawn.mockResolvedValueOnce({ id: ptyId, incarnationId: `${ptyId}-incarnation` }) + await runtime.createTerminal(`path:${WORKTREE_PATH}`, { + command: 'claude', + launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: {} }, + launchAgent: 'claude' + }) + const env: Record = spawn.mock.lastCall?.[0]?.env ?? {} + expect(env.ORCA_PANE_KEY).toBeTruthy() + expect(env.ORCA_AGENT_LAUNCH_TOKEN).toBeTruthy() + return { ptyId, paneKey: env.ORCA_PANE_KEY, launchToken: env.ORCA_AGENT_LAUNCH_TOKEN } +} + +const RESTORED_TAB = '11111111-1111-4111-8111-111111111111' +const RESTORED_PANE = `${RESTORED_TAB}:22222222-2222-4222-8222-222222222222` + +/** A restored daemon pane the user typed `claude` into. It holds no launch token; its only + * authority is the receipt a listing's controller-inventory refresh mints for an exactly + * restored surface (`rememberRestoredOrchestrationAuthority`). */ +function restoredPaneWithListingReceipt(runtime: OrcaRuntimeService, ptyId: string) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these protected members exist on the runtime; the listing path that calls them needs a live controller inventory. + const internals = runtime as unknown as { + recordPtyWorktree: ( + ptyId: string, + worktreeId: string, + state: Record + ) => unknown + rememberRestoredOrchestrationAuthority: ( + pty: unknown, + terminalHandle: string, + incarnationId: string + ) => void + } + const pty = internals.recordPtyWorktree(ptyId, TEST_WORKTREE_ID, { + connected: true, + tabId: RESTORED_TAB, + paneKey: RESTORED_PANE, + incarnationId: 'restored-incarnation' + }) + internals.rememberRestoredOrchestrationAuthority(pty, `term-${ptyId}`, 'restored-incarnation') + return { ptyId, paneKey: RESTORED_PANE } +} + +type CommandEndPath = 'shell bytes' | 'daemon fact' + +/** A command end, then the verification it starts. */ +async function endCommand( + runtime: OrcaRuntimeService, + ptyId: string, + path: CommandEndPath +): Promise { + if (path === 'shell bytes') { + runtime.onPtyData(ptyId, '\x1b]133;D;0\x07', Date.now()) + } else { + runtime.emitDaemonPtyTransientFact(ptyId, { kind: 'command-finished', exitCode: 0 }) + } + for (let tick = 0; tick < 10; tick += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +function expectNoReaderLostTheRow( + server: AgentHookServer, + readers: Readers, + paneKey: string, + state: string +): void { + expect(liveRow(server, paneKey)?.state).toBe(state) + expect(readers.windowClears).toEqual([]) + expect(readers.subscriberClears).toEqual([]) +} + +function expectEveryReaderSawTheClear( + server: AgentHookServer, + readers: Readers, + paneKey: string +): void { + expect(liveRow(server, paneKey)).toBeUndefined() + // Desktop window and dashboard popout share this listener. + expect(readers.windowClears).toContainEqual({ paneKey }) + // Tab-title spinner and session-stats recorder subscribe here. + expect(readers.subscriberClears).toContainEqual({ paneKey }) + // Mobile `session.tabs` republish. + expect(readers.republishedWorktrees).toContain(TEST_WORKTREE_ID) +} + +describe('a command end whose agent exit is verified clears the row for every reader', () => { + for (const path of ['shell bytes', 'daemon fact'] as const) { + it(`Orca-launched Claude pane, no listing (${path})`, async () => { + const host = await wireHost() + const { server, runtime, readers } = host + const pane = await launchClaudePane(host, `pty-launched-${path.replace(' ', '-')}`) + await claudeIsWorking(server, pane) + readers.republishedWorktrees.length = 0 + + await endCommand(runtime, pane.ptyId, path) + + expectEveryReaderSawTheClear(server, readers, pane.paneKey) + // The shell outlived the command, so the session stays resumable in place. + const remnant = server.getStatusSnapshotForPane(pane.paneKey) + expect(remnant).toHaveLength(1) + expect(remnant[0]?.providerSessionOnly).toBe(true) + expect(remnant[0]?.launchToken).toBeUndefined() + // The fence holds: a late event from the exited process cannot repaint the pane. + await postClaudeHook(server, pane, { hook_event_name: 'Stop', session_id: 'claude-session' }) + expect(liveRow(server, pane.paneKey)).toBeUndefined() + }) + + it(`restored Claude pane whose authority came from a prior listing (${path})`, async () => { + const { server, runtime, readers } = await wireHost() + const pane = restoredPaneWithListingReceipt(runtime, `pty-restored-${path.replace(' ', '-')}`) + await claudeIsWorking(server, pane) + readers.republishedWorktrees.length = 0 + + await endCommand(runtime, pane.ptyId, path) + + expectEveryReaderSawTheClear(server, readers, pane.paneKey) + expect(server.getStatusSnapshotForPane(pane.paneKey)[0]?.providerSessionOnly).toBe(true) + }) + } + + it('a Done row is cleared too: an exited agent is not waiting for the user', async () => { + const host = await wireHost() + const { server, runtime, readers } = host + const pane = await launchClaudePane(host, 'pty-launched-done') + await claudeIsWorking(server, pane) + await postClaudeHook(server, pane, { hook_event_name: 'Stop', session_id: 'claude-session' }) + expect(liveRow(server, pane.paneKey)?.state).toBe('done') + + await endCommand(runtime, pane.ptyId, 'shell bytes') + + expectEveryReaderSawTheClear(server, readers, pane.paneKey) + }) + + it("falls back to the Claude process's own identity when the foreground cannot say", async () => { + probe.mockResolvedValue('exited') + const host = await wireHost(async () => 'node') + const { server, runtime, readers } = host + const pane = await launchClaudePane(host, 'pty-launched-presence') + // Presence needs the agent's own process identity on the row. + await claudeIsWorkingWithProcess(server, pane) + readers.republishedWorktrees.length = 0 + + await endCommand(runtime, pane.ptyId, 'daemon fact') + + expect(probe).toHaveBeenCalled() + expectEveryReaderSawTheClear(server, readers, pane.paneKey) + }) + + it('a PTY exit clears every reader and keeps no resume identity', async () => { + const host = await wireHost() + const { server, runtime, readers } = host + const pane = await launchClaudePane(host, 'pty-launched-exit') + await claudeIsWorking(server, pane) + readers.republishedWorktrees.length = 0 + + await runtime.onPtyExit(pane.ptyId, 0, `${pane.ptyId}-incarnation`) + + expectEveryReaderSawTheClear(server, readers, pane.paneKey) + expect(server.getStatusSnapshotForPane(pane.paneKey)).toEqual([]) + }) +}) + +describe('a command end that does not prove the agent exited keeps its row everywhere', () => { + for (const path of ['shell bytes', 'daemon fact'] as const) { + it(`a nested shell's leaked 133;D under a live agent (${path})`, async () => { + const host = await wireHost(async () => 'claude') + const { server, runtime, readers } = host + const pane = await launchClaudePane(host, `pty-nested-${path.replace(' ', '-')}`) + await claudeIsWorking(server, pane) + + await endCommand(runtime, pane.ptyId, path) + + expectNoReaderLostTheRow(server, readers, pane.paneKey, 'working') + // No fence either: the live agent's next event still lands. + await postClaudeHook(server, pane, { hook_event_name: 'Stop', session_id: 'claude-session' }) + expect(liveRow(server, pane.paneKey)?.state).toBe('done') + }) + } + + it('an execution host that cannot answer is not evidence of exit', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const host = await wireHost(async () => { + throw new Error('relay unreachable') + }) + const { server, runtime, readers } = host + const pane = await launchClaudePane(host, 'pty-unverifiable') + await claudeIsWorking(server, pane) + + await endCommand(runtime, pane.ptyId, 'daemon fact') + + expectNoReaderLostTheRow(server, readers, pane.paneKey, 'working') + expect(warn).toHaveBeenCalledWith( + '[agent-status] command end: agent exit unverifiable; pane rows kept', + { ptyId: pane.ptyId } + ) + }) + + it('still drops launch authority at once: the token lives on in the shell', async () => { + const host = await wireHost(async () => 'claude') + const { runtime } = host + const pane = await launchClaudePane(host, 'pty-authority') + expect(runtime.readPaneLaunchAuthority(pane.paneKey)?.launchTokenHash).toBe( + createHash('sha256').update(pane.launchToken).digest('hex') + ) + + runtime.emitDaemonPtyTransientFact(pane.ptyId, { kind: 'command-finished', exitCode: 0 }) + + expect(runtime.readPaneLaunchAuthority(pane.paneKey)).toEqual({ launchTokenHash: null }) + }) +}) + +describe('the launch token a shell keeps after its command ends', () => { + it('never vouches for a later process that inherits it, before or after a restart', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-inherited-token-')) + teardowns.push(() => rmSync(userDataPath, { recursive: true, force: true })) + // The foreground shows an agent again: a new `claude` the user typed in the same shell. + const host = await wireHost(async () => 'claude', userDataPath) + const { server, runtime } = host + const pane = await launchClaudePane(host, 'pty-inherited-token') + const attest = (target: AgentHookServer, terminalProvenance: 'current_runtime' | 'restored') => + target.attestCompatibilityAuthority({ + paneKey: pane.paneKey, + launchTokenHash: createHash('sha256').update(pane.launchToken).digest('hex'), + connectionId: null, + terminalProvenance + }) + await claudeIsWorking(server, pane) + expect(attest(server, 'current_runtime')).not.toBeNull() + + await endCommand(runtime, pane.ptyId, 'daemon fact') + // The new process posts with the token it inherited from the shell's environment. + await postClaudeHook(server, pane, { + hook_event_name: 'UserPromptSubmit', + session_id: 'second-session', + prompt: 'a different task' + }) + + expect(liveRow(server, pane.paneKey)?.prompt).toBe('a different task') + expect(liveRow(server, pane.paneKey)?.launchToken).toBeUndefined() + expect(attest(server, 'current_runtime')).toBeNull() + + server.flushStatusPersistSync() + server.stop() + const restarted = new AgentHookServer() + servers.push(restarted) + await restarted.start({ env: 'production', userDataPath }) + expect(restarted.getHydratedAuthorityCommitments()).toHaveLength(0) + expect(attest(restarted, 'restored')).toBeNull() + }) + + it('still vouches for the agent it was minted for while that agent runs', async () => { + const host = await wireHost(async () => 'claude') + const { server } = host + const pane = await launchClaudePane(host, 'pty-live-token') + await claudeIsWorking(server, pane) + await postClaudeHook(server, pane, { + hook_event_name: 'PreToolUse', + session_id: 'claude-session', + tool_name: 'Bash' + }) + + expect(liveRow(server, pane.paneKey)?.launchToken).toBe(pane.launchToken) + expect( + server.attestCompatibilityAuthority({ + paneKey: pane.paneKey, + launchTokenHash: createHash('sha256').update(pane.launchToken).digest('hex'), + connectionId: null, + terminalProvenance: 'current_runtime' + }) + ).not.toBeNull() + }) +}) diff --git a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts index dbbb124d34aa..0c7b96a61f65 100644 --- a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts +++ b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts @@ -18,7 +18,7 @@ export class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRuntimeWithSc this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) return case 'command-finished': - this.retirePtyAgentLaunchAuthority(ptyId) + this.retirePtyAgentLaunchAuthority(ptyId, 'command') this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode: fact.exitCode diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index 2e52e6f07483..a19ce86acf78 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -23,6 +23,7 @@ import type { FleetAgentStatusEvidence } from '../../shared/orchestration-fleet- import { readOrchestrationFleetAgentStatusSnapshot } from './orchestration-fleet-agent-status-snapshot' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { matchesProcessIncarnation } from './orchestration/worker-terminal-process-liveness' +import { verifyCommandEndAgentExit } from './command-end-agent-exit-verdict' export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller { /** Every pane key this PTY could be addressed by, including restored receipts. */ @@ -125,7 +126,20 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim } } - protected retirePtyAgentLaunchAuthority(ptyId: string): void { + /** The launch token this runtime still honours for a pane's PTY; null when it has no live PTY there. */ + readPaneLaunchAuthority(paneKey: string): { launchTokenHash: string | null } | null { + const pty = this.getPtyRecordForPaneKey(paneKey) + if (!pty?.connected) { + return null + } + return { + launchTokenHash: pty.launchToken + ? createHash('sha256').update(pty.launchToken).digest('hex') + : null + } + } + + protected retirePtyAgentLaunchAuthority(ptyId: string, ended: 'command' | 'pty'): void { const pty = this.ptysById.get(ptyId) if (!pty) { return @@ -141,8 +155,46 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim pty.launchToken = null pty.launchIncarnationId = null pty.launchAgent = null + if (ended === 'pty') { + for (const paneKey of paneKeys) { + this.retireAgentHookCompatibilityAuthorityFn?.(paneKey) + } + return + } + // Why authority now and rows later: the launch token lives on in the shell's environment, but + // a 133;D alone never proves the agent exited — a full-screen agent's nested shells leak one. for (const paneKey of paneKeys) { - this.retireAgentHookCompatibilityAuthorityFn?.(paneKey) + this.retireAgentHookCompatibilityAuthorityFn?.(paneKey, { authorityOnly: true }) + } + void this.retirePaneRowsAfterVerifiedAgentExit(pty, paneKeys) + } + + protected async retirePaneRowsAfterVerifiedAgentExit( + pty: RuntimePtyWorktreeRecord, + paneKeys: Set + ): Promise { + const { ptyId, incarnationId } = pty + const controller = this.ptyController + const verdict = await verifyCommandEndAgentExit(paneKeys, { + readForegroundProcess: async () => + controller + ? await (controller.confirmForegroundProcess?.(ptyId) ?? + controller.getForegroundProcess(ptyId)) + : null, + checkHookAgentPresence: async (paneKey) => + (await this.checkHookAgentPresenceFn?.(paneKey)) ?? null + }) + // Why: a PTY exit or respawn meanwhile settles the pane through its own teardown. + if (this.ptysById.get(ptyId) !== pty || !pty.connected || pty.incarnationId !== incarnationId) { + return + } + if (verdict === 'exited') { + // The shell outlived its agent, so the session stays resumable in place. + for (const paneKey of paneKeys) { + this.retireAgentHookCompatibilityAuthorityFn?.(paneKey, { preserveResumeIdentity: true }) + } + } else if (verdict === 'unverifiable') { + console.warn('[agent-status] command end: agent exit unverifiable; pane rows kept', { ptyId }) } } diff --git a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts index 2e6f30770b0f..a0aa78ed20ad 100644 --- a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts +++ b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts @@ -147,7 +147,7 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime }, onCommandFinished: (exitCode: number | null) => { void this.recheckHookAgentPresenceForPty(ptyId) - this.retirePtyAgentLaunchAuthority(ptyId) + this.retirePtyAgentLaunchAuthority(ptyId, 'command') this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) this.openCodeRunLifetime.onCommandFinished(ptyId, exitCode) }, diff --git a/src/main/runtime/orca-runtime-on-pty-exit.ts b/src/main/runtime/orca-runtime-on-pty-exit.ts index abcf2fcf57f4..e5ea59248fe9 100644 --- a/src/main/runtime/orca-runtime-on-pty-exit.ts +++ b/src/main/runtime/orca-runtime-on-pty-exit.ts @@ -56,7 +56,7 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte }) this.restoredOrchestrationAuthorityByPtyId.delete(ptyId) } else { - this.retirePtyAgentLaunchAuthority(ptyId) + this.retirePtyAgentLaunchAuthority(ptyId, 'pty') } const processDeathCertified = exitCode >= 0 || options.hostExitConfirmed === true || options.providerExitObserved === true diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index 37f8121f99ae..fc6da1e6731e 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -88,7 +88,12 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin }) => AgentHookAuthorityAttestation | null) | null - protected readonly retireAgentHookCompatibilityAuthorityFn: ((paneKey: string) => void) | null + protected readonly retireAgentHookCompatibilityAuthorityFn: + | (( + paneKey: string, + options?: { authorityOnly?: boolean; preserveResumeIdentity?: boolean } + ) => void) + | null protected readonly checkHookAgentPresenceFn: | ((paneKey: string) => Promise<'live' | 'unverifiable' | 'exited' | null>) diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index ca95bf9dc270..bbc7090cf472 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -81,7 +81,10 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { connectionId: string | null terminalProvenance: 'current_runtime' | 'restored' }) => AgentHookAuthorityAttestation | null - retireAgentHookCompatibilityAuthority?: (paneKey: string) => void + retireAgentHookCompatibilityAuthority?: ( + paneKey: string, + options?: { authorityOnly?: boolean; preserveResumeIdentity?: boolean } + ) => void checkHookAgentPresence?: ( paneKey: string ) => Promise<'live' | 'unverifiable' | 'exited' | null> diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness.spec.ts index 3d4dadc08378..88c0da226fb4 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness.spec.ts @@ -269,7 +269,8 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('pty-authority', '\x1b]133;D;0\x07', 100) - expect(retireAuthority).toHaveBeenCalledWith(spawnEnv.ORCA_PANE_KEY) + // Authority ends at the mark; the pane's rows wait for a verified exit. + expect(retireAuthority).toHaveBeenCalledWith(spawnEnv.ORCA_PANE_KEY, { authorityOnly: true }) expect(runtime.verifyOrchestrationCompatibilityCaller(evidence)).toBeNull() expect((await runtime.listTerminals()).terminals).toEqual([ expect.not.objectContaining({ agentIdentity: expect.anything() }) @@ -329,7 +330,7 @@ describe('OrcaRuntimeService', () => { runtime.onPtyExit('pty-restored-exit', 0, 'restored-exit') runtime.onPtyExit('pty-ordinary-shell', 0, 'ordinary-shell') - expect(retireAuthority).toHaveBeenCalledWith(firstPane) + expect(retireAuthority).toHaveBeenCalledWith(firstPane, { authorityOnly: true }) expect(retireAuthority).toHaveBeenCalledWith(secondPane) expect(retireAuthority).toHaveBeenCalledTimes(2) }) diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index cfd0fa294020..dec6c8616be8 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -115,8 +115,8 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { agentHookServer.getStatusSnapshotForPane(paneKey), attestAgentHookCompatibilityAuthority: (candidate) => agentHookServer.attestCompatibilityAuthority(candidate), - retireAgentHookCompatibilityAuthority: (paneKey) => - agentHookServer.retirePaneAuthority(paneKey), + retireAgentHookCompatibilityAuthority: (paneKey, options) => + agentHookServer.retirePaneAuthority(paneKey, undefined, options), checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), @@ -163,6 +163,10 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { }) app.once('will-quit', () => sessionSearch?.dispose()) state.runtime = runtime + // Why: the runtime owns which launch token a pane still honours; ingest derives from it. + agentHookServer.setPaneLaunchAuthorityReader((paneKey) => + runtime.readPaneLaunchAuthority(paneKey) + ) agentHookServer.subscribeEnrichedStatus((enriched) => recordObservedAgentStatusPaneIdentity(observedPaneIdentities, enriched.paneKey, runtime) ) diff --git a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts index 70b13e22a41f..8f85babc6208 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts @@ -81,11 +81,9 @@ export function registerAgentStatusListeners(args: { pendingAgentStatusEvents.splice(index, 1) } } - const store = useAppStore.getState() - if (store.agentStatusByPaneKey[data.paneKey]?.state === 'done') { - return - } - store.removeAgentStatus(data.paneKey) + // Why no Done exemption: the host clears a pane only when its agent ended or the pane went + // away, and an exited agent is not waiting on the user. Retention is presentation policy. + useAppStore.getState().removeAgentStatus(data.paneKey) } ) if (unsubscribeAgentStatusClear) { diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts index b043abb40428..230ccc124d64 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts @@ -510,7 +510,9 @@ describe('useIpcEvents agent status snapshot integration', () => { ) }) - it('keeps a completed worktree-attributed row when main reports pane teardown', async () => { + // Why: Done means the agent's TUI is alive and finished its turn; the host clears a pane only + // once its agent ended or the pane went away, so the desktop must not keep a Done the host dropped. + it('clears a completed worktree-attributed row when main reports pane teardown', async () => { const removeAgentStatus = vi.fn() const onClearListenerRef: { current: ((data: AgentStatusClearIpcPayload) => void) | null @@ -565,6 +567,6 @@ describe('useIpcEvents agent status snapshot integration', () => { onClearListenerRef.current({ paneKey: FUTURE_PANE_KEY }) - expect(removeAgentStatus).not.toHaveBeenCalled() + expect(removeAgentStatus).toHaveBeenCalledWith(FUTURE_PANE_KEY) }) }) diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-queue-ordering.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-queue-ordering.test.ts index 88f199bb3a4e..dc7f8814ae53 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-queue-ordering.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-queue-ordering.test.ts @@ -25,7 +25,7 @@ describe('useIpcEvents agent status snapshot integration', () => { vi.useRealTimers() }) - it('preserves queued set-clear order for working removal and done retention', async () => { + it('preserves queued set-clear order: a queued done lands, then the clear removes it', async () => { vi.useFakeTimers() let storeState: StoreLike const applyStatusUpdate = ( @@ -152,10 +152,8 @@ describe('useIpcEvents agent status snapshot integration', () => { expect(setAgentStatuses.mock.calls[1][0][0]).toEqual( expect.objectContaining({ payload: expect.objectContaining({ state: 'done' }) }) ) - expect(removeAgentStatus).toHaveBeenCalledTimes(1) - expect(storeState.agentStatusByPaneKey).toEqual({ - [FUTURE_PANE_KEY]: expect.objectContaining({ state: 'done' }) - }) + expect(removeAgentStatus).toHaveBeenCalledTimes(2) + expect(storeState.agentStatusByPaneKey).toEqual({}) vi.advanceTimersByTime(40) expect(setAgentStatus).toHaveBeenCalledTimes(2) expect(setAgentStatuses).toHaveBeenCalledTimes(2) From 62c313032475a6f22ca9bb1894cbc94a05d495df Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:46:36 -0700 Subject: [PATCH 02/19] test(agent-status): pin that authority-only retirement leaves nothing to rehydrate --- .../agent-hooks/server-row-removal-notifies-readers.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/agent-hooks/server-row-removal-notifies-readers.test.ts b/src/main/agent-hooks/server-row-removal-notifies-readers.test.ts index d4b48f9ef566..e3a5bdc3f42e 100644 --- a/src/main/agent-hooks/server-row-removal-notifies-readers.test.ts +++ b/src/main/agent-hooks/server-row-removal-notifies-readers.test.ts @@ -200,6 +200,11 @@ describe('retirePaneAuthority', () => { expect(attest()).toBeNull() expect(seen.windowClears).toEqual([]) expect(seen.subscriberClears).toEqual([]) + // The kept row no longer carries the token, so a restart cannot rehydrate its authority. + server.flushStatusPersistSync() + const restarted = await startServer() + expect(restarted.getHydratedAuthorityCommitments()).toHaveLength(0) + restarted.stop() // Not fenced: the live agent's next event still lands. server.ingestRemote(claudeEvent('PostToolUse', 'still going'), CONNECTION) expect(liveRow(server)?.prompt).toBe('still going') From 1f9440e560a86be7ec23f7976f86ec376b42abe4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:18:16 -0700 Subject: [PATCH 03/19] fix(agent-status): verify agent exit at every command end through the ended-process clear A command end (OSC 133;D) only ended a pane's rows when that pane still held launch authority, and the first command end consumed that authority. A leaked 133;D under a live full-screen agent therefore spent the one check, and the agent's real exit later left a Done row in the sidebar, dashboard, worktree ps and mobile. A shell's name in the foreground also outranked a live agent pid, and the verdict fenced the pane, so a wrong verdict hid a live agent. Now every command end of a PTY whose panes hold a live host row asks again, whatever launched the agent: - the hook's own agent pid still running keeps the row; - the spawned shell owning the foreground with no stopped job clears it through the store's ended-process clear, keeping the resume identity and fencing nothing; - anything else keeps the row until the next command end asks again. The clear skips a row that changed while the check ran, a command end that lands mid-check is checked too, and both the byte path and the daemon path use the same check. Full retirement stays for pane close and PTY exit. Launch-token liveness is now derived where authority becomes durable: at every row write (hook ingest, relay, replay, transcript poll, assistant retry), when an authority observation is recorded, and when the status file is persisted. The runtime answers from the PTY record whether or not it is connected. Command-end revocation no longer rewrites live rows, so a live Codex transcript poll keeps running. --- docs/reference/agent-status-store.md | 16 +- ...erver-row-removal-notifies-readers.test.ts | 105 ++-- src/main/agent-hooks/server.ts | 1 + .../server/server-authority-evidence.ts | 31 +- .../server/server-authority-fences.ts | 16 +- src/main/agent-hooks/server/server-cleanup.ts | 64 +-- .../server/server-ingest-normalization.ts | 5 +- .../server/server-ingest-remote.ts | 5 +- .../agent-hooks/server/server-lifecycle.ts | 2 +- .../agent-hooks/server/server-persistence.ts | 8 +- src/main/agent-hooks/server/server-state.ts | 6 +- .../server/server-status-disposition.ts | 6 +- .../server/server-status-update.ts | 6 +- src/main/agent-hooks/server/server-types.ts | 10 + src/main/orcad/orcad-entry.ts | 4 +- .../agent-status-store-wiring.test-fixture.ts | 7 +- .../runtime/command-end-agent-exit-verdict.ts | 44 -- ...ommand-end-agent-exit-verification.test.ts | 302 ++++++++++++ .../command-end-agent-exit-verifier.ts | 82 ++++ .../command-end-host-wiring.test-fixture.ts | 215 +++++++++ ...-authority-retirement-reader-clear.test.ts | 454 ------------------ ...h-token-liveness-after-command-end.test.ts | 232 +++++++++ ...-runtime-emit-daemon-pty-transient-fact.ts | 2 +- ...me-get-orchestration-dispatch-authority.ts | 81 ++-- ...e-get-unpersisted-tracked-title-for-pty.ts | 3 +- .../orca-runtime-preserved-branch-cleanup.ts | 12 +- src/main/runtime/orca-runtime-state-fields.ts | 12 +- .../startup/main-process-runtime-service.ts | 4 +- 28 files changed, 1033 insertions(+), 702 deletions(-) delete mode 100644 src/main/runtime/command-end-agent-exit-verdict.ts create mode 100644 src/main/runtime/command-end-agent-exit-verification.test.ts create mode 100644 src/main/runtime/command-end-agent-exit-verifier.ts create mode 100644 src/main/runtime/command-end-host-wiring.test-fixture.ts delete mode 100644 src/main/runtime/launch-authority-retirement-reader-clear.test.ts create mode 100644 src/main/runtime/launch-token-liveness-after-command-end.test.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index c5ef1609dd25..8cec3529a959 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -87,10 +87,18 @@ delete the copies.** Every host row removal notifies every reader in the same step: a teardown or an ended process emits the pane clear, a dismissal emits the status drop. Launch-authority retirement is no -exception. A command end (OSC 133;D) ends a pane's launch authority at once, because every later -process in that shell inherits the token, but it removes the pane's rows only after the agent's -exit is verified (a shell in the foreground, or the hook's own process gone). `unverifiable` -keeps the rows. +exception. The host clears a pane only when its agent ended or the pane went away, so a reader +drops the row on a clear and never keeps it as a finished (Done) agent. + +A command end (OSC 133;D) ends a pane's launch authority at once, because every later process in +that shell inherits the token. It does not prove the agent exited: a full-screen agent's nested +shells leak their own 133;D. So at every command end of a PTY whose panes hold a live row, the +runtime asks the execution host again. The hook's own agent process still running keeps the row; +the spawned shell owning the foreground with no job stopped clears it as an ended process, keeping +the resume identity. Any other answer keeps the row, and nothing is latched: the PTY's next command +end asks again, and its exit clears it. A verdict acts only on the row it checked, so a session +that started while it was read keeps its row. SSH hosts cannot answer the shell check yet, so there +only the agent's own exit hook or the PTY's exit removes a row the desktop pane did not drop. ## PR 1a: structured sessions publish into the store diff --git a/src/main/agent-hooks/server-row-removal-notifies-readers.test.ts b/src/main/agent-hooks/server-row-removal-notifies-readers.test.ts index e3a5bdc3f42e..f3cc7779f486 100644 --- a/src/main/agent-hooks/server-row-removal-notifies-readers.test.ts +++ b/src/main/agent-hooks/server-row-removal-notifies-readers.test.ts @@ -89,9 +89,13 @@ const REMOVALS: Record void> = { }, dropStatusEntriesByTabPrefix: (server) => server.dropStatusEntriesByTabPrefix('tab-1'), clearStatusEntriesForConnection: (server) => server.clearStatusEntriesForConnection(CONNECTION), - 'retirePaneAuthority (renderer pane close)': (server) => server.retirePaneAuthority(PANE), - 'retirePaneAuthority (runtime command end)': (server) => - server.retirePaneAuthority(PANE, undefined, { preserveResumeIdentity: true }) + 'retirePaneAuthority (pane close, PTY exit)': (server) => server.retirePaneAuthority(PANE), + 'reconcileEndedProcessForPaneKeys (verified command-end exit)': (server) => { + server.reconcileEndedProcessForPaneKeys([PANE], { + preserveResumeIdentity: true, + armedRowReceivedAt: liveRow(server)!.receivedAt + }) + } } describe('every host row removal notifies readers', () => { @@ -123,10 +127,12 @@ describe('retirePaneAuthority', () => { seedClaudeRow(server) const seen = recordReaderNotifications(server) - server.retirePaneAuthority(PANE, undefined, { preserveResumeIdentity: true }) + server.retirePaneAuthority(PANE) expect(seen.windowClears).toEqual([{ paneKey: PANE }]) expect(seen.subscriberClears).toEqual([{ paneKey: PANE }]) + // No shell is left to resume into, so the resume identity goes too. + expect(server.getStatusSnapshotForPane(PANE)).toEqual([]) } finally { server.stop() } @@ -137,7 +143,7 @@ describe('retirePaneAuthority', () => { try { const seen = recordReaderNotifications(server) - server.retirePaneAuthority(PANE, undefined, { preserveResumeIdentity: true }) + server.retirePaneAuthority(PANE) expect(seen.windowClears).toEqual([]) expect(seen.subscriberClears).toEqual([]) @@ -146,42 +152,14 @@ describe('retirePaneAuthority', () => { } }) - it('keeps the resume identity on a command end, as the ended-process clear does', async () => { - const server = await startServer() - try { - seedClaudeRow(server) - - server.retirePaneAuthority(PANE, undefined, { preserveResumeIdentity: true }) - - const rows = server.getStatusSnapshotForPane(PANE) - expect(rows).toHaveLength(1) - expect(rows[0]?.providerSessionOnly).toBe(true) - expect(rows[0]?.providerSession?.id).toBe('resume-me') - expect(rows[0]?.launchToken).toBeUndefined() - - // The fence still holds: a late event from the exited process cannot repaint the pane. - server.ingestRemote(claudeEvent('PostToolUse'), CONNECTION) - expect(liveRow(server)).toBeUndefined() - // A new turn proves a new process owns the pane and replaces the remnant. - server.ingestRemote(claudeEvent('UserPromptSubmit', 'next task'), CONNECTION) - expect(liveRow(server)?.prompt).toBe('next task') - - // The remnant must not bring back retired launch authority after a restart. - server.retirePaneAuthority(PANE, undefined, { preserveResumeIdentity: true }) - server.flushStatusPersistSync() - server.stop() - const restarted = await startServer() - expect(restarted.getStatusSnapshotForPane(PANE)[0]?.launchToken).toBeUndefined() - expect(restarted.getHydratedAuthorityCommitments()).toHaveLength(0) - restarted.stop() - } finally { - server.stop() - } - }) - it('on a command end ends only launch authority: the row, its fence and its readers stay', async () => { const server = await startServer() try { + // The runtime's answer: it honours the token until the command ends. + let runtimeTokenHash: string | null = createHash('sha256') + .update('launch-token') + .digest('hex') + server.setPaneLaunchAuthorityReader(() => ({ launchTokenHash: runtimeTokenHash })) seedClaudeRow(server) const seen = recordReaderNotifications(server) const attest = () => @@ -193,34 +171,73 @@ describe('retirePaneAuthority', () => { }) expect(attest()).not.toBeNull() + runtimeTokenHash = null server.retirePaneAuthority(PANE, undefined, { authorityOnly: true }) expect(liveRow(server)?.state).toBe('working') - expect(liveRow(server)?.launchToken).toBeUndefined() expect(attest()).toBeNull() expect(seen.windowClears).toEqual([]) expect(seen.subscriberClears).toEqual([]) - // The kept row no longer carries the token, so a restart cannot rehydrate its authority. + // The kept row still holds the token it arrived with; the file derives that it is dead. server.flushStatusPersistSync() const restarted = await startServer() expect(restarted.getHydratedAuthorityCommitments()).toHaveLength(0) restarted.stop() - // Not fenced: the live agent's next event still lands. + // Not fenced: the live agent's next event still lands, without the dead token. server.ingestRemote(claudeEvent('PostToolUse', 'still going'), CONNECTION) expect(liveRow(server)?.prompt).toBe('still going') + expect(liveRow(server)?.launchToken).toBeUndefined() + expect(attest()).toBeNull() } finally { server.stop() } }) +}) - it('takes the resume identity too when no shell is left to resume into', async () => { +describe('reconcileEndedProcessForPaneKeys after a verified command-end exit', () => { + it('keeps the resume identity, fences nothing, and keeps no launch authority', async () => { const server = await startServer() try { seedClaudeRow(server) - server.retirePaneAuthority(PANE) + server.reconcileEndedProcessForPaneKeys([PANE], { + preserveResumeIdentity: true, + armedRowReceivedAt: liveRow(server)!.receivedAt + }) - expect(server.getStatusSnapshotForPane(PANE)).toEqual([]) + const rows = server.getStatusSnapshotForPane(PANE) + expect(rows).toHaveLength(1) + expect(rows[0]?.providerSessionOnly).toBe(true) + expect(rows[0]?.providerSession?.id).toBe('resume-me') + expect(rows[0]?.launchToken).toBeUndefined() + server.flushStatusPersistSync() + const restarted = await startServer() + expect(restarted.getHydratedAuthorityCommitments()).toHaveLength(0) + restarted.stop() + // A new agent in the same shell shows at once. + server.ingestRemote(claudeEvent('UserPromptSubmit', 'next task'), CONNECTION) + expect(liveRow(server)?.prompt).toBe('next task') + } finally { + server.stop() + } + }) + + it('skips a pane whose row changed after the verdict was armed', async () => { + const server = await startServer() + try { + seedClaudeRow(server) + const armedRowReceivedAt = liveRow(server)!.receivedAt + await new Promise((resolve) => setTimeout(resolve, 2)) + server.ingestRemote(claudeEvent('UserPromptSubmit', 'a new session'), CONNECTION) + const seen = recordReaderNotifications(server) + + server.reconcileEndedProcessForPaneKeys([PANE], { + preserveResumeIdentity: true, + armedRowReceivedAt + }) + + expect(liveRow(server)?.prompt).toBe('a new session') + expect(seen.windowClears).toEqual([]) } finally { server.stop() } diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 5c88dfd62d8c..57dc227663b9 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -14,6 +14,7 @@ export type { AgentHookStatusRowMutation, AgentHookStatusChangeEntry, AgentHookStatusFreshnessObservation, + EndedProcessReconcileOptions, EnrichedAgentHookEventPayload } from './server/server-types' export type { AgentHookSource } diff --git a/src/main/agent-hooks/server/server-authority-evidence.ts b/src/main/agent-hooks/server/server-authority-evidence.ts index c456c4bba56a..138b9e89cfe7 100644 --- a/src/main/agent-hooks/server/server-authority-evidence.ts +++ b/src/main/agent-hooks/server/server-authority-evidence.ts @@ -9,7 +9,7 @@ import type { import { AgentHookServerStatusRetries } from './server-status-retries' /** What the runtime holds for a pane's PTY: the hash of the launch token it still honours, or null - * once that authority ended. Null overall when the runtime has no PTY for the pane. */ + * once that authority ended. Null overall when the runtime has no record of a PTY for the pane. */ export type PaneLaunchAuthorityReader = ( paneKey: string ) => { launchTokenHash: string | null } | null @@ -21,29 +21,24 @@ export abstract class AgentHookServerAuthorityEvidence extends AgentHookServerSt this.paneLaunchAuthorityReader = reader } + /** The event without its launch token once that token's authority ended. Every process a shell + * starts inherits the token, so after a command end it proves nothing; it is honoured while the + * runtime still holds it for the pane or a commitment of this host still vouches for it. Derived + * at every write and at persist, never stored; a pane with no PTY record keeps today's rule. */ protected withLiveLaunchToken(event: T): T { - const launchToken = this.liveLaunchToken(event.paneKey, event.launchToken) - return launchToken === event.launchToken ? event : { ...event, launchToken } - } - - /** The event's launch token, or undefined once its authority ended. Every process a shell starts - * inherits the token, so after a command end it proves nothing; a token is honoured while the - * runtime still holds it for the pane or a commitment of this host still vouches for it. - * Derived from that state, never stored: a pane the runtime does not know keeps today's rule. */ - protected liveLaunchToken(paneKey: string, launchToken: string | undefined): string | undefined { - const token = launchToken?.trim() - const runtime = token ? this.paneLaunchAuthorityReader?.(paneKey) : null + const token = event.launchToken?.trim() + const runtime = token ? this.paneLaunchAuthorityReader?.(event.paneKey) : null if (!token || !runtime) { - return launchToken + return event } const launchTokenHash = createHash('sha256').update(token).digest('hex') - const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) - return runtime.launchTokenHash === launchTokenHash || + const ownerPaneKey = this.resolvePaneKeyAlias(event.paneKey) + const live = + runtime.launchTokenHash === launchTokenHash || this.persistedAuthorityCommitmentsByPaneKey.get(ownerPaneKey)?.launchTokenHash === launchTokenHash || this.hydratedLaunchTokenHashByPaneKey.get(ownerPaneKey) === launchTokenHash - ? launchToken - : undefined + return live ? event : { ...event, launchToken: undefined } } attestCompatibilityAuthority(candidate: { @@ -100,7 +95,7 @@ export abstract class AgentHookServerAuthorityEvidence extends AgentHookServerSt } protected recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void { - const evidence = this.toAuthorityEvidence(payload) + const evidence = this.toAuthorityEvidence(this.withLiveLaunchToken(payload)) if (evidence) { this.currentAuthorityObservations.set(evidence.paneKey, evidence) this.persistedAuthorityCommitmentsByPaneKey.set(evidence.paneKey, evidence) diff --git a/src/main/agent-hooks/server/server-authority-fences.ts b/src/main/agent-hooks/server/server-authority-fences.ts index e438fc6719d8..6be726b38567 100644 --- a/src/main/agent-hooks/server/server-authority-fences.ts +++ b/src/main/agent-hooks/server/server-authority-fences.ts @@ -8,11 +8,6 @@ import type { } from './server-types' export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuthorityAliases { - /** Re-admits a dropped row's resume identity; null when it carried no resumable session. */ - protected abstract admitResumeIdentityRemnant( - row: EnrichedAgentHookEventPayload - ): EnrichedAgentHookEventPayload | null - /** Ends only the launch authority a pane's rows vouch for; rows and status fences stay. */ protected abstract revokePaneLaunchAuthority(paneKey: string): void @@ -20,12 +15,8 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth retirePaneAuthority( paneKey: string, retirementId?: string, - options?: { - /** A command end: the agent may still be alive, so only its launch authority ends now. */ - authorityOnly?: boolean - /** The pane's shell outlived its agent; keep the remnant as the ended-process clear does. */ - preserveResumeIdentity?: boolean - } + /** A command end: the agent may still be alive, so only its launch authority ends now. */ + options?: { authorityOnly?: boolean } ): void { if (options?.authorityOnly) { this.revokePaneLaunchAuthority(paneKey) @@ -85,8 +76,7 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth this.notifyPaneKeyAliasPersistenceListener() } for (const row of retiredRows) { - const retained = options?.preserveResumeIdentity ? this.admitResumeIdentityRemnant(row) : null - this.commitStatusRowMutation(row, retained) + this.commitStatusRowMutation(row, undefined) } if (hadStatus || authorityChanged) { this.scheduleStatusPersist() diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index 883192c6910f..2adea1eb655b 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -6,7 +6,7 @@ import { } from '../../../shared/agent-hook-listener/listener-state' import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE } from '../../../shared/agent-status-legacy-adapter' import type { AgentStatusCacheIdentity } from '../../../shared/agent-status-types' -import type { EnrichedAgentHookEventPayload } from './server-types' +import type { EndedProcessReconcileOptions, EnrichedAgentHookEventPayload } from './server-types' import { AgentHookServerAuthorityFences } from './server-authority-fences' export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFences { @@ -27,46 +27,19 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen return { ...resumeIdentity, providerSessionOnly: true, retainedForLiveness: true } } - protected admitResumeIdentityRemnant( - row: EnrichedAgentHookEventPayload - ): EnrichedAgentHookEventPayload | null { - const retained = this.toRetainedProviderSessionRow(row) - if (retained) { - admitLegacyAgentStatus( - this.state, - 'main-status-cleanup', - retained, - AGENT_STATUS_2A_CURRENT_PRODUCER_MODE - ) - } - return retained - } - /** The launch token lives on in the shell's environment after its command ends, so its authority - * must end now even while the agent may still run: forget what vouched for it, and strip it from - * kept rows so a restart cannot rehydrate it. Ingest then derives that the token is dead. */ + * ends now even while the agent may still run: forget what vouched for it. Rows stay untouched; + * every later write and the persisted file derive that the token they carry is dead. */ protected revokePaneLaunchAuthority(paneKey: string): void { const paneKeys = new Set([paneKey, this.resolvePaneKeyAlias(paneKey)]) let changed = this.revokeHydratedAuthorityForPaneKeys(paneKeys) for (const key of paneKeys) { changed = this.currentAuthorityObservations.delete(key) || changed - const row = this.state.lastStatusByPaneKey.get(key) as - | EnrichedAgentHookEventPayload - | undefined - if (row?.launchToken) { - const { launchToken: _launchToken, ...withoutToken } = row - admitLegacyAgentStatus( - this.state, - 'main-status-cleanup', - withoutToken, - AGENT_STATUS_2A_CURRENT_PRODUCER_MODE - ) - changed = true - } + // Why: the kept row may have persisted its token's hash; rewrite the file without it. + changed = Boolean(this.state.lastStatusByPaneKey.get(key)?.launchToken) || changed } if (changed) { this.scheduleStatusPersist() - this.notifyStatusChangeListeners() } } @@ -158,14 +131,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen * so a dead pane is cleaned up identically however its keys were resolved. */ reconcileEndedProcessForPaneKeys( paneKeys: Iterable, - options?: { - endedPresence?: AgentProcessPresence - /** The pane's PTY outlived its agent (a confirmed shell foreground), so the session can still - * be resumed in place — keep the `providerSessionOnly` remnant the paired `agentStatus:drop` - * minted for exactly this case. A certified PTY exit passes nothing: there is no pane left to - * resume into, and dropping it matches what `clearProviderPtyState` already does. */ - preserveResumeIdentity?: boolean - } + options?: EndedProcessReconcileOptions & { endedPresence?: AgentProcessPresence } ): number { // A certified PTY exit passes no resume identity; a surviving shell may opt into the remnant. let cleared = 0 @@ -174,20 +140,22 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen if (!this.hasLiveClaimsForPaneKey(resolvedPaneKey)) { continue } + const previous = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + if ( + options?.armedRowReceivedAt !== undefined && + previous?.receivedAt !== options.armedRowReceivedAt + ) { + continue + } const resumeRow = options?.preserveResumeIdentity - ? this.toRetainedProviderSessionRow( - this.state.lastStatusByPaneKey.get(resolvedPaneKey) as - | EnrichedAgentHookEventPayload - | undefined - ) + ? this.toRetainedProviderSessionRow(previous) : null const retained = resumeRow && options?.endedPresence ? { ...resumeRow, agentPresence: options.endedPresence } : resumeRow - const previous = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as - | EnrichedAgentHookEventPayload - | undefined this.clearPaneState(resolvedPaneKey, { emitStatusRowMutation: false }) if (retained) { admitLegacyAgentStatus( diff --git a/src/main/agent-hooks/server/server-ingest-normalization.ts b/src/main/agent-hooks/server/server-ingest-normalization.ts index 82cdf7ec94a2..5e71e6ef033f 100644 --- a/src/main/agent-hooks/server/server-ingest-normalization.ts +++ b/src/main/agent-hooks/server/server-ingest-normalization.ts @@ -78,10 +78,7 @@ export abstract class AgentHookServerIngestNormalization extends AgentHookServer if (statusDisposition === 'suppress') { return } - const event = - statusDisposition === 'restart' - ? { ...replay, launchToken: undefined } - : this.withLiveLaunchToken(replay) + const event = statusDisposition === 'restart' ? { ...replay, launchToken: undefined } : replay if (statusDisposition === 'restart') { this.observations.rebind(event.paneKey) } diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index 56c7340ec7a0..28eac6e56dc1 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -264,10 +264,7 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS ...(restartedAuthority?.authorityRestartId ? { authorityRestartId: restartedAuthority.authorityRestartId } : {}), - launchToken: - statusDisposition === 'restart' - ? undefined - : this.liveLaunchToken(paneKey, envelope.launchToken), + launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken, tabId, worktreeId, connectionId: trimmedConnectionId, diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index a8a8c9060fb6..e6e1bf80061d 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -130,7 +130,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv } : {}) } - : this.withLiveLaunchToken(normalized.event) + : normalized.event if (statusDisposition === 'restart') { // Why: a retired pane accepting a new turn is a different agent session behind the // same key — later observations must not be ordered against the retired one. diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts index 74a37c441f29..8c61a4873f71 100644 --- a/src/main/agent-hooks/server/server-persistence.ts +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -46,8 +46,10 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio launchToken, ...persistedPayload } = enrichedPayload - const launchTokenHash = launchToken?.trim() - ? createHash('sha256').update(launchToken.trim()).digest('hex') + // Why derived: a row written before its pane's command ended still carries that token. + const liveLaunchToken = this.withLiveLaunchToken({ paneKey, launchToken }).launchToken?.trim() + const launchTokenHash = liveLaunchToken + ? createHash('sha256').update(liveLaunchToken).digest('hex') : this.hydratedLaunchTokenHashByPaneKey.get(paneKey) // `payload.mainAgent` rides inside the payload; the legacy `claudeLeadBoundaryChildOnly` flag it // replaced is read at hydrate and never written again. @@ -55,7 +57,7 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio ...persistedPayload, ...(launchTokenHash ? { launchTokenHash } : {}) } - const commitment = this.toAuthorityEvidence(payload, launchTokenHash) + const commitment = launchTokenHash ? this.toAuthorityEvidence(payload, launchTokenHash) : null if (commitment && !conflictedCommitments.has(paneKey)) { const existing = authorityCommitments[paneKey] if (existing && !authorityCommitmentsMatch(existing, commitment)) { diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 49bb8ade3ac9..736ea8365565 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -35,6 +35,7 @@ import type { AgentHookStatusChangeEntry, AgentHookStatusFreshnessObservation, AgentPromptSentDedupeEntry, + EndedProcessReconcileOptions, EnrichedAgentHookEventPayload, NormalizedLocalHook, PaneKeyAliasEntry, @@ -267,7 +268,7 @@ export abstract class AgentHookServerState { abstract reconcileEndedProcessForPaneKeys( paneKeys: Iterable, - options?: { preserveResumeIdentity?: boolean; endedPresence?: AgentProcessPresence } + options?: EndedProcessReconcileOptions & { endedPresence?: AgentProcessPresence } ): number protected abstract clearPaneState( @@ -282,6 +283,9 @@ export abstract class AgentHookServerState { protected abstract hydrateLastStatusFromDisk(): void protected abstract captureHydratedAuthorityCommitments(): void protected abstract recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void + protected abstract withLiveLaunchToken( + event: T + ): T protected abstract toAuthorityEvidence( payload: AgentHookEventPayload | EnrichedAgentHookEventPayload, launchTokenHashOverride?: string diff --git a/src/main/agent-hooks/server/server-status-disposition.ts b/src/main/agent-hooks/server/server-status-disposition.ts index ef633d7c2c80..ddf64edd00e6 100644 --- a/src/main/agent-hooks/server/server-status-disposition.ts +++ b/src/main/agent-hooks/server/server-status-disposition.ts @@ -104,9 +104,9 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt } return 'accept' } - // Why: command completion retires launch authority but leaves its shell pane reusable. - // A live new-turn event proves a new agent process owns the retired pane just like a - // fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386). + // Why: retirement fences a pane whose key a new process can still reuse (a respawn, a + // reattach). A live new-turn event proves a new agent process owns the retired pane just like + // a fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386). // Why the classifier, not literals: only 5 of 18 sources name their boundary // `UserPromptSubmit`/`SessionStart`; the rest stayed retired forever. // Why four branches: `source` collapses to undefined when an older relay omits the field, diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index bb40c187134f..419db5f87e9b 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -22,12 +22,16 @@ import { AgentHookServerStatusApplication } from './server-status-application' export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusApplication { protected applyNormalizedStatus( - incoming: AgentHookEventPayload & { authorityRestartId?: string }, + event: AgentHookEventPayload & { authorityRestartId?: string }, onAccepted?: () => void, origin: AgentStatusObservationOrigin = 'hook', observedAt?: number, mutationBefore?: EnrichedAgentHookEventPayload ): EnrichedAgentHookEventPayload | undefined { + // Why here: every writer lands through this function, including the transcript poll and the + // assistant-message retry that re-read a hook body, so no row can take back a token whose + // authority ended. + const incoming = this.withLiveLaunchToken(event) const transitioned = transitionHookPresence( incoming, this.state.lastStatusByPaneKey.get(incoming.paneKey) diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index 79b4e569cdd6..298be94f7c51 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -90,6 +90,16 @@ export type AgentHookAuthorityAttestation = Readonly<{ source: 'current_hook' | 'hydrated_commitment' }> +/** How an ended-process clear treats the pane it reaches. */ +export type EndedProcessReconcileOptions = { + /** The pane's PTY outlived its agent (a confirmed shell foreground), so the session can still be + * resumed in place: keep the `providerSessionOnly` remnant. A certified PTY exit passes nothing. */ + preserveResumeIdentity?: boolean + /** The `receivedAt` of the row the caller checked; a pane whose row changed since is skipped, so + * a slow verdict cannot clear a session that started meanwhile. */ + armedRowReceivedAt?: number +} + export type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void export type StatusFreshnessListener = (status: AgentHookStatusFreshnessObservation) => void export type ProviderSessionChangeListener = ( diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 6c0e9bbad227..06e8f1112ca6 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -237,8 +237,8 @@ async function startOrcadRuntime( readChildWork: (subject) => agentHookServer.getStructuredChildWorkViews(subject) }, checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), - reconcileAgentStatusForEndedProcess: (paneKeys) => - agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), + reconcileAgentStatusForEndedProcess: (paneKeys, options) => + agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys, options), buildAgentHookPtyEnv: () => isAgentStatusHooksEnabled(profileStore.getSettings()) ? agentHookServer.buildPtyEnv() : {}, // Why the dedupe here and not in the instance: `apply` closes and reconstructs diff --git a/src/main/runtime/agent-status-store-wiring.test-fixture.ts b/src/main/runtime/agent-status-store-wiring.test-fixture.ts index 1f399e0464f0..7ded52d9eaac 100644 --- a/src/main/runtime/agent-status-store-wiring.test-fixture.ts +++ b/src/main/runtime/agent-status-store-wiring.test-fixture.ts @@ -26,7 +26,8 @@ export function makeAgentStatusStoreWiring(): { paneKey: string ) => ReturnType reconcileAgentStatusForEndedProcess: ( - paneKeys: Parameters[0] + paneKeys: Parameters[0], + options?: Parameters[1] ) => void } /** Call once the runtime exists; returns the republish teardown. */ @@ -42,8 +43,8 @@ export function makeAgentStatusStoreWiring(): { getAgentProviderSessionSnapshot: () => statusStore.getStatusSnapshot(), getAgentProviderSessionRowsForPane: (paneKey) => statusStore.getStatusSnapshotForPane(paneKey), - reconcileAgentStatusForEndedProcess: (paneKeys) => { - statusStore.reconcileEndedProcessForPaneKeys(paneKeys) + reconcileAgentStatusForEndedProcess: (paneKeys, options) => { + statusStore.reconcileEndedProcessForPaneKeys(paneKeys, options) } }, attach: (runtime) => installHookStatusSessionTabsRepublish(statusStore, () => runtime) diff --git a/src/main/runtime/command-end-agent-exit-verdict.ts b/src/main/runtime/command-end-agent-exit-verdict.ts deleted file mode 100644 index 9acd5a1928ad..000000000000 --- a/src/main/runtime/command-end-agent-exit-verdict.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { AgentProcessVerdict } from '../../shared/agent-process-presence' -import { recognizeAgentProcess } from '../../shared/agent-process-recognition' -import { isShellProcess } from '../../shared/shell-process-detection' - -type CommandEndAgentExitDeps = { - /** A fresh foreground read on the PTY's execution host; throws or answers null when it cannot. */ - readForegroundProcess(): Promise - /** The hook presence check: a Claude row's own pid and start time; null when no hook identified it. */ - checkHookAgentPresence(paneKey: string): Promise -} - -/** - * Whether the agent behind a command end (OSC 133;D) really exited. A full-screen agent's nested - * shells leak their own 133;D onto the pane, so the mark alone never proves it. The foreground - * answers first, as the renderer's command-finished confirmation does: an agent there is `live`, - * a shell there is `exited`. Anything else (no answer, a wrapper, another command) falls back to - * the hook presence check, and without proof either way the verdict is `unverifiable`. - */ -export async function verifyCommandEndAgentExit( - paneKeys: Iterable, - deps: CommandEndAgentExitDeps -): Promise { - let foreground: string | null = null - try { - foreground = await deps.readForegroundProcess() - } catch { - // Loss of contact with the execution host is never evidence the agent exited. - } - if (foreground && recognizeAgentProcess(foreground)) { - return 'live' - } - if (foreground && isShellProcess(foreground)) { - return 'exited' - } - const verdicts = await Promise.all( - Array.from(paneKeys, (paneKey) => deps.checkHookAgentPresence(paneKey).catch(() => null)) - ) - if (verdicts.includes('live')) { - return 'live' - } - return verdicts.includes('exited') && !verdicts.includes('unverifiable') - ? 'exited' - : 'unverifiable' -} diff --git a/src/main/runtime/command-end-agent-exit-verification.test.ts b/src/main/runtime/command-end-agent-exit-verification.test.ts new file mode 100644 index 000000000000..f9ef673d149d --- /dev/null +++ b/src/main/runtime/command-end-agent-exit-verification.test.ts @@ -0,0 +1,302 @@ +import { createHash } from 'node:crypto' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + endCommand, + expectEveryReaderSawTheClear, + expectNoReaderLostTheRow, + launchAgentPane, + liveRow, + postHook, + settle, + shellPane, + wireCommandEndHost, + type CommandEndHost +} from './command-end-host-wiring.test-fixture' + +// A command end (OSC 133;D) in a pane whose agent holds a live row asks the execution host whether +// that agent exited, every time, whatever launched it. A verified exit clears the row for every +// reader in one step; anything short of proof keeps it until the next command end asks again. +// A full-screen agent's nested shells leak their own 133;D, so the mark alone proves nothing. + +const probe = vi.hoisted(() => vi.fn()) +vi.mock('../../shared/agent-process-presence-probe', () => ({ probeAgentProcessPresence: probe })) + +vi.mock('../git/worktree', () => { + const worktrees = [ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/retirement-clear', + isBare: false, + isMainWorktree: false + } + ] + return { + listWorktrees: vi.fn().mockResolvedValue(worktrees), + listWorktreesStrict: vi.fn().mockResolvedValue(worktrees) + } +}) + +const hosts: CommandEndHost[] = [] + +afterEach(() => { + probe.mockReset() + for (const host of hosts.splice(0)) { + host.teardown() + } + vi.restoreAllMocks() +}) + +async function wire(): Promise { + const host = await wireCommandEndHost() + hosts.push(host) + return host +} + +const CLAUDE_PROCESS = { + agentProcess: JSON.stringify({ pid: 4001, platform: process.platform, startTime: 'birth' }) +} + +async function claudeIsWorking( + host: CommandEndHost, + pane: { paneKey: string; launchToken?: string }, + extra: Record = {} +): Promise { + await postHook( + host.server, + 'claude', + pane, + { hook_event_name: 'UserPromptSubmit', session_id: 'claude-session', prompt: 'review the PR' }, + extra + ) + expect(liveRow(host.server, pane.paneKey)?.state).toBe('working') +} + +async function claudeIsDone(host: CommandEndHost, pane: { paneKey: string }): Promise { + await postHook(host.server, 'claude', pane, { + hook_event_name: 'Stop', + session_id: 'claude-session' + }) + expect(liveRow(host.server, pane.paneKey)?.state).toBe('done') +} + +const TAB = '11111111-1111-4111-8111-111111111111' +const LEAF = '22222222-2222-4222-8222-222222222222' + +describe('a command end whose agent exit is verified clears the row for every reader', () => { + for (const path of ['shell bytes', 'daemon fact'] as const) { + const id = path.replace(' ', '-') + + it(`Orca-launched pane (${path})`, async () => { + const host = await wire() + const pane = await launchAgentPane(host, `pty-launched-${id}`) + await claudeIsWorking(host, pane) + host.readers.republishedWorktrees.length = 0 + + await endCommand(host.runtime, pane.ptyId, path) + + expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) + // The shell outlived its agent, so the session stays resumable in place. + const remnant = host.server.getStatusSnapshotForPane(pane.paneKey) + expect(remnant).toHaveLength(1) + expect(remnant[0]?.providerSessionOnly).toBe(true) + expect(remnant[0]?.launchToken).toBeUndefined() + }) + + it(`restored pane whose authority came from a prior listing (${path})`, async () => { + const host = await wire() + const pane = shellPane(host.runtime, `pty-restored-${id}`, { + tabId: TAB, + leafId: LEAF, + listingReceipt: true + }) + await claudeIsWorking(host, pane) + host.readers.republishedWorktrees.length = 0 + + await endCommand(host.runtime, pane.ptyId, path) + + expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) + }) + + it(`a pane the user typed the agent into, with no launch authority (${path})`, async () => { + const host = await wire() + const pane = shellPane(host.runtime, `pty-typed-${id}`, { tabId: TAB, leafId: LEAF }) + await claudeIsWorking(host, pane) + host.readers.republishedWorktrees.length = 0 + + await endCommand(host.runtime, pane.ptyId, path) + + expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) + }) + } + + it('a Done row is cleared too: an exited agent is not waiting for the user', async () => { + const host = await wire() + const pane = await launchAgentPane(host, 'pty-launched-done') + await claudeIsWorking(host, pane) + await claudeIsDone(host, pane) + + await endCommand(host.runtime, pane.ptyId, 'shell bytes') + + expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) + }) + + it("Claude's own process gone clears the row even when the shell check cannot say", async () => { + probe.mockResolvedValue('exited') + const host = await wire() + host.shellOwnsForeground.mockResolvedValue(false) + const pane = await launchAgentPane(host, 'pty-launched-presence') + await claudeIsWorking(host, pane, CLAUDE_PROCESS) + host.readers.republishedWorktrees.length = 0 + + await endCommand(host.runtime, pane.ptyId, 'daemon fact') + + expect(probe).toHaveBeenCalled() + expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) + }) + + it('a PTY exit clears every reader and keeps no resume identity', async () => { + const host = await wire() + const pane = await launchAgentPane(host, 'pty-launched-exit') + await claudeIsWorking(host, pane) + host.readers.republishedWorktrees.length = 0 + + await host.runtime.onPtyExit(pane.ptyId, 0, `${pane.ptyId}-incarnation`) + + expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) + expect(host.server.getStatusSnapshotForPane(pane.paneKey)).toEqual([]) + }) + + it('fences nothing: the next agent the user starts in that shell shows', async () => { + const host = await wire() + const pane = await launchAgentPane(host, 'pty-launched-next') + await claudeIsWorking(host, pane) + await endCommand(host.runtime, pane.ptyId, 'shell bytes') + expect(liveRow(host.server, pane.paneKey)).toBeUndefined() + + await postHook(host.server, 'claude', pane, { + hook_event_name: 'PreToolUse', + session_id: 'next-session', + tool_name: 'Bash' + }) + + expect(liveRow(host.server, pane.paneKey)?.state).toBe('working') + }) +}) + +describe('a command end that does not prove the agent exited keeps its row everywhere', () => { + for (const path of ['shell bytes', 'daemon fact'] as const) { + it(`a nested shell's leaked 133;D under a live TUI, then its real exit (${path})`, async () => { + const host = await wire() + const pane = await launchAgentPane(host, `pty-nested-${path.replace(' ', '-')}`) + await claudeIsWorking(host, pane) + await claudeIsDone(host, pane) + // The full-screen agent still owns the foreground. + host.shellOwnsForeground.mockResolvedValue(false) + + await endCommand(host.runtime, pane.ptyId, path) + + expectNoReaderLostTheRow(host.server, host.readers, pane.paneKey, 'done') + + // The agent quits; its shell's prompt returns. + host.shellOwnsForeground.mockResolvedValue(true) + await endCommand(host.runtime, pane.ptyId, path) + + expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) + }) + } + + it('a live agent pid outranks a shell in the foreground (a suspended or nested agent)', async () => { + probe.mockResolvedValue('live') + const host = await wire() + host.shellOwnsForeground.mockResolvedValue(true) + const pane = await launchAgentPane(host, 'pty-live-pid') + await claudeIsWorking(host, pane, CLAUDE_PROCESS) + + await endCommand(host.runtime, pane.ptyId, 'shell bytes') + + expectNoReaderLostTheRow(host.server, host.readers, pane.paneKey, 'working') + }) + + it('an unanswered check keeps the row and is asked again at the next command end', async () => { + const host = await wire() + host.shellOwnsForeground.mockRejectedValueOnce(new Error('host unreachable')) + const pane = await launchAgentPane(host, 'pty-unanswered') + await claudeIsWorking(host, pane) + + await endCommand(host.runtime, pane.ptyId, 'daemon fact') + expectNoReaderLostTheRow(host.server, host.readers, pane.paneKey, 'working') + + await endCommand(host.runtime, pane.ptyId, 'daemon fact') + expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) + }) + + it('a session that starts while the check is read keeps its row', async () => { + const host = await wire() + let answer: (shellOwnsForeground: boolean) => void = () => {} + host.shellOwnsForeground.mockImplementationOnce( + () => new Promise((resolve) => (answer = resolve)) + ) + const pane = await launchAgentPane(host, 'pty-next-session') + await claudeIsWorking(host, pane) + + await endCommand(host.runtime, pane.ptyId, 'shell bytes') + // The first agent exited; the user starts another before the slow read lands. + await new Promise((resolve) => setTimeout(resolve, 2)) + await postHook(host.server, 'claude', pane, { + hook_event_name: 'UserPromptSubmit', + session_id: 'second-session', + prompt: 'a different task' + }) + answer(true) + await settle() + + expect(liveRow(host.server, pane.paneKey)?.prompt).toBe('a different task') + expect(host.readers.windowClears).toEqual([]) + }) + + it('a command end that lands while a check is in flight is checked too', async () => { + const host = await wire() + let answer: (shellOwnsForeground: boolean) => void = () => {} + host.shellOwnsForeground.mockImplementationOnce( + () => new Promise((resolve) => (answer = resolve)) + ) + const pane = await launchAgentPane(host, 'pty-in-flight') + await claudeIsWorking(host, pane) + + // A leaked 133;D starts a check that sees the agent; the real exit lands before it answers. + await endCommand(host.runtime, pane.ptyId, 'daemon fact') + await endCommand(host.runtime, pane.ptyId, 'daemon fact') + answer(false) + await settle() + + expect(host.shellOwnsForeground).toHaveBeenCalledTimes(2) + expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) + }) + + it('asks nothing for a shell whose panes hold no live row', async () => { + const host = await wire() + const pane = shellPane(host.runtime, 'pty-plain-shell', { tabId: TAB, leafId: LEAF }) + + await endCommand(host.runtime, pane.ptyId, 'shell bytes') + await endCommand(host.runtime, pane.ptyId, 'daemon fact') + + expect(host.shellOwnsForeground).not.toHaveBeenCalled() + }) + + it('still drops launch authority at once: the token lives on in the shell', async () => { + const host = await wire() + host.shellOwnsForeground.mockResolvedValue(false) + const pane = await launchAgentPane(host, 'pty-authority') + expect(host.runtime.readPaneLaunchAuthority(pane.paneKey)?.launchTokenHash).toBe( + createHash('sha256').update(pane.launchToken).digest('hex') + ) + + host.runtime.emitDaemonPtyTransientFact(pane.ptyId, { + kind: 'command-finished', + exitCode: 0 + }) + + expect(host.runtime.readPaneLaunchAuthority(pane.paneKey)).toEqual({ launchTokenHash: null }) + }) +}) diff --git a/src/main/runtime/command-end-agent-exit-verifier.ts b/src/main/runtime/command-end-agent-exit-verifier.ts new file mode 100644 index 000000000000..ab8c9b9df9c9 --- /dev/null +++ b/src/main/runtime/command-end-agent-exit-verifier.ts @@ -0,0 +1,82 @@ +import type { AgentProcessVerdict } from '../../shared/agent-process-presence' + +type CommandEndAgentExitDeps = { + /** The PTY's pane keys whose host row is live (not a resume remnant), each with that row's + * `receivedAt`: the anchor a verdict may act on. */ + readLiveRowAnchors(ptyId: string): Map + /** The hook presence check: the row's own agent pid and start time. An `exited` answer has + * already cleared that row; null when no hook identified a process. */ + checkHookAgentPresence(paneKey: string): Promise + /** The PTY's spawned shell owns the foreground with no job stopped; false when its host cannot say. */ + confirmShellOwnsForeground(ptyId: string): Promise + /** Clears a pane whose agent exited, unless its row changed after `armedRowReceivedAt`. */ + reconcileEndedProcess(paneKey: string, armedRowReceivedAt: number): void +} + +/** + * Whether the agent behind a command end (OSC 133;D) exited. A full-screen agent's nested shells + * leak their own 133;D, so the mark alone proves nothing. A live agent pid wins; then the spawned + * shell owning the foreground with nothing stopped proves the exit. Anything else keeps the row. + */ +export async function verifyCommandEndAgentExit( + ptyId: string, + paneKeys: Iterable, + deps: Pick +): Promise { + const verdicts = await Promise.all( + Array.from(paneKeys, (paneKey) => deps.checkHookAgentPresence(paneKey).catch(() => null)) + ) + if (verdicts.includes('live')) { + return 'live' + } + return (await deps.confirmShellOwnsForeground(ptyId).catch(() => false)) + ? 'exited' + : 'unverifiable' +} + +/** + * Re-derives every command end, for every PTY whose panes hold a live row, whether its agent exited. + * Nothing is latched: a row a verdict kept is asked about again at the PTY's next command end. + */ +export class CommandEndAgentExitVerifier { + /** Per PTY: whether another command end arrived while a verdict was in flight. */ + private readonly running = new Map() + + constructor(private readonly deps: CommandEndAgentExitDeps) {} + + onCommandEnd(ptyId: string): void { + const running = this.running.get(ptyId) + if (running) { + // Why rerun, not skip: this mark may be the real exit, after the in-flight read saw the agent. + running.rerun = true + return + } + void this.run(ptyId) + } + + private async run(ptyId: string): Promise { + const state = { rerun: true } + this.running.set(ptyId, state) + try { + while (state.rerun) { + state.rerun = false + await this.verifyOnce(ptyId) + } + } finally { + this.running.delete(ptyId) + } + } + + private async verifyOnce(ptyId: string): Promise { + const anchors = this.deps.readLiveRowAnchors(ptyId) + if (anchors.size === 0) { + return + } + if ((await verifyCommandEndAgentExit(ptyId, anchors.keys(), this.deps)) !== 'exited') { + return + } + for (const [paneKey, receivedAt] of anchors) { + this.deps.reconcileEndedProcess(paneKey, receivedAt) + } + } +} diff --git a/src/main/runtime/command-end-host-wiring.test-fixture.ts b/src/main/runtime/command-end-host-wiring.test-fixture.ts new file mode 100644 index 000000000000..328e5fe86289 --- /dev/null +++ b/src/main/runtime/command-end-host-wiring.test-fixture.ts @@ -0,0 +1,215 @@ +import { expect, vi } from 'vitest' +import type { AgentStatusClearIpcPayload } from '../../shared/agent-status-types' +import { AgentHookServer } from '../agent-hooks/server' +import { installHookStatusSessionTabsRepublish } from '../agent-hooks/hook-status-session-tabs-republish' +import { OrcaRuntimeService } from './orca-runtime' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' + +// The host wiring `main-process-runtime-service.ts` performs, plus every pane-clear reader, for the +// command-end specs. Callers must mock `../git/worktree` to list WORKTREE_PATH. + +export const WORKTREE_PATH = '/tmp/worktree-a' +export const TEST_WORKTREE_ID = `repo-1::${WORKTREE_PATH}` + +export type Readers = { + windowClears: AgentStatusClearIpcPayload[] + subscriberClears: AgentStatusClearIpcPayload[] + republishedWorktrees: string[] +} + +export type CommandEndHost = { + server: AgentHookServer + runtime: OrcaRuntimeService + readers: Readers + spawn: ReturnType + /** The execution host's answer to "does the spawned shell own the foreground, nothing stopped?" */ + shellOwnsForeground: ReturnType Promise>> + teardown: () => void +} + +export async function wireCommandEndHost( + options: { userDataPath?: string; server?: AgentHookServer } = {} +): Promise { + const server = options.server ?? new AgentHookServer() + if (!options.server) { + await server.start({ + env: 'production', + ...(options.userDataPath ? { userDataPath: options.userDataPath } : {}) + }) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared fixture store implements only the members these runtime paths read. + const runtime = new OrcaRuntimeService(makeStore() as never, undefined, { + getAgentStatusSnapshot: () => + server.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + getAgentProviderSessionSnapshot: () => server.getStatusSnapshot(), + getAgentProviderSessionRowsForPane: (paneKey) => server.getStatusSnapshotForPane(paneKey), + attestAgentHookCompatibilityAuthority: (candidate) => + server.attestCompatibilityAuthority(candidate), + retireAgentHookCompatibilityAuthority: (paneKey, retireOptions) => + server.retirePaneAuthority(paneKey, undefined, retireOptions), + checkHookAgentPresence: (paneKey) => server.checkAgentPresence(paneKey), + reconcileAgentStatusForEndedProcess: (paneKeys, reconcileOptions) => + server.reconcileEndedProcessForPaneKeys(paneKeys, reconcileOptions) + }) + server.setPaneLaunchAuthorityReader((paneKey) => runtime.readPaneLaunchAuthority(paneKey)) + const readers: Readers = { windowClears: [], subscriberClears: [], republishedWorktrees: [] } + server.setPaneStatusClearListener((clear) => readers.windowClears.push(clear)) + const unsubscribeClears = server.subscribePaneStatusClear((clear) => + readers.subscriberClears.push(clear) + ) + vi.spyOn(runtime, 'touchMobileSessionTabsForWorktree').mockImplementation((worktreeId) => { + readers.republishedWorktrees.push(worktreeId) + }) + const uninstallRepublish = installHookStatusSessionTabsRepublish(server, () => runtime) + const spawn = vi.fn() + const shellOwnsForeground = vi.fn<(ptyId: string) => Promise>(async () => true) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + confirmShellForeground: shellOwnsForeground + }) + return { + server, + runtime, + readers, + spawn, + shellOwnsForeground, + teardown: () => { + unsubscribeClears() + uninstallRepublish() + server.stop() + } + } +} + +export async function postHook( + server: AgentHookServer, + source: 'claude' | 'codex' | 'opencode', + pane: { paneKey: string; launchToken?: string }, + payload: Record, + extra: Record = {} +): Promise { + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/${source}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ + paneKey: pane.paneKey, + ...(pane.launchToken ? { launchToken: pane.launchToken } : {}), + tabId: pane.paneKey.split(':')[0], + worktreeId: TEST_WORKTREE_ID, + env: 'production', + ...extra, + payload + }) + }) + expect(response.status).toBe(204) +} + +export function liveRow(server: AgentHookServer, paneKey: string) { + return server + .getStatusSnapshotForPane(paneKey) + .find((row) => row.providerSessionOnly !== true && row.paneKey === paneKey) +} + +/** An agent Orca launched into a fresh pane: it holds a launch token and a launch agent. */ +export async function launchAgentPane( + host: Pick, + ptyId: string, + agent: 'claude' | 'codex' | 'opencode' = 'claude' +): Promise<{ ptyId: string; paneKey: string; launchToken: string }> { + host.spawn.mockResolvedValueOnce({ id: ptyId, incarnationId: `${ptyId}-incarnation` }) + await host.runtime.createTerminal(`path:${WORKTREE_PATH}`, { + command: agent, + launchConfig: { agentCommand: agent, agentArgs: '', agentEnv: {} }, + launchAgent: agent + }) + const env: Record = host.spawn.mock.lastCall?.[0]?.env ?? {} + expect(env.ORCA_PANE_KEY).toBeTruthy() + expect(env.ORCA_AGENT_LAUNCH_TOKEN).toBeTruthy() + return { ptyId, paneKey: env.ORCA_PANE_KEY, launchToken: env.ORCA_AGENT_LAUNCH_TOKEN } +} + +type RuntimeInternals = { + recordPtyWorktree: (ptyId: string, worktreeId: string, state: Record) => unknown + rememberRestoredOrchestrationAuthority: ( + pty: unknown, + terminalHandle: string, + incarnationId: string + ) => void +} + +/** A shell pane Orca did not launch an agent into: no launch token, no launch agent, no receipt. + * With `listingReceipt`, a restored daemon pane whose only authority is the receipt a listing's + * controller-inventory refresh mints for an exactly restored surface. */ +export function shellPane( + runtime: OrcaRuntimeService, + ptyId: string, + options: { tabId: string; leafId: string; listingReceipt?: boolean } +): { ptyId: string; paneKey: string } { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these protected members exist on the runtime; the listing path that calls them needs a live controller inventory. + const internals = runtime as unknown as RuntimeInternals + const paneKey = `${options.tabId}:${options.leafId}` + const pty = internals.recordPtyWorktree(ptyId, TEST_WORKTREE_ID, { + connected: true, + tabId: options.tabId, + paneKey, + incarnationId: `${ptyId}-incarnation` + }) + if (options.listingReceipt) { + internals.rememberRestoredOrchestrationAuthority(pty, `term-${ptyId}`, `${ptyId}-incarnation`) + } + return { ptyId, paneKey } +} + +export type CommandEndPath = 'shell bytes' | 'daemon fact' + +/** A command end on either path main hears it on, then the verification it starts. */ +export async function endCommand( + runtime: OrcaRuntimeService, + ptyId: string, + path: CommandEndPath +): Promise { + if (path === 'shell bytes') { + runtime.onPtyData(ptyId, '\x1b]133;D;0\x07', Date.now()) + } else { + runtime.emitDaemonPtyTransientFact(ptyId, { kind: 'command-finished', exitCode: 0 }) + } + await settle() +} + +export async function settle(ticks = 10): Promise { + for (let tick = 0; tick < ticks; tick += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +export function expectNoReaderLostTheRow( + server: AgentHookServer, + readers: Readers, + paneKey: string, + state: string +): void { + expect(liveRow(server, paneKey)?.state).toBe(state) + expect(readers.windowClears).toEqual([]) + expect(readers.subscriberClears).toEqual([]) +} + +export function expectEveryReaderSawTheClear( + server: AgentHookServer, + readers: Readers, + paneKey: string +): void { + expect(liveRow(server, paneKey)).toBeUndefined() + // Desktop window and dashboard popout share this listener. + expect(readers.windowClears).toContainEqual({ paneKey }) + // Tab-title spinner and session-stats recorder subscribe here. + expect(readers.subscriberClears).toContainEqual({ paneKey }) + // Mobile `session.tabs` republish. + expect(readers.republishedWorktrees).toContain(TEST_WORKTREE_ID) +} diff --git a/src/main/runtime/launch-authority-retirement-reader-clear.test.ts b/src/main/runtime/launch-authority-retirement-reader-clear.test.ts deleted file mode 100644 index a4c1df9c29f3..000000000000 --- a/src/main/runtime/launch-authority-retirement-reader-clear.test.ts +++ /dev/null @@ -1,454 +0,0 @@ -import { createHash } from 'node:crypto' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentStatusClearIpcPayload } from '../../shared/agent-status-types' -import { AgentHookServer } from '../agent-hooks/server' -import { installHookStatusSessionTabsRepublish } from '../agent-hooks/hook-status-session-tabs-republish' -import { OrcaRuntimeService } from './orca-runtime' -import { makeStore } from './runtime-rpc-worktree-store-fixtures' - -// A command end (OSC 133;D) in a pane holding launch authority retires that authority. The pane's -// rows go only once the agent's exit is verified, and then every reader hears it in the same step: -// before, the host deleted them on the bare mark and told only `worktree ps` and mobile, so the -// desktop window, dashboard popout, tab-title spinner and stats recorder kept a row the host had -// deleted, and a nested shell's leaked 133;D erased a live agent's row. - -const WORKTREE_PATH = '/tmp/worktree-a' -const TEST_WORKTREE_ID = `repo-1::${WORKTREE_PATH}` - -const probe = vi.hoisted(() => vi.fn()) -vi.mock('../../shared/agent-process-presence-probe', () => ({ probeAgentProcessPresence: probe })) - -vi.mock('../git/worktree', () => { - const worktrees = [ - { - path: '/tmp/worktree-a', - head: 'abc', - branch: 'feature/retirement-clear', - isBare: false, - isMainWorktree: false - } - ] - return { - listWorktrees: vi.fn().mockResolvedValue(worktrees), - listWorktreesStrict: vi.fn().mockResolvedValue(worktrees) - } -}) - -const servers: AgentHookServer[] = [] -const teardowns: (() => void)[] = [] - -afterEach(() => { - probe.mockReset() - for (const teardown of teardowns.splice(0)) { - teardown() - } - for (const server of servers.splice(0)) { - server.stop() - } - vi.restoreAllMocks() -}) - -type Readers = { - windowClears: AgentStatusClearIpcPayload[] - subscriberClears: AgentStatusClearIpcPayload[] - republishedWorktrees: string[] -} - -/** The host wiring `main-process-runtime-service.ts` performs, plus every pane-clear reader. */ -async function wireHost( - foreground: () => Promise = async () => 'zsh', - userDataPath?: string -): Promise<{ - server: AgentHookServer - runtime: OrcaRuntimeService - readers: Readers - spawn: ReturnType -}> { - const server = new AgentHookServer() - servers.push(server) - await server.start({ env: 'production', ...(userDataPath ? { userDataPath } : {}) }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared fixture store implements only the members these runtime paths read. - const runtime = new OrcaRuntimeService(makeStore() as never, undefined, { - getAgentStatusSnapshot: () => - server.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), - getAgentProviderSessionSnapshot: () => server.getStatusSnapshot(), - getAgentProviderSessionRowsForPane: (paneKey) => server.getStatusSnapshotForPane(paneKey), - attestAgentHookCompatibilityAuthority: (candidate) => - server.attestCompatibilityAuthority(candidate), - retireAgentHookCompatibilityAuthority: (paneKey, options) => - server.retirePaneAuthority(paneKey, undefined, options), - checkHookAgentPresence: (paneKey) => server.checkAgentPresence(paneKey), - reconcileAgentStatusForEndedProcess: (paneKeys) => - server.reconcileEndedProcessForPaneKeys(paneKeys) - }) - server.setPaneLaunchAuthorityReader((paneKey) => runtime.readPaneLaunchAuthority(paneKey)) - const readers: Readers = { windowClears: [], subscriberClears: [], republishedWorktrees: [] } - server.setPaneStatusClearListener((clear) => readers.windowClears.push(clear)) - teardowns.push(server.subscribePaneStatusClear((clear) => readers.subscriberClears.push(clear))) - vi.spyOn(runtime, 'touchMobileSessionTabsForWorktree').mockImplementation((worktreeId) => { - readers.republishedWorktrees.push(worktreeId) - }) - teardowns.push(installHookStatusSessionTabsRepublish(server, () => runtime)) - const spawn = vi.fn() - runtime.setPtyController({ - spawn, - write: () => true, - kill: () => true, - getForegroundProcess: foreground, - confirmForegroundProcess: foreground - }) - return { server, runtime, readers, spawn } -} - -async function postClaudeHook( - server: AgentHookServer, - pane: { paneKey: string; launchToken?: string }, - payload: Record -): Promise { - const env = server.buildPtyEnv() - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify({ - paneKey: pane.paneKey, - ...(pane.launchToken ? { launchToken: pane.launchToken } : {}), - tabId: pane.paneKey.split(':')[0], - worktreeId: TEST_WORKTREE_ID, - env: 'production', - payload - }) - }) - expect(response.status).toBe(204) -} - -async function claudeIsWorking( - server: AgentHookServer, - pane: { paneKey: string; launchToken?: string } -): Promise { - await postClaudeHook(server, pane, { - hook_event_name: 'UserPromptSubmit', - session_id: 'claude-session', - prompt: 'review the PR' - }) - expect(liveRow(server, pane.paneKey)?.state).toBe('working') -} - -async function claudeIsWorkingWithProcess( - server: AgentHookServer, - pane: { paneKey: string; launchToken?: string } -): Promise { - const env = server.buildPtyEnv() - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify({ - paneKey: pane.paneKey, - ...(pane.launchToken ? { launchToken: pane.launchToken } : {}), - tabId: pane.paneKey.split(':')[0], - worktreeId: TEST_WORKTREE_ID, - env: 'production', - agentProcess: JSON.stringify({ pid: 4001, platform: process.platform, startTime: 'birth' }), - payload: { - hook_event_name: 'UserPromptSubmit', - session_id: 'claude-session', - prompt: 'review the PR' - } - }) - }) - expect(response.status).toBe(204) - expect(server.hasVerifiableAgentProcess(pane.paneKey)).toBe(true) -} - -function liveRow(server: AgentHookServer, paneKey: string) { - return server - .getStatusSnapshotForPane(paneKey) - .find((row) => row.providerSessionOnly !== true && row.paneKey === paneKey) -} - -/** An agent Orca launched into a fresh pane: it holds a launch token and a launch agent. */ -async function launchClaudePane( - host: { runtime: OrcaRuntimeService; spawn: ReturnType }, - ptyId: string -) { - const { runtime, spawn } = host - spawn.mockResolvedValueOnce({ id: ptyId, incarnationId: `${ptyId}-incarnation` }) - await runtime.createTerminal(`path:${WORKTREE_PATH}`, { - command: 'claude', - launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: {} }, - launchAgent: 'claude' - }) - const env: Record = spawn.mock.lastCall?.[0]?.env ?? {} - expect(env.ORCA_PANE_KEY).toBeTruthy() - expect(env.ORCA_AGENT_LAUNCH_TOKEN).toBeTruthy() - return { ptyId, paneKey: env.ORCA_PANE_KEY, launchToken: env.ORCA_AGENT_LAUNCH_TOKEN } -} - -const RESTORED_TAB = '11111111-1111-4111-8111-111111111111' -const RESTORED_PANE = `${RESTORED_TAB}:22222222-2222-4222-8222-222222222222` - -/** A restored daemon pane the user typed `claude` into. It holds no launch token; its only - * authority is the receipt a listing's controller-inventory refresh mints for an exactly - * restored surface (`rememberRestoredOrchestrationAuthority`). */ -function restoredPaneWithListingReceipt(runtime: OrcaRuntimeService, ptyId: string) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these protected members exist on the runtime; the listing path that calls them needs a live controller inventory. - const internals = runtime as unknown as { - recordPtyWorktree: ( - ptyId: string, - worktreeId: string, - state: Record - ) => unknown - rememberRestoredOrchestrationAuthority: ( - pty: unknown, - terminalHandle: string, - incarnationId: string - ) => void - } - const pty = internals.recordPtyWorktree(ptyId, TEST_WORKTREE_ID, { - connected: true, - tabId: RESTORED_TAB, - paneKey: RESTORED_PANE, - incarnationId: 'restored-incarnation' - }) - internals.rememberRestoredOrchestrationAuthority(pty, `term-${ptyId}`, 'restored-incarnation') - return { ptyId, paneKey: RESTORED_PANE } -} - -type CommandEndPath = 'shell bytes' | 'daemon fact' - -/** A command end, then the verification it starts. */ -async function endCommand( - runtime: OrcaRuntimeService, - ptyId: string, - path: CommandEndPath -): Promise { - if (path === 'shell bytes') { - runtime.onPtyData(ptyId, '\x1b]133;D;0\x07', Date.now()) - } else { - runtime.emitDaemonPtyTransientFact(ptyId, { kind: 'command-finished', exitCode: 0 }) - } - for (let tick = 0; tick < 10; tick += 1) { - await new Promise((resolve) => setTimeout(resolve, 0)) - } -} - -function expectNoReaderLostTheRow( - server: AgentHookServer, - readers: Readers, - paneKey: string, - state: string -): void { - expect(liveRow(server, paneKey)?.state).toBe(state) - expect(readers.windowClears).toEqual([]) - expect(readers.subscriberClears).toEqual([]) -} - -function expectEveryReaderSawTheClear( - server: AgentHookServer, - readers: Readers, - paneKey: string -): void { - expect(liveRow(server, paneKey)).toBeUndefined() - // Desktop window and dashboard popout share this listener. - expect(readers.windowClears).toContainEqual({ paneKey }) - // Tab-title spinner and session-stats recorder subscribe here. - expect(readers.subscriberClears).toContainEqual({ paneKey }) - // Mobile `session.tabs` republish. - expect(readers.republishedWorktrees).toContain(TEST_WORKTREE_ID) -} - -describe('a command end whose agent exit is verified clears the row for every reader', () => { - for (const path of ['shell bytes', 'daemon fact'] as const) { - it(`Orca-launched Claude pane, no listing (${path})`, async () => { - const host = await wireHost() - const { server, runtime, readers } = host - const pane = await launchClaudePane(host, `pty-launched-${path.replace(' ', '-')}`) - await claudeIsWorking(server, pane) - readers.republishedWorktrees.length = 0 - - await endCommand(runtime, pane.ptyId, path) - - expectEveryReaderSawTheClear(server, readers, pane.paneKey) - // The shell outlived the command, so the session stays resumable in place. - const remnant = server.getStatusSnapshotForPane(pane.paneKey) - expect(remnant).toHaveLength(1) - expect(remnant[0]?.providerSessionOnly).toBe(true) - expect(remnant[0]?.launchToken).toBeUndefined() - // The fence holds: a late event from the exited process cannot repaint the pane. - await postClaudeHook(server, pane, { hook_event_name: 'Stop', session_id: 'claude-session' }) - expect(liveRow(server, pane.paneKey)).toBeUndefined() - }) - - it(`restored Claude pane whose authority came from a prior listing (${path})`, async () => { - const { server, runtime, readers } = await wireHost() - const pane = restoredPaneWithListingReceipt(runtime, `pty-restored-${path.replace(' ', '-')}`) - await claudeIsWorking(server, pane) - readers.republishedWorktrees.length = 0 - - await endCommand(runtime, pane.ptyId, path) - - expectEveryReaderSawTheClear(server, readers, pane.paneKey) - expect(server.getStatusSnapshotForPane(pane.paneKey)[0]?.providerSessionOnly).toBe(true) - }) - } - - it('a Done row is cleared too: an exited agent is not waiting for the user', async () => { - const host = await wireHost() - const { server, runtime, readers } = host - const pane = await launchClaudePane(host, 'pty-launched-done') - await claudeIsWorking(server, pane) - await postClaudeHook(server, pane, { hook_event_name: 'Stop', session_id: 'claude-session' }) - expect(liveRow(server, pane.paneKey)?.state).toBe('done') - - await endCommand(runtime, pane.ptyId, 'shell bytes') - - expectEveryReaderSawTheClear(server, readers, pane.paneKey) - }) - - it("falls back to the Claude process's own identity when the foreground cannot say", async () => { - probe.mockResolvedValue('exited') - const host = await wireHost(async () => 'node') - const { server, runtime, readers } = host - const pane = await launchClaudePane(host, 'pty-launched-presence') - // Presence needs the agent's own process identity on the row. - await claudeIsWorkingWithProcess(server, pane) - readers.republishedWorktrees.length = 0 - - await endCommand(runtime, pane.ptyId, 'daemon fact') - - expect(probe).toHaveBeenCalled() - expectEveryReaderSawTheClear(server, readers, pane.paneKey) - }) - - it('a PTY exit clears every reader and keeps no resume identity', async () => { - const host = await wireHost() - const { server, runtime, readers } = host - const pane = await launchClaudePane(host, 'pty-launched-exit') - await claudeIsWorking(server, pane) - readers.republishedWorktrees.length = 0 - - await runtime.onPtyExit(pane.ptyId, 0, `${pane.ptyId}-incarnation`) - - expectEveryReaderSawTheClear(server, readers, pane.paneKey) - expect(server.getStatusSnapshotForPane(pane.paneKey)).toEqual([]) - }) -}) - -describe('a command end that does not prove the agent exited keeps its row everywhere', () => { - for (const path of ['shell bytes', 'daemon fact'] as const) { - it(`a nested shell's leaked 133;D under a live agent (${path})`, async () => { - const host = await wireHost(async () => 'claude') - const { server, runtime, readers } = host - const pane = await launchClaudePane(host, `pty-nested-${path.replace(' ', '-')}`) - await claudeIsWorking(server, pane) - - await endCommand(runtime, pane.ptyId, path) - - expectNoReaderLostTheRow(server, readers, pane.paneKey, 'working') - // No fence either: the live agent's next event still lands. - await postClaudeHook(server, pane, { hook_event_name: 'Stop', session_id: 'claude-session' }) - expect(liveRow(server, pane.paneKey)?.state).toBe('done') - }) - } - - it('an execution host that cannot answer is not evidence of exit', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const host = await wireHost(async () => { - throw new Error('relay unreachable') - }) - const { server, runtime, readers } = host - const pane = await launchClaudePane(host, 'pty-unverifiable') - await claudeIsWorking(server, pane) - - await endCommand(runtime, pane.ptyId, 'daemon fact') - - expectNoReaderLostTheRow(server, readers, pane.paneKey, 'working') - expect(warn).toHaveBeenCalledWith( - '[agent-status] command end: agent exit unverifiable; pane rows kept', - { ptyId: pane.ptyId } - ) - }) - - it('still drops launch authority at once: the token lives on in the shell', async () => { - const host = await wireHost(async () => 'claude') - const { runtime } = host - const pane = await launchClaudePane(host, 'pty-authority') - expect(runtime.readPaneLaunchAuthority(pane.paneKey)?.launchTokenHash).toBe( - createHash('sha256').update(pane.launchToken).digest('hex') - ) - - runtime.emitDaemonPtyTransientFact(pane.ptyId, { kind: 'command-finished', exitCode: 0 }) - - expect(runtime.readPaneLaunchAuthority(pane.paneKey)).toEqual({ launchTokenHash: null }) - }) -}) - -describe('the launch token a shell keeps after its command ends', () => { - it('never vouches for a later process that inherits it, before or after a restart', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-inherited-token-')) - teardowns.push(() => rmSync(userDataPath, { recursive: true, force: true })) - // The foreground shows an agent again: a new `claude` the user typed in the same shell. - const host = await wireHost(async () => 'claude', userDataPath) - const { server, runtime } = host - const pane = await launchClaudePane(host, 'pty-inherited-token') - const attest = (target: AgentHookServer, terminalProvenance: 'current_runtime' | 'restored') => - target.attestCompatibilityAuthority({ - paneKey: pane.paneKey, - launchTokenHash: createHash('sha256').update(pane.launchToken).digest('hex'), - connectionId: null, - terminalProvenance - }) - await claudeIsWorking(server, pane) - expect(attest(server, 'current_runtime')).not.toBeNull() - - await endCommand(runtime, pane.ptyId, 'daemon fact') - // The new process posts with the token it inherited from the shell's environment. - await postClaudeHook(server, pane, { - hook_event_name: 'UserPromptSubmit', - session_id: 'second-session', - prompt: 'a different task' - }) - - expect(liveRow(server, pane.paneKey)?.prompt).toBe('a different task') - expect(liveRow(server, pane.paneKey)?.launchToken).toBeUndefined() - expect(attest(server, 'current_runtime')).toBeNull() - - server.flushStatusPersistSync() - server.stop() - const restarted = new AgentHookServer() - servers.push(restarted) - await restarted.start({ env: 'production', userDataPath }) - expect(restarted.getHydratedAuthorityCommitments()).toHaveLength(0) - expect(attest(restarted, 'restored')).toBeNull() - }) - - it('still vouches for the agent it was minted for while that agent runs', async () => { - const host = await wireHost(async () => 'claude') - const { server } = host - const pane = await launchClaudePane(host, 'pty-live-token') - await claudeIsWorking(server, pane) - await postClaudeHook(server, pane, { - hook_event_name: 'PreToolUse', - session_id: 'claude-session', - tool_name: 'Bash' - }) - - expect(liveRow(server, pane.paneKey)?.launchToken).toBe(pane.launchToken) - expect( - server.attestCompatibilityAuthority({ - paneKey: pane.paneKey, - launchTokenHash: createHash('sha256').update(pane.launchToken).digest('hex'), - connectionId: null, - terminalProvenance: 'current_runtime' - }) - ).not.toBeNull() - }) -}) diff --git a/src/main/runtime/launch-token-liveness-after-command-end.test.ts b/src/main/runtime/launch-token-liveness-after-command-end.test.ts new file mode 100644 index 000000000000..d0866e459071 --- /dev/null +++ b/src/main/runtime/launch-token-liveness-after-command-end.test.ts @@ -0,0 +1,232 @@ +import { createHash } from 'node:crypto' +import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from '../agent-hooks/server' +import { + endCommand, + launchAgentPane, + liveRow, + postHook, + wireCommandEndHost, + type CommandEndHost +} from './command-end-host-wiring.test-fixture' + +// A launch token lives in the shell's environment, so every process started after the launched +// agent's command ended inherits it. Its authority ends at that command end, and no later write — +// a hook, the Codex transcript poll, a reconnect, the persisted file — may vouch for it again. + +vi.mock('../git/worktree', () => { + const worktrees = [ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/retirement-clear', + isBare: false, + isMainWorktree: false + } + ] + return { + listWorktrees: vi.fn().mockResolvedValue(worktrees), + listWorktreesStrict: vi.fn().mockResolvedValue(worktrees) + } +}) + +const teardowns: (() => void)[] = [] + +afterEach(() => { + for (const teardown of teardowns.splice(0)) { + teardown() + } + vi.restoreAllMocks() +}) + +function tempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)) + teardowns.push(() => rmSync(dir, { recursive: true, force: true })) + return dir +} + +/** A host whose agent still owns the foreground: every command end is a leaked one. */ +async function wireLiveAgentHost(userDataPath?: string): Promise { + const host = await wireCommandEndHost(userDataPath ? { userDataPath } : {}) + teardowns.push(host.teardown) + host.shellOwnsForeground.mockResolvedValue(false) + return host +} + +async function restart(userDataPath: string, previous: AgentHookServer): Promise { + previous.flushStatusPersistSync() + previous.stop() + const restarted = new AgentHookServer() + teardowns.push(() => restarted.stop()) + await restarted.start({ env: 'production', userDataPath }) + return restarted +} + +function attest( + server: AgentHookServer, + pane: { paneKey: string; launchToken: string }, + terminalProvenance: 'current_runtime' | 'restored' +) { + return server.attestCompatibilityAuthority({ + paneKey: pane.paneKey, + launchTokenHash: createHash('sha256').update(pane.launchToken).digest('hex'), + connectionId: null, + terminalProvenance + }) +} + +function setPtyConnected(host: CommandEndHost, ptyId: string, connected: boolean): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ptysById is the runtime's protected PTY record map; a relay drop or inventory miss flips exactly this flag. + const records = (host.runtime as unknown as { ptysById: Map }) + .ptysById + records.get(ptyId)!.connected = connected +} + +describe('the launch token a shell keeps after its command ends', () => { + it('never vouches for a later process that inherits it, before or after a restart', async () => { + const userDataPath = tempDir('orca-inherited-token-') + const host = await wireLiveAgentHost(userDataPath) + const pane = await launchAgentPane(host, 'pty-inherited-token') + await postHook(host.server, 'claude', pane, { + hook_event_name: 'UserPromptSubmit', + session_id: 'claude-session', + prompt: 'review the PR' + }) + expect(attest(host.server, pane, 'current_runtime')).not.toBeNull() + + await endCommand(host.runtime, pane.ptyId, 'daemon fact') + // A new process posts with the token it inherited from the shell's environment. + await postHook(host.server, 'claude', pane, { + hook_event_name: 'UserPromptSubmit', + session_id: 'second-session', + prompt: 'a different task' + }) + + expect(liveRow(host.server, pane.paneKey)?.prompt).toBe('a different task') + expect(liveRow(host.server, pane.paneKey)?.launchToken).toBeUndefined() + expect(attest(host.server, pane, 'current_runtime')).toBeNull() + + const restarted = await restart(userDataPath, host.server) + expect(restarted.getHydratedAuthorityCommitments()).toHaveLength(0) + expect(attest(restarted, pane, 'restored')).toBeNull() + }) + + it('is not persisted from the row the live agent wrote before its command ended', async () => { + const userDataPath = tempDir('orca-row-token-') + const host = await wireLiveAgentHost(userDataPath) + const pane = await launchAgentPane(host, 'pty-row-token') + await postHook(host.server, 'claude', pane, { + hook_event_name: 'UserPromptSubmit', + session_id: 'claude-session', + prompt: 'review the PR' + }) + + await endCommand(host.runtime, pane.ptyId, 'shell bytes') + + const restarted = await restart(userDataPath, host.server) + expect(restarted.getHydratedAuthorityCommitments()).toHaveLength(0) + expect(attest(restarted, pane, 'restored')).toBeNull() + }) + + it('is not re-minted while the PTY record reads disconnected', async () => { + const userDataPath = tempDir('orca-disconnected-token-') + const host = await wireLiveAgentHost(userDataPath) + const pane = await launchAgentPane(host, 'pty-disconnected-token') + await postHook(host.server, 'claude', pane, { + hook_event_name: 'UserPromptSubmit', + session_id: 'claude-session', + prompt: 'review the PR' + }) + await endCommand(host.runtime, pane.ptyId, 'shell bytes') + + // A relay drop or inventory miss marks the record disconnected; the agent keeps posting. + setPtyConnected(host, pane.ptyId, false) + await postHook(host.server, 'claude', pane, { + hook_event_name: 'PreToolUse', + session_id: 'claude-session', + tool_name: 'Bash' + }) + expect(attest(host.server, pane, 'current_runtime')).toBeNull() + setPtyConnected(host, pane.ptyId, true) + await postHook(host.server, 'claude', pane, { + hook_event_name: 'Stop', + session_id: 'claude-session' + }) + + expect(liveRow(host.server, pane.paneKey)?.launchToken).toBeUndefined() + expect(attest(host.server, pane, 'current_runtime')).toBeNull() + const restarted = await restart(userDataPath, host.server) + expect(restarted.getHydratedAuthorityCommitments()).toHaveLength(0) + expect(attest(restarted, pane, 'restored')).toBeNull() + }) + + it('still vouches for the agent it was minted for while that agent runs', async () => { + const host = await wireLiveAgentHost() + const pane = await launchAgentPane(host, 'pty-live-token') + await postHook(host.server, 'claude', pane, { + hook_event_name: 'PreToolUse', + session_id: 'claude-session', + tool_name: 'Bash' + }) + + expect(liveRow(host.server, pane.paneKey)?.launchToken).toBe(pane.launchToken) + expect(attest(host.server, pane, 'current_runtime')).not.toBeNull() + }) +}) + +const CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f316f' +const SECOND_CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f3170' + +function transcriptLine(record: unknown): string { + return `${JSON.stringify(record)}\n` +} + +function spawnLine(threadId: string, agentPath: string): string { + return transcriptLine({ + type: 'event_msg', + payload: { + type: 'sub_agent_activity', + occurred_at_ms: 1234, + agent_thread_id: threadId, + agent_path: agentPath, + kind: 'started' + } + }) +} + +describe('a live Codex lead after a leaked command end', () => { + it('keeps polling its transcript, and the poll cannot bring the token back', async () => { + const userDataPath = tempDir('orca-codex-poll-') + const transcripts = tempDir('orca-codex-rollout-') + const parentPath = join(transcripts, 'rollout-parent.jsonl') + const started = transcriptLine({ type: 'event_msg', payload: { type: 'task_started' } }) + writeFileSync(parentPath, spawnLine(CHILD_ID, '/root/pr_review')) + writeFileSync(join(transcripts, `rollout-child-${CHILD_ID}.jsonl`), started) + writeFileSync(join(transcripts, `rollout-child-${SECOND_CHILD_ID}.jsonl`), started) + const host = await wireLiveAgentHost(userDataPath) + const pane = await launchAgentPane(host, 'pty-codex-poll', 'codex') + await postHook(host.server, 'codex', pane, { + hook_event_name: 'PostToolUse', + session_id: 'root-session', + transcript_path: parentPath, + tool_name: 'collaborationspawn_agent' + }) + expect(liveRow(host.server, pane.paneKey)?.subagents).toHaveLength(1) + + await endCommand(host.runtime, pane.ptyId, 'shell bytes') + appendFileSync(parentPath, spawnLine(SECOND_CHILD_ID, '/root/perf_audit')) + + await vi.waitFor(() => expect(liveRow(host.server, pane.paneKey)?.subagents).toHaveLength(2), { + timeout: 3_000, + interval: 50 + }) + expect(liveRow(host.server, pane.paneKey)?.launchToken).toBeUndefined() + expect(attest(host.server, pane, 'current_runtime')).toBeNull() + const restarted = await restart(userDataPath, host.server) + expect(restarted.getHydratedAuthorityCommitments()).toHaveLength(0) + expect(attest(restarted, pane, 'restored')).toBeNull() + }) +}) diff --git a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts index 0c7b96a61f65..44d9c3fe301b 100644 --- a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts +++ b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts @@ -18,7 +18,7 @@ export class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRuntimeWithSc this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) return case 'command-finished': - this.retirePtyAgentLaunchAuthority(ptyId, 'command') + this.settleAgentAtCommandEnd(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode: fact.exitCode diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index a19ce86acf78..f7ae18bbf111 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -23,7 +23,7 @@ import type { FleetAgentStatusEvidence } from '../../shared/orchestration-fleet- import { readOrchestrationFleetAgentStatusSnapshot } from './orchestration-fleet-agent-status-snapshot' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { matchesProcessIncarnation } from './orchestration/worker-terminal-process-liveness' -import { verifyCommandEndAgentExit } from './command-end-agent-exit-verdict' +import { CommandEndAgentExitVerifier } from './command-end-agent-exit-verifier' export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller { /** Every pane key this PTY could be addressed by, including restored receipts. */ @@ -126,10 +126,11 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim } } - /** The launch token this runtime still honours for a pane's PTY; null when it has no live PTY there. */ + /** The launch token this runtime still honours for a pane's PTY; null only when it has no record + * of one. A disconnected record still answers: a disconnect drops no authority, retirement does. */ readPaneLaunchAuthority(paneKey: string): { launchTokenHash: string | null } | null { const pty = this.getPtyRecordForPaneKey(paneKey) - if (!pty?.connected) { + if (!pty) { return null } return { @@ -139,6 +140,38 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim } } + protected readonly commandEndAgentExit = new CommandEndAgentExitVerifier({ + readLiveRowAnchors: (ptyId) => { + const anchors = new Map() + for (const paneKey of this.collectAgentStatusPaneKeysForPty(ptyId)) { + const row = this.getAgentProviderSessionRowsForPaneFn?.(paneKey).find( + (entry) => entry.providerSessionOnly !== true + ) + if (row) { + anchors.set(paneKey, row.receivedAt) + } + } + return anchors + }, + checkHookAgentPresence: async (paneKey) => + (await this.checkHookAgentPresenceFn?.(paneKey)) ?? null, + confirmShellOwnsForeground: async (ptyId) => + (await this.ptyController?.confirmShellForeground?.(ptyId)) ?? false, + reconcileEndedProcess: (paneKey, armedRowReceivedAt) => + this.reconcileAgentStatusForEndedProcessFn?.([paneKey], { + // The shell outlived its agent, so the session stays resumable in place. + preserveResumeIdentity: true, + armedRowReceivedAt + }) + }) + + /** A command end (OSC 133;D): the launch token lives on in the shell's environment, so its + * authority ends now; the rows wait for proof the agent exited, since nested shells leak a 133;D. */ + protected settleAgentAtCommandEnd(ptyId: string): void { + this.retirePtyAgentLaunchAuthority(ptyId, 'command') + this.commandEndAgentExit.onCommandEnd(ptyId) + } + protected retirePtyAgentLaunchAuthority(ptyId: string, ended: 'command' | 'pty'): void { const pty = this.ptysById.get(ptyId) if (!pty) { @@ -155,46 +188,12 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim pty.launchToken = null pty.launchIncarnationId = null pty.launchAgent = null - if (ended === 'pty') { - for (const paneKey of paneKeys) { - this.retireAgentHookCompatibilityAuthorityFn?.(paneKey) - } - return - } - // Why authority now and rows later: the launch token lives on in the shell's environment, but - // a 133;D alone never proves the agent exited — a full-screen agent's nested shells leak one. for (const paneKey of paneKeys) { - this.retireAgentHookCompatibilityAuthorityFn?.(paneKey, { authorityOnly: true }) - } - void this.retirePaneRowsAfterVerifiedAgentExit(pty, paneKeys) - } - - protected async retirePaneRowsAfterVerifiedAgentExit( - pty: RuntimePtyWorktreeRecord, - paneKeys: Set - ): Promise { - const { ptyId, incarnationId } = pty - const controller = this.ptyController - const verdict = await verifyCommandEndAgentExit(paneKeys, { - readForegroundProcess: async () => - controller - ? await (controller.confirmForegroundProcess?.(ptyId) ?? - controller.getForegroundProcess(ptyId)) - : null, - checkHookAgentPresence: async (paneKey) => - (await this.checkHookAgentPresenceFn?.(paneKey)) ?? null - }) - // Why: a PTY exit or respawn meanwhile settles the pane through its own teardown. - if (this.ptysById.get(ptyId) !== pty || !pty.connected || pty.incarnationId !== incarnationId) { - return - } - if (verdict === 'exited') { - // The shell outlived its agent, so the session stays resumable in place. - for (const paneKey of paneKeys) { - this.retireAgentHookCompatibilityAuthorityFn?.(paneKey, { preserveResumeIdentity: true }) + if (ended === 'command') { + this.retireAgentHookCompatibilityAuthorityFn?.(paneKey, { authorityOnly: true }) + } else { + this.retireAgentHookCompatibilityAuthorityFn?.(paneKey) } - } else if (verdict === 'unverifiable') { - console.warn('[agent-status] command end: agent exit unverifiable; pane rows kept', { ptyId }) } } diff --git a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts index a0aa78ed20ad..8e9caf165dbf 100644 --- a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts +++ b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts @@ -146,8 +146,7 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime this.openCodeRunLifetime.onCommandStarted(ptyId) }, onCommandFinished: (exitCode: number | null) => { - void this.recheckHookAgentPresenceForPty(ptyId) - this.retirePtyAgentLaunchAuthority(ptyId, 'command') + this.settleAgentAtCommandEnd(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) this.openCodeRunLifetime.onCommandFinished(ptyId, exitCode) }, diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index fc6da1e6731e..5813930a0221 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -12,7 +12,10 @@ import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect- import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' import type { ObservedAgentStatusPaneIdentity } from '../ipc/agent-status-ipc-boundary' -import type { AgentHookAuthorityAttestation } from '../agent-hooks/server' +import type { + AgentHookAuthorityAttestation, + EndedProcessReconcileOptions +} from '../agent-hooks/server' import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types' import type { AiVaultPrepareSessionResumeArgs, @@ -89,10 +92,7 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin | null protected readonly retireAgentHookCompatibilityAuthorityFn: - | (( - paneKey: string, - options?: { authorityOnly?: boolean; preserveResumeIdentity?: boolean } - ) => void) + | ((paneKey: string, options?: { authorityOnly?: boolean }) => void) | null protected readonly checkHookAgentPresenceFn: @@ -100,7 +100,7 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin | null protected readonly reconcileAgentStatusForEndedProcessFn: - | ((paneKeys: Iterable) => void) + | ((paneKeys: Iterable, options?: EndedProcessReconcileOptions) => void) | null protected readonly canRecoverPersistentLocalPtysFn: () => boolean diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index bbc7090cf472..51153e542088 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -9,7 +9,10 @@ import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect- import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' import type { ObservedAgentStatusPaneIdentity } from '../ipc/agent-status-ipc-boundary' -import type { AgentHookAuthorityAttestation } from '../agent-hooks/server' +import type { + AgentHookAuthorityAttestation, + EndedProcessReconcileOptions +} from '../agent-hooks/server' import type { AiVaultPrepareSessionResumeArgs, AiVaultPrepareSessionResumeResult @@ -83,12 +86,15 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { }) => AgentHookAuthorityAttestation | null retireAgentHookCompatibilityAuthority?: ( paneKey: string, - options?: { authorityOnly?: boolean; preserveResumeIdentity?: boolean } + options?: { authorityOnly?: boolean } ) => void checkHookAgentPresence?: ( paneKey: string ) => Promise<'live' | 'unverifiable' | 'exited' | null> - reconcileAgentStatusForEndedProcess?: (paneKeys: Iterable) => void + reconcileAgentStatusForEndedProcess?: ( + paneKeys: Iterable, + options?: EndedProcessReconcileOptions + ) => void canRecoverPersistentLocalPtys?: () => boolean // Why: the device registry lives on the RPC server, which is constructed with this runtime; // a closure defers the lookup past that ordering instead of inverting ownership. diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index dec6c8616be8..4fb212fd7c23 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -118,8 +118,8 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { retireAgentHookCompatibilityAuthority: (paneKey, options) => agentHookServer.retirePaneAuthority(paneKey, undefined, options), checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), - reconcileAgentStatusForEndedProcess: (paneKeys) => - agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), + reconcileAgentStatusForEndedProcess: (paneKeys, options) => + agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys, options), canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, // Why: evaluated per call, not captured — the RPC server that owns the device registry is // constructed with this runtime and does not exist yet at this point. From ce44d1ad9b76e223a43e7d4e70c0b7235820a912 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:18:58 -0700 Subject: [PATCH 04/19] fix(agent-status): keep an agent the host cleared out of the retained Done list Once the host's pane clear started reaching the renderer for Done rows, a Done agent that quit to the shell became a sticky "Done" in the sidebar and dashboard (and unread), while worktree ps and mobile showed nothing: the clear removed the live row without the retention suppressor that the renderer's own drop plants, and the renderer's later drop found no row. The host clears a pane only when its agent ended or the pane went away, so the clear listener now removes the row with agentGone: it plants the retention suppressor and drops a retained snapshot already taken for that pane. Nothing is echoed back to main, which already removed its row. --- .../agent-status-host-clear-retention.test.ts | 149 ++++++++++++++++++ .../ipc-events/agent-status-listeners.ts | 6 +- ...cEvents-agent-status-pane-teardown.test.ts | 4 +- ...Events-agent-status-queue-ordering.test.ts | 2 +- .../slices/agent-status-authority-actions.ts | 7 +- .../slices/agent-status-cleanup-actions.ts | 18 ++- .../slices/agent-status-slice-contract.ts | 5 +- 7 files changed, 178 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-host-clear-retention.test.ts diff --git a/src/renderer/src/hooks/ipc-events/agent-status-host-clear-retention.test.ts b/src/renderer/src/hooks/ipc-events/agent-status-host-clear-retention.test.ts new file mode 100644 index 000000000000..dff3376a37fd --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-host-clear-retention.test.ts @@ -0,0 +1,149 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { resetAgentPaneAuthorityAliasesForTests } from '@/store/slices/agent-pane-authority' +import { useRetainedAgentsSync } from '@/components/dashboard/useRetainedAgents' +import type { + AgentStatusClearIpcPayload, + AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { registerAgentStatusListeners } from './agent-status-listeners' + +// The host clears a pane only when its agent ended or the pane went away. A Done agent that quit +// is not waiting on the user, so the clear must not turn into a retained Done in the sidebar or +// dashboard while `worktree ps` and mobile show nothing. + +const initialAppState = useAppStore.getInitialState() +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') + +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 1 +} +const worktree: Worktree = { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/wt-1', + head: 'abc123', + branch: 'feature', + isBare: false, + isMainWorktree: false, + displayName: 'feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1 +} + +const doneEntry: AgentStatusEntry = { + state: 'done', + prompt: 'Fix it', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + paneKey: PANE_KEY, + terminalTitle: 'Claude', + stateHistory: [], + agentType: 'claude' +} + +let hostClear: (data: AgentStatusClearIpcPayload) => void = () => {} +const drop = vi.fn() + +beforeEach(() => { + useAppStore.setState(initialAppState, true) + resetAgentPaneAuthorityAliasesForTests() + vi.stubGlobal('api', { + agentStatus: { + onSet: () => () => {}, + onClear: (callback: (data: AgentStatusClearIpcPayload) => void) => { + hostClear = callback + return () => {} + }, + drop + } + }) + registerAgentStatusListeners({ + unsubs: [], + enqueueLiveAgentStatus: () => {}, + drainQueuedLiveAgentStatusesForPane: () => {}, + pendingAgentStatusEvents: [], + transientClearWatermarkByConnectionId: new Map(), + liveAgentStatusBurstQueue: [] + }) + useAppStore.setState({ + repos: [repo], + worktreesByRepo: { [repo.id]: [worktree] }, + tabsByWorktree: { + [worktree.id]: [ + { + id: 'tab-1', + worktreeId: worktree.id, + title: 'Claude', + ptyId: 'pty-1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + agentStatusByPaneKey: { [PANE_KEY]: doneEntry }, + agentStatusEpoch: initialAppState.agentStatusEpoch + 1 + }) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() + drop.mockReset() + useAppStore.setState(initialAppState, true) + resetAgentPaneAuthorityAliasesForTests() +}) + +async function flushEffects(): Promise { + await act(async () => { + await Promise.resolve() + }) +} + +describe('a host pane clear of a Done agent', () => { + it('removes the row everywhere and leaves no retained Done', async () => { + renderHook(() => useRetainedAgentsSync()) + await flushEffects() + + act(() => hostClear({ paneKey: PANE_KEY })) + await flushEffects() + + const state = useAppStore.getState() + expect(state.agentStatusByPaneKey[PANE_KEY]).toBeUndefined() + expect(state.retainedAgentsByPaneKey[PANE_KEY]).toBeUndefined() + // The host already removed its row; echoing a drop back would be a second removal. + expect(drop).not.toHaveBeenCalled() + }) + + it('also drops a Done the retention list took before the clear arrived', async () => { + renderHook(() => useRetainedAgentsSync()) + await flushEffects() + // The renderer's own PTY-exit cleanup removed the row first; retention kept it. + act(() => useAppStore.getState().removeAgentStatus(PANE_KEY)) + await flushEffects() + expect(useAppStore.getState().retainedAgentsByPaneKey[PANE_KEY]?.entry.state).toBe('done') + + act(() => hostClear({ paneKey: PANE_KEY })) + await flushEffects() + + expect(useAppStore.getState().retainedAgentsByPaneKey[PANE_KEY]).toBeUndefined() + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts index 8f85babc6208..025ce05611c2 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts @@ -72,7 +72,7 @@ export function registerAgentStatusListeners(args: { if (!('paneKey' in data) || typeof data.paneKey !== 'string') { return } - // Why: preserve set→clear FIFO so a queued completion still survives pane teardown. + // Why: preserve set→clear FIFO so a status queued before the clear cannot land after it. if (liveAgentStatusBurstQueue.some((queued) => queued.paneKey === data.paneKey)) { drainQueuedLiveAgentStatusesForPane(data.paneKey) } @@ -82,8 +82,8 @@ export function registerAgentStatusListeners(args: { } } // Why no Done exemption: the host clears a pane only when its agent ended or the pane went - // away, and an exited agent is not waiting on the user. Retention is presentation policy. - useAppStore.getState().removeAgentStatus(data.paneKey) + // away, and an exited agent is not waiting on the user. No echo to main: the host cleared it. + useAppStore.getState().removeAgentStatus(data.paneKey, { agentGone: true }) } ) if (unsubscribeAgentStatusClear) { diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts index 230ccc124d64..c05ac1d38e24 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts @@ -382,7 +382,7 @@ describe('useIpcEvents agent status snapshot integration', () => { onClearListenerRef.current({ paneKey: FUTURE_PANE_KEY }) expect(removeAgentStatus).toHaveBeenCalledTimes(1) - expect(removeAgentStatus).toHaveBeenCalledWith(FUTURE_PANE_KEY) + expect(removeAgentStatus).toHaveBeenCalledWith(FUTURE_PANE_KEY, { agentGone: true }) }) it('blocks cleared snapshots across remount and accepts newer reconnect replay', async () => { @@ -567,6 +567,6 @@ describe('useIpcEvents agent status snapshot integration', () => { onClearListenerRef.current({ paneKey: FUTURE_PANE_KEY }) - expect(removeAgentStatus).toHaveBeenCalledWith(FUTURE_PANE_KEY) + expect(removeAgentStatus).toHaveBeenCalledWith(FUTURE_PANE_KEY, { agentGone: true }) }) }) diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-queue-ordering.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-queue-ordering.test.ts index dc7f8814ae53..2204b3155a76 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-queue-ordering.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-queue-ordering.test.ts @@ -133,7 +133,7 @@ describe('useIpcEvents agent status snapshot integration', () => { expect(setAgentStatus).toHaveBeenCalledTimes(1) onClearListenerRef.current({ paneKey: FUTURE_PANE_KEY }) - expect(removeAgentStatus).toHaveBeenCalledWith(FUTURE_PANE_KEY) + expect(removeAgentStatus).toHaveBeenCalledWith(FUTURE_PANE_KEY, { agentGone: true }) expect(storeState.agentStatusByPaneKey).toEqual({}) vi.advanceTimersByTime(40) diff --git a/src/renderer/src/store/slices/agent-status-authority-actions.ts b/src/renderer/src/store/slices/agent-status-authority-actions.ts index 2001f6587523..27cbf34b249b 100644 --- a/src/renderer/src/store/slices/agent-status-authority-actions.ts +++ b/src/renderer/src/store/slices/agent-status-authority-actions.ts @@ -166,10 +166,9 @@ export function createAgentStatusAuthorityActions( return { recentlyRetiredAgentStatusPaneKeys: next } }) // Why: deliberately OUTSIDE the guard above, and not gated on having cleared - // anything here. This map is not a mirror of main's — main fences panes the - // renderer never hears about (retirePtyAgentLaunchAuthority on command-finished - // and PTY exit calls the hook server directly, and nothing pushes that back), and - // this map is per-window and non-persisted, so a renderer reload empties it while + // anything here. This map is not a mirror of main's — main fences panes it retires + // itself on PTY exit, which the renderer hears only as a pane clear, not as a fence — + // and this map is per-window and non-persisted, so a renderer reload empties it while // main's survives. Gating the send on a local tombstone reintroduces STA-4114 for // exactly those panes. The send is idempotent and main refuses closed tabs itself. if (typeof window !== 'undefined') { diff --git a/src/renderer/src/store/slices/agent-status-cleanup-actions.ts b/src/renderer/src/store/slices/agent-status-cleanup-actions.ts index e47369e791c5..071ddca9495c 100644 --- a/src/renderer/src/store/slices/agent-status-cleanup-actions.ts +++ b/src/renderer/src/store/slices/agent-status-cleanup-actions.ts @@ -49,14 +49,16 @@ export function createAgentStatusCleanupActions( }) }, - removeAgentStatus: (paneKey) => { + removeAgentStatus: (paneKey, opts) => { const current = get() + const agentGone = opts?.agentGone === true // Why no ack/cleared-at/manual-unread in the guard: PTY exit calls this unconditionally, // including unverified exits from a lost SSH link. A retained-only pane keeps its read // state (see preserveActivityClearedState); only a row that is actually here gets swept. if ( !(paneKey in current.agentStatusByPaneKey) && !(paneKey in current.agentLaunchConfigByPaneKey) && + !(agentGone && paneKey in current.retainedAgentsByPaneKey) && !Object.values(current.migrationUnsupportedByPtyId).some( (entry) => entry.paneKey === paneKey ) @@ -69,6 +71,16 @@ export function createAgentStatusCleanupActions( if (hasLive) { delete next[paneKey] } + // Why: an agent that is gone is not waiting on the user, so its Done must not live on as a + // retained row — suppress the one this removal would leave and drop any already taken. + const hasRetained = agentGone && paneKey in s.retainedAgentsByPaneKey + const nextRetained = hasRetained + ? { ...s.retainedAgentsByPaneKey } + : s.retainedAgentsByPaneKey + if (hasRetained) { + delete nextRetained[paneKey] + } + const needsSuppressor = agentGone && hasLive && !(paneKey in s.retentionSuppressedPaneKeys) const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey const nextLaunchConfigs = hasLaunchConfig ? { ...s.agentLaunchConfigByPaneKey } @@ -88,6 +100,10 @@ export function createAgentStatusCleanupActions( agentStatusByPaneKey: next, agentLaunchConfigByPaneKey: nextLaunchConfigs, migrationUnsupportedByPtyId: migrationUnsupported.next, + ...(hasRetained ? { retainedAgentsByPaneKey: nextRetained } : {}), + ...(needsSuppressor + ? { retentionSuppressedPaneKeys: { ...s.retentionSuppressedPaneKeys, [paneKey]: true } } + : {}), ...(nextAck !== s.acknowledgedAgentsByPaneKey ? { acknowledgedAgentsByPaneKey: nextAck } : {}), diff --git a/src/renderer/src/store/slices/agent-status-slice-contract.ts b/src/renderer/src/store/slices/agent-status-slice-contract.ts index 0cf46a222886..e8efb2c575d2 100644 --- a/src/renderer/src/store/slices/agent-status-slice-contract.ts +++ b/src/renderer/src/store/slices/agent-status-slice-contract.ts @@ -120,8 +120,9 @@ export type AgentStatusSlice = { setMigrationUnsupportedPty: (entry: MigrationUnsupportedPtyEntry) => void clearMigrationUnsupportedPty: (ptyId: string) => void - /** Remove a single entry (e.g., when a pane's terminal exits). */ - removeAgentStatus: (paneKey: string) => void + /** Remove a single entry (e.g., when a pane's terminal exits). `agentGone` (the host cleared the + * pane: its agent ended or the pane went away) also keeps the row out of retention. */ + removeAgentStatus: (paneKey: string, opts?: { agentGone?: boolean }) => void /** Remove all entries whose paneKey starts with the given prefix (tab close prefix-sweep). */ removeAgentStatusByTabPrefix: (tabIdPrefix: string) => void From 9e76ff10446c3c653062c00b92698e321665620c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:19:17 -0700 Subject: [PATCH 05/19] test(agent-status): run the STA-4557 suite on the production retirement wiring The suite dropped the retirement options and never wired the launch-authority reader, so it stayed green even with the reader wiring deleted. It now wires the hook server to the runtime the way main-process-runtime-service.ts does. --- ...pencode-finished-session-authority.test.ts | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/src/main/runtime/opencode-finished-session-authority.test.ts b/src/main/runtime/opencode-finished-session-authority.test.ts index ded271c06175..78895939a2b1 100644 --- a/src/main/runtime/opencode-finished-session-authority.test.ts +++ b/src/main/runtime/opencode-finished-session-authority.test.ts @@ -53,18 +53,37 @@ type LaunchedOpenCodePane = { async function launchOpenCodePane(options: { ptyId: string getForegroundProcess: () => Promise - retireAgentHookCompatibilityAuthority?: (paneKey: string) => void - attestAgentHookCompatibilityAuthority?: OrcaRuntimeServiceDeps['attestAgentHookCompatibilityAuthority'] + /** Wires this hook server to the runtime the way `main-process-runtime-service.ts` does. */ + server?: AgentHookServer }): Promise { const spawn = vi.fn().mockResolvedValue({ id: options.ptyId, incarnationId: 'incarnation-1' }) - const runtime = new OrcaRuntimeService(makeStore() as never, undefined, { - attestAgentHookCompatibilityAuthority: - options.attestAgentHookCompatibilityAuthority ?? - ((candidate) => ({ paneKey: candidate.paneKey, source: 'current_hook' as const })), - ...(options.retireAgentHookCompatibilityAuthority - ? { retireAgentHookCompatibilityAuthority: options.retireAgentHookCompatibilityAuthority } - : {}) - }) + const { server } = options + const runtime = new OrcaRuntimeService( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared fixture store implements only the members these runtime paths read. + makeStore() as never, + undefined, + server + ? { + getAgentStatusSnapshot: () => + server.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + getAgentProviderSessionSnapshot: () => server.getStatusSnapshot(), + getAgentProviderSessionRowsForPane: (paneKey) => server.getStatusSnapshotForPane(paneKey), + attestAgentHookCompatibilityAuthority: (candidate) => + server.attestCompatibilityAuthority(candidate), + retireAgentHookCompatibilityAuthority: (paneKey, retireOptions) => + server.retirePaneAuthority(paneKey, undefined, retireOptions), + checkHookAgentPresence: (paneKey) => server.checkAgentPresence(paneKey), + reconcileAgentStatusForEndedProcess: (paneKeys, reconcileOptions) => + server.reconcileEndedProcessForPaneKeys(paneKeys, reconcileOptions) + } + : { + attestAgentHookCompatibilityAuthority: (candidate) => ({ + paneKey: candidate.paneKey, + source: 'current_hook' as const + }) + } + ) + server?.setPaneLaunchAuthorityReader((paneKey) => runtime.readPaneLaunchAuthority(paneKey)) runtime.setPtyController({ spawn, write: () => true, @@ -91,8 +110,6 @@ async function launchOpenCodePane(options: { } } -type OrcaRuntimeServiceDeps = NonNullable[2]> - /** Drain the microtask + timer queues the deferred foreground read chains through. */ async function settle(ticks = 40): Promise { for (let tick = 0; tick < ticks; tick += 1) { @@ -174,9 +191,7 @@ describe('OpenCode finished-session launch authority (STA-4557)', () => { ptyId: 'pty-opencode-reuse', // OpenCode is a TUI: it is still the foreground process when its command completes. getForegroundProcess: async () => 'opencode', - retireAgentHookCompatibilityAuthority: (paneKey) => server.retirePaneAuthority(paneKey), - attestAgentHookCompatibilityAuthority: (candidate) => - server.attestCompatibilityAuthority(candidate) + server }) const hookEnv = server.buildPtyEnv() const post = (payload: Record): Promise => @@ -233,9 +248,7 @@ describe('OpenCode finished-session launch authority (STA-4557)', () => { const pane = await launchOpenCodePane({ ptyId: 'pty-opencode-session-boundary', getForegroundProcess: () => foreground, - retireAgentHookCompatibilityAuthority: (paneKey) => server.retirePaneAuthority(paneKey), - attestAgentHookCompatibilityAuthority: (candidate) => - server.attestCompatibilityAuthority(candidate) + server }) const hookEnv = server.buildPtyEnv() // Both sessions post the same launchToken: it lives in the PTY env, so every @@ -287,7 +300,7 @@ describe('OpenCode finished-session launch authority (STA-4557)', () => { const pane = await launchOpenCodePane({ ptyId: 'pty-opencode-restart', getForegroundProcess: async () => 'opencode', - retireAgentHookCompatibilityAuthority: (paneKey) => first.retirePaneAuthority(paneKey) + server: first }) const hookEnv = first.buildPtyEnv() await fetch(`http://127.0.0.1:${hookEnv.ORCA_AGENT_HOOK_PORT}/hook/opencode`, { From bf83e5a26e8ec77b17977774dd8860a6a8e9b99e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:46:28 -0700 Subject: [PATCH 06/19] fix(agent-status): let an SSH pane's relay evidence prove its agent exited An SSH provider has no shell-owns-the-foreground check, so the command-end verifier always kept an SSH pane's row. A headless host (orca serve, orcad, orchestration workers) then kept an exited agent's Done row for the life of the terminal, where it used to be deleted at the command end. The verifier now reads the relay's fenced foreground evidence, admitted the way the desktop pane admits it: the shell's own process group in front with no agent named in it means the agent exited. Anything else keeps the row. The evidence has no stopped-job signal, so a Ctrl+Z'd SSH agent reads as exited, as it already does on a mounted desktop pane. --- ...ommand-end-agent-exit-verification.test.ts | 76 +++++++++++++++++++ .../command-end-agent-exit-verifier.ts | 45 ++++++++++- .../command-end-host-wiring.test-fixture.ts | 23 +++++- ...me-get-orchestration-dispatch-authority.ts | 9 ++- 4 files changed, 146 insertions(+), 7 deletions(-) diff --git a/src/main/runtime/command-end-agent-exit-verification.test.ts b/src/main/runtime/command-end-agent-exit-verification.test.ts index f9ef673d149d..e0f89c1192ba 100644 --- a/src/main/runtime/command-end-agent-exit-verification.test.ts +++ b/src/main/runtime/command-end-agent-exit-verification.test.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence' import { endCommand, expectEveryReaderSawTheClear, @@ -300,3 +301,78 @@ describe('a command end that does not prove the agent exited keeps its row every expect(host.runtime.readPaneLaunchAuthority(pane.paneKey)).toEqual({ launchTokenHash: null }) }) }) + +/** What an SSH relay reports for a PTY: the shell's group in front unless another group is. */ +function relayEvidence( + ptyId: string, + front: { processName: string | null; foregroundPgid: number } | 'unverifiable' +): { foregroundProcessEvidence: RemoteForegroundEvidence } { + const observation = { + authorityGeneration: 'relay-generation', + observationEpoch: 1, + capturedAgeMs: 0, + ptyId, + ptyIncarnationId: `${ptyId}-incarnation` + } + return { + foregroundProcessEvidence: + front === 'unverifiable' + ? { ...observation, verdict: 'unverifiable', reason: 'process_table_unreadable' } + : { + ...observation, + verdict: 'live', + processName: front.processName, + fence: { + platform: 'posix', + shellPid: 100, + shellStartTime: 'shell-birth', + tty: '/dev/pts/3', + foregroundPgid: front.foregroundPgid + } + } + } +} + +describe("an SSH pane answers from its relay's foreground evidence", () => { + async function sshAgentPane(host: CommandEndHost, ptyId: string) { + const pane = shellPane(host.runtime, ptyId, { + tabId: TAB, + leafId: LEAF, + connectionId: 'conn-1' + }) + await claudeIsWorking(host, pane) + host.readers.republishedWorktrees.length = 0 + return pane + } + + it('the shell back in front with no agent named clears the row everywhere', async () => { + const host = await wire() + host.inspectProcess.mockImplementation(async (ptyId) => + relayEvidence(ptyId, { processName: null, foregroundPgid: 100 }) + ) + const pane = await sshAgentPane(host, 'pty-ssh-exit') + + await endCommand(host.runtime, pane.ptyId, 'shell bytes') + + expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) + expect(host.shellOwnsForeground).not.toHaveBeenCalled() + }) + + for (const [name, front] of [ + ['the agent still in front', { processName: 'claude', foregroundPgid: 200 }], + // Job control off (`set +m`): the agent runs in the shell's own group. + ['an agent in the shell group', { processName: 'claude', foregroundPgid: 100 }], + ['another command in front', { processName: null, foregroundPgid: 200 }], + ['a relay that cannot read its process table', 'unverifiable'] + ] as const) { + it(`${name} keeps the row`, async () => { + const host = await wire() + host.inspectProcess.mockImplementation(async (ptyId) => relayEvidence(ptyId, front)) + const pane = await sshAgentPane(host, `pty-ssh-${name.replace(/\W+/g, '-')}`) + + await endCommand(host.runtime, pane.ptyId, 'shell bytes') + + expectNoReaderLostTheRow(host.server, host.readers, pane.paneKey, 'working') + }) + } +}) diff --git a/src/main/runtime/command-end-agent-exit-verifier.ts b/src/main/runtime/command-end-agent-exit-verifier.ts index ab8c9b9df9c9..2bbbb74c184f 100644 --- a/src/main/runtime/command-end-agent-exit-verifier.ts +++ b/src/main/runtime/command-end-agent-exit-verifier.ts @@ -1,4 +1,7 @@ import type { AgentProcessVerdict } from '../../shared/agent-process-presence' +import { admitRemoteForegroundEvidence } from '../../shared/remote-foreground-evidence-admission' +import { parseAppSshPtyId } from '../../shared/ssh-pty-id' +import type { RuntimePtyController } from './runtime-pty-controller-contract' type CommandEndAgentExitDeps = { /** The PTY's pane keys whose host row is live (not a resume remnant), each with that row's @@ -7,7 +10,8 @@ type CommandEndAgentExitDeps = { /** The hook presence check: the row's own agent pid and start time. An `exited` answer has * already cleared that row; null when no hook identified a process. */ checkHookAgentPresence(paneKey: string): Promise - /** The PTY's spawned shell owns the foreground with no job stopped; false when its host cannot say. */ + /** The PTY's spawned shell owns the foreground with nothing else running; false when its host + * cannot say. */ confirmShellOwnsForeground(ptyId: string): Promise /** Clears a pane whose agent exited, unless its row changed after `armedRowReceivedAt`. */ reconcileEndedProcess(paneKey: string, armedRowReceivedAt: number): void @@ -80,3 +84,42 @@ export class CommandEndAgentExitVerifier { } } } + +/** + * Local and daemon providers prove it from the process table. An SSH host has no shell confirm, so + * it answers from the same fenced foreground evidence the desktop pane reads: the shell's own group + * in front with no agent named in it. + */ +export async function confirmShellOwnsPtyForeground( + controller: RuntimePtyController | null, + pty: { ptyId: string; connectionId: string | null; incarnationId: string | null } | undefined +): Promise { + if (!controller || !pty) { + return false + } + if (!pty.connectionId) { + return (await controller.confirmShellForeground?.(pty.ptyId)) ?? false + } + if (!controller.inspectProcess || !pty.incarnationId) { + return false + } + const requestStartedAtMonotonic = performance.now() + const inspection = await controller.inspectProcess(pty.ptyId, { + expectedIncarnationId: pty.incarnationId + }) + const evidence = admitRemoteForegroundEvidence(inspection.foregroundProcessEvidence, { + expectedPtyId: parseAppSshPtyId(pty.ptyId)?.relayPtyId ?? pty.ptyId, + expectedIncarnationId: pty.incarnationId, + requestStartedAtMonotonic, + receivedAtMonotonic: performance.now(), + lastAuthorityGeneration: null, + lastObservationEpoch: -1 + }) + // Why TEMPORARY: this evidence cannot see a stopped job, so a Ctrl+Z'd agent reads as exited. + return ( + evidence?.verdict === 'live' && + evidence.processName === null && + evidence.fence.platform === 'posix' && + evidence.fence.foregroundPgid === evidence.fence.shellPid + ) +} diff --git a/src/main/runtime/command-end-host-wiring.test-fixture.ts b/src/main/runtime/command-end-host-wiring.test-fixture.ts index 328e5fe86289..33b07a97845a 100644 --- a/src/main/runtime/command-end-host-wiring.test-fixture.ts +++ b/src/main/runtime/command-end-host-wiring.test-fixture.ts @@ -1,5 +1,6 @@ import { expect, vi } from 'vitest' import type { AgentStatusClearIpcPayload } from '../../shared/agent-status-types' +import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence' import { AgentHookServer } from '../agent-hooks/server' import { installHookStatusSessionTabsRepublish } from '../agent-hooks/hook-status-session-tabs-republish' import { OrcaRuntimeService } from './orca-runtime' @@ -24,6 +25,12 @@ export type CommandEndHost = { spawn: ReturnType /** The execution host's answer to "does the spawned shell own the foreground, nothing stopped?" */ shellOwnsForeground: ReturnType Promise>> + /** An SSH host's fenced foreground evidence, keyed to the PTY's incarnation. */ + inspectProcess: ReturnType< + typeof vi.fn< + (ptyId: string) => Promise<{ foregroundProcessEvidence?: RemoteForegroundEvidence }> + > + > teardown: () => void } @@ -63,12 +70,20 @@ export async function wireCommandEndHost( const uninstallRepublish = installHookStatusSessionTabsRepublish(server, () => runtime) const spawn = vi.fn() const shellOwnsForeground = vi.fn<(ptyId: string) => Promise>(async () => true) + const inspectProcess = vi.fn< + (ptyId: string) => Promise<{ foregroundProcessEvidence?: RemoteForegroundEvidence }> + >(async () => ({})) runtime.setPtyController({ spawn, write: () => true, kill: () => true, getForegroundProcess: async () => null, - confirmShellForeground: shellOwnsForeground + confirmShellForeground: shellOwnsForeground, + inspectProcess: async (ptyId) => ({ + foregroundProcess: null, + hasChildProcesses: false, + ...(await inspectProcess(ptyId)) + }) }) return { server, @@ -76,6 +91,7 @@ export async function wireCommandEndHost( readers, spawn, shellOwnsForeground, + inspectProcess, teardown: () => { unsubscribeClears() uninstallRepublish() @@ -150,7 +166,7 @@ type RuntimeInternals = { export function shellPane( runtime: OrcaRuntimeService, ptyId: string, - options: { tabId: string; leafId: string; listingReceipt?: boolean } + options: { tabId: string; leafId: string; listingReceipt?: boolean; connectionId?: string } ): { ptyId: string; paneKey: string } { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these protected members exist on the runtime; the listing path that calls them needs a live controller inventory. const internals = runtime as unknown as RuntimeInternals @@ -159,7 +175,8 @@ export function shellPane( connected: true, tabId: options.tabId, paneKey, - incarnationId: `${ptyId}-incarnation` + incarnationId: `${ptyId}-incarnation`, + ...(options.connectionId ? { connectionId: options.connectionId } : {}) }) if (options.listingReceipt) { internals.rememberRestoredOrchestrationAuthority(pty, `term-${ptyId}`, `${ptyId}-incarnation`) diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index f7ae18bbf111..2e2fa04938f4 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -23,7 +23,10 @@ import type { FleetAgentStatusEvidence } from '../../shared/orchestration-fleet- import { readOrchestrationFleetAgentStatusSnapshot } from './orchestration-fleet-agent-status-snapshot' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { matchesProcessIncarnation } from './orchestration/worker-terminal-process-liveness' -import { CommandEndAgentExitVerifier } from './command-end-agent-exit-verifier' +import { + CommandEndAgentExitVerifier, + confirmShellOwnsPtyForeground +} from './command-end-agent-exit-verifier' export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller { /** Every pane key this PTY could be addressed by, including restored receipts. */ @@ -155,8 +158,8 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim }, checkHookAgentPresence: async (paneKey) => (await this.checkHookAgentPresenceFn?.(paneKey)) ?? null, - confirmShellOwnsForeground: async (ptyId) => - (await this.ptyController?.confirmShellForeground?.(ptyId)) ?? false, + confirmShellOwnsForeground: (ptyId) => + confirmShellOwnsPtyForeground(this.ptyController, this.ptysById.get(ptyId)), reconcileEndedProcess: (paneKey, armedRowReceivedAt) => this.reconcileAgentStatusForEndedProcessFn?.([paneKey], { // The shell outlived its agent, so the session stays resumable in place. From 73faf3e07395d37ed13d5b6f63a1825d5f9aa689 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:02:08 -0700 Subject: [PATCH 07/19] refactor(agent-status): build every host's agent-hook runtime deps in one place The desktop, orcad and the specs each spelled out how the runtime reads and clears the hook server's status store and retires launch authority. The retirement and ended-process options are optional, so a host that dropped one still typechecked and every spec stayed green, while every command end would then fully retire a live agent's pane. agent-hook-runtime-deps.ts now builds those deps and wires the runtime as the hook server's launch-authority reader. The desktop, orcad, the command-end specs and the STA-4557 suite all use it, and a unit test pins that both options reach the hook server. --- .../agent-hook-runtime-deps.test.ts | 61 +++++++++++++++++++ .../agent-hooks/agent-hook-runtime-deps.ts | 56 +++++++++++++++++ src/main/orcad/orcad-entry.ts | 10 +-- .../command-end-host-wiring.test-fixture.ts | 40 ++++++------ ...pencode-finished-session-authority.test.ts | 22 +++---- ...time-structured-status-sink-wiring.test.ts | 9 +-- .../startup/main-process-runtime-service.ts | 28 +++------ 7 files changed, 164 insertions(+), 62 deletions(-) create mode 100644 src/main/agent-hooks/agent-hook-runtime-deps.test.ts create mode 100644 src/main/agent-hooks/agent-hook-runtime-deps.ts diff --git a/src/main/agent-hooks/agent-hook-runtime-deps.test.ts b/src/main/agent-hooks/agent-hook-runtime-deps.test.ts new file mode 100644 index 000000000000..a61e93148d8e --- /dev/null +++ b/src/main/agent-hooks/agent-hook-runtime-deps.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest' +import { + agentHookLaunchAuthorityRuntimeDeps, + agentHookStatusStoreRuntimeDeps, + wireRuntimeLaunchAuthorityReader +} from './agent-hook-runtime-deps' + +// Every host builds its runtime's agent-hook deps here. Both options are optional, so a builder +// that dropped one would still typecheck: a command end would then fully retire a live agent's +// pane, and a verified exit would clear a session that started meanwhile. + +type LaunchAuthorityReader = (paneKey: string) => { launchTokenHash: string | null } | null + +function fakeServer() { + const wired: { reader: LaunchAuthorityReader | null } = { reader: null } + return { + wired, + getStatusSnapshot: vi.fn(() => []), + getStatusSnapshotForPane: vi.fn(() => []), + checkAgentPresence: vi.fn(async () => null), + reconcileEndedProcessForPaneKeys: vi.fn(() => 0), + attestCompatibilityAuthority: vi.fn(() => null), + retirePaneAuthority: vi.fn(), + setPaneLaunchAuthorityReader: vi.fn((reader: LaunchAuthorityReader | null) => { + wired.reader = reader + }) + } +} + +describe('agent-hook runtime deps', () => { + it('forward the command-end retirement options', () => { + const server = fakeServer() + + agentHookLaunchAuthorityRuntimeDeps(server).retireAgentHookCompatibilityAuthority('pane', { + authorityOnly: true + }) + + expect(server.retirePaneAuthority).toHaveBeenCalledWith('pane', undefined, { + authorityOnly: true + }) + }) + + it('forward the ended-process options', () => { + const server = fakeServer() + const options = { preserveResumeIdentity: true, armedRowReceivedAt: 42 } + + agentHookStatusStoreRuntimeDeps(server).reconcileAgentStatusForEndedProcess(['pane'], options) + + expect(server.reconcileEndedProcessForPaneKeys).toHaveBeenCalledWith(['pane'], options) + }) + + it('hand the hook server the runtime as its launch-authority reader', () => { + const server = fakeServer() + const runtime = { readPaneLaunchAuthority: vi.fn(() => ({ launchTokenHash: 'hash' })) } + + wireRuntimeLaunchAuthorityReader(server, runtime) + + expect(server.wired.reader?.('pane')).toEqual({ launchTokenHash: 'hash' }) + expect(runtime.readPaneLaunchAuthority).toHaveBeenCalledWith('pane') + }) +}) diff --git a/src/main/agent-hooks/agent-hook-runtime-deps.ts b/src/main/agent-hooks/agent-hook-runtime-deps.ts new file mode 100644 index 000000000000..505c52edadf8 --- /dev/null +++ b/src/main/agent-hooks/agent-hook-runtime-deps.ts @@ -0,0 +1,56 @@ +import type { AgentHookServer } from './server' + +type Server = Pick< + AgentHookServer, + | 'getStatusSnapshot' + | 'getStatusSnapshotForPane' + | 'checkAgentPresence' + | 'reconcileEndedProcessForPaneKeys' + | 'attestCompatibilityAuthority' + | 'retirePaneAuthority' + | 'setPaneLaunchAuthorityReader' +> + +/** How every host's runtime reads and clears the agent-hook status store. One copy, so the desktop, + * orcad and the specs cannot drift on what a command end or an ended process passes through. */ +export function agentHookStatusStoreRuntimeDeps(server: Server) { + return { + // Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at + // query time so mobile shows the same agents. + getAgentStatusSnapshot: () => + server.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + // Why: the filter above hides resume-identity rows from the live-agent views, but those rows + // carry the provider session mobile native chat addresses transcripts by — Pi publishes + // identity that way and would otherwise be unreachable. + getAgentProviderSessionSnapshot: () => server.getStatusSnapshot(), + getAgentProviderSessionRowsForPane: (paneKey: string) => + server.getStatusSnapshotForPane(paneKey), + checkHookAgentPresence: (paneKey: string) => server.checkAgentPresence(paneKey), + reconcileAgentStatusForEndedProcess: ( + ...args: Parameters + ) => { + server.reconcileEndedProcessForPaneKeys(...args) + } + } +} + +/** Orchestration launch authority: the hook server attests it, the runtime retires it. */ +export function agentHookLaunchAuthorityRuntimeDeps(server: Server) { + return { + attestAgentHookCompatibilityAuthority: ( + candidate: Parameters[0] + ) => server.attestCompatibilityAuthority(candidate), + retireAgentHookCompatibilityAuthority: ( + paneKey: string, + options?: Parameters[2] + ) => server.retirePaneAuthority(paneKey, undefined, options) + } +} + +/** Why: the runtime owns which launch token a pane still honours; the hook server derives from it. */ +export function wireRuntimeLaunchAuthorityReader( + server: Server, + runtime: { readPaneLaunchAuthority(paneKey: string): { launchTokenHash: string | null } | null } +): void { + server.setPaneLaunchAuthorityReader((paneKey) => runtime.readPaneLaunchAuthority(paneKey)) +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 06e8f1112ca6..4c3bd519b67a 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -132,6 +132,7 @@ async function startOrcadRuntime( // 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 { agentHookStatusStoreRuntimeDeps } = await import('../agent-hooks/agent-hook-runtime-deps') const { installHookStatusSessionTabsRepublish } = await import('../agent-hooks/hook-status-session-tabs-republish') const { AgentStatusObservedPaneIdentities, AgentStatusObservedPaneIdentityCapture } = @@ -221,11 +222,7 @@ async function startOrcadRuntime( 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), + ...agentHookStatusStoreRuntimeDeps(agentHookServer), // 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), @@ -236,9 +233,6 @@ async function startOrcadRuntime( agentHookServer.ingestStructuredChildWork(subject, evidence, provider), readChildWork: (subject) => agentHookServer.getStructuredChildWorkViews(subject) }, - checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), - reconcileAgentStatusForEndedProcess: (paneKeys, options) => - agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys, options), buildAgentHookPtyEnv: () => isAgentStatusHooksEnabled(profileStore.getSettings()) ? agentHookServer.buildPtyEnv() : {}, // Why the dedupe here and not in the instance: `apply` closes and reconstructs diff --git a/src/main/runtime/command-end-host-wiring.test-fixture.ts b/src/main/runtime/command-end-host-wiring.test-fixture.ts index 33b07a97845a..bae55f4d63e7 100644 --- a/src/main/runtime/command-end-host-wiring.test-fixture.ts +++ b/src/main/runtime/command-end-host-wiring.test-fixture.ts @@ -3,11 +3,17 @@ import type { AgentStatusClearIpcPayload } from '../../shared/agent-status-types import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence' import { AgentHookServer } from '../agent-hooks/server' import { installHookStatusSessionTabsRepublish } from '../agent-hooks/hook-status-session-tabs-republish' +import { + agentHookLaunchAuthorityRuntimeDeps, + agentHookStatusStoreRuntimeDeps, + wireRuntimeLaunchAuthorityReader +} from '../agent-hooks/agent-hook-runtime-deps' import { OrcaRuntimeService } from './orca-runtime' +import type { RuntimePtyController } from './runtime-pty-controller-contract' import { makeStore } from './runtime-rpc-worktree-store-fixtures' -// The host wiring `main-process-runtime-service.ts` performs, plus every pane-clear reader, for the -// command-end specs. Callers must mock `../git/worktree` to list WORKTREE_PATH. +// The host wiring `main-process-runtime-service.ts` performs (through the same builders), plus every +// pane-clear reader, for the command-end specs. Callers must mock `../git/worktree` to list WORKTREE_PATH. export const WORKTREE_PATH = '/tmp/worktree-a' export const TEST_WORKTREE_ID = `repo-1::${WORKTREE_PATH}` @@ -35,7 +41,12 @@ export type CommandEndHost = { } export async function wireCommandEndHost( - options: { userDataPath?: string; server?: AgentHookServer } = {} + options: { + userDataPath?: string + server?: AgentHookServer + /** Replaces the stubbed process answers, e.g. with a real terminal daemon's. */ + controller?: Pick + } = {} ): Promise { const server = options.server ?? new AgentHookServer() if (!options.server) { @@ -46,19 +57,10 @@ export async function wireCommandEndHost( } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared fixture store implements only the members these runtime paths read. const runtime = new OrcaRuntimeService(makeStore() as never, undefined, { - getAgentStatusSnapshot: () => - server.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), - getAgentProviderSessionSnapshot: () => server.getStatusSnapshot(), - getAgentProviderSessionRowsForPane: (paneKey) => server.getStatusSnapshotForPane(paneKey), - attestAgentHookCompatibilityAuthority: (candidate) => - server.attestCompatibilityAuthority(candidate), - retireAgentHookCompatibilityAuthority: (paneKey, retireOptions) => - server.retirePaneAuthority(paneKey, undefined, retireOptions), - checkHookAgentPresence: (paneKey) => server.checkAgentPresence(paneKey), - reconcileAgentStatusForEndedProcess: (paneKeys, reconcileOptions) => - server.reconcileEndedProcessForPaneKeys(paneKeys, reconcileOptions) + ...agentHookStatusStoreRuntimeDeps(server), + ...agentHookLaunchAuthorityRuntimeDeps(server) }) - server.setPaneLaunchAuthorityReader((paneKey) => runtime.readPaneLaunchAuthority(paneKey)) + wireRuntimeLaunchAuthorityReader(server, runtime) const readers: Readers = { windowClears: [], subscriberClears: [], republishedWorktrees: [] } server.setPaneStatusClearListener((clear) => readers.windowClears.push(clear)) const unsubscribeClears = server.subscribePaneStatusClear((clear) => @@ -83,7 +85,8 @@ export async function wireCommandEndHost( foregroundProcess: null, hasChildProcesses: false, ...(await inspectProcess(ptyId)) - }) + }), + ...options.controller }) return { server, @@ -137,9 +140,10 @@ export function liveRow(server: AgentHookServer, paneKey: string) { export async function launchAgentPane( host: Pick, ptyId: string, - agent: 'claude' | 'codex' | 'opencode' = 'claude' + agent: 'claude' | 'codex' | 'opencode' = 'claude', + incarnationId = `${ptyId}-incarnation` ): Promise<{ ptyId: string; paneKey: string; launchToken: string }> { - host.spawn.mockResolvedValueOnce({ id: ptyId, incarnationId: `${ptyId}-incarnation` }) + host.spawn.mockResolvedValueOnce({ id: ptyId, incarnationId }) await host.runtime.createTerminal(`path:${WORKTREE_PATH}`, { command: agent, launchConfig: { agentCommand: agent, agentArgs: '', agentEnv: {} }, diff --git a/src/main/runtime/opencode-finished-session-authority.test.ts b/src/main/runtime/opencode-finished-session-authority.test.ts index 78895939a2b1..c4cfdaae1b59 100644 --- a/src/main/runtime/opencode-finished-session-authority.test.ts +++ b/src/main/runtime/opencode-finished-session-authority.test.ts @@ -4,6 +4,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer } from '../agent-hooks/server' +import { + agentHookLaunchAuthorityRuntimeDeps, + agentHookStatusStoreRuntimeDeps, + wireRuntimeLaunchAuthorityReader +} from '../agent-hooks/agent-hook-runtime-deps' import { OrcaRuntimeService } from './orca-runtime' import { makeStore } from './runtime-rpc-worktree-store-fixtures' @@ -64,17 +69,8 @@ async function launchOpenCodePane(options: { undefined, server ? { - getAgentStatusSnapshot: () => - server.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), - getAgentProviderSessionSnapshot: () => server.getStatusSnapshot(), - getAgentProviderSessionRowsForPane: (paneKey) => server.getStatusSnapshotForPane(paneKey), - attestAgentHookCompatibilityAuthority: (candidate) => - server.attestCompatibilityAuthority(candidate), - retireAgentHookCompatibilityAuthority: (paneKey, retireOptions) => - server.retirePaneAuthority(paneKey, undefined, retireOptions), - checkHookAgentPresence: (paneKey) => server.checkAgentPresence(paneKey), - reconcileAgentStatusForEndedProcess: (paneKeys, reconcileOptions) => - server.reconcileEndedProcessForPaneKeys(paneKeys, reconcileOptions) + ...agentHookStatusStoreRuntimeDeps(server), + ...agentHookLaunchAuthorityRuntimeDeps(server) } : { attestAgentHookCompatibilityAuthority: (candidate) => ({ @@ -83,7 +79,9 @@ async function launchOpenCodePane(options: { }) } ) - server?.setPaneLaunchAuthorityReader((paneKey) => runtime.readPaneLaunchAuthority(paneKey)) + if (server) { + wireRuntimeLaunchAuthorityReader(server, runtime) + } runtime.setPtyController({ spawn, write: () => true, diff --git a/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts index b931af574632..904b5dc7335c 100644 --- a/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts +++ b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts @@ -22,11 +22,10 @@ import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-sess type OrcaRuntimeDeps = NonNullable[2]> -/** Renaming either option reddens this list, and dropping either from a host's construction - * reddens the assertion below. Both are needed: the runtime class does not typecheck its own - * `this` calls, and each entry point wires the store separately. */ +/** Renaming the option reddens this list, and dropping it (or the shared store deps) from a + * host's construction reddens the assertion below. Both are needed: the runtime class does not + * typecheck its own `this` calls, and each entry point wires the store separately. */ const AGENT_STATUS_STORE_DEPS = [ - 'getAgentStatusSnapshot', 'structuredAgentStatusSink' ] as const satisfies readonly (keyof OrcaRuntimeDeps)[] @@ -66,6 +65,8 @@ describe('every host that constructs a runtime wires the agent-status store', () for (const dep of AGENT_STATUS_STORE_DEPS) { expect(construction).toContain(`${dep}:`) } + // `getAgentStatusSnapshot` and the rest of the store reads come from the shared builder. + expect(construction).toContain('...agentHookStatusStoreRuntimeDeps(agentHookServer)') // A sink without it leaves the host holding no child records for that entry point. expect(construction).toContain('publishChildWork: (subject, evidence, provider) =>') expect(construction).toContain('ingestStructuredChildWork(subject, evidence, provider)') diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 4fb212fd7c23..3a77bd0c1f03 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -9,6 +9,11 @@ import { app } from 'electron' import { OrcaRuntimeService } from '../runtime/orca-runtime' import { getLocalPtyProvider, getSshPtyProvider, clearProviderPtyState } from '../ipc/pty' import { agentHookServer } from '../agent-hooks/server' +import { + agentHookLaunchAuthorityRuntimeDeps, + agentHookStatusStoreRuntimeDeps, + wireRuntimeLaunchAuthorityReader +} from '../agent-hooks/agent-hook-runtime-deps' import { browserManager } from '../browser/browser-manager' import { loadAgentSessionClaimSigner } from '../runtime/agent-session-claim-identity' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' @@ -92,9 +97,8 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { } }, getDesktopWindowStatus, - // Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at query time so mobile shows the same agents. - getAgentStatusSnapshot: () => - agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + ...agentHookStatusStoreRuntimeDeps(agentHookServer), + ...agentHookLaunchAuthorityRuntimeDeps(agentHookServer), // Why: structured chats have no hooks, so the host writes their projections here itself; the // snapshot above then lists them for the CLI and mobile without a second store. structuredAgentStatusSink: { @@ -107,19 +111,6 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { // 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 the pane owns now. readObservedAgentStatusPaneIdentity: (paneKey) => observedPaneIdentities.read(paneKey), - // Why: the filter above hides resume-identity rows from the live-agent views, but - // those rows carry the provider session mobile native chat addresses transcripts - // by — Pi publishes identity that way and would otherwise be unreachable. - getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(), - getAgentProviderSessionRowsForPane: (paneKey) => - agentHookServer.getStatusSnapshotForPane(paneKey), - attestAgentHookCompatibilityAuthority: (candidate) => - agentHookServer.attestCompatibilityAuthority(candidate), - retireAgentHookCompatibilityAuthority: (paneKey, options) => - agentHookServer.retirePaneAuthority(paneKey, undefined, options), - checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), - reconcileAgentStatusForEndedProcess: (paneKeys, options) => - agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys, options), canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, // Why: evaluated per call, not captured — the RPC server that owns the device registry is // constructed with this runtime and does not exist yet at this point. @@ -163,10 +154,7 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { }) app.once('will-quit', () => sessionSearch?.dispose()) state.runtime = runtime - // Why: the runtime owns which launch token a pane still honours; ingest derives from it. - agentHookServer.setPaneLaunchAuthorityReader((paneKey) => - runtime.readPaneLaunchAuthority(paneKey) - ) + wireRuntimeLaunchAuthorityReader(agentHookServer, runtime) agentHookServer.subscribeEnrichedStatus((enriched) => recordObservedAgentStatusPaneIdentity(observedPaneIdentities, enriched.paneKey, runtime) ) From 93b607c30f321aecaaa7efb1b652755e0890d914 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:02:10 -0700 Subject: [PATCH 08/19] fix(agent-status): prove a daemon pane's agent exit from its fenced process evidence The terminal daemon, the default local backend, answers its shell confirm from its byte scanner, which only proves a shell after a full-screen exit, and every command end resets it. So a Codex run in the normal screen buffer that quit was never verified: its Done row stayed on hidden desktop panes, on headless hosts, in worktree ps and on mobile. When the provider's shell confirm says no, the verifier now reads the provider's fenced foreground evidence through the existing process inspection, the same check SSH panes already use: the shell's own process group in front with no agent named in it. A local node-pty reports no such evidence, so it keeps its stricter check. A daemon too old to send evidence keeps the row, as before. The evidence cannot see a stopped job, so a Ctrl+Z'd agent on a daemon pane reads as exited, as on SSH. The verifier also logs, per pane, a check or clear that throws instead of leaving an unhandled rejection. --- docs/reference/agent-status-store.md | 14 +- .../command-end-agent-exit-daemon.test.ts | 185 ++++++++++++++++++ ...ommand-end-agent-exit-verification.test.ts | 3 +- .../command-end-agent-exit-verifier.ts | 24 ++- 4 files changed, 211 insertions(+), 15 deletions(-) create mode 100644 src/main/runtime/command-end-agent-exit-daemon.test.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 8cec3529a959..38426f61ed2a 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -93,12 +93,14 @@ drops the row on a clear and never keeps it as a finished (Done) agent. A command end (OSC 133;D) ends a pane's launch authority at once, because every later process in that shell inherits the token. It does not prove the agent exited: a full-screen agent's nested shells leak their own 133;D. So at every command end of a PTY whose panes hold a live row, the -runtime asks the execution host again. The hook's own agent process still running keeps the row; -the spawned shell owning the foreground with no job stopped clears it as an ended process, keeping -the resume identity. Any other answer keeps the row, and nothing is latched: the PTY's next command -end asks again, and its exit clears it. A verdict acts only on the row it checked, so a session -that started while it was read keeps its row. SSH hosts cannot answer the shell check yet, so there -only the agent's own exit hook or the PTY's exit removes a row the desktop pane did not drop. +runtime asks the execution host again. The hook's own agent process still running keeps the row. +The pane's shell back in front clears it as an ended process, keeping the resume identity: a local +node-pty proves that from a fresh process-table read (the spawned shell in the foreground, no job +stopped); the terminal daemon and an SSH relay prove it from their fenced foreground evidence (the +shell's own process group in front, no agent named), which cannot see a stopped job. Any other +answer keeps the row, and nothing is latched: the PTY's next command end asks again, and a +confirmed PTY exit clears it. A verdict acts only on the row it checked, so a session that started +while it was read keeps its row. ## PR 1a: structured sessions publish into the store diff --git a/src/main/runtime/command-end-agent-exit-daemon.test.ts b/src/main/runtime/command-end-agent-exit-daemon.test.ts new file mode 100644 index 000000000000..ce850aed2a79 --- /dev/null +++ b/src/main/runtime/command-end-agent-exit-daemon.test.ts @@ -0,0 +1,185 @@ +import '../daemon/mock-descendant-sweep' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProcessTableRow } from '../../shared/process-table-snapshot' +import type * as ProcessTableSnapshotReader from '../../shared/process-table-snapshot-reader' +import type { SubprocessHandle } from '../daemon/session-subprocess-handle' +import { TerminalHost } from '../daemon/terminal-host' +import { + endCommand, + expectEveryReaderSawTheClear, + expectNoReaderLostTheRow, + launchAgentPane, + liveRow, + postHook, + wireCommandEndHost, + type CommandEndHost, + type CommandEndPath +} from './command-end-host-wiring.test-fixture' + +// The terminal daemon, Orca's default local backend, answers its shell confirm from its byte +// scanner, which only proves a shell after a full-screen exit. A Codex run in the normal screen +// buffer that quits must still be verified, from the daemon's fenced process evidence. + +const processTable = vi.hoisted((): { rows: ProcessTableRow[] } => ({ rows: [] })) +vi.mock('../../shared/process-table-snapshot-reader', async (importOriginal) => ({ + ...(await importOriginal()), + getStrictProcessTableSnapshotWithAge: async () => ({ rows: processTable.rows, capturedAgeMs: 0 }) +})) + +vi.mock('../git/worktree', () => { + const worktrees = [ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/retirement-clear', + isBare: false, + isMainWorktree: false + } + ] + return { + listWorktrees: vi.fn().mockResolvedValue(worktrees), + listWorktreesStrict: vi.fn().mockResolvedValue(worktrees) + } +}) + +const SHELL_PID = 99_999 +const AGENT_PID = 100_100 + +/** The pane's process table: its zsh alone in front, or a Codex process group in front of it. */ +function paneProcesses(front: 'shell' | 'codex'): ProcessTableRow[] { + const tty = '/dev/pts/7' + const foregroundPgid = front === 'shell' ? SHELL_PID : AGENT_PID + const shell = { + pid: SHELL_PID, + ppid: 1, + pgid: SHELL_PID, + tpgid: foregroundPgid, + tty, + startTime: 'shell-birth', + stat: front === 'shell' ? 'Ss+' : 'Ss', + command: '/bin/zsh' + } + const codex = { + pid: AGENT_PID, + ppid: SHELL_PID, + pgid: AGENT_PID, + tpgid: foregroundPgid, + tty, + startTime: 'codex-birth', + stat: 'S+', + command: 'node /opt/homebrew/bin/codex' + } + return front === 'shell' ? [shell] : [shell, codex] +} + +function createSubprocess(): { handle: SubprocessHandle; emit: (data: string) => void } { + let onData: ((data: string) => void) | null = null + let onExit: ((code: number) => void) | null = null + const handle: SubprocessHandle = { + pid: SHELL_PID, + getForegroundProcess: vi.fn(() => 'zsh'), + // The fresh process read exists, but the daemon only asks it after a full-screen exit. + confirmShellForeground: vi.fn(async () => true), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => onExit?.(0)), + terminateOwnedTree: () => 'unavailable', + forceKill: vi.fn(() => onExit?.(137)), + signal: vi.fn(), + onData: (callback) => { + onData = callback + }, + onExit: (callback) => { + onExit = callback + }, + dispose: vi.fn() + } + return { handle, emit: (data) => onData?.(data) } +} + +type DaemonPane = { + host: CommandEndHost + pane: { ptyId: string; paneKey: string; launchToken: string } + emit: (data: string) => void +} + +const teardowns: (() => Promise | void)[] = [] + +afterEach(async () => { + for (const teardown of teardowns.splice(0)) { + await teardown() + } + processTable.rows = [] + vi.restoreAllMocks() +}) + +/** An Orca-launched Codex pane whose PTY is a real daemon session. */ +async function launchDaemonCodexPane(ptyId: string): Promise { + const subprocess = createSubprocess() + const terminalHost = new TerminalHost({ spawnSubprocess: () => subprocess.handle }) + teardowns.push(() => terminalHost.dispose()) + const session = await terminalHost.createOrAttach({ + sessionId: ptyId, + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + const host = await wireCommandEndHost({ + controller: { + confirmShellForeground: (id) => terminalHost.confirmShellForeground(id), + inspectProcess: (id, options) => terminalHost.inspectProcess(id, options) + } + }) + teardowns.push(host.teardown) + const pane = await launchAgentPane(host, ptyId, 'codex', session.incarnationId) + await postHook(host.server, 'codex', pane, { + hook_event_name: 'UserPromptSubmit', + session_id: 'codex-session', + prompt: 'review the PR' + }) + await postHook(host.server, 'codex', pane, { + hook_event_name: 'Stop', + session_id: 'codex-session' + }) + expect(liveRow(host.server, pane.paneKey)?.state).toBe('done') + host.readers.republishedWorktrees.length = 0 + return { host, pane, emit: subprocess.emit } +} + +/** The same bytes reach the daemon's scanner and main's command-end path. */ +async function runCommandToItsEnd(daemonPane: DaemonPane, path: CommandEndPath): Promise { + // Codex renders inline in the normal screen buffer: no alternate-screen episode. + daemonPane.emit('\x1b]133;C\x07codex output\r\n\x1b]133;D;0\x07\x1b]133;A\x07$ ') + await endCommand(daemonPane.host.runtime, daemonPane.pane.ptyId, path) +} + +describe('a normal-buffer agent on a terminal-daemon pane', () => { + for (const path of ['shell bytes', 'daemon fact'] as const) { + it(`quits to the shell: the row clears for every reader (${path})`, async () => { + const daemonPane = await launchDaemonCodexPane(`pty-daemon-exit-${path.replace(' ', '-')}`) + processTable.rows = paneProcesses('shell') + + await runCommandToItsEnd(daemonPane, path) + + expectEveryReaderSawTheClear( + daemonPane.host.server, + daemonPane.host.readers, + daemonPane.pane.paneKey + ) + }) + + it(`still in front after a nested shell's marker: the row stays (${path})`, async () => { + const daemonPane = await launchDaemonCodexPane(`pty-daemon-live-${path.replace(' ', '-')}`) + processTable.rows = paneProcesses('codex') + + await runCommandToItsEnd(daemonPane, path) + + expectNoReaderLostTheRow( + daemonPane.host.server, + daemonPane.host.readers, + daemonPane.pane.paneKey, + 'done' + ) + }) + } +}) diff --git a/src/main/runtime/command-end-agent-exit-verification.test.ts b/src/main/runtime/command-end-agent-exit-verification.test.ts index e0f89c1192ba..e51f3c8b84c6 100644 --- a/src/main/runtime/command-end-agent-exit-verification.test.ts +++ b/src/main/runtime/command-end-agent-exit-verification.test.ts @@ -335,6 +335,8 @@ function relayEvidence( describe("an SSH pane answers from its relay's foreground evidence", () => { async function sshAgentPane(host: CommandEndHost, ptyId: string) { + // The SSH provider has no shell confirm, so the runtime controller answers false. + host.shellOwnsForeground.mockResolvedValue(false) const pane = shellPane(host.runtime, ptyId, { tabId: TAB, leafId: LEAF, @@ -355,7 +357,6 @@ describe("an SSH pane answers from its relay's foreground evidence", () => { await endCommand(host.runtime, pane.ptyId, 'shell bytes') expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) - expect(host.shellOwnsForeground).not.toHaveBeenCalled() }) for (const [name, front] of [ diff --git a/src/main/runtime/command-end-agent-exit-verifier.ts b/src/main/runtime/command-end-agent-exit-verifier.ts index 2bbbb74c184f..5eed609f10a6 100644 --- a/src/main/runtime/command-end-agent-exit-verifier.ts +++ b/src/main/runtime/command-end-agent-exit-verifier.ts @@ -55,7 +55,9 @@ export class CommandEndAgentExitVerifier { running.rerun = true return } - void this.run(ptyId) + this.run(ptyId).catch((error: unknown) => { + console.error('[agent-status] command-end exit check failed', { ptyId, error }) + }) } private async run(ptyId: string): Promise { @@ -80,25 +82,31 @@ export class CommandEndAgentExitVerifier { return } for (const [paneKey, receivedAt] of anchors) { - this.deps.reconcileEndedProcess(paneKey, receivedAt) + try { + this.deps.reconcileEndedProcess(paneKey, receivedAt) + } catch (error) { + // Why per pane: one pane's failed clear must not strand its siblings' rows. + console.error('[agent-status] command-end exit clear failed', { ptyId, paneKey, error }) + } } } } /** - * Local and daemon providers prove it from the process table. An SSH host has no shell confirm, so - * it answers from the same fenced foreground evidence the desktop pane reads: the shell's own group - * in front with no agent named in it. + * A local node-pty answers from a fresh process-table read. The terminal daemon answers its shell + * confirm from its byte scanner, which only proves a shell after a full-screen exit, and an SSH host + * has none; both also report fenced foreground evidence (a local node-pty reports none). That + * evidence proves the exit as the shell's own process group in front with no agent named in it. */ export async function confirmShellOwnsPtyForeground( controller: RuntimePtyController | null, - pty: { ptyId: string; connectionId: string | null; incarnationId: string | null } | undefined + pty: { ptyId: string; incarnationId: string | null } | undefined ): Promise { if (!controller || !pty) { return false } - if (!pty.connectionId) { - return (await controller.confirmShellForeground?.(pty.ptyId)) ?? false + if (await controller.confirmShellForeground?.(pty.ptyId)) { + return true } if (!controller.inspectProcess || !pty.incarnationId) { return false From 1a6136666f7ad97666606285ca6f7a065160155c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:02:12 -0700 Subject: [PATCH 09/19] fix(agent-status): persist a launch token only while something vouches for it A row the agent wrote before its command ended still carries the launch token. If the runtime later pruned that pane's PTY record (an SSH record after an unverifiable disconnect, whose row survives), the next save found no record, kept the token, and wrote a commitment that vouched for the dead token again after a restart. Saving now fails closed: a row's token is hashed and committed only when the runtime holds it or a commitment of this host still vouches for it. Ingest keeps passing a token for a pane with no PTY record. --- .../server/server-authority-evidence.ts | 14 ++++--- .../agent-hooks/server/server-persistence.ts | 5 ++- src/main/agent-hooks/server/server-state.ts | 3 +- ...h-token-liveness-after-command-end.test.ts | 38 +++++++++++++++++-- 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/main/agent-hooks/server/server-authority-evidence.ts b/src/main/agent-hooks/server/server-authority-evidence.ts index 138b9e89cfe7..2803f8be3bcb 100644 --- a/src/main/agent-hooks/server/server-authority-evidence.ts +++ b/src/main/agent-hooks/server/server-authority-evidence.ts @@ -24,17 +24,21 @@ export abstract class AgentHookServerAuthorityEvidence extends AgentHookServerSt /** The event without its launch token once that token's authority ended. Every process a shell * starts inherits the token, so after a command end it proves nothing; it is honoured while the * runtime still holds it for the pane or a commitment of this host still vouches for it. Derived - * at every write and at persist, never stored; a pane with no PTY record keeps today's rule. */ - protected withLiveLaunchToken(event: T): T { + * at every write and at persist, never stored. A pane with no PTY record keeps today's rule at + * ingest; `requireVoucher` (persist) fails closed there, since a revoked token has no voucher. */ + protected withLiveLaunchToken( + event: T, + options?: { requireVoucher?: boolean } + ): T { const token = event.launchToken?.trim() - const runtime = token ? this.paneLaunchAuthorityReader?.(event.paneKey) : null - if (!token || !runtime) { + const runtime = token ? (this.paneLaunchAuthorityReader?.(event.paneKey) ?? null) : null + if (!token || (!runtime && !options?.requireVoucher)) { return event } const launchTokenHash = createHash('sha256').update(token).digest('hex') const ownerPaneKey = this.resolvePaneKeyAlias(event.paneKey) const live = - runtime.launchTokenHash === launchTokenHash || + runtime?.launchTokenHash === launchTokenHash || this.persistedAuthorityCommitmentsByPaneKey.get(ownerPaneKey)?.launchTokenHash === launchTokenHash || this.hydratedLaunchTokenHashByPaneKey.get(ownerPaneKey) === launchTokenHash diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts index 8c61a4873f71..ad6b4bc4199b 100644 --- a/src/main/agent-hooks/server/server-persistence.ts +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -47,7 +47,10 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio ...persistedPayload } = enrichedPayload // Why derived: a row written before its pane's command ended still carries that token. - const liveLaunchToken = this.withLiveLaunchToken({ paneKey, launchToken }).launchToken?.trim() + const liveLaunchToken = this.withLiveLaunchToken( + { paneKey, launchToken }, + { requireVoucher: true } + ).launchToken?.trim() const launchTokenHash = liveLaunchToken ? createHash('sha256').update(liveLaunchToken).digest('hex') : this.hydratedLaunchTokenHashByPaneKey.get(paneKey) diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 736ea8365565..6fbb9a9c369b 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -284,7 +284,8 @@ export abstract class AgentHookServerState { protected abstract captureHydratedAuthorityCommitments(): void protected abstract recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void protected abstract withLiveLaunchToken( - event: T + event: T, + options?: { requireVoucher?: boolean } ): T protected abstract toAuthorityEvidence( payload: AgentHookEventPayload | EnrichedAgentHookEventPayload, diff --git a/src/main/runtime/launch-token-liveness-after-command-end.test.ts b/src/main/runtime/launch-token-liveness-after-command-end.test.ts index d0866e459071..901139888459 100644 --- a/src/main/runtime/launch-token-liveness-after-command-end.test.ts +++ b/src/main/runtime/launch-token-liveness-after-command-end.test.ts @@ -78,11 +78,18 @@ function attest( }) } +type RuntimeRecords = { + ptysById: Map + dropDisconnectedPtyRecord: (ptyId: string) => void +} + +function runtimeRecords(host: CommandEndHost): RuntimeRecords { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ptysById and dropDisconnectedPtyRecord are the runtime's protected PTY record members; a relay drop, an inventory miss and the record prune act on exactly these. + return host.runtime as unknown as RuntimeRecords +} + function setPtyConnected(host: CommandEndHost, ptyId: string, connected: boolean): void { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ptysById is the runtime's protected PTY record map; a relay drop or inventory miss flips exactly this flag. - const records = (host.runtime as unknown as { ptysById: Map }) - .ptysById - records.get(ptyId)!.connected = connected + runtimeRecords(host).ptysById.get(ptyId)!.connected = connected } describe('the launch token a shell keeps after its command ends', () => { @@ -163,6 +170,29 @@ describe('the launch token a shell keeps after its command ends', () => { expect(attest(restarted, pane, 'restored')).toBeNull() }) + it('is not persisted once the runtime prunes the PTY record of a kept SSH row', async () => { + const userDataPath = tempDir('orca-pruned-record-token-') + const host = await wireLiveAgentHost(userDataPath) + const pane = await launchAgentPane(host, 'pty-pruned-record-token') + await postHook(host.server, 'claude', pane, { + hook_event_name: 'UserPromptSubmit', + session_id: 'claude-session', + prompt: 'review the PR' + }) + await endCommand(host.runtime, pane.ptyId, 'shell bytes') + + // An SSH record pruned after an unverifiable disconnect: its host-owned row survives. + const record = runtimeRecords(host).ptysById.get(pane.ptyId)! + record.connectionId = 'conn-ssh' + record.connected = false + runtimeRecords(host).dropDisconnectedPtyRecord(pane.ptyId) + expect(liveRow(host.server, pane.paneKey)?.state).toBe('working') + + const restarted = await restart(userDataPath, host.server) + expect(restarted.getHydratedAuthorityCommitments()).toHaveLength(0) + expect(attest(restarted, pane, 'restored')).toBeNull() + }) + it('still vouches for the agent it was minted for while that agent runs', async () => { const host = await wireLiveAgentHost() const pane = await launchAgentPane(host, 'pty-live-token') From bd5665ef0d56b5b84f78e5eaf2a29e4d1fff07de Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:09:27 -0700 Subject: [PATCH 10/19] fix(agent-status): refuse a relay's replay of an agent session whose exit was proven On every SSH reconnect main asks the relay to replay the status it cached for each pane. A verified command-end exit no longer fences the pane, so that replay brought an exited agent's Done row back on every surface; a pane the user wasn't typing in kept it. Each ended-process clear now records which session ended (agent type and provider session). The status disposition, which every ingest path calls, refuses a replay of that session. Anything newer -- a live event, a new turn, another session's replay -- is admitted and drops the record, as do clearPaneState and a size bound. Live late hooks are not refused: an exit verdict can be wrong, and the agent's next event is how it recovers. An anchored clear also skips a resume-only remnant, so a Claude exit the pid check already cleared no longer tells every reader twice. --- .../server-ended-session-replay.test.ts | 117 ++++++++++++++++++ src/main/agent-hooks/server/server-cleanup.ts | 6 +- .../server/server-ingest-normalization.ts | 4 +- .../server/server-ingest-remote.ts | 4 +- .../agent-hooks/server/server-lifecycle.ts | 4 +- src/main/agent-hooks/server/server-state.ts | 9 +- .../server/server-status-disposition.ts | 34 ++++- .../agent-hooks/server/server-tab-cleanup.ts | 1 + src/main/agent-hooks/server/server-types.ts | 3 + 9 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 src/main/agent-hooks/server-ended-session-replay.test.ts diff --git a/src/main/agent-hooks/server-ended-session-replay.test.ts b/src/main/agent-hooks/server-ended-session-replay.test.ts new file mode 100644 index 000000000000..8173081b9bb8 --- /dev/null +++ b/src/main/agent-hooks/server-ended-session-replay.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { AgentHookServer } from './server' + +// A reconnecting SSH relay re-sends the status it cached for every pane. Once a pane's agent was +// proven to have exited, a replay of that same session must not bring its row back; a new turn or +// another session is newer evidence and must. + +const PANE = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const CONNECTION = 'conn-a' + +const servers: AgentHookServer[] = [] + +afterEach(() => { + for (const server of servers.splice(0)) { + server.stop() + } +}) + +async function startServer(): Promise { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production' }) + return server +} + +function event( + agentType: 'claude' | 'codex', + hookEventName: string, + options: { + state?: 'working' | 'done' + sessionId?: string + prompt?: string + replay?: boolean + } = {} +) { + return { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + source: agentType, + hookEventName, + ...(options.sessionId ? { providerSession: { key: 'session_id', id: options.sessionId } } : {}), + ...(options.replay ? { isReplay: true } : {}), + payload: { state: options.state ?? 'working', prompt: options.prompt ?? 'task', agentType } + } +} + +function liveRow(server: AgentHookServer) { + return server.getStatusSnapshotForPane(PANE).find((row) => row.providerSessionOnly !== true) +} + +/** The agent finished its turn, then its exit was verified at a command end. */ +function doneThenVerifiedExit( + server: AgentHookServer, + agentType: 'claude' | 'codex', + sessionId?: string +) { + server.ingestRemote(event(agentType, 'UserPromptSubmit', { sessionId }), CONNECTION) + server.ingestRemote(event(agentType, 'Stop', { state: 'done', sessionId }), CONNECTION) + expect(liveRow(server)?.state).toBe('done') + server.reconcileEndedProcessForPaneKeys([PANE], { + preserveResumeIdentity: true, + armedRowReceivedAt: liveRow(server)!.receivedAt + }) + expect(liveRow(server)).toBeUndefined() +} + +describe('a relay replay after a verified agent exit', () => { + for (const [agentType, sessionId] of [ + ['claude', 'claude-session'], + ['codex', undefined] + ] as const) { + it(`does not bring back the ended ${agentType} session`, async () => { + const server = await startServer() + doneThenVerifiedExit(server, agentType, sessionId) + + server.ingestRemote( + event(agentType, 'Stop', { state: 'done', sessionId, replay: true }), + CONNECTION + ) + + expect(liveRow(server)).toBeUndefined() + }) + } + + it('admits a replay of another session, started while the relay was away', async () => { + const server = await startServer() + doneThenVerifiedExit(server, 'claude', 'claude-session') + + server.ingestRemote( + event('claude', 'Stop', { state: 'done', sessionId: 'next-session', replay: true }), + CONNECTION + ) + + expect(liveRow(server)?.state).toBe('done') + }) + + it('admits a new turn, after which a replay is ordinary evidence again', async () => { + const server = await startServer() + doneThenVerifiedExit(server, 'codex') + + server.ingestRemote(event('codex', 'UserPromptSubmit', { prompt: 'next task' }), CONNECTION) + server.ingestRemote(event('codex', 'Stop', { state: 'done', replay: true }), CONNECTION) + + expect(liveRow(server)?.state).toBe('done') + }) + + it('admits a live, non-replayed event from the ended session (it may still be alive)', async () => { + const server = await startServer() + doneThenVerifiedExit(server, 'codex') + + server.ingestRemote(event('codex', 'PostToolUse'), CONNECTION) + + expect(liveRow(server)?.state).toBe('working') + }) +}) diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index 2adea1eb655b..d5a0cae4a7a1 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -143,9 +143,10 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen const previous = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as | EnrichedAgentHookEventPayload | undefined + // Why remnants too: the anchor names a live row, and a pid check may have ended it already. if ( options?.armedRowReceivedAt !== undefined && - previous?.receivedAt !== options.armedRowReceivedAt + (previous?.receivedAt !== options.armedRowReceivedAt || previous.providerSessionOnly) ) { continue } @@ -157,6 +158,9 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen ? { ...resumeRow, agentPresence: options.endedPresence } : resumeRow this.clearPaneState(resolvedPaneKey, { emitStatusRowMutation: false }) + if (previous && !previous.providerSessionOnly) { + this.recordEndedAgentSession(resolvedPaneKey, previous) + } if (retained) { admitLegacyAgentStatus( this.state, diff --git a/src/main/agent-hooks/server/server-ingest-normalization.ts b/src/main/agent-hooks/server/server-ingest-normalization.ts index 5e71e6ef033f..d75b2dfbf779 100644 --- a/src/main/agent-hooks/server/server-ingest-normalization.ts +++ b/src/main/agent-hooks/server/server-ingest-normalization.ts @@ -73,7 +73,9 @@ export abstract class AgentHookServerIngestNormalization extends AgentHookServer hookEventName: replay.hookEventName, isReplay: true, hasExplicitPrompt: replay.hasExplicitPrompt, - launchToken: replay.launchToken + launchToken: replay.launchToken, + agentType: replay.payload.agentType, + providerSessionId: replay.providerSession?.id ?? null }) if (statusDisposition === 'suppress') { return diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index 28eac6e56dc1..2c56ee14b799 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -173,7 +173,9 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS hookEventName, isReplay: envelope.isReplay === true, hasExplicitPrompt: envelope.hasExplicitPrompt === true, - launchToken: envelope.launchToken + launchToken: envelope.launchToken, + agentType: normalizedPayload.agentType, + providerSessionId: providerSession?.id ?? null }) if (statusDisposition === 'suppress') { return diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index e6e1bf80061d..2fd8786b9055 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -110,7 +110,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv hookEventName: normalized.event.hookEventName, isReplay: normalized.event.isReplay, hasExplicitPrompt: normalized.event.hasExplicitPrompt, - launchToken: normalized.event.launchToken + launchToken: normalized.event.launchToken, + agentType: normalized.event.payload.agentType, + providerSessionId: normalized.event.providerSession?.id ?? null }) : 'suppress' if (normalized.event && statusDisposition !== 'suppress') { diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 6fbb9a9c369b..8e850bab6038 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -35,6 +35,7 @@ import type { AgentHookStatusChangeEntry, AgentHookStatusFreshnessObservation, AgentPromptSentDedupeEntry, + EndedAgentSession, EndedProcessReconcileOptions, EnrichedAgentHookEventPayload, NormalizedLocalHook, @@ -150,6 +151,8 @@ export abstract class AgentHookServerState { protected closedAgentStatusTabIds = new Set() protected closedAgentStatusPaneKeys = new Set() protected restartedStatusLaunchTokenHashByPaneKey = new Map() + /** The agent session whose exit a pane's last ended-process clear proved; see disposition. */ + protected endedAgentSessionByPaneKey = new Map() protected connectionTimestampWatermarkById = new Map() // Why: survives the row itself. A transport clear deletes the pane's status row on purpose // (absence, not completion), but the *age* of the evidence a later replay restates is not a @@ -184,8 +187,12 @@ export abstract class AgentHookServerState { isReplay?: boolean hasExplicitPrompt?: boolean launchToken?: string - } + } & EndedAgentSession ): 'accept' | 'restart' | 'suppress' + protected abstract recordEndedAgentSession( + paneKey: string, + row: EnrichedAgentHookEventPayload + ): void protected abstract isClosedAgentStatusTabForPaneKey(paneKey: string): boolean protected abstract takeRetiredPaneRestartId(paneKey: string): string | undefined protected abstract recordRetiredPaneFence( diff --git a/src/main/agent-hooks/server/server-status-disposition.ts b/src/main/agent-hooks/server/server-status-disposition.ts index ddf64edd00e6..d326a9eb8393 100644 --- a/src/main/agent-hooks/server/server-status-disposition.ts +++ b/src/main/agent-hooks/server/server-status-disposition.ts @@ -8,7 +8,12 @@ import { CLOSED_AGENT_STATUS_TAB_IDS_MAX, RETIRED_PANE_FENCES_MAX } from './server-constants' -import type { RetiredPaneAlias, RetiredPaneFence } from './server-types' +import type { + EndedAgentSession, + EnrichedAgentHookEventPayload, + RetiredPaneAlias, + RetiredPaneFence +} from './server-types' import { AgentHookServerStatusInference } from './server-status-inference' export abstract class AgentHookServerStatusDisposition extends AgentHookServerStatusInference { @@ -52,7 +57,7 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt launchToken?: string /** A process-lifetime Working: a fresh command whose foreground argv proves a new agent run. */ processNewTurn?: boolean - } + } & EndedAgentSession ): 'accept' | 'restart' | 'suppress' { const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) const paneRetired = @@ -66,6 +71,19 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt ) { return 'suppress' } + const ended = this.endedAgentSessionByPaneKey.get(ownerPaneKey) + if (ended && event) { + // Why: a reconnecting relay re-sends the status it cached before the exit; only a replay of + // that same session restates it. Anything else is newer evidence, so the record dies. + if ( + event.isReplay === true && + event.agentType === ended.agentType && + (event.providerSessionId ?? null) === ended.providerSessionId + ) { + return 'suppress' + } + this.endedAgentSessionByPaneKey.delete(ownerPaneKey) + } const retirementFence = this.retiredPaneFencesByKey.get(ownerPaneKey) if ( paneRetired && @@ -209,6 +227,18 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt } } + protected recordEndedAgentSession(paneKey: string, row: EnrichedAgentHookEventPayload): void { + this.endedAgentSessionByPaneKey.delete(paneKey) + this.endedAgentSessionByPaneKey.set(paneKey, { + agentType: row.payload.agentType, + providerSessionId: row.providerSession?.id ?? null + }) + const oldest = this.endedAgentSessionByPaneKey.keys().next().value + if (this.endedAgentSessionByPaneKey.size > CLOSED_AGENT_STATUS_PANE_KEYS_MAX && oldest) { + this.endedAgentSessionByPaneKey.delete(oldest) + } + } + protected markPaneClosedForAgentStatus(paneKey: string): void { this.closedAgentStatusPaneKeys.delete(paneKey) this.closedAgentStatusPaneKeys.add(paneKey) diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index 599665cc98b5..84418460b3a9 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -115,6 +115,7 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { this.currentAuthorityObservations.delete(resolvedPaneKey) this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey) + this.endedAgentSessionByPaneKey.delete(resolvedPaneKey) // Why: the pane itself is gone, so its observation clock describes nothing a later pane owns. this.evidenceObservedAtByPaneKey.delete(resolvedPaneKey) let clearedAlias = false diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index 298be94f7c51..394c61ac9b5b 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -90,6 +90,9 @@ export type AgentHookAuthorityAttestation = Readonly<{ source: 'current_hook' | 'hydrated_commitment' }> +/** Who a proven exit ended: a replay restating that session's old status must not bring it back. */ +export type EndedAgentSession = { agentType?: string; providerSessionId?: string | null } + /** How an ended-process clear treats the pane it reaches. */ export type EndedProcessReconcileOptions = { /** The pane's PTY outlived its agent (a confirmed shell foreground), so the session can still be From bcc0d646d8c669ba60ef91c74c2c45e0cbfa9958 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:09:29 -0700 Subject: [PATCH 11/19] fix(agent-status): take a WSL pane's command end as its agent's exit The host cannot inspect a WSL guest's processes and wsl.exe is not a shell, so a WSL pane could never prove an exit: a hidden pane, worktree ps and mobile kept an exited agent's Done row for the life of the terminal, while a visible WSL pane's desktop drops it at the command end. For a WSL PTY the host now treats the command end as the exit, as the desktop pane does, after the agent pid check (which still keeps a live agent the host can probe). A nested shell's 133;D under a live WSL TUI drops its row, as the desktop pane already does; that stays until a guest-side proof exists. --- docs/reference/agent-status-store.md | 21 ++++-- ...ommand-end-agent-exit-verification.test.ts | 71 +++++++++++++++++++ .../command-end-host-wiring.test-fixture.ts | 11 ++- ...me-get-orchestration-dispatch-authority.ts | 11 ++- 4 files changed, 103 insertions(+), 11 deletions(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 38426f61ed2a..572a4e79f959 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -94,13 +94,20 @@ A command end (OSC 133;D) ends a pane's launch authority at once, because every that shell inherits the token. It does not prove the agent exited: a full-screen agent's nested shells leak their own 133;D. So at every command end of a PTY whose panes hold a live row, the runtime asks the execution host again. The hook's own agent process still running keeps the row. -The pane's shell back in front clears it as an ended process, keeping the resume identity: a local -node-pty proves that from a fresh process-table read (the spawned shell in the foreground, no job -stopped); the terminal daemon and an SSH relay prove it from their fenced foreground evidence (the -shell's own process group in front, no agent named), which cannot see a stopped job. Any other -answer keeps the row, and nothing is latched: the PTY's next command end asks again, and a -confirmed PTY exit clears it. A verdict acts only on the row it checked, so a session that started -while it was read keeps its row. +The pane's shell back in front clears it as an ended process, keeping the resume identity: +- a local node-pty proves that from a fresh process-table read (the spawned shell in the + foreground, no job stopped); +- the terminal daemon and an SSH relay prove it from their fenced foreground evidence (the + shell's own process group in front, no agent named), which cannot see a stopped job; a Windows + daemon or relay cannot answer; +- a WSL pane cannot be inspected from the host, so its command end stands as the exit, as the + desktop pane treats it (temporary, until a guest-side proof exists). + +Any other answer keeps the row, and nothing is latched: the PTY's next command end asks again, and +a confirmed PTY exit clears it. A verdict acts only on the row it checked, so a session that +started while it was read keeps its row. The clear also records which session ended, so a +reconnecting SSH relay's replay of that session's cached status is refused; any newer evidence (a +live event, a new turn, another session) is admitted and drops the record. ## PR 1a: structured sessions publish into the store diff --git a/src/main/runtime/command-end-agent-exit-verification.test.ts b/src/main/runtime/command-end-agent-exit-verification.test.ts index e51f3c8b84c6..aa270c218f58 100644 --- a/src/main/runtime/command-end-agent-exit-verification.test.ts +++ b/src/main/runtime/command-end-agent-exit-verification.test.ts @@ -156,6 +156,18 @@ describe('a command end whose agent exit is verified clears the row for every re expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) }) + it("Claude's own process gone tells each reader once, without a second shell check", async () => { + probe.mockResolvedValue('exited') + const host = await wire() + const pane = await launchAgentPane(host, 'pty-launched-presence-once') + await claudeIsWorking(host, pane, CLAUDE_PROCESS) + + await endCommand(host.runtime, pane.ptyId, 'shell bytes') + + expect(host.readers.windowClears).toEqual([{ paneKey: pane.paneKey }]) + expect(host.readers.subscriberClears).toEqual([{ paneKey: pane.paneKey }]) + }) + it('a PTY exit clears every reader and keeps no resume identity', async () => { const host = await wire() const pane = await launchAgentPane(host, 'pty-launched-exit') @@ -347,6 +359,32 @@ describe("an SSH pane answers from its relay's foreground evidence", () => { return pane } + it("a reconnecting relay's replay does not bring the exited agent back", async () => { + const host = await wire() + host.inspectProcess.mockImplementation(async (ptyId) => + relayEvidence(ptyId, { processName: null, foregroundPgid: 100 }) + ) + const pane = await sshAgentPane(host, 'pty-ssh-replay') + await endCommand(host.runtime, pane.ptyId, 'shell bytes') + expect(liveRow(host.server, pane.paneKey)).toBeUndefined() + + host.server.ingestRemote( + { + paneKey: pane.paneKey, + tabId: TAB, + worktreeId: 'wt-1', + source: 'claude', + hookEventName: 'UserPromptSubmit', + isReplay: true, + providerSession: { key: 'session_id', id: 'claude-session' }, + payload: { state: 'working', prompt: 'review the PR', agentType: 'claude' } + }, + 'conn-1' + ) + + expect(liveRow(host.server, pane.paneKey)).toBeUndefined() + }) + it('the shell back in front with no agent named clears the row everywhere', async () => { const host = await wire() host.inspectProcess.mockImplementation(async (ptyId) => @@ -377,3 +415,36 @@ describe("an SSH pane answers from its relay's foreground evidence", () => { }) } }) + +describe('a WSL pane, whose guest the host cannot inspect', () => { + it('takes the command end as the exit, as the desktop pane does', async () => { + const host = await wire() + host.shellOwnsForeground.mockResolvedValue(false) + const pane = shellPane(host.runtime, 'pty-wsl-exit', { + tabId: TAB, + leafId: LEAF, + wslDistro: 'Ubuntu' + }) + await claudeIsWorking(host, pane) + host.readers.republishedWorktrees.length = 0 + + await endCommand(host.runtime, pane.ptyId, 'shell bytes') + + expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) + }) + + it('still keeps an agent whose own process the host proves alive', async () => { + probe.mockResolvedValue('live') + const host = await wire() + const pane = shellPane(host.runtime, 'pty-wsl-live-pid', { + tabId: TAB, + leafId: LEAF, + wslDistro: 'Ubuntu' + }) + await claudeIsWorking(host, pane, CLAUDE_PROCESS) + + await endCommand(host.runtime, pane.ptyId, 'shell bytes') + + expectNoReaderLostTheRow(host.server, host.readers, pane.paneKey, 'working') + }) +}) diff --git a/src/main/runtime/command-end-host-wiring.test-fixture.ts b/src/main/runtime/command-end-host-wiring.test-fixture.ts index bae55f4d63e7..eba0ec333d02 100644 --- a/src/main/runtime/command-end-host-wiring.test-fixture.ts +++ b/src/main/runtime/command-end-host-wiring.test-fixture.ts @@ -170,7 +170,13 @@ type RuntimeInternals = { export function shellPane( runtime: OrcaRuntimeService, ptyId: string, - options: { tabId: string; leafId: string; listingReceipt?: boolean; connectionId?: string } + options: { + tabId: string + leafId: string + listingReceipt?: boolean + connectionId?: string + wslDistro?: string + } ): { ptyId: string; paneKey: string } { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these protected members exist on the runtime; the listing path that calls them needs a live controller inventory. const internals = runtime as unknown as RuntimeInternals @@ -180,7 +186,8 @@ export function shellPane( tabId: options.tabId, paneKey, incarnationId: `${ptyId}-incarnation`, - ...(options.connectionId ? { connectionId: options.connectionId } : {}) + ...(options.connectionId ? { connectionId: options.connectionId } : {}), + ...(options.wslDistro ? { isWsl: true, wslDistro: options.wslDistro } : {}) }) if (options.listingReceipt) { internals.rememberRestoredOrchestrationAuthority(pty, `term-${ptyId}`, `${ptyId}-incarnation`) diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index 2e2fa04938f4..2851d7d76590 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -158,8 +158,15 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim }, checkHookAgentPresence: async (paneKey) => (await this.checkHookAgentPresenceFn?.(paneKey)) ?? null, - confirmShellOwnsForeground: (ptyId) => - confirmShellOwnsPtyForeground(this.ptyController, this.ptysById.get(ptyId)), + confirmShellOwnsForeground: (ptyId) => { + const pty = this.ptysById.get(ptyId) + // Why TEMPORARY: the host cannot inspect a WSL guest's processes, so its command end stands as + // the exit, as the desktop pane already treats it; a leaked nested-shell 133;D drops a live row. + if (pty && (pty.isWsl || pty.wslDistro || this.wslDistroByPtyId.has(ptyId))) { + return Promise.resolve(true) + } + return confirmShellOwnsPtyForeground(this.ptyController, pty) + }, reconcileEndedProcess: (paneKey, armedRowReceivedAt) => this.reconcileAgentStatusForEndedProcessFn?.([paneKey], { // The shell outlived its agent, so the session stays resumable in place. From 1ae4b56d3b2bebda33461ff3beaf38c375c99d90 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:21:15 -0700 Subject: [PATCH 12/19] fix(agent-status): read every host's shell proof through one ladder The command-end verifier asked each provider a yes/no shell question, so it could not tell 'something else is in front' from 'this host cannot tell', and needed a WSL-only branch. Hosts that cannot tell (a WSL guest, a Windows daemon or relay, a host that predates foreground evidence) kept an exited agent's row on hidden panes, worktree ps and mobile, while the desktop pane and main's old retirement dropped it. Each provider now answers proveShellForeground with shell, other or unprovable, and rejects when its host cannot be reached: - local node-pty: its process-table check; a wsl.exe root is unprovable; - terminal daemon: its own confirm, then its fenced evidence; Windows and daemons without evidence are unprovable; - SSH: the relay's fenced evidence; a Windows relay or one without evidence is unprovable, a client-only answer rejects. The verifier keeps the row for a live pid, for other and for an unreachable host, and takes the command end as the exit for shell and for unprovable, as the desktop pane does. The WSL-specific branch is gone. --- docs/reference/agent-status-store.md | 34 ++-- .../daemon/daemon-pty-process-inspection.ts | 25 +++ src/main/daemon/daemon-pty-router.ts | 8 + .../daemon/degraded-daemon-pty-provider.ts | 2 + src/main/ipc/pty/runtime/controller.ts | 3 + src/main/ipc/pty/runtime/operations.ts | 9 + .../local-pty-provider-shell-proof.test.ts | 25 +++ src/main/providers/local-pty-provider.ts | 12 ++ src/main/providers/pty-provider-contract.ts | 7 + .../providers/shell-foreground-proof.test.ts | 135 ++++++++++++++ src/main/providers/shell-foreground-proof.ts | 73 ++++++++ src/main/providers/ssh-pty-provider.test.ts | 31 ++++ src/main/providers/ssh-pty-provider.ts | 17 +- .../command-end-agent-exit-daemon.test.ts | 30 ++- ...ommand-end-agent-exit-verification.test.ts | 171 +++++------------- .../command-end-agent-exit-verifier.ts | 71 ++------ .../command-end-host-wiring.test-fixture.ts | 29 +-- ...h-token-liveness-after-command-end.test.ts | 2 +- ...me-get-orchestration-dispatch-authority.ts | 21 +-- .../runtime-pty-controller-contract.ts | 7 + 20 files changed, 481 insertions(+), 231 deletions(-) create mode 100644 src/main/providers/local-pty-provider-shell-proof.test.ts create mode 100644 src/main/providers/shell-foreground-proof.test.ts create mode 100644 src/main/providers/shell-foreground-proof.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 572a4e79f959..eb370423fdd2 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -93,21 +93,25 @@ drops the row on a clear and never keeps it as a finished (Done) agent. A command end (OSC 133;D) ends a pane's launch authority at once, because every later process in that shell inherits the token. It does not prove the agent exited: a full-screen agent's nested shells leak their own 133;D. So at every command end of a PTY whose panes hold a live row, the -runtime asks the execution host again. The hook's own agent process still running keeps the row. -The pane's shell back in front clears it as an ended process, keeping the resume identity: -- a local node-pty proves that from a fresh process-table read (the spawned shell in the - foreground, no job stopped); -- the terminal daemon and an SSH relay prove it from their fenced foreground evidence (the - shell's own process group in front, no agent named), which cannot see a stopped job; a Windows - daemon or relay cannot answer; -- a WSL pane cannot be inspected from the host, so its command end stands as the exit, as the - desktop pane treats it (temporary, until a guest-side proof exists). - -Any other answer keeps the row, and nothing is latched: the PTY's next command end asks again, and -a confirmed PTY exit clears it. A verdict acts only on the row it checked, so a session that -started while it was read keeps its row. The clear also records which session ended, so a -reconnecting SSH relay's replay of that session's cached status is refused; any newer evidence (a -live event, a new turn, another session) is admitted and drops the record. +runtime asks the execution host again, and reads its answer in this order: +1. The hook's own agent process still running keeps the row. +2. The pane's own shell proven back in front clears it as an ended process, keeping the resume + identity. A local node-pty proves that from a fresh process-table read (spawned shell in the + foreground, no job stopped); the terminal daemon and an SSH relay from their fenced foreground + evidence (the shell's own process group in front, no agent named), which cannot see a stopped + job (temporary). +3. Something else in front keeps the row. +4. A host that cannot be reached keeps it: loss of contact is never evidence of an exit. +5. A host that answered but cannot tell (a WSL guest, a Windows daemon or relay, a daemon or relay + that predates foreground evidence) leaves the mark as the only evidence, so it stands as the + exit, as the desktop pane treats it. Temporary: there a nested shell's 133;D under a live TUI + drops its row, until those hosts report shell-owns-the-foreground evidence. + +A kept row is not latched: the PTY's next command end asks again, and a confirmed PTY exit clears +it. A verdict acts only on the row it checked, so a session that started while it was read keeps +its row. The clear also records which session ended, so a reconnecting SSH relay's replay of that +session's cached status is refused; any newer evidence (a live event, a new turn, another +session) is admitted and drops the record. ## PR 1a: structured sessions publish into the store diff --git a/src/main/daemon/daemon-pty-process-inspection.ts b/src/main/daemon/daemon-pty-process-inspection.ts index 335c641da627..0a1483a5e612 100644 --- a/src/main/daemon/daemon-pty-process-inspection.ts +++ b/src/main/daemon/daemon-pty-process-inspection.ts @@ -9,6 +9,10 @@ import { } from './types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' import { clientOnlyUnverifiableInspection } from '../../shared/terminal-process-inspection' +import { + proveDaemonShellForeground, + type ShellForegroundProof +} from '../providers/shell-foreground-proof' export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshots { // Why: daemon-backed PTYs can host long-lived agents while detached; cleanup prompts must not treat them as idle shells. @@ -91,6 +95,27 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot } } + async proveShellForeground( + id: string, + options?: { expectedIncarnationId?: string } + ): Promise { + if (this.protocolVersion < COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION) { + return 'unprovable' + } + return proveDaemonShellForeground({ + ptyId: id, + incarnationId: options?.expectedIncarnationId ?? null, + platform: process.platform, + confirmShellForeground: async () => + ( + await this.client.request<{ confirmed: boolean }>('confirmShellForeground', { + sessionId: id + }) + ).confirmed === true, + inspectProcess: () => this.inspectProcess(id, options) + }) + } + async serialize(ids: string[]): Promise { const sessions: Record = {} for (const id of ids) { diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 0534c9d28697..a14f36b67ce6 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -9,6 +9,7 @@ import type { PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' +import type { ShellForegroundProof } from '../providers/shell-foreground-proof' import { shouldHandoffDaemonHistory } from './daemon-history-handoff' import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events' import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' @@ -204,6 +205,13 @@ export class DaemonPtyRouter implements IPtyProvider { return (await this.adapterFor(id).confirmShellForeground?.(id)) ?? false } + proveShellForeground( + id: string, + options?: { expectedIncarnationId?: string } + ): Promise { + return this.adapterFor(id).proveShellForeground(id, options) + } + async serialize(ids: string[]): Promise { return this.current.serialize(ids) } diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 0a8868a8d808..35e27dc22262 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -199,6 +199,8 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { async confirmShellForeground(id: string): Promise { return (await this.providerFor(id).confirmShellForeground?.(id)) ?? false } + proveShellForeground: IPtyProvider['proveShellForeground'] = (id, options) => + this.providerFor(id).proveShellForeground?.(id, options) ?? Promise.resolve('other') async serialize(ids: string[]): Promise { return this.fallback.serialize(ids) diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index 4013e06e37fa..b2ea94e8068e 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -13,6 +13,7 @@ import { resetInputModesFromRuntimeController, confirmForegroundProcessFromRuntimeController, confirmShellForegroundFromRuntimeController, + proveShellForegroundFromRuntimeController, getCwdFromRuntimeController, getForegroundProcessFromRuntimeController, getRendererSerializerGenerationFromRuntimeController, @@ -68,6 +69,8 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi inspectProcess: (ptyId, options) => inspectProcessFromRuntimeController(ptyId, options), confirmForegroundProcess: (ptyId) => confirmForegroundProcessFromRuntimeController(ptyId), confirmShellForeground: (ptyId) => confirmShellForegroundFromRuntimeController(ptyId), + proveShellForeground: (ptyId, options) => + proveShellForegroundFromRuntimeController(ptyId, options), getCwd: (ptyId) => getCwdFromRuntimeController(ptyId), hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId), clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId), diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index c555c0b31021..87b277e2dc55 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -6,6 +6,7 @@ import { ptySizes } from '../delivery/visibility-state' import { rendererSerializerReadiness } from '../pane/serializer-state' import { getProviderForPty, localProvider } from '../provider/registry' import { inspectPtyProviderProcess } from '../../../providers/pty-process-inspection' +import type { ShellForegroundProof } from '../../../providers/shell-foreground-proof' import type { PtyRuntimeControllerDeps } from './controller-deps' import { writeRefused, @@ -161,6 +162,14 @@ export async function confirmShellForegroundFromRuntimeController(ptyId: string) } } +// Why no catch: a host that cannot be reached must reject, never read as an answer. +export async function proveShellForegroundFromRuntimeController( + ptyId: string, + options?: { expectedIncarnationId?: string } +): Promise { + return (await getProviderForPty(ptyId).proveShellForeground?.(ptyId, options)) ?? 'other' +} + export async function getCwdFromRuntimeController(ptyId: string) { try { const cwd = await getProviderForPty(ptyId).getCwd(ptyId) diff --git a/src/main/providers/local-pty-provider-shell-proof.test.ts b/src/main/providers/local-pty-provider-shell-proof.test.ts new file mode 100644 index 000000000000..ef1cc65448ea --- /dev/null +++ b/src/main/providers/local-pty-provider-shell-proof.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { LocalPtyProvider } from './local-pty-provider' +import { ptyShellPath } from './local-pty-provider-state' + +// A WSL pane's root process is wsl.exe: the host's process table cannot see the guest's shell, so +// the provider answers that it cannot tell rather than that something else is in front. + +afterEach(() => { + ptyShellPath.clear() +}) + +describe('LocalPtyProvider shell proof', () => { + it('cannot tell for a WSL pane', async () => { + ptyShellPath.set('pty-wsl', 'C:\\Windows\\System32\\wsl.exe') + + await expect(new LocalPtyProvider().proveShellForeground('pty-wsl')).resolves.toBe('unprovable') + }) + + it('answers from the process table for a pane whose root is a shell', async () => { + ptyShellPath.set('pty-zsh', '/bin/zsh') + + // No live process is registered under this id, so the table read proves nothing. + await expect(new LocalPtyProvider().proveShellForeground('pty-zsh')).resolves.toBe('other') + }) +}) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 9c2f3c80c9cd..782467a3db33 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -24,10 +24,13 @@ import { clearPtyState, pendingLocalPtySpawns, ptyProcesses, + ptyShellPath, resetLoadGeneration, type DataCallback, type ExitCallback } from './local-pty-provider-state' +import { isShellProcess } from '../../shared/shell-process-detection' +import type { ShellForegroundProof } from './shell-foreground-proof' import { clearLocalPtyBuffer, closeLocalPtyStartupQueryAuthority, @@ -170,6 +173,15 @@ export class LocalPtyProvider implements IPtyProvider { return confirmLocalPtyShellForeground(id) } + async proveShellForeground(id: string): Promise { + const shellPath = ptyShellPath.get(id) + // Why: a WSL pane's root is wsl.exe, and the guest's processes are out of this host's reach. + if (shellPath && !isShellProcess(shellPath)) { + return 'unprovable' + } + return (await confirmLocalPtyShellForeground(id)) ? 'shell' : 'other' + } + async serialize(_ids: string[]): Promise { return '{}' } diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index e10fd44df46d..6b541cebaebb 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -12,6 +12,7 @@ import type { AgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' import type { PtyProcessInfo } from './pty-process-info' +import type { ShellForegroundProof } from './shell-foreground-proof' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' import type { TerminalOwner } from '../../shared/terminal-owner' import type { WriteSettlement } from '../../shared/pty-write-settlement' @@ -241,6 +242,12 @@ export type IPtyProvider = { confirmForegroundProcess?: (id: string) => Promise /** Fresh execution-host proof that the spawned shell owns the PTY foreground. */ confirmShellForeground?: (id: string) => Promise + /** Whether the pane's own shell is back in front, or the host cannot tell; rejects when the host + * cannot be reached. */ + proveShellForeground?: ( + id: string, + options?: { expectedIncarnationId?: string } + ) => Promise serialize(ids: string[]): Promise revive(state: string): Promise // Why: deadlineMs bounds the underlying RPC exactly like shutdown's deadlineMs. diff --git a/src/main/providers/shell-foreground-proof.test.ts b/src/main/providers/shell-foreground-proof.test.ts new file mode 100644 index 000000000000..cbc972b9b909 --- /dev/null +++ b/src/main/providers/shell-foreground-proof.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' +import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence' +import { + proveDaemonShellForeground, + shellForegroundProofFromInspection +} from './shell-foreground-proof' + +// How a daemon or relay answer becomes a proof the command-end verifier acts on: `shell` and +// `unprovable` clear the row, `other` keeps it. + +const PTY = 'pty-1' +const INCARNATION = 'pty-1-incarnation' + +function observation(overrides: { capturedAgeMs?: number; ptyIncarnationId?: string } = {}) { + return { + authorityGeneration: 'host-generation', + observationEpoch: 1, + capturedAgeMs: overrides.capturedAgeMs ?? 0, + ptyId: PTY, + ptyIncarnationId: overrides.ptyIncarnationId ?? INCARNATION + } +} + +function live(processName: string | null, foregroundPgid: number): RemoteForegroundEvidence { + return { + ...observation(), + verdict: 'live', + processName, + fence: { + platform: 'posix', + shellPid: 100, + shellStartTime: 'shell-birth', + tty: '/dev/pts/3', + foregroundPgid + } + } +} + +function proof(evidence: RemoteForegroundEvidence | undefined) { + return shellForegroundProofFromInspection( + { + foregroundProcess: null, + hasChildProcesses: false, + ...(evidence ? { foregroundProcessEvidence: evidence } : {}) + }, + { ptyId: PTY, incarnationId: INCARNATION, requestStartedAtMonotonic: performance.now() } + ) +} + +describe('fenced foreground evidence as a shell proof', () => { + it('proves the shell when its own group is in front and no agent is named', () => { + expect(proof(live(null, 100))).toBe('shell') + }) + + const keepsTheRow: [string, RemoteForegroundEvidence][] = [ + ['an agent in front', live('codex', 200)], + ['another command in front', live(null, 200)], + // Job control off (`set +m`): the agent runs in the shell's own group. + ['an agent in the shell group', live('claude', 100)], + [ + 'an unreadable process table', + { ...observation(), verdict: 'unverifiable', reason: 'process_table_unreadable' } + ], + ['evidence for another incarnation', { ...live(null, 100), ptyIncarnationId: 'replacement' }], + ['stale evidence', { ...live(null, 100), capturedAgeMs: 5_000 }] + ] + it.each(keepsTheRow)('keeps the row for %s', (_name, evidence) => { + expect(proof(evidence)).toBe('other') + }) + + const cannotTell: [string, RemoteForegroundEvidence | undefined][] = [ + [ + 'a Windows host', + { ...observation(), verdict: 'unverifiable', reason: 'windows_ssh_foreground_unavailable' } + ], + ['a host that predates foreground evidence', undefined] + ] + it.each(cannotTell)('cannot tell for %s', (_name, evidence) => { + expect(proof(evidence)).toBe('unprovable') + }) +}) + +describe('an inspection the host never answered', () => { + it('rejects, so the row is kept: loss of contact is never evidence', () => { + expect(() => + shellForegroundProofFromInspection( + { + foregroundProcess: null, + hasChildProcesses: false, + verdict: 'unverifiable', + reason: 'transport_loss' + }, + { ptyId: PTY, incarnationId: INCARNATION, requestStartedAtMonotonic: performance.now() } + ) + ).toThrow('transport_loss') + }) +}) + +describe('the terminal daemon shell proof', () => { + const daemon = (platform: NodeJS.Platform, confirmed: boolean) => + proveDaemonShellForeground({ + ptyId: PTY, + incarnationId: INCARNATION, + platform, + confirmShellForeground: async () => confirmed, + inspectProcess: async () => ({ + foregroundProcess: null, + hasChildProcesses: false, + foregroundProcessEvidence: live(null, 100) + }) + }) + + it("trusts the daemon's own confirm, then its evidence", async () => { + await expect(daemon('linux', true)).resolves.toBe('shell') + await expect(daemon('linux', false)).resolves.toBe('shell') + }) + + it('cannot tell on Windows, where the daemon has no foreground evidence', async () => { + await expect(daemon('win32', false)).resolves.toBe('unprovable') + }) + + it('rejects when the daemon cannot be reached', async () => { + await expect( + proveDaemonShellForeground({ + ptyId: PTY, + incarnationId: INCARNATION, + platform: 'linux', + confirmShellForeground: async () => { + throw new Error('daemon socket closed') + }, + inspectProcess: async () => ({ foregroundProcess: null, hasChildProcesses: false }) + }) + ).rejects.toThrow('daemon socket closed') + }) +}) diff --git a/src/main/providers/shell-foreground-proof.ts b/src/main/providers/shell-foreground-proof.ts new file mode 100644 index 000000000000..67acfa302e66 --- /dev/null +++ b/src/main/providers/shell-foreground-proof.ts @@ -0,0 +1,73 @@ +import { admitRemoteForegroundEvidence } from '../../shared/remote-foreground-evidence-admission' +import { isClientOnlyUnverifiableInspection } from '../../shared/terminal-process-inspection' +import type { PtyProcessInspection } from './pty-process-inspection' + +/** + * What a PTY's execution host says about its own shell being back in front: + * - `shell`: proven; whatever ran in the pane has ended. + * - `other`: something else is in front (an agent, another job, a stopped job), or the host's + * answer could not be trusted (stale, another incarnation, an unreadable process table). + * - `unprovable`: the host answered but has no way to tell (a WSL guest, a Windows host, a host + * that predates foreground evidence). + * A host that cannot be reached rejects instead: loss of contact is never evidence of an exit. + */ +export type ShellForegroundProof = 'shell' | 'other' | 'unprovable' + +/** Reads the fenced foreground evidence a terminal daemon or SSH relay inspection carries. */ +export function shellForegroundProofFromInspection( + inspection: PtyProcessInspection, + expected: { + ptyId: string + incarnationId: string | null + requestStartedAtMonotonic: number + } +): ShellForegroundProof { + // Why throw: a client-only verdict (transport loss) is no answer from the host at all. + if (isClientOnlyUnverifiableInspection(inspection)) { + throw new Error(`execution host gave no answer: ${inspection.reason}`) + } + if (!('foregroundProcessEvidence' in inspection) || !inspection.foregroundProcessEvidence) { + return 'unprovable' + } + const evidence = admitRemoteForegroundEvidence(inspection.foregroundProcessEvidence, { + expectedPtyId: expected.ptyId, + expectedIncarnationId: expected.incarnationId, + requestStartedAtMonotonic: expected.requestStartedAtMonotonic, + receivedAtMonotonic: performance.now(), + lastAuthorityGeneration: null, + lastObservationEpoch: -1 + }) + if (evidence?.verdict === 'unverifiable') { + return evidence.reason === 'windows_ssh_foreground_unavailable' ? 'unprovable' : 'other' + } + // Why TEMPORARY: this evidence cannot see a stopped job, so a Ctrl+Z'd agent reads as `shell`. + return evidence?.verdict === 'live' && + evidence.processName === null && + evidence.fence.platform === 'posix' && + evidence.fence.foregroundPgid === evidence.fence.shellPid + ? 'shell' + : 'other' +} + +/** The terminal daemon's own confirm proves a shell only after a full-screen exit, so its fenced + * evidence is read next; that evidence is POSIX-only, so a Windows daemon cannot tell. */ +export async function proveDaemonShellForeground(args: { + ptyId: string + incarnationId: string | null + platform: NodeJS.Platform + confirmShellForeground: () => Promise + inspectProcess: () => Promise +}): Promise { + if (await args.confirmShellForeground()) { + return 'shell' + } + if (args.platform === 'win32') { + return 'unprovable' + } + const requestStartedAtMonotonic = performance.now() + return shellForegroundProofFromInspection(await args.inspectProcess(), { + ptyId: args.ptyId, + incarnationId: args.incarnationId, + requestStartedAtMonotonic + }) +} diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 8530691b1599..f8b70cf0b9da 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -288,6 +288,37 @@ describe('SshPtyProvider', () => { }) }) + it('proves the shell from relay evidence fenced to the raw relay id', async () => { + mux.request.mockResolvedValue({ + foregroundProcess: null, + hasChildProcesses: false, + foregroundProcessEvidence: { + authorityGeneration: 'relay-generation', + observationEpoch: 1, + capturedAgeMs: 0, + ptyId: 'pty-1', + ptyIncarnationId: 'incarnation-1', + verdict: 'live', + processName: null, + fence: { + platform: 'posix', + shellPid: 100, + shellStartTime: 'shell-birth', + tty: '/dev/pts/3', + foregroundPgid: 100 + } + } + }) + + await expect( + provider.proveShellForeground(scopedPty1, { expectedIncarnationId: 'incarnation-1' }) + ).resolves.toBe('shell') + expectRequest(mux.request, 'pty.inspectProcess', { + id: 'pty-1', + expectedIncarnationId: 'incarnation-1' + }) + }) + it('probes the additive foreground-evidence capability', async () => { mux.request.mockResolvedValue({ foregroundProcessEvidenceVersion: 1 }) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 59088247e18b..12e0eb2f670a 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -2,7 +2,11 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import type { WriteSettlement } from '../../shared/pty-write-settlement' import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' -import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' +import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' +import { + shellForegroundProofFromInspection, + type ShellForegroundProof +} from './shell-foreground-proof' import { createSshPtyAppliedSizeReader } from './ssh-pty-applied-size' import type { RemoteCliBridgeEnv, @@ -70,6 +74,17 @@ export class SshPtyProvider implements IPtyProvider { id: string, options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean } ): Promise => this.rpcOperations.inspectProcess(id, options) + proveShellForeground = async ( + id: string, + options?: { expectedIncarnationId?: string } + ): Promise => { + const requestStartedAtMonotonic = performance.now() + return shellForegroundProofFromInspection(await this.inspectProcess(id, options), { + ptyId: parseAppSshPtyId(id)?.relayPtyId ?? id, + incarnationId: options?.expectedIncarnationId ?? null, + requestStartedAtMonotonic + }) + } serialize = (ids: string[]): Promise => this.rpcOperations.serialize(ids) revive = (state: string): Promise => this.rpcOperations.revive(state) getDefaultShell = (): Promise => this.rpcOperations.getDefaultShell() diff --git a/src/main/runtime/command-end-agent-exit-daemon.test.ts b/src/main/runtime/command-end-agent-exit-daemon.test.ts index ce850aed2a79..e1265292839f 100644 --- a/src/main/runtime/command-end-agent-exit-daemon.test.ts +++ b/src/main/runtime/command-end-agent-exit-daemon.test.ts @@ -4,6 +4,7 @@ import type { ProcessTableRow } from '../../shared/process-table-snapshot' import type * as ProcessTableSnapshotReader from '../../shared/process-table-snapshot-reader' import type { SubprocessHandle } from '../daemon/session-subprocess-handle' import { TerminalHost } from '../daemon/terminal-host' +import { proveDaemonShellForeground } from '../providers/shell-foreground-proof' import { endCommand, expectEveryReaderSawTheClear, @@ -114,7 +115,10 @@ afterEach(async () => { }) /** An Orca-launched Codex pane whose PTY is a real daemon session. */ -async function launchDaemonCodexPane(ptyId: string): Promise { +async function launchDaemonCodexPane( + ptyId: string, + platform: NodeJS.Platform = 'linux' +): Promise { const subprocess = createSubprocess() const terminalHost = new TerminalHost({ spawnSubprocess: () => subprocess.handle }) teardowns.push(() => terminalHost.dispose()) @@ -126,8 +130,15 @@ async function launchDaemonCodexPane(ptyId: string): Promise { }) const host = await wireCommandEndHost({ controller: { - confirmShellForeground: (id) => terminalHost.confirmShellForeground(id), - inspectProcess: (id, options) => terminalHost.inspectProcess(id, options) + // The daemon adapter's proof, over a real daemon session instead of its socket. + proveShellForeground: (id, options) => + proveDaemonShellForeground({ + ptyId: id, + incarnationId: options?.expectedIncarnationId ?? null, + platform, + confirmShellForeground: () => terminalHost.confirmShellForeground(id), + inspectProcess: () => terminalHost.inspectProcess(id, options) + }) } }) teardowns.push(host.teardown) @@ -182,4 +193,17 @@ describe('a normal-buffer agent on a terminal-daemon pane', () => { ) }) } + + it('on Windows, where the daemon has no foreground evidence, takes the command end as the exit', async () => { + const daemonPane = await launchDaemonCodexPane('pty-daemon-windows', 'win32') + processTable.rows = paneProcesses('codex') + + await runCommandToItsEnd(daemonPane, 'daemon fact') + + expectEveryReaderSawTheClear( + daemonPane.host.server, + daemonPane.host.readers, + daemonPane.pane.paneKey + ) + }) }) diff --git a/src/main/runtime/command-end-agent-exit-verification.test.ts b/src/main/runtime/command-end-agent-exit-verification.test.ts index aa270c218f58..4c9ca1d63b0a 100644 --- a/src/main/runtime/command-end-agent-exit-verification.test.ts +++ b/src/main/runtime/command-end-agent-exit-verification.test.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence' +import type { ShellForegroundProof } from '../providers/shell-foreground-proof' import { endCommand, expectEveryReaderSawTheClear, @@ -145,7 +145,7 @@ describe('a command end whose agent exit is verified clears the row for every re it("Claude's own process gone clears the row even when the shell check cannot say", async () => { probe.mockResolvedValue('exited') const host = await wire() - host.shellOwnsForeground.mockResolvedValue(false) + host.shellProof.mockResolvedValue('other') const pane = await launchAgentPane(host, 'pty-launched-presence') await claudeIsWorking(host, pane, CLAUDE_PROCESS) host.readers.republishedWorktrees.length = 0 @@ -205,14 +205,14 @@ describe('a command end that does not prove the agent exited keeps its row every await claudeIsWorking(host, pane) await claudeIsDone(host, pane) // The full-screen agent still owns the foreground. - host.shellOwnsForeground.mockResolvedValue(false) + host.shellProof.mockResolvedValue('other') await endCommand(host.runtime, pane.ptyId, path) expectNoReaderLostTheRow(host.server, host.readers, pane.paneKey, 'done') // The agent quits; its shell's prompt returns. - host.shellOwnsForeground.mockResolvedValue(true) + host.shellProof.mockResolvedValue('shell') await endCommand(host.runtime, pane.ptyId, path) expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) @@ -222,7 +222,7 @@ describe('a command end that does not prove the agent exited keeps its row every it('a live agent pid outranks a shell in the foreground (a suspended or nested agent)', async () => { probe.mockResolvedValue('live') const host = await wire() - host.shellOwnsForeground.mockResolvedValue(true) + host.shellProof.mockResolvedValue('shell') const pane = await launchAgentPane(host, 'pty-live-pid') await claudeIsWorking(host, pane, CLAUDE_PROCESS) @@ -233,7 +233,7 @@ describe('a command end that does not prove the agent exited keeps its row every it('an unanswered check keeps the row and is asked again at the next command end', async () => { const host = await wire() - host.shellOwnsForeground.mockRejectedValueOnce(new Error('host unreachable')) + host.shellProof.mockRejectedValueOnce(new Error('host unreachable')) const pane = await launchAgentPane(host, 'pty-unanswered') await claudeIsWorking(host, pane) @@ -246,9 +246,9 @@ describe('a command end that does not prove the agent exited keeps its row every it('a session that starts while the check is read keeps its row', async () => { const host = await wire() - let answer: (shellOwnsForeground: boolean) => void = () => {} - host.shellOwnsForeground.mockImplementationOnce( - () => new Promise((resolve) => (answer = resolve)) + let answer: (proof: ShellForegroundProof) => void = () => {} + host.shellProof.mockImplementationOnce( + () => new Promise((resolve) => (answer = resolve)) ) const pane = await launchAgentPane(host, 'pty-next-session') await claudeIsWorking(host, pane) @@ -261,7 +261,7 @@ describe('a command end that does not prove the agent exited keeps its row every session_id: 'second-session', prompt: 'a different task' }) - answer(true) + answer('shell') await settle() expect(liveRow(host.server, pane.paneKey)?.prompt).toBe('a different task') @@ -270,9 +270,9 @@ describe('a command end that does not prove the agent exited keeps its row every it('a command end that lands while a check is in flight is checked too', async () => { const host = await wire() - let answer: (shellOwnsForeground: boolean) => void = () => {} - host.shellOwnsForeground.mockImplementationOnce( - () => new Promise((resolve) => (answer = resolve)) + let answer: (proof: ShellForegroundProof) => void = () => {} + host.shellProof.mockImplementationOnce( + () => new Promise((resolve) => (answer = resolve)) ) const pane = await launchAgentPane(host, 'pty-in-flight') await claudeIsWorking(host, pane) @@ -280,10 +280,10 @@ describe('a command end that does not prove the agent exited keeps its row every // A leaked 133;D starts a check that sees the agent; the real exit lands before it answers. await endCommand(host.runtime, pane.ptyId, 'daemon fact') await endCommand(host.runtime, pane.ptyId, 'daemon fact') - answer(false) + answer('other') await settle() - expect(host.shellOwnsForeground).toHaveBeenCalledTimes(2) + expect(host.shellProof).toHaveBeenCalledTimes(2) expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) }) @@ -294,12 +294,12 @@ describe('a command end that does not prove the agent exited keeps its row every await endCommand(host.runtime, pane.ptyId, 'shell bytes') await endCommand(host.runtime, pane.ptyId, 'daemon fact') - expect(host.shellOwnsForeground).not.toHaveBeenCalled() + expect(host.shellProof).not.toHaveBeenCalled() }) it('still drops launch authority at once: the token lives on in the shell', async () => { const host = await wire() - host.shellOwnsForeground.mockResolvedValue(false) + host.shellProof.mockResolvedValue('other') const pane = await launchAgentPane(host, 'pty-authority') expect(host.runtime.readPaneLaunchAuthority(pane.paneKey)?.launchTokenHash).toBe( createHash('sha256').update(pane.launchToken).digest('hex') @@ -314,57 +314,41 @@ describe('a command end that does not prove the agent exited keeps its row every }) }) -/** What an SSH relay reports for a PTY: the shell's group in front unless another group is. */ -function relayEvidence( - ptyId: string, - front: { processName: string | null; foregroundPgid: number } | 'unverifiable' -): { foregroundProcessEvidence: RemoteForegroundEvidence } { - const observation = { - authorityGeneration: 'relay-generation', - observationEpoch: 1, - capturedAgeMs: 0, - ptyId, - ptyIncarnationId: `${ptyId}-incarnation` - } - return { - foregroundProcessEvidence: - front === 'unverifiable' - ? { ...observation, verdict: 'unverifiable', reason: 'process_table_unreadable' } - : { - ...observation, - verdict: 'live', - processName: front.processName, - fence: { - platform: 'posix', - shellPid: 100, - shellStartTime: 'shell-birth', - tty: '/dev/pts/3', - foregroundPgid: front.foregroundPgid - } - } - } -} +describe('a host that answers but cannot tell (WSL, Windows, no foreground evidence)', () => { + it('leaves the command end as the evidence, as the desktop pane does', async () => { + const host = await wire() + host.shellProof.mockResolvedValue('unprovable') + const pane = await launchAgentPane(host, 'pty-unprovable') + await claudeIsWorking(host, pane) + host.readers.republishedWorktrees.length = 0 + + await endCommand(host.runtime, pane.ptyId, 'daemon fact') -describe("an SSH pane answers from its relay's foreground evidence", () => { - async function sshAgentPane(host: CommandEndHost, ptyId: string) { - // The SSH provider has no shell confirm, so the runtime controller answers false. - host.shellOwnsForeground.mockResolvedValue(false) - const pane = shellPane(host.runtime, ptyId, { + expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) + }) + + it('still keeps an agent whose own process the host proves alive', async () => { + probe.mockResolvedValue('live') + const host = await wire() + host.shellProof.mockResolvedValue('unprovable') + const pane = await launchAgentPane(host, 'pty-unprovable-live-pid') + await claudeIsWorking(host, pane, CLAUDE_PROCESS) + + await endCommand(host.runtime, pane.ptyId, 'shell bytes') + + expectNoReaderLostTheRow(host.server, host.readers, pane.paneKey, 'working') + }) +}) + +describe('an SSH pane after a verified exit', () => { + it("does not come back when a reconnecting relay replays the agent's cached status", async () => { + const host = await wire() + const pane = shellPane(host.runtime, 'pty-ssh-replay', { tabId: TAB, leafId: LEAF, connectionId: 'conn-1' }) await claudeIsWorking(host, pane) - host.readers.republishedWorktrees.length = 0 - return pane - } - - it("a reconnecting relay's replay does not bring the exited agent back", async () => { - const host = await wire() - host.inspectProcess.mockImplementation(async (ptyId) => - relayEvidence(ptyId, { processName: null, foregroundPgid: 100 }) - ) - const pane = await sshAgentPane(host, 'pty-ssh-replay') await endCommand(host.runtime, pane.ptyId, 'shell bytes') expect(liveRow(host.server, pane.paneKey)).toBeUndefined() @@ -384,67 +368,4 @@ describe("an SSH pane answers from its relay's foreground evidence", () => { expect(liveRow(host.server, pane.paneKey)).toBeUndefined() }) - - it('the shell back in front with no agent named clears the row everywhere', async () => { - const host = await wire() - host.inspectProcess.mockImplementation(async (ptyId) => - relayEvidence(ptyId, { processName: null, foregroundPgid: 100 }) - ) - const pane = await sshAgentPane(host, 'pty-ssh-exit') - - await endCommand(host.runtime, pane.ptyId, 'shell bytes') - - expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) - }) - - for (const [name, front] of [ - ['the agent still in front', { processName: 'claude', foregroundPgid: 200 }], - // Job control off (`set +m`): the agent runs in the shell's own group. - ['an agent in the shell group', { processName: 'claude', foregroundPgid: 100 }], - ['another command in front', { processName: null, foregroundPgid: 200 }], - ['a relay that cannot read its process table', 'unverifiable'] - ] as const) { - it(`${name} keeps the row`, async () => { - const host = await wire() - host.inspectProcess.mockImplementation(async (ptyId) => relayEvidence(ptyId, front)) - const pane = await sshAgentPane(host, `pty-ssh-${name.replace(/\W+/g, '-')}`) - - await endCommand(host.runtime, pane.ptyId, 'shell bytes') - - expectNoReaderLostTheRow(host.server, host.readers, pane.paneKey, 'working') - }) - } -}) - -describe('a WSL pane, whose guest the host cannot inspect', () => { - it('takes the command end as the exit, as the desktop pane does', async () => { - const host = await wire() - host.shellOwnsForeground.mockResolvedValue(false) - const pane = shellPane(host.runtime, 'pty-wsl-exit', { - tabId: TAB, - leafId: LEAF, - wslDistro: 'Ubuntu' - }) - await claudeIsWorking(host, pane) - host.readers.republishedWorktrees.length = 0 - - await endCommand(host.runtime, pane.ptyId, 'shell bytes') - - expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) - }) - - it('still keeps an agent whose own process the host proves alive', async () => { - probe.mockResolvedValue('live') - const host = await wire() - const pane = shellPane(host.runtime, 'pty-wsl-live-pid', { - tabId: TAB, - leafId: LEAF, - wslDistro: 'Ubuntu' - }) - await claudeIsWorking(host, pane, CLAUDE_PROCESS) - - await endCommand(host.runtime, pane.ptyId, 'shell bytes') - - expectNoReaderLostTheRow(host.server, host.readers, pane.paneKey, 'working') - }) }) diff --git a/src/main/runtime/command-end-agent-exit-verifier.ts b/src/main/runtime/command-end-agent-exit-verifier.ts index 5eed609f10a6..c47aa5819cb1 100644 --- a/src/main/runtime/command-end-agent-exit-verifier.ts +++ b/src/main/runtime/command-end-agent-exit-verifier.ts @@ -1,7 +1,5 @@ import type { AgentProcessVerdict } from '../../shared/agent-process-presence' -import { admitRemoteForegroundEvidence } from '../../shared/remote-foreground-evidence-admission' -import { parseAppSshPtyId } from '../../shared/ssh-pty-id' -import type { RuntimePtyController } from './runtime-pty-controller-contract' +import type { ShellForegroundProof } from '../providers/shell-foreground-proof' type CommandEndAgentExitDeps = { /** The PTY's pane keys whose host row is live (not a resume remnant), each with that row's @@ -10,22 +8,26 @@ type CommandEndAgentExitDeps = { /** The hook presence check: the row's own agent pid and start time. An `exited` answer has * already cleared that row; null when no hook identified a process. */ checkHookAgentPresence(paneKey: string): Promise - /** The PTY's spawned shell owns the foreground with nothing else running; false when its host - * cannot say. */ - confirmShellOwnsForeground(ptyId: string): Promise + /** The execution host's answer on the pane's own shell; rejects when the host cannot be reached. */ + proveShellForeground(ptyId: string): Promise /** Clears a pane whose agent exited, unless its row changed after `armedRowReceivedAt`. */ reconcileEndedProcess(paneKey: string, armedRowReceivedAt: number): void } /** * Whether the agent behind a command end (OSC 133;D) exited. A full-screen agent's nested shells - * leak their own 133;D, so the mark alone proves nothing. A live agent pid wins; then the spawned - * shell owning the foreground with nothing stopped proves the exit. Anything else keeps the row. + * leak their own 133;D, so the mark alone proves nothing where the host can look: + * 1. a live agent pid keeps the row; + * 2. the pane's own shell proven back in front means it exited; + * 3. something else in front keeps the row; + * 4. a host that cannot be reached keeps it (loss of contact is never evidence of an exit); + * 5. a host that answered but cannot tell (WSL, Windows, a host without foreground evidence) + * leaves the mark as the only evidence, so it stands as the exit, as on the desktop pane. */ export async function verifyCommandEndAgentExit( ptyId: string, paneKeys: Iterable, - deps: Pick + deps: Pick ): Promise { const verdicts = await Promise.all( Array.from(paneKeys, (paneKey) => deps.checkHookAgentPresence(paneKey).catch(() => null)) @@ -33,9 +35,14 @@ export async function verifyCommandEndAgentExit( if (verdicts.includes('live')) { return 'live' } - return (await deps.confirmShellOwnsForeground(ptyId).catch(() => false)) - ? 'exited' - : 'unverifiable' + let proof: ShellForegroundProof + try { + proof = await deps.proveShellForeground(ptyId) + } catch { + return 'unverifiable' + } + // Why TEMPORARY for `unprovable`: a nested shell's mark under a live TUI there drops its row. + return proof === 'other' ? 'unverifiable' : 'exited' } /** @@ -91,43 +98,3 @@ export class CommandEndAgentExitVerifier { } } } - -/** - * A local node-pty answers from a fresh process-table read. The terminal daemon answers its shell - * confirm from its byte scanner, which only proves a shell after a full-screen exit, and an SSH host - * has none; both also report fenced foreground evidence (a local node-pty reports none). That - * evidence proves the exit as the shell's own process group in front with no agent named in it. - */ -export async function confirmShellOwnsPtyForeground( - controller: RuntimePtyController | null, - pty: { ptyId: string; incarnationId: string | null } | undefined -): Promise { - if (!controller || !pty) { - return false - } - if (await controller.confirmShellForeground?.(pty.ptyId)) { - return true - } - if (!controller.inspectProcess || !pty.incarnationId) { - return false - } - const requestStartedAtMonotonic = performance.now() - const inspection = await controller.inspectProcess(pty.ptyId, { - expectedIncarnationId: pty.incarnationId - }) - const evidence = admitRemoteForegroundEvidence(inspection.foregroundProcessEvidence, { - expectedPtyId: parseAppSshPtyId(pty.ptyId)?.relayPtyId ?? pty.ptyId, - expectedIncarnationId: pty.incarnationId, - requestStartedAtMonotonic, - receivedAtMonotonic: performance.now(), - lastAuthorityGeneration: null, - lastObservationEpoch: -1 - }) - // Why TEMPORARY: this evidence cannot see a stopped job, so a Ctrl+Z'd agent reads as exited. - return ( - evidence?.verdict === 'live' && - evidence.processName === null && - evidence.fence.platform === 'posix' && - evidence.fence.foregroundPgid === evidence.fence.shellPid - ) -} diff --git a/src/main/runtime/command-end-host-wiring.test-fixture.ts b/src/main/runtime/command-end-host-wiring.test-fixture.ts index eba0ec333d02..44066fe0fdf1 100644 --- a/src/main/runtime/command-end-host-wiring.test-fixture.ts +++ b/src/main/runtime/command-end-host-wiring.test-fixture.ts @@ -1,6 +1,6 @@ import { expect, vi } from 'vitest' import type { AgentStatusClearIpcPayload } from '../../shared/agent-status-types' -import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence' +import type { ShellForegroundProof } from '../providers/shell-foreground-proof' import { AgentHookServer } from '../agent-hooks/server' import { installHookStatusSessionTabsRepublish } from '../agent-hooks/hook-status-session-tabs-republish' import { @@ -29,14 +29,8 @@ export type CommandEndHost = { runtime: OrcaRuntimeService readers: Readers spawn: ReturnType - /** The execution host's answer to "does the spawned shell own the foreground, nothing stopped?" */ - shellOwnsForeground: ReturnType Promise>> - /** An SSH host's fenced foreground evidence, keyed to the PTY's incarnation. */ - inspectProcess: ReturnType< - typeof vi.fn< - (ptyId: string) => Promise<{ foregroundProcessEvidence?: RemoteForegroundEvidence }> - > - > + /** The execution host's answer on the pane's own shell; reject for a host that can't be reached. */ + shellProof: ReturnType Promise>> teardown: () => void } @@ -45,7 +39,7 @@ export async function wireCommandEndHost( userDataPath?: string server?: AgentHookServer /** Replaces the stubbed process answers, e.g. with a real terminal daemon's. */ - controller?: Pick + controller?: Pick } = {} ): Promise { const server = options.server ?? new AgentHookServer() @@ -71,21 +65,13 @@ export async function wireCommandEndHost( }) const uninstallRepublish = installHookStatusSessionTabsRepublish(server, () => runtime) const spawn = vi.fn() - const shellOwnsForeground = vi.fn<(ptyId: string) => Promise>(async () => true) - const inspectProcess = vi.fn< - (ptyId: string) => Promise<{ foregroundProcessEvidence?: RemoteForegroundEvidence }> - >(async () => ({})) + const shellProof = vi.fn<(ptyId: string) => Promise>(async () => 'shell') runtime.setPtyController({ spawn, write: () => true, kill: () => true, getForegroundProcess: async () => null, - confirmShellForeground: shellOwnsForeground, - inspectProcess: async (ptyId) => ({ - foregroundProcess: null, - hasChildProcesses: false, - ...(await inspectProcess(ptyId)) - }), + proveShellForeground: (ptyId) => shellProof(ptyId), ...options.controller }) return { @@ -93,8 +79,7 @@ export async function wireCommandEndHost( runtime, readers, spawn, - shellOwnsForeground, - inspectProcess, + shellProof, teardown: () => { unsubscribeClears() uninstallRepublish() diff --git a/src/main/runtime/launch-token-liveness-after-command-end.test.ts b/src/main/runtime/launch-token-liveness-after-command-end.test.ts index 901139888459..df5772d2c41a 100644 --- a/src/main/runtime/launch-token-liveness-after-command-end.test.ts +++ b/src/main/runtime/launch-token-liveness-after-command-end.test.ts @@ -52,7 +52,7 @@ function tempDir(prefix: string): string { async function wireLiveAgentHost(userDataPath?: string): Promise { const host = await wireCommandEndHost(userDataPath ? { userDataPath } : {}) teardowns.push(host.teardown) - host.shellOwnsForeground.mockResolvedValue(false) + host.shellProof.mockResolvedValue('other') return host } diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index 2851d7d76590..08171bf59d15 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -23,10 +23,7 @@ import type { FleetAgentStatusEvidence } from '../../shared/orchestration-fleet- import { readOrchestrationFleetAgentStatusSnapshot } from './orchestration-fleet-agent-status-snapshot' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { matchesProcessIncarnation } from './orchestration/worker-terminal-process-liveness' -import { - CommandEndAgentExitVerifier, - confirmShellOwnsPtyForeground -} from './command-end-agent-exit-verifier' +import { CommandEndAgentExitVerifier } from './command-end-agent-exit-verifier' export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller { /** Every pane key this PTY could be addressed by, including restored receipts. */ @@ -158,14 +155,14 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim }, checkHookAgentPresence: async (paneKey) => (await this.checkHookAgentPresenceFn?.(paneKey)) ?? null, - confirmShellOwnsForeground: (ptyId) => { - const pty = this.ptysById.get(ptyId) - // Why TEMPORARY: the host cannot inspect a WSL guest's processes, so its command end stands as - // the exit, as the desktop pane already treats it; a leaked nested-shell 133;D drops a live row. - if (pty && (pty.isWsl || pty.wslDistro || this.wslDistroByPtyId.has(ptyId))) { - return Promise.resolve(true) - } - return confirmShellOwnsPtyForeground(this.ptyController, pty) + proveShellForeground: async (ptyId) => { + const incarnationId = this.ptysById.get(ptyId)?.incarnationId + return ( + (await this.ptyController?.proveShellForeground?.( + ptyId, + incarnationId ? { expectedIncarnationId: incarnationId } : undefined + )) ?? 'other' + ) }, reconcileEndedProcess: (paneKey, armedRowReceivedAt) => this.reconcileAgentStatusForEndedProcessFn?.([paneKey], { diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 60c1e18cf10a..262d2ef376be 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -11,6 +11,7 @@ import type { PtyBindingSourceExpectation } from '../persistence' import type { ExecutionHostId } from '../../shared/execution-host' import type { PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' +import type { ShellForegroundProof } from '../providers/shell-foreground-proof' import type { WriteSettlement } from '../../shared/pty-write-settlement' import type { TerminalInputKind } from '../../shared/terminal-input-kind' @@ -122,6 +123,12 @@ export type RuntimePtyController = { ): Promise confirmForegroundProcess?(ptyId: string): Promise confirmShellForeground?(ptyId: string): Promise + /** Whether the pane's own shell is back in front, or its host cannot tell; rejects when the host + * cannot be reached. */ + proveShellForeground?( + ptyId: string, + options?: { expectedIncarnationId?: PtyIncarnationId } + ): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resetInputModes?(ptyId: string): Promise From 5b78bae5867eb005eee7ade414d8baad45ba4d75 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:56:47 -0700 Subject: [PATCH 13/19] fix(agent-status): only a WSL root makes a local pane unprovable The local provider answered 'cannot tell' for any root process outside the known shell names, so a pane rooted in an unlisted POSIX shell (yash, oksh) took every command end as an exit although its own process table can say. Only a wsl.exe root, whose guest the host cannot see, now answers that. --- .../local-pty-provider-shell-proof.test.ts | 13 ++++++++----- src/main/providers/local-pty-provider.ts | 5 ++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/providers/local-pty-provider-shell-proof.test.ts b/src/main/providers/local-pty-provider-shell-proof.test.ts index ef1cc65448ea..843d29bd3c1c 100644 --- a/src/main/providers/local-pty-provider-shell-proof.test.ts +++ b/src/main/providers/local-pty-provider-shell-proof.test.ts @@ -16,10 +16,13 @@ describe('LocalPtyProvider shell proof', () => { await expect(new LocalPtyProvider().proveShellForeground('pty-wsl')).resolves.toBe('unprovable') }) - it('answers from the process table for a pane whose root is a shell', async () => { - ptyShellPath.set('pty-zsh', '/bin/zsh') + it.each(['/bin/zsh', '/usr/bin/yash'])( + 'answers from the process table for a pane rooted in %s, known shell or not', + async (shellPath) => { + ptyShellPath.set('pty-posix', shellPath) - // No live process is registered under this id, so the table read proves nothing. - await expect(new LocalPtyProvider().proveShellForeground('pty-zsh')).resolves.toBe('other') - }) + // No live process is registered under this id, so the table read proves nothing. + await expect(new LocalPtyProvider().proveShellForeground('pty-posix')).resolves.toBe('other') + } + ) }) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 782467a3db33..cfa4b4778f14 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -1,3 +1,4 @@ +import { win32 as pathWin32 } from 'node:path' import type * as pty from 'node-pty' import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import { @@ -29,7 +30,6 @@ import { type DataCallback, type ExitCallback } from './local-pty-provider-state' -import { isShellProcess } from '../../shared/shell-process-detection' import type { ShellForegroundProof } from './shell-foreground-proof' import { clearLocalPtyBuffer, @@ -174,9 +174,8 @@ export class LocalPtyProvider implements IPtyProvider { } async proveShellForeground(id: string): Promise { - const shellPath = ptyShellPath.get(id) // Why: a WSL pane's root is wsl.exe, and the guest's processes are out of this host's reach. - if (shellPath && !isShellProcess(shellPath)) { + if (pathWin32.basename(ptyShellPath.get(id) ?? '').toLowerCase() === 'wsl.exe') { return 'unprovable' } return (await confirmLocalPtyShellForeground(id)) ? 'shell' : 'other' From 8da5a3ea02ffb7bb3a3b39adc2ada7dd34bf8024 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:56:48 -0700 Subject: [PATCH 14/19] test(agent-status): fail when the desktop host drops its launch-authority wiring Both deps are optional, so dropping the launch-authority spread or the runtime's token-reader wiring from main-process-runtime-service.ts still typechecked and every spec stayed green. --- ...a-runtime-structured-status-sink-wiring.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts index 904b5dc7335c..f60886aa4489 100644 --- a/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts +++ b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts @@ -78,6 +78,20 @@ describe('every host that constructs a runtime wires the agent-status store', () ) }) +describe('the desktop host wires launch authority', () => { + // Why: both are optional deps, so dropping either still typechecks, and the hook server would + // then keep vouching for a launch token every later process in the shell inherits. + it('passes the launch-authority deps and wires the runtime as the token reader', () => { + const relativePath = 'startup/main-process-runtime-service.ts' + expect(runtimeConstruction(relativePath)).toContain( + '...agentHookLaunchAuthorityRuntimeDeps(agentHookServer)' + ) + expect(readFileSync(join(MAIN_ROOT, relativePath), 'utf8')).toContain( + 'wireRuntimeLaunchAuthorityReader(agentHookServer, runtime)' + ) + }) +}) + describe('structured status sink wiring', () => { it('hands the host the sink the runtime was constructed with', async () => { installed.deps = null From 1830b6fe2bc8d9cf61a2bb1ff49dff65872ddcd3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:56:50 -0700 Subject: [PATCH 15/19] fix(agent-status): re-ask an unread exit proof and never trust a pre-exit capture The daemon and SSH relay answer from a shared process-table capture that can be up to 500 ms old, or already running. Read right after a command end it could still show the exiting agent in front, so the row was kept and nothing asked again until the user ran another command in that pane. - Evidence whose capture can have begun before the command end no longer counts. That, an unreadable table, unadmitted evidence and an unreachable host are 'unread' and are asked again past the cache's lifetime, then once more; a proven 'other' in front waits for its own command end. A PTY exit drops a scheduled re-ask. - Daemons from v27 until the shell-confirm request existed answer it as an unknown request; that alone now falls through to their fenced evidence. Any other failure still rejects and keeps the row. - When the exiting agent's own late hook rewrites its row mid-check, the check runs again (bounded); a different session's row is still left alone. - The verdict names 'host cannot tell' apart from 'exited': clearing the row there is a presentation choice, not a proven exit. --- docs/reference/agent-status-store.md | 20 +- .../daemon-pty-adapter-shell-proof.test.ts | 88 +++++++++ .../daemon/daemon-pty-process-inspection.ts | 35 +++- src/main/daemon/daemon-pty-router.ts | 7 +- src/main/ipc/pty/runtime/operations.ts | 7 +- src/main/providers/pty-provider-contract.ts | 4 +- .../providers/shell-foreground-proof.test.ts | 35 +++- src/main/providers/shell-foreground-proof.ts | 75 +++++++- src/main/providers/ssh-pty-provider.ts | 21 +-- .../command-end-agent-exit-daemon.test.ts | 46 ++++- ...ommand-end-agent-exit-verification.test.ts | 98 +++++++++- .../command-end-agent-exit-verifier.test.ts | 53 ++++++ .../command-end-agent-exit-verifier.ts | 178 ++++++++++++++---- ...me-get-orchestration-dispatch-authority.ts | 31 ++- src/main/runtime/orca-runtime-on-pty-exit.ts | 1 + .../runtime-pty-controller-contract.ts | 7 +- 16 files changed, 591 insertions(+), 115 deletions(-) create mode 100644 src/main/daemon/daemon-pty-adapter-shell-proof.test.ts create mode 100644 src/main/runtime/command-end-agent-exit-verifier.test.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index eb370423fdd2..213ffc979eb6 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -99,17 +99,19 @@ runtime asks the execution host again, and reads its answer in this order: identity. A local node-pty proves that from a fresh process-table read (spawned shell in the foreground, no job stopped); the terminal daemon and an SSH relay from their fenced foreground evidence (the shell's own process group in front, no agent named), which cannot see a stopped - job (temporary). -3. Something else in front keeps the row. -4. A host that cannot be reached keeps it: loss of contact is never evidence of an exit. -5. A host that answered but cannot tell (a WSL guest, a Windows daemon or relay, a daemon or relay - that predates foreground evidence) leaves the mark as the only evidence, so it stands as the - exit, as the desktop pane treats it. Temporary: there a nested shell's 133;D under a live TUI - drops its row, until those hosts report shell-owns-the-foreground evidence. + job (temporary), and only from a capture that began after the command end. +3. Something else proven in front keeps the row; that command ends with its own 133;D. +4. No trustworthy answer (a host that cannot be reached, an unreadable process table, a capture + older than the command end) keeps the row, and is asked again a bounded number of times: loss + of contact is never evidence of an exit. +5. A host that answered but cannot tell (a WSL guest, a Windows daemon or relay, a daemon that + predates foreground evidence) leaves the mark as the only evidence, so it stands as the exit. + That is a presentation choice, not a proven exit. Temporary: there a nested shell's 133;D + under a live TUI drops its row, until those hosts report shell-owns-the-foreground evidence. A kept row is not latched: the PTY's next command end asks again, and a confirmed PTY exit clears -it. A verdict acts only on the row it checked, so a session that started while it was read keeps -its row. The clear also records which session ended, so a reconnecting SSH relay's replay of that +it. A verdict acts only on the row it checked: a session that started while it was read keeps its +row, and a row the same session rewrote meanwhile (its own late hook) is checked again. The clear also records which session ended, so a reconnecting SSH relay's replay of that session's cached status is refused; any newer evidence (a live event, a new turn, another session) is admitted and drops the record. diff --git a/src/main/daemon/daemon-pty-adapter-shell-proof.test.ts b/src/main/daemon/daemon-pty-adapter-shell-proof.test.ts new file mode 100644 index 000000000000..562661a9b86e --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-shell-proof.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest' +import { DaemonPtyAdapter } from './daemon-pty-adapter' + +// Daemons survive app updates. Those from v27 until the shell-confirm request existed answer it as +// unknown, yet still send fenced foreground evidence, which must decide; a dead socket must not. + +type ClientInternals = { + client: { request: ReturnType; disconnect: ReturnType } +} + +const SHELL_IN_FRONT = { + foregroundProcess: null, + hasChildProcesses: false, + foregroundProcessEvidence: { + authorityGeneration: 'daemon-generation', + observationEpoch: 1, + capturedAgeMs: 0, + ptyId: 'sess-a', + ptyIncarnationId: 'inc-1', + verdict: 'live', + processName: null, + fence: { + platform: 'posix', + shellPid: 100, + shellStartTime: 'shell-birth', + tty: '/dev/pts/3', + foregroundPgid: 100 + } + } +} + +function createAdapter(protocolVersion: number, request: ReturnType) { + const adapter = new DaemonPtyAdapter({ + socketPath: '/tmp/orca-shell-proof.sock', + tokenPath: '/tmp/orca-shell-proof.token', + protocolVersion + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the adapter's private client is replaced with a request stub, as the steady-state compat spec does. + ;(adapter as unknown as ClientInternals).client = { request, disconnect: vi.fn() } + return adapter +} + +describe.skipIf(process.platform === 'win32')('daemon shell proof across protocol versions', () => { + it.each([27, 30, 35])( + 'v%i answers the shell confirm as unknown; its evidence proves the shell', + async (protocolVersion) => { + const request = vi.fn(async (type: string) => { + if (type === 'confirmShellForeground') { + throw new Error('Unknown request type: confirmShellForeground') + } + return SHELL_IN_FRONT + }) + const adapter = createAdapter(protocolVersion, request) + + await expect( + adapter.proveShellForeground('sess-a', { expectedIncarnationId: 'inc-1' }) + ).resolves.toBe('shell') + adapter.dispose() + } + ) + + it('does not mistake a failed shell confirm for an old daemon', async () => { + const request = vi.fn(async (type: string) => { + if (type === 'confirmShellForeground') { + throw new Error('Daemon connection lost') + } + return SHELL_IN_FRONT + }) + const adapter = createAdapter(35, request) + + await expect( + adapter.proveShellForeground('sess-a', { expectedIncarnationId: 'inc-1' }) + ).rejects.toThrow('Daemon connection lost') + adapter.dispose() + }) + + it('rejects when the daemon cannot be reached, so the row is kept', async () => { + const request = vi.fn(async () => { + throw new Error('Daemon connection lost') + }) + const adapter = createAdapter(35, request) + + await expect( + adapter.proveShellForeground('sess-a', { expectedIncarnationId: 'inc-1' }) + ).rejects.toThrow('Daemon connection lost') + adapter.dispose() + }) +}) diff --git a/src/main/daemon/daemon-pty-process-inspection.ts b/src/main/daemon/daemon-pty-process-inspection.ts index 0a1483a5e612..16d8a2579e91 100644 --- a/src/main/daemon/daemon-pty-process-inspection.ts +++ b/src/main/daemon/daemon-pty-process-inspection.ts @@ -11,8 +11,10 @@ import type { PtyProcessInspection } from '../providers/pty-process-inspection' import { clientOnlyUnverifiableInspection } from '../../shared/terminal-process-inspection' import { proveDaemonShellForeground, - type ShellForegroundProof + type ShellForegroundProof, + type ShellForegroundProofOptions } from '../providers/shell-foreground-proof' +import { isUnknownRequestTypeError } from './daemon-endpoint-errors' export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshots { // Why: daemon-backed PTYs can host long-lived agents while detached; cleanup prompts must not treat them as idle shells. @@ -97,22 +99,35 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot async proveShellForeground( id: string, - options?: { expectedIncarnationId?: string } + options?: ShellForegroundProofOptions ): Promise { if (this.protocolVersion < COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION) { return 'unprovable' } + const expectedIncarnationId = options?.expectedIncarnationId return proveDaemonShellForeground({ ptyId: id, - incarnationId: options?.expectedIncarnationId ?? null, + incarnationId: expectedIncarnationId ?? null, + ...(options?.notCapturedBefore !== undefined + ? { notCapturedBefore: options.notCapturedBefore } + : {}), platform: process.platform, - confirmShellForeground: async () => - ( - await this.client.request<{ confirmed: boolean }>('confirmShellForeground', { - sessionId: id - }) - ).confirmed === true, - inspectProcess: () => this.inspectProcess(id, options) + confirmShellForeground: () => + this.client + .request<{ confirmed: boolean }>('confirmShellForeground', { sessionId: id }) + .then( + (result) => result.confirmed === true, + (error: unknown) => { + // Why: daemons from before this request (v27-v36) answer it as unknown; their fenced + // evidence still answers. Any other failure is loss of contact and must reject. + if (isUnknownRequestTypeError(error)) { + return false + } + throw error + } + ), + inspectProcess: () => + this.inspectProcess(id, expectedIncarnationId ? { expectedIncarnationId } : undefined) }) } diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index a14f36b67ce6..9f7bd9509b4a 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -9,7 +9,10 @@ import type { PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' -import type { ShellForegroundProof } from '../providers/shell-foreground-proof' +import type { + ShellForegroundProof, + ShellForegroundProofOptions +} from '../providers/shell-foreground-proof' import { shouldHandoffDaemonHistory } from './daemon-history-handoff' import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events' import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' @@ -207,7 +210,7 @@ export class DaemonPtyRouter implements IPtyProvider { proveShellForeground( id: string, - options?: { expectedIncarnationId?: string } + options?: ShellForegroundProofOptions ): Promise { return this.adapterFor(id).proveShellForeground(id, options) } diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index 87b277e2dc55..5e0d64e533ca 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -6,7 +6,10 @@ import { ptySizes } from '../delivery/visibility-state' import { rendererSerializerReadiness } from '../pane/serializer-state' import { getProviderForPty, localProvider } from '../provider/registry' import { inspectPtyProviderProcess } from '../../../providers/pty-process-inspection' -import type { ShellForegroundProof } from '../../../providers/shell-foreground-proof' +import type { + ShellForegroundProof, + ShellForegroundProofOptions +} from '../../../providers/shell-foreground-proof' import type { PtyRuntimeControllerDeps } from './controller-deps' import { writeRefused, @@ -165,7 +168,7 @@ export async function confirmShellForegroundFromRuntimeController(ptyId: string) // Why no catch: a host that cannot be reached must reject, never read as an answer. export async function proveShellForegroundFromRuntimeController( ptyId: string, - options?: { expectedIncarnationId?: string } + options?: ShellForegroundProofOptions ): Promise { return (await getProviderForPty(ptyId).proveShellForeground?.(ptyId, options)) ?? 'other' } diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 6b541cebaebb..209003558713 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -12,7 +12,7 @@ import type { AgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' import type { PtyProcessInfo } from './pty-process-info' -import type { ShellForegroundProof } from './shell-foreground-proof' +import type { ShellForegroundProof, ShellForegroundProofOptions } from './shell-foreground-proof' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' import type { TerminalOwner } from '../../shared/terminal-owner' import type { WriteSettlement } from '../../shared/pty-write-settlement' @@ -246,7 +246,7 @@ export type IPtyProvider = { * cannot be reached. */ proveShellForeground?: ( id: string, - options?: { expectedIncarnationId?: string } + options?: ShellForegroundProofOptions ) => Promise serialize(ids: string[]): Promise revive(state: string): Promise diff --git a/src/main/providers/shell-foreground-proof.test.ts b/src/main/providers/shell-foreground-proof.test.ts index cbc972b9b909..c550957a7463 100644 --- a/src/main/providers/shell-foreground-proof.test.ts +++ b/src/main/providers/shell-foreground-proof.test.ts @@ -36,14 +36,20 @@ function live(processName: string | null, foregroundPgid: number): RemoteForegro } } -function proof(evidence: RemoteForegroundEvidence | undefined) { +function proof(evidence: RemoteForegroundEvidence | undefined, notCapturedBefore?: number) { + const requestStartedAtMonotonic = performance.now() return shellForegroundProofFromInspection( { foregroundProcess: null, hasChildProcesses: false, ...(evidence ? { foregroundProcessEvidence: evidence } : {}) }, - { ptyId: PTY, incarnationId: INCARNATION, requestStartedAtMonotonic: performance.now() } + { + ptyId: PTY, + incarnationId: INCARNATION, + requestStartedAtMonotonic, + ...(notCapturedBefore !== undefined ? { notCapturedBefore } : {}) + } ) } @@ -52,20 +58,37 @@ describe('fenced foreground evidence as a shell proof', () => { expect(proof(live(null, 100))).toBe('shell') }) - const keepsTheRow: [string, RemoteForegroundEvidence][] = [ + const somethingElseInFront: [string, RemoteForegroundEvidence][] = [ ['an agent in front', live('codex', 200)], ['another command in front', live(null, 200)], // Job control off (`set +m`): the agent runs in the shell's own group. ['an agent in the shell group', live('claude', 100)], + [ + 'a multiplexer in the pane', + { ...observation(), verdict: 'unverifiable', reason: 'multiplexer_boundary' } + ] + ] + it.each(somethingElseInFront)('proves something else in front for %s', (_name, evidence) => { + expect(proof(evidence)).toBe('other') + }) + + const unread: [string, RemoteForegroundEvidence][] = [ [ 'an unreadable process table', { ...observation(), verdict: 'unverifiable', reason: 'process_table_unreadable' } ], ['evidence for another incarnation', { ...live(null, 100), ptyIncarnationId: 'replacement' }], - ['stale evidence', { ...live(null, 100), capturedAgeMs: 5_000 }] + ['evidence past the admission age', { ...live(null, 100), capturedAgeMs: 5_000 }] ] - it.each(keepsTheRow)('keeps the row for %s', (_name, evidence) => { - expect(proof(evidence)).toBe('other') + it.each(unread)('reads no answer from %s', (_name, evidence) => { + expect(proof(evidence)).toBe('unread') + }) + + it('reads no answer from a shared capture that began before the command end', () => { + const commandEndedAt = performance.now() - 50 + // Captured 300 ms before the request: the exiting agent was still in front then. + expect(proof({ ...live('codex', 200), capturedAgeMs: 300 }, commandEndedAt)).toBe('unread') + expect(proof({ ...live(null, 100), capturedAgeMs: 10 }, commandEndedAt)).toBe('shell') }) const cannotTell: [string, RemoteForegroundEvidence | undefined][] = [ diff --git a/src/main/providers/shell-foreground-proof.ts b/src/main/providers/shell-foreground-proof.ts index 67acfa302e66..b2e81da29bb8 100644 --- a/src/main/providers/shell-foreground-proof.ts +++ b/src/main/providers/shell-foreground-proof.ts @@ -5,13 +5,29 @@ import type { PtyProcessInspection } from './pty-process-inspection' /** * What a PTY's execution host says about its own shell being back in front: * - `shell`: proven; whatever ran in the pane has ended. - * - `other`: something else is in front (an agent, another job, a stopped job), or the host's - * answer could not be trusted (stale, another incarnation, an unreadable process table). - * - `unprovable`: the host answered but has no way to tell (a WSL guest, a Windows host, a host + * - `other`: something else is proven in front (an agent, another job, a stopped job, a + * multiplexer), which ends with its own command end. + * - `unread`: the answer could not be trusted (captured before the command end, stale, an + * unreadable process table): asking again soon can still prove it. + * - `unprovable`: the host answered but has no way to tell (a WSL guest, a Windows host, a daemon * that predates foreground evidence). * A host that cannot be reached rejects instead: loss of contact is never evidence of an exit. */ -export type ShellForegroundProof = 'shell' | 'other' | 'unprovable' +export type ShellForegroundProof = 'shell' | 'other' | 'unread' | 'unprovable' + +export type ShellForegroundProofOptions = { + expectedIncarnationId?: string + /** Main's monotonic clock at the command end; evidence captured earlier predates the exit. */ + notCapturedBefore?: number +} + +/** Unverifiable reasons that prove something other than the pane's shell holds it. */ +const OTHER_IN_FRONT_REASONS = new Set([ + 'multiplexer_boundary', + 'tty_boundary', + 'ambiguous_foreground_group', + 'incarnation_mismatch' +]) /** Reads the fenced foreground evidence a terminal daemon or SSH relay inspection carries. */ export function shellForegroundProofFromInspection( @@ -20,6 +36,7 @@ export function shellForegroundProofFromInspection( ptyId: string incarnationId: string | null requestStartedAtMonotonic: number + notCapturedBefore?: number } ): ShellForegroundProof { // Why throw: a client-only verdict (transport loss) is no answer from the host at all. @@ -37,12 +54,29 @@ export function shellForegroundProofFromInspection( lastAuthorityGeneration: null, lastObservationEpoch: -1 }) - if (evidence?.verdict === 'unverifiable') { - return evidence.reason === 'windows_ssh_foreground_unavailable' ? 'unprovable' : 'other' + if (!evidence) { + return 'unread' + } + // Why the request start: the host serializes after it, and `capturedAgeMs` bounds the capture's + // age from above, so this is the earliest the capture can have started. A shared capture that + // began before the command end still shows the exiting agent in front. + if ( + expected.notCapturedBefore !== undefined && + expected.requestStartedAtMonotonic - evidence.capturedAgeMs < expected.notCapturedBefore + ) { + return 'unread' + } + if (evidence.verdict === 'unverifiable') { + if (evidence.reason === 'windows_ssh_foreground_unavailable') { + return 'unprovable' + } + return OTHER_IN_FRONT_REASONS.has(evidence.reason) ? 'other' : 'unread' + } + if (evidence.verdict !== 'live') { + return 'other' } // Why TEMPORARY: this evidence cannot see a stopped job, so a Ctrl+Z'd agent reads as `shell`. - return evidence?.verdict === 'live' && - evidence.processName === null && + return evidence.processName === null && evidence.fence.platform === 'posix' && evidence.fence.foregroundPgid === evidence.fence.shellPid ? 'shell' @@ -54,6 +88,7 @@ export function shellForegroundProofFromInspection( export async function proveDaemonShellForeground(args: { ptyId: string incarnationId: string | null + notCapturedBefore?: number platform: NodeJS.Platform confirmShellForeground: () => Promise inspectProcess: () => Promise @@ -68,6 +103,28 @@ export async function proveDaemonShellForeground(args: { return shellForegroundProofFromInspection(await args.inspectProcess(), { ptyId: args.ptyId, incarnationId: args.incarnationId, - requestStartedAtMonotonic + requestStartedAtMonotonic, + ...(args.notCapturedBefore !== undefined ? { notCapturedBefore: args.notCapturedBefore } : {}) + }) +} + +/** An SSH relay has no shell confirm; its fenced evidence answers, keyed to the relay's own PTY id. */ +export async function proveRelayShellForeground( + relayPtyId: string, + options: ShellForegroundProofOptions | undefined, + inspectProcess: (options?: { expectedIncarnationId: string }) => Promise +): Promise { + const expectedIncarnationId = options?.expectedIncarnationId + const requestStartedAtMonotonic = performance.now() + const inspection = await inspectProcess( + expectedIncarnationId ? { expectedIncarnationId } : undefined + ) + return shellForegroundProofFromInspection(inspection, { + ptyId: relayPtyId, + incarnationId: expectedIncarnationId ?? null, + requestStartedAtMonotonic, + ...(options?.notCapturedBefore !== undefined + ? { notCapturedBefore: options.notCapturedBefore } + : {}) }) } diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 12e0eb2f670a..59691366f35c 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -4,8 +4,9 @@ import type { WriteSettlement } from '../../shared/pty-write-settlement' import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' import { - shellForegroundProofFromInspection, - type ShellForegroundProof + proveRelayShellForeground, + type ShellForegroundProof, + type ShellForegroundProofOptions } from './shell-foreground-proof' import { createSshPtyAppliedSizeReader } from './ssh-pty-applied-size' import type { @@ -74,17 +75,13 @@ export class SshPtyProvider implements IPtyProvider { id: string, options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean } ): Promise => this.rpcOperations.inspectProcess(id, options) - proveShellForeground = async ( + proveShellForeground = ( id: string, - options?: { expectedIncarnationId?: string } - ): Promise => { - const requestStartedAtMonotonic = performance.now() - return shellForegroundProofFromInspection(await this.inspectProcess(id, options), { - ptyId: parseAppSshPtyId(id)?.relayPtyId ?? id, - incarnationId: options?.expectedIncarnationId ?? null, - requestStartedAtMonotonic - }) - } + options?: ShellForegroundProofOptions + ): Promise => + proveRelayShellForeground(parseAppSshPtyId(id)?.relayPtyId ?? id, options, (inspect) => + this.inspectProcess(id, inspect) + ) serialize = (ids: string[]): Promise => this.rpcOperations.serialize(ids) revive = (state: string): Promise => this.rpcOperations.revive(state) getDefaultShell = (): Promise => this.rpcOperations.getDefaultShell() diff --git a/src/main/runtime/command-end-agent-exit-daemon.test.ts b/src/main/runtime/command-end-agent-exit-daemon.test.ts index e1265292839f..75611f2621c0 100644 --- a/src/main/runtime/command-end-agent-exit-daemon.test.ts +++ b/src/main/runtime/command-end-agent-exit-daemon.test.ts @@ -21,10 +21,20 @@ import { // scanner, which only proves a shell after a full-screen exit. A Codex run in the normal screen // buffer that quits must still be verified, from the daemon's fenced process evidence. -const processTable = vi.hoisted((): { rows: ProcessTableRow[] } => ({ rows: [] })) +const processTable = vi.hoisted( + (): { + rows: ProcessTableRow[] + captures: { rows: ProcessTableRow[]; capturedAgeMs: number }[] + } => ({ + rows: [], + captures: [] + }) +) vi.mock('../../shared/process-table-snapshot-reader', async (importOriginal) => ({ ...(await importOriginal()), - getStrictProcessTableSnapshotWithAge: async () => ({ rows: processTable.rows, capturedAgeMs: 0 }) + // A queued capture is served first, as the shared cache would; then a fresh one. + getStrictProcessTableSnapshotWithAge: async () => + processTable.captures.shift() ?? { rows: processTable.rows, capturedAgeMs: 0 } })) vi.mock('../git/worktree', () => { @@ -111,6 +121,7 @@ afterEach(async () => { await teardown() } processTable.rows = [] + processTable.captures = [] vi.restoreAllMocks() }) @@ -135,9 +146,18 @@ async function launchDaemonCodexPane( proveDaemonShellForeground({ ptyId: id, incarnationId: options?.expectedIncarnationId ?? null, + ...(options?.notCapturedBefore !== undefined + ? { notCapturedBefore: options.notCapturedBefore } + : {}), platform, confirmShellForeground: () => terminalHost.confirmShellForeground(id), - inspectProcess: () => terminalHost.inspectProcess(id, options) + inspectProcess: () => + terminalHost.inspectProcess( + id, + options?.expectedIncarnationId + ? { expectedIncarnationId: options.expectedIncarnationId } + : undefined + ) }) } }) @@ -206,4 +226,24 @@ describe('a normal-buffer agent on a terminal-daemon pane', () => { daemonPane.pane.paneKey ) }) + + it('re-asks when the shared capture predates the exit, and clears on the fresh one', async () => { + const daemonPane = await launchDaemonCodexPane('pty-daemon-stale-capture') + // A poll's capture from just before the exit, served from the cache 300 ms later. + processTable.captures = [{ rows: paneProcesses('codex'), capturedAgeMs: 300 }] + processTable.rows = paneProcesses('shell') + + await runCommandToItsEnd(daemonPane, 'daemon fact') + expect(liveRow(daemonPane.host.server, daemonPane.pane.paneKey)?.state).toBe('done') + + await vi.waitFor( + () => + expectEveryReaderSawTheClear( + daemonPane.host.server, + daemonPane.host.readers, + daemonPane.pane.paneKey + ), + { timeout: 2_000, interval: 50 } + ) + }) }) diff --git a/src/main/runtime/command-end-agent-exit-verification.test.ts b/src/main/runtime/command-end-agent-exit-verification.test.ts index 4c9ca1d63b0a..1cf7f7225b76 100644 --- a/src/main/runtime/command-end-agent-exit-verification.test.ts +++ b/src/main/runtime/command-end-agent-exit-verification.test.ts @@ -244,28 +244,50 @@ describe('a command end that does not prove the agent exited keeps its row every expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) }) - it('a session that starts while the check is read keeps its row', async () => { + for (const answerProof of ['shell', 'unprovable'] as const) { + it(`a session that starts while the check is read keeps its row (${answerProof})`, async () => { + const host = await wire() + let answer: (proof: ShellForegroundProof) => void = () => {} + host.shellProof.mockImplementationOnce( + () => new Promise((resolve) => (answer = resolve)) + ) + const pane = await launchAgentPane(host, `pty-next-session-${answerProof}`) + await claudeIsWorking(host, pane) + + await endCommand(host.runtime, pane.ptyId, 'shell bytes') + // The first agent exited; the user starts another before the slow read lands. + await new Promise((resolve) => setTimeout(resolve, 2)) + await postHook(host.server, 'claude', pane, { + hook_event_name: 'UserPromptSubmit', + session_id: 'second-session', + prompt: 'a different task' + }) + answer(answerProof) + await settle() + + expect(liveRow(host.server, pane.paneKey)?.prompt).toBe('a different task') + expect(host.readers.windowClears).toEqual([]) + expect(host.shellProof).toHaveBeenCalledTimes(1) + }) + } + + it("the exiting agent's own late hook mid-check gets a second look, which clears it", async () => { const host = await wire() let answer: (proof: ShellForegroundProof) => void = () => {} host.shellProof.mockImplementationOnce( () => new Promise((resolve) => (answer = resolve)) ) - const pane = await launchAgentPane(host, 'pty-next-session') + const pane = await launchAgentPane(host, 'pty-late-own-hook') await claudeIsWorking(host, pane) await endCommand(host.runtime, pane.ptyId, 'shell bytes') - // The first agent exited; the user starts another before the slow read lands. await new Promise((resolve) => setTimeout(resolve, 2)) - await postHook(host.server, 'claude', pane, { - hook_event_name: 'UserPromptSubmit', - session_id: 'second-session', - prompt: 'a different task' - }) + await claudeIsDone(host, pane) answer('shell') await settle() - expect(liveRow(host.server, pane.paneKey)?.prompt).toBe('a different task') - expect(host.readers.windowClears).toEqual([]) + expect(host.shellProof).toHaveBeenCalledTimes(2) + expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) }) it('a command end that lands while a check is in flight is checked too', async () => { @@ -369,3 +391,59 @@ describe('an SSH pane after a verified exit', () => { expect(liveRow(host.server, pane.paneKey)).toBeUndefined() }) }) + +describe('an answer that could not be read', () => { + it('is asked again shortly, without waiting for another command end', async () => { + const host = await wire() + host.shellProof.mockResolvedValueOnce('unread') + const pane = await launchAgentPane(host, 'pty-unread-reask') + await claudeIsWorking(host, pane) + + await endCommand(host.runtime, pane.ptyId, 'daemon fact') + expectNoReaderLostTheRow(host.server, host.readers, pane.paneKey, 'working') + + await vi.waitFor(() => expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey), { + timeout: 2_000, + interval: 50 + }) + expect(host.shellProof).toHaveBeenCalledTimes(2) + }) + + it('is asked a bounded number of times, then waits for the next command end', async () => { + const host = await wire() + host.shellProof.mockResolvedValue('unread') + const pane = await launchAgentPane(host, 'pty-unread-bounded') + await claudeIsWorking(host, pane) + + await endCommand(host.runtime, pane.ptyId, 'daemon fact') + await new Promise((resolve) => setTimeout(resolve, 2_400)) + + expect(host.shellProof).toHaveBeenCalledTimes(3) + expectNoReaderLostTheRow(host.server, host.readers, pane.paneKey, 'working') + }) + + it('is not asked again once something else is proven in front', async () => { + const host = await wire() + host.shellProof.mockResolvedValue('other') + const pane = await launchAgentPane(host, 'pty-other-no-reask') + await claudeIsWorking(host, pane) + + await endCommand(host.runtime, pane.ptyId, 'daemon fact') + await new Promise((resolve) => setTimeout(resolve, 800)) + + expect(host.shellProof).toHaveBeenCalledTimes(1) + }) + + it('leaves no re-ask behind once the PTY exits', async () => { + const host = await wire() + host.shellProof.mockResolvedValue('unread') + const pane = await launchAgentPane(host, 'pty-unread-exit') + await claudeIsWorking(host, pane) + + await endCommand(host.runtime, pane.ptyId, 'daemon fact') + await host.runtime.onPtyExit(pane.ptyId, 0, `${pane.ptyId}-incarnation`) + await new Promise((resolve) => setTimeout(resolve, 800)) + + expect(host.shellProof).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/runtime/command-end-agent-exit-verifier.test.ts b/src/main/runtime/command-end-agent-exit-verifier.test.ts new file mode 100644 index 000000000000..294fa6d822c2 --- /dev/null +++ b/src/main/runtime/command-end-agent-exit-verifier.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + CommandEndAgentExitVerifier, + type CommandEndRowAnchor +} from './command-end-agent-exit-verifier' +import type { ShellForegroundProof } from '../providers/shell-foreground-proof' + +// The re-ask after an unread answer is bounded and owns its timer: a PTY exit must leave nothing +// scheduled, even when the PTY's rows survive it (an unconfirmed SSH exit keeps them). + +afterEach(() => { + vi.useRealTimers() +}) + +function verifierAnswering(proof: ShellForegroundProof) { + const anchor: CommandEndRowAnchor = { receivedAt: 1, agentType: 'codex', providerSessionId: 's' } + const proveShellForeground = vi.fn(async () => proof) + const reconcileEndedProcess = vi.fn() + const verifier = new CommandEndAgentExitVerifier({ + readLiveRowAnchors: () => new Map([['tab:pane', anchor]]), + checkHookAgentPresence: async () => null, + proveShellForeground, + reconcileEndedProcess, + now: () => performance.now() + }) + return { verifier, proveShellForeground, reconcileEndedProcess } +} + +describe('CommandEndAgentExitVerifier re-asks', () => { + it('asks at most three times for one command end', async () => { + vi.useFakeTimers() + const { verifier, proveShellForeground } = verifierAnswering('unread') + + verifier.onCommandEnd('pty-1') + await vi.runAllTimersAsync() + + expect(proveShellForeground).toHaveBeenCalledTimes(3) + expect(vi.getTimerCount()).toBe(0) + }) + + it('drops a scheduled re-ask when the PTY exits, though its rows survive', async () => { + vi.useFakeTimers() + const { verifier, proveShellForeground } = verifierAnswering('unread') + + verifier.onCommandEnd('pty-1') + await vi.advanceTimersByTimeAsync(0) + verifier.onPtyExit('pty-1') + await vi.runAllTimersAsync() + + expect(proveShellForeground).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/main/runtime/command-end-agent-exit-verifier.ts b/src/main/runtime/command-end-agent-exit-verifier.ts index c47aa5819cb1..42fdf8e5c2e4 100644 --- a/src/main/runtime/command-end-agent-exit-verifier.ts +++ b/src/main/runtime/command-end-agent-exit-verifier.ts @@ -1,34 +1,72 @@ import type { AgentProcessVerdict } from '../../shared/agent-process-presence' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } from '../../shared/process-table-snapshot' import type { ShellForegroundProof } from '../providers/shell-foreground-proof' +/** The live row a verdict may act on: its last write, and the agent session that wrote it. */ +export type CommandEndRowAnchor = { + receivedAt: number + agentType: string | undefined + providerSessionId: string | null +} + +/** The anchor of a pane's live row among the host's rows for that pane, if it has one. */ +export function liveRowAnchor( + rows: readonly AgentStatusIpcPayload[] | undefined +): CommandEndRowAnchor | null { + const row = rows?.find((entry) => entry.providerSessionOnly !== true) + return row + ? { + receivedAt: row.receivedAt, + agentType: row.agentType, + providerSessionId: row.providerSession?.id ?? null + } + : null +} + type CommandEndAgentExitDeps = { - /** The PTY's pane keys whose host row is live (not a resume remnant), each with that row's - * `receivedAt`: the anchor a verdict may act on. */ - readLiveRowAnchors(ptyId: string): Map + /** The PTY's pane keys whose host row is live (not a resume remnant), with that row's anchor. */ + readLiveRowAnchors(ptyId: string): Map /** The hook presence check: the row's own agent pid and start time. An `exited` answer has * already cleared that row; null when no hook identified a process. */ checkHookAgentPresence(paneKey: string): Promise - /** The execution host's answer on the pane's own shell; rejects when the host cannot be reached. */ - proveShellForeground(ptyId: string): Promise + /** The execution host's answer on the pane's own shell, from evidence captured no earlier than + * `notCapturedBefore`; rejects when the host cannot be reached. */ + proveShellForeground(ptyId: string, notCapturedBefore: number): Promise /** Clears a pane whose agent exited, unless its row changed after `armedRowReceivedAt`. */ reconcileEndedProcess(paneKey: string, armedRowReceivedAt: number): void + now(): number } /** - * Whether the agent behind a command end (OSC 133;D) exited. A full-screen agent's nested shells + * What a command end (OSC 133;D) says about the agent behind it. A full-screen agent's nested shells * leak their own 133;D, so the mark alone proves nothing where the host can look: - * 1. a live agent pid keeps the row; - * 2. the pane's own shell proven back in front means it exited; - * 3. something else in front keeps the row; - * 4. a host that cannot be reached keeps it (loss of contact is never evidence of an exit); - * 5. a host that answered but cannot tell (WSL, Windows, a host without foreground evidence) - * leaves the mark as the only evidence, so it stands as the exit, as on the desktop pane. + * - `live`: the agent's own process is alive; + * - `exited`: the pane's own shell is proven back in front; + * - `other`: something else is proven in front, which ends with its own command end; + * - `unread`: no trustworthy answer (unreachable host, unreadable or stale answer); + * - `unprovable`: the host answered but cannot tell (a WSL guest, a Windows daemon or relay, an + * old daemon), so the mark is the only evidence there is. */ +export type CommandEndExitVerdict = 'live' | 'exited' | 'other' | 'unread' | 'unprovable' + +/** Why TEMPORARY for `unprovable`: the mark stands as the exit, so a nested shell's mark under a + * live TUI on those hosts drops its row. Loss of contact (`unread`) never clears a row. */ +function clearsRow(verdict: CommandEndExitVerdict): boolean { + return verdict === 'exited' || verdict === 'unprovable' +} + +/** Re-asks for an unread answer: past the shared process-table cache's lifetime, then once more. */ +const UNREAD_REASK_DELAYS_MS = [PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS + 100, 1_500] +/** Re-checks after the exiting agent's own late write changed the row mid-check. */ +const SAME_SESSION_RECHECKS = 2 + export async function verifyCommandEndAgentExit( ptyId: string, paneKeys: Iterable, + notCapturedBefore: number, deps: Pick -): Promise { +): Promise { const verdicts = await Promise.all( Array.from(paneKeys, (paneKey) => deps.checkHookAgentPresence(paneKey).catch(() => null)) ) @@ -37,21 +75,35 @@ export async function verifyCommandEndAgentExit( } let proof: ShellForegroundProof try { - proof = await deps.proveShellForeground(ptyId) + proof = await deps.proveShellForeground(ptyId, notCapturedBefore) } catch { - return 'unverifiable' + return 'unread' } - // Why TEMPORARY for `unprovable`: a nested shell's mark under a live TUI there drops its row. - return proof === 'other' ? 'unverifiable' : 'exited' + return proof === 'shell' ? 'exited' : proof +} + +function sameAgentSession(armed: CommandEndRowAnchor, current: CommandEndRowAnchor): boolean { + // Why a null session never matches: then a new agent of the same type cannot be told apart. + return ( + armed.providerSessionId !== null && + armed.providerSessionId === current.providerSessionId && + armed.agentType === current.agentType + ) +} + +type RunState = { + rerun: boolean + cancelled: boolean + wake: (() => void) | null } /** * Re-derives every command end, for every PTY whose panes hold a live row, whether its agent exited. - * Nothing is latched: a row a verdict kept is asked about again at the PTY's next command end. + * Nothing is latched: a kept row is asked about again at the PTY's next command end, and an unread + * answer a bounded number of times before that. */ export class CommandEndAgentExitVerifier { - /** Per PTY: whether another command end arrived while a verdict was in flight. */ - private readonly running = new Map() + private readonly running = new Map() constructor(private readonly deps: CommandEndAgentExitDeps) {} @@ -60,6 +112,7 @@ export class CommandEndAgentExitVerifier { if (running) { // Why rerun, not skip: this mark may be the real exit, after the in-flight read saw the agent. running.rerun = true + running.wake?.() return } this.run(ptyId).catch((error: unknown) => { @@ -67,34 +120,95 @@ export class CommandEndAgentExitVerifier { }) } + /** The PTY's own teardown settles its rows; drop any pending re-ask. */ + onPtyExit(ptyId: string): void { + const running = this.running.get(ptyId) + if (running) { + running.cancelled = true + running.wake?.() + } + } + private async run(ptyId: string): Promise { - const state = { rerun: true } + const state: RunState = { rerun: true, cancelled: false, wake: null } this.running.set(ptyId, state) try { - while (state.rerun) { + while (state.rerun && !state.cancelled) { state.rerun = false - await this.verifyOnce(ptyId) + await this.verifyOnce(ptyId, state) } } finally { this.running.delete(ptyId) } } - private async verifyOnce(ptyId: string): Promise { - const anchors = this.deps.readLiveRowAnchors(ptyId) - if (anchors.size === 0) { - return - } - if ((await verifyCommandEndAgentExit(ptyId, anchors.keys(), this.deps)) !== 'exited') { - return + private async verifyOnce(ptyId: string, state: RunState): Promise { + const commandEndedAt = this.deps.now() + let reasks = 0 + let rechecks = 0 + while (!state.cancelled) { + const anchors = this.deps.readLiveRowAnchors(ptyId) + if (anchors.size === 0) { + return + } + const verdict = await verifyCommandEndAgentExit( + ptyId, + anchors.keys(), + commandEndedAt, + this.deps + ) + if (clearsRow(verdict)) { + const rewritten = this.clearUnchangedRows(ptyId, anchors) + if (!rewritten || rechecks >= SAME_SESSION_RECHECKS) { + return + } + rechecks += 1 + continue + } + if (verdict !== 'unread' || reasks >= UNREAD_REASK_DELAYS_MS.length || state.rerun) { + return + } + await this.wait(state, UNREAD_REASK_DELAYS_MS[reasks]) + reasks += 1 + if (state.rerun) { + return + } } - for (const [paneKey, receivedAt] of anchors) { + } + + /** Clears each pane whose row is as armed. True when the same agent session rewrote one mid-check + * (its own late hook), which needs another look; a different session's row is left alone. */ + private clearUnchangedRows(ptyId: string, anchors: Map): boolean { + const current = this.deps.readLiveRowAnchors(ptyId) + let rewritten = false + for (const [paneKey, armed] of anchors) { + const now = current.get(paneKey) + if (now && now.receivedAt !== armed.receivedAt) { + rewritten ||= sameAgentSession(armed, now) + continue + } try { - this.deps.reconcileEndedProcess(paneKey, receivedAt) + this.deps.reconcileEndedProcess(paneKey, armed.receivedAt) } catch (error) { // Why per pane: one pane's failed clear must not strand its siblings' rows. console.error('[agent-status] command-end exit clear failed', { ptyId, paneKey, error }) } } + return rewritten + } + + private wait(state: RunState, delayMs: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + state.wake = null + resolve() + }, delayMs) + timer.unref?.() + state.wake = () => { + clearTimeout(timer) + state.wake = null + resolve() + } + }) } } diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index 08171bf59d15..18f6b5ccdd4e 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -23,7 +23,11 @@ import type { FleetAgentStatusEvidence } from '../../shared/orchestration-fleet- import { readOrchestrationFleetAgentStatusSnapshot } from './orchestration-fleet-agent-status-snapshot' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { matchesProcessIncarnation } from './orchestration/worker-terminal-process-liveness' -import { CommandEndAgentExitVerifier } from './command-end-agent-exit-verifier' +import { + CommandEndAgentExitVerifier, + liveRowAnchor, + type CommandEndRowAnchor +} from './command-end-agent-exit-verifier' export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller { /** Every pane key this PTY could be addressed by, including restored receipts. */ @@ -142,34 +146,29 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim protected readonly commandEndAgentExit = new CommandEndAgentExitVerifier({ readLiveRowAnchors: (ptyId) => { - const anchors = new Map() + const anchors = new Map() for (const paneKey of this.collectAgentStatusPaneKeysForPty(ptyId)) { - const row = this.getAgentProviderSessionRowsForPaneFn?.(paneKey).find( - (entry) => entry.providerSessionOnly !== true - ) - if (row) { - anchors.set(paneKey, row.receivedAt) + const anchor = liveRowAnchor(this.getAgentProviderSessionRowsForPaneFn?.(paneKey)) + if (anchor) { + anchors.set(paneKey, anchor) } } return anchors }, checkHookAgentPresence: async (paneKey) => (await this.checkHookAgentPresenceFn?.(paneKey)) ?? null, - proveShellForeground: async (ptyId) => { - const incarnationId = this.ptysById.get(ptyId)?.incarnationId - return ( - (await this.ptyController?.proveShellForeground?.( - ptyId, - incarnationId ? { expectedIncarnationId: incarnationId } : undefined - )) ?? 'other' - ) + proveShellForeground: async (ptyId, notCapturedBefore) => { + const expectedIncarnationId = this.ptysById.get(ptyId)?.incarnationId ?? undefined + const options = { expectedIncarnationId, notCapturedBefore } + return (await this.ptyController?.proveShellForeground?.(ptyId, options)) ?? 'other' }, reconcileEndedProcess: (paneKey, armedRowReceivedAt) => this.reconcileAgentStatusForEndedProcessFn?.([paneKey], { // The shell outlived its agent, so the session stays resumable in place. preserveResumeIdentity: true, armedRowReceivedAt - }) + }), + now: () => performance.now() }) /** A command end (OSC 133;D): the launch token lives on in the shell's environment, so its diff --git a/src/main/runtime/orca-runtime-on-pty-exit.ts b/src/main/runtime/orca-runtime-on-pty-exit.ts index e5ea59248fe9..df0581665638 100644 --- a/src/main/runtime/orca-runtime-on-pty-exit.ts +++ b/src/main/runtime/orca-runtime-on-pty-exit.ts @@ -58,6 +58,7 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte } else { this.retirePtyAgentLaunchAuthority(ptyId, 'pty') } + this.commandEndAgentExit.onPtyExit(ptyId) const processDeathCertified = exitCode >= 0 || options.hostExitConfirmed === true || options.providerExitObserved === true if (processDeathCertified && exitPaneKeys.size > 0) { diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 262d2ef376be..18374f3e0f31 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -11,7 +11,10 @@ import type { PtyBindingSourceExpectation } from '../persistence' import type { ExecutionHostId } from '../../shared/execution-host' import type { PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' -import type { ShellForegroundProof } from '../providers/shell-foreground-proof' +import type { + ShellForegroundProof, + ShellForegroundProofOptions +} from '../providers/shell-foreground-proof' import type { WriteSettlement } from '../../shared/pty-write-settlement' import type { TerminalInputKind } from '../../shared/terminal-input-kind' @@ -127,7 +130,7 @@ export type RuntimePtyController = { * cannot be reached. */ proveShellForeground?( ptyId: string, - options?: { expectedIncarnationId?: PtyIncarnationId } + options?: ShellForegroundProofOptions ): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise From a3a38fe093339cede613e22a96ecfcc349542226 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:19:50 -0700 Subject: [PATCH 16/19] fix(agent-status): prove a daemon pane's exit on the first ask The check that refuses a process-table capture from before the command end bounded the capture's start by the request's start minus its age. The age runs from the start of the `ps` to the reply, so even a capture the check itself triggered failed it: every daemon and SSH exit took a second inspection and cleared about 0.6 s later. The daemon is on this machine, so its reply arrives as it is sent: the bound now uses the reply's arrival there. SSH keeps the request-start bound, since Orca can't tell when the relay's reply left. After a proven exit, a row rewritten mid-check is now checked again whoever wrote it (a new agent then reads as in front), so an agent without a session id gets its second look too; where the host cannot tell, only the same session's rewrite is. Drops an unreachable reason from the 'something else in front' set. --- docs/reference/agent-status-store.md | 6 +- ...ell-foreground-proof-fresh-capture.test.ts | 107 ++++++++++++++++++ src/main/providers/shell-foreground-proof.ts | 21 ++-- .../command-end-agent-exit-daemon.test.ts | 36 ++++-- ...ommand-end-agent-exit-verification.test.ts | 33 +++++- .../command-end-agent-exit-verifier.ts | 22 ++-- 6 files changed, 198 insertions(+), 27 deletions(-) create mode 100644 src/main/providers/shell-foreground-proof-fresh-capture.test.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 213ffc979eb6..780729529b9d 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -110,8 +110,10 @@ runtime asks the execution host again, and reads its answer in this order: under a live TUI drops its row, until those hosts report shell-owns-the-foreground evidence. A kept row is not latched: the PTY's next command end asks again, and a confirmed PTY exit clears -it. A verdict acts only on the row it checked: a session that started while it was read keeps its -row, and a row the same session rewrote meanwhile (its own late hook) is checked again. The clear also records which session ended, so a reconnecting SSH relay's replay of that +it. A verdict acts only on the row it checked. After a proven exit, a row rewritten meanwhile is +checked again (a new agent then reads as in front, the exiting one's late hook clears); where the +host cannot tell, only the same session's own rewrite is, and another session's row is left +alone. The clear also records which session ended, so a reconnecting SSH relay's replay of that session's cached status is refused; any newer evidence (a live event, a new turn, another session) is admitted and drops the record. diff --git a/src/main/providers/shell-foreground-proof-fresh-capture.test.ts b/src/main/providers/shell-foreground-proof-fresh-capture.test.ts new file mode 100644 index 000000000000..3bf6a1ca287a --- /dev/null +++ b/src/main/providers/shell-foreground-proof-fresh-capture.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProcessTableRow } from '../../shared/process-table-snapshot' +import { createProcessTableSnapshotReader } from '../../shared/process-table-snapshot-reader' +import { CommandEndAgentExitVerifier } from '../runtime/command-end-agent-exit-verifier' +import { resolveRemoteForegroundEvidence } from './agent-foreground-process-batch' +import { proveDaemonShellForeground } from './shell-foreground-proof' + +// The daemon's evidence comes from a shared process-table capture whose age runs from the start of +// a `ps` that takes time. A capture the check itself triggers after the command end must prove the +// exit on the first ask; one already cached from before the exit must not. + +const SHELL_PID = 100 +const PS_MS = 60 + +const shellOnly: ProcessTableRow[] = [ + { + pid: SHELL_PID, + ppid: 1, + pgid: SHELL_PID, + tpgid: SHELL_PID, + tty: '/dev/pts/3', + startTime: 'shell-birth', + stat: 'Ss+', + command: '/bin/zsh' + } +] + +function daemonPane() { + const ps = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, PS_MS)) + return shellOnly + }) + const reader = createProcessTableSnapshotReader({ runPs: ps, now: Date.now }) + const reconcileEndedProcess = vi.fn() + const verifier = new CommandEndAgentExitVerifier({ + readLiveRowAnchors: () => + new Map([['tab:pane', { receivedAt: 1, agentType: 'codex', providerSessionId: 's' }]]), + checkHookAgentPresence: async () => null, + proveShellForeground: (_ptyId, notCapturedBefore) => + proveDaemonShellForeground({ + ptyId: 'pty-1', + incarnationId: 'inc-1', + notCapturedBefore, + platform: 'linux', + // The daemon's own confirm only proves a shell after a full-screen exit. + confirmShellForeground: async () => false, + inspectProcess: async () => { + const { value, capturedAgeMs } = await reader.getSnapshotWithAge() + return { + foregroundProcess: null, + hasChildProcesses: false, + foregroundProcessEvidence: resolveRemoteForegroundEvidence( + { rootPid: SHELL_PID, fallbackProcess: 'zsh' }, + { + ptyId: 'pty-1', + ptyIncarnationId: 'inc-1', + authorityGeneration: 'daemon', + observationEpoch: ps.mock.calls.length, + capturedAgeMs, + platform: 'linux' + }, + value + ) + } + } + }), + reconcileEndedProcess, + now: () => performance.now() + }) + return { ps, reader, verifier, reconcileEndedProcess } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('a daemon exit proof from the shared process-table capture', () => { + it('clears on the first ask when the check triggers the capture', async () => { + const { ps, verifier, reconcileEndedProcess } = daemonPane() + + verifier.onCommandEnd('pty-1') + + await vi.waitFor(() => expect(reconcileEndedProcess).toHaveBeenCalledTimes(1), { + timeout: 400, + interval: 10 + }) + expect(ps).toHaveBeenCalledTimes(1) + }) + + it('asks again when the capture it is served began before the command end', async () => { + const { ps, reader, verifier, reconcileEndedProcess } = daemonPane() + const pollerCapture = reader.getSnapshotWithAge() + + // The command end lands while another pane's poll is mid-`ps`. + await new Promise((resolve) => setTimeout(resolve, PS_MS / 2)) + verifier.onCommandEnd('pty-1') + await pollerCapture + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(reconcileEndedProcess).not.toHaveBeenCalled() + + await vi.waitFor(() => expect(reconcileEndedProcess).toHaveBeenCalledTimes(1), { + timeout: 2_000, + interval: 20 + }) + expect(ps).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/providers/shell-foreground-proof.ts b/src/main/providers/shell-foreground-proof.ts index b2e81da29bb8..ba7f06c0576f 100644 --- a/src/main/providers/shell-foreground-proof.ts +++ b/src/main/providers/shell-foreground-proof.ts @@ -25,8 +25,7 @@ export type ShellForegroundProofOptions = { const OTHER_IN_FRONT_REASONS = new Set([ 'multiplexer_boundary', 'tty_boundary', - 'ambiguous_foreground_group', - 'incarnation_mismatch' + 'ambiguous_foreground_group' ]) /** Reads the fenced foreground evidence a terminal daemon or SSH relay inspection carries. */ @@ -37,6 +36,8 @@ export function shellForegroundProofFromInspection( incarnationId: string | null requestStartedAtMonotonic: number notCapturedBefore?: number + /** A host on this machine (the terminal daemon): its reply arrives as it is sent. */ + replyIsLocal?: boolean } ): ShellForegroundProof { // Why throw: a client-only verdict (transport loss) is no answer from the host at all. @@ -46,23 +47,28 @@ export function shellForegroundProofFromInspection( if (!('foregroundProcessEvidence' in inspection) || !inspection.foregroundProcessEvidence) { return 'unprovable' } + const receivedAtMonotonic = performance.now() const evidence = admitRemoteForegroundEvidence(inspection.foregroundProcessEvidence, { expectedPtyId: expected.ptyId, expectedIncarnationId: expected.incarnationId, requestStartedAtMonotonic: expected.requestStartedAtMonotonic, - receivedAtMonotonic: performance.now(), + receivedAtMonotonic, lastAuthorityGeneration: null, lastObservationEpoch: -1 }) if (!evidence) { return 'unread' } - // Why the request start: the host serializes after it, and `capturedAgeMs` bounds the capture's - // age from above, so this is the earliest the capture can have started. A shared capture that - // began before the command end still shows the exiting agent in front. + // Why: a shared capture that began before the command end still shows the exiting agent in + // front. `capturedAgeMs` runs from the capture's start to its reply, so subtracting it from the + // reply's send time gives the start: a local reply is sent as it arrives; a remote one was sent + // at the earliest when the request left, which only errs early. + const replySentAt = expected.replyIsLocal + ? receivedAtMonotonic + : expected.requestStartedAtMonotonic if ( expected.notCapturedBefore !== undefined && - expected.requestStartedAtMonotonic - evidence.capturedAgeMs < expected.notCapturedBefore + replySentAt - evidence.capturedAgeMs < expected.notCapturedBefore ) { return 'unread' } @@ -104,6 +110,7 @@ export async function proveDaemonShellForeground(args: { ptyId: args.ptyId, incarnationId: args.incarnationId, requestStartedAtMonotonic, + replyIsLocal: true, ...(args.notCapturedBefore !== undefined ? { notCapturedBefore: args.notCapturedBefore } : {}) }) } diff --git a/src/main/runtime/command-end-agent-exit-daemon.test.ts b/src/main/runtime/command-end-agent-exit-daemon.test.ts index 75611f2621c0..51ca53ab7fdd 100644 --- a/src/main/runtime/command-end-agent-exit-daemon.test.ts +++ b/src/main/runtime/command-end-agent-exit-daemon.test.ts @@ -25,16 +25,27 @@ const processTable = vi.hoisted( (): { rows: ProcessTableRow[] captures: { rows: ProcessTableRow[]; capturedAgeMs: number }[] + reads: number } => ({ rows: [], - captures: [] + captures: [], + reads: 0 }) ) vi.mock('../../shared/process-table-snapshot-reader', async (importOriginal) => ({ ...(await importOriginal()), - // A queued capture is served first, as the shared cache would; then a fresh one. - getStrictProcessTableSnapshotWithAge: async () => - processTable.captures.shift() ?? { rows: processTable.rows, capturedAgeMs: 0 } + // A queued capture is served first, as the shared cache would. Otherwise a fresh capture, aged + // as the host stamps it: from the start of a `ps` that takes time. + getStrictProcessTableSnapshotWithAge: async () => { + processTable.reads += 1 + const queued = processTable.captures.shift() + if (queued) { + return queued + } + const startedAt = Date.now() + await new Promise((resolve) => setTimeout(resolve, 40)) + return { rows: processTable.rows, capturedAgeMs: Date.now() - startedAt } + } })) vi.mock('../git/worktree', () => { @@ -122,6 +133,7 @@ afterEach(async () => { } processTable.rows = [] processTable.captures = [] + processTable.reads = 0 vi.restoreAllMocks() }) @@ -192,11 +204,17 @@ describe('a normal-buffer agent on a terminal-daemon pane', () => { await runCommandToItsEnd(daemonPane, path) - expectEveryReaderSawTheClear( - daemonPane.host.server, - daemonPane.host.readers, - daemonPane.pane.paneKey + // On the first ask: well before the re-ask would run. + await vi.waitFor( + () => + expectEveryReaderSawTheClear( + daemonPane.host.server, + daemonPane.host.readers, + daemonPane.pane.paneKey + ), + { timeout: 400, interval: 20 } ) + expect(processTable.reads).toBe(1) }) it(`still in front after a nested shell's marker: the row stays (${path})`, async () => { @@ -204,6 +222,8 @@ describe('a normal-buffer agent on a terminal-daemon pane', () => { processTable.rows = paneProcesses('codex') await runCommandToItsEnd(daemonPane, path) + await vi.waitFor(() => expect(processTable.reads).toBe(1), { timeout: 400, interval: 20 }) + await new Promise((resolve) => setTimeout(resolve, 100)) expectNoReaderLostTheRow( daemonPane.host.server, diff --git a/src/main/runtime/command-end-agent-exit-verification.test.ts b/src/main/runtime/command-end-agent-exit-verification.test.ts index 1cf7f7225b76..fa09f7382c12 100644 --- a/src/main/runtime/command-end-agent-exit-verification.test.ts +++ b/src/main/runtime/command-end-agent-exit-verification.test.ts @@ -244,10 +244,16 @@ describe('a command end that does not prove the agent exited keeps its row every expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) }) - for (const answerProof of ['shell', 'unprovable'] as const) { + for (const [answerProof, looks] of [ + // After a proven exit the rewritten row gets another look, which finds the new agent in front. + ['shell', 2], + // Where the host cannot tell, another look would answer the same: the new session is left alone. + ['unprovable', 1] + ] as const) { it(`a session that starts while the check is read keeps its row (${answerProof})`, async () => { const host = await wire() let answer: (proof: ShellForegroundProof) => void = () => {} + host.shellProof.mockResolvedValue('other') host.shellProof.mockImplementationOnce( () => new Promise((resolve) => (answer = resolve)) ) @@ -267,10 +273,33 @@ describe('a command end that does not prove the agent exited keeps its row every expect(liveRow(host.server, pane.paneKey)?.prompt).toBe('a different task') expect(host.readers.windowClears).toEqual([]) - expect(host.shellProof).toHaveBeenCalledTimes(1) + expect(host.shellProof).toHaveBeenCalledTimes(looks) }) } + it('an agent without a session id gets a second look at its own late hook after a proven exit', async () => { + const host = await wire() + let answer: (proof: ShellForegroundProof) => void = () => {} + host.shellProof.mockImplementationOnce( + () => new Promise((resolve) => (answer = resolve)) + ) + const pane = await launchAgentPane(host, 'pty-late-hook-no-session') + await postHook(host.server, 'claude', pane, { + hook_event_name: 'UserPromptSubmit', + prompt: 'review the PR' + }) + expect(liveRow(host.server, pane.paneKey)?.providerSession).toBeUndefined() + + await endCommand(host.runtime, pane.ptyId, 'shell bytes') + await new Promise((resolve) => setTimeout(resolve, 2)) + await postHook(host.server, 'claude', pane, { hook_event_name: 'Stop' }) + answer('shell') + await settle() + + expect(host.shellProof).toHaveBeenCalledTimes(2) + expectEveryReaderSawTheClear(host.server, host.readers, pane.paneKey) + }) + it("the exiting agent's own late hook mid-check gets a second look, which clears it", async () => { const host = await wire() let answer: (proof: ShellForegroundProof) => void = () => {} diff --git a/src/main/runtime/command-end-agent-exit-verifier.ts b/src/main/runtime/command-end-agent-exit-verifier.ts index 42fdf8e5c2e4..92e64a29ac38 100644 --- a/src/main/runtime/command-end-agent-exit-verifier.ts +++ b/src/main/runtime/command-end-agent-exit-verifier.ts @@ -58,8 +58,8 @@ function clearsRow(verdict: CommandEndExitVerdict): boolean { /** Re-asks for an unread answer: past the shared process-table cache's lifetime, then once more. */ const UNREAD_REASK_DELAYS_MS = [PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS + 100, 1_500] -/** Re-checks after the exiting agent's own late write changed the row mid-check. */ -const SAME_SESSION_RECHECKS = 2 +/** Re-checks after a row was rewritten mid-check (often the exiting agent's own late hook). */ +const REWRITE_RECHECKS = 2 export async function verifyCommandEndAgentExit( ptyId: string, @@ -158,8 +158,8 @@ export class CommandEndAgentExitVerifier { this.deps ) if (clearsRow(verdict)) { - const rewritten = this.clearUnchangedRows(ptyId, anchors) - if (!rewritten || rechecks >= SAME_SESSION_RECHECKS) { + const rewritten = this.clearUnchangedRows(ptyId, anchors, verdict) + if (!rewritten || rechecks >= REWRITE_RECHECKS) { return } rechecks += 1 @@ -176,15 +176,21 @@ export class CommandEndAgentExitVerifier { } } - /** Clears each pane whose row is as armed. True when the same agent session rewrote one mid-check - * (its own late hook), which needs another look; a different session's row is left alone. */ - private clearUnchangedRows(ptyId: string, anchors: Map): boolean { + /** Clears each pane whose row is as armed, and says whether a row rewritten mid-check needs + * another look. After a proven exit any rewrite does: a new agent reads as in front. Where the + * host cannot tell, the look would answer the same, so only the exiting session's own late hook + * earns one and a different session's row is left alone. */ + private clearUnchangedRows( + ptyId: string, + anchors: Map, + verdict: CommandEndExitVerdict + ): boolean { const current = this.deps.readLiveRowAnchors(ptyId) let rewritten = false for (const [paneKey, armed] of anchors) { const now = current.get(paneKey) if (now && now.receivedAt !== armed.receivedAt) { - rewritten ||= sameAgentSession(armed, now) + rewritten ||= verdict === 'exited' || sameAgentSession(armed, now) continue } try { From c37fee53d0c9b7a9394daf9ea49722b42b1dddb2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:33:42 -0700 Subject: [PATCH 17/19] fix(agent-status): prove a macOS daemon pane's exit from this machine's process table QA on macOS (default terminal-daemon backend): a parked pane's Gemini quit to the shell and its Done row stayed on the sidebar, dashboard and worktree ps, also after a later command end. On macOS the daemon spawns the shell under /usr/bin/login for TCC attribution, so the PTY's root process is login and the shell is its child in its own process group. The daemon's fenced evidence names the root and the terminal's foreground group, and the proof required them to be the same process: never true there, so every answer was 'something else in front' and the row was kept at every command end. The daemon runs on this machine, so the proof now takes only the PTY's root from the daemon and reads this machine's process table fresh: the shallowest shell under the root holds the foreground and no job is stopped, as the local provider's check does. That also sees a Ctrl+Z'd agent and ignores a shared capture taken before the exit. SSH keeps the relay's evidence, whose root is the shell. --- docs/reference/agent-status-store.md | 5 +- .../daemon-pty-adapter-shell-proof.test.ts | 7 ++ .../daemon/daemon-pty-process-inspection.ts | 7 +- ...gent-foreground-process-shell-path.test.ts | 24 +++- .../providers/agent-foreground-process.ts | 36 ++++-- ...ell-foreground-proof-fresh-capture.test.ts | 107 ------------------ .../providers/shell-foreground-proof.test.ts | 42 +++++-- src/main/providers/shell-foreground-proof.ts | 82 ++++++++------ .../command-end-agent-exit-daemon.test.ts | 94 +++++++++++---- 9 files changed, 215 insertions(+), 189 deletions(-) delete mode 100644 src/main/providers/shell-foreground-proof-fresh-capture.test.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 780729529b9d..a4929877d968 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -96,8 +96,9 @@ shells leak their own 133;D. So at every command end of a PTY whose panes hold a runtime asks the execution host again, and reads its answer in this order: 1. The hook's own agent process still running keeps the row. 2. The pane's own shell proven back in front clears it as an ended process, keeping the resume - identity. A local node-pty proves that from a fresh process-table read (spawned shell in the - foreground, no job stopped); the terminal daemon and an SSH relay from their fenced foreground + identity. A local node-pty, and the terminal daemon (which is on the same machine), prove that + from a fresh read of this machine's process table: the pane's shell, under its login(1) wrapper + on macOS, in the foreground and no job stopped. An SSH relay proves it from its fenced foreground evidence (the shell's own process group in front, no agent named), which cannot see a stopped job (temporary), and only from a capture that began after the command end. 3. Something else proven in front keeps the row; that command ends with its own 133;D. diff --git a/src/main/daemon/daemon-pty-adapter-shell-proof.test.ts b/src/main/daemon/daemon-pty-adapter-shell-proof.test.ts index 562661a9b86e..05431dd3aef2 100644 --- a/src/main/daemon/daemon-pty-adapter-shell-proof.test.ts +++ b/src/main/daemon/daemon-pty-adapter-shell-proof.test.ts @@ -1,9 +1,16 @@ import { describe, expect, it, vi } from 'vitest' +import type * as AgentForegroundProcess from '../providers/agent-foreground-process' import { DaemonPtyAdapter } from './daemon-pty-adapter' // Daemons survive app updates. Those from v27 until the shell-confirm request existed answer it as // unknown, yet still send fenced foreground evidence, which must decide; a dead socket must not. +// This host's own process-table read of the pane: its shell in front. +vi.mock('../providers/agent-foreground-process', async (importOriginal) => ({ + ...(await importOriginal()), + confirmPaneShellForegroundProcess: async () => true +})) + type ClientInternals = { client: { request: ReturnType; disconnect: ReturnType } } diff --git a/src/main/daemon/daemon-pty-process-inspection.ts b/src/main/daemon/daemon-pty-process-inspection.ts index 16d8a2579e91..77df73ed1095 100644 --- a/src/main/daemon/daemon-pty-process-inspection.ts +++ b/src/main/daemon/daemon-pty-process-inspection.ts @@ -15,6 +15,7 @@ import { type ShellForegroundProofOptions } from '../providers/shell-foreground-proof' import { isUnknownRequestTypeError } from './daemon-endpoint-errors' +import { confirmPaneShellForegroundProcess } from '../providers/agent-foreground-process' export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshots { // Why: daemon-backed PTYs can host long-lived agents while detached; cleanup prompts must not treat them as idle shells. @@ -108,9 +109,6 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot return proveDaemonShellForeground({ ptyId: id, incarnationId: expectedIncarnationId ?? null, - ...(options?.notCapturedBefore !== undefined - ? { notCapturedBefore: options.notCapturedBefore } - : {}), platform: process.platform, confirmShellForeground: () => this.client @@ -127,7 +125,8 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot } ), inspectProcess: () => - this.inspectProcess(id, expectedIncarnationId ? { expectedIncarnationId } : undefined) + this.inspectProcess(id, expectedIncarnationId ? { expectedIncarnationId } : undefined), + confirmPaneShellForeground: confirmPaneShellForegroundProcess }) } diff --git a/src/main/providers/agent-foreground-process-shell-path.test.ts b/src/main/providers/agent-foreground-process-shell-path.test.ts index 5eccd10c5481..fbd1b69668a3 100644 --- a/src/main/providers/agent-foreground-process-shell-path.test.ts +++ b/src/main/providers/agent-foreground-process-shell-path.test.ts @@ -9,7 +9,10 @@ vi.mock('child_process', () => ({ })) import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot-reader' -import { confirmShellForegroundProcess } from './agent-foreground-process' +import { + confirmPaneShellForegroundProcess, + confirmShellForegroundProcess +} from './agent-foreground-process' // Why: the POSIX reader wraps execFile with promisify, so the mock must honor the Node callback contract. function mockPs(stdout: string): void { @@ -51,4 +54,23 @@ describe('confirmShellForegroundProcess with a spawned shell path', () => { await expect(confirmShellForegroundProcess(100, '/Users/John Doe/bin/zsh')).resolves.toBe(true) }) + + // The terminal daemon knows the PTY's root (login(1) on macOS) but not which shell it spawned. + it.each([ + ['its shell alone in front', ['101 100 101 101 S+ -zsh'], true], + [ + 'an agent in front', + ['101 100 101 102 S -zsh', '102 101 102 102 S+ node /usr/bin/codex'], + false + ], + [ + 'an agent suspended with Ctrl+Z', + ['101 100 101 101 S+ -zsh', '102 101 102 101 T node codex'], + false + ] + ] as const)('a pane rooted in login(1), with %s', async (_name, rows, shellInFront) => { + mockPs(['100 99 100 101 Ss /usr/bin/login -pfl developer /bin/zsh', ...rows].join('\n')) + + await expect(confirmPaneShellForegroundProcess(100)).resolves.toBe(shellInFront) + }) }) diff --git a/src/main/providers/agent-foreground-process.ts b/src/main/providers/agent-foreground-process.ts index da6929ff410c..4c418684142c 100644 --- a/src/main/providers/agent-foreground-process.ts +++ b/src/main/providers/agent-foreground-process.ts @@ -89,27 +89,41 @@ export async function confirmShellForegroundProcess( return false } } + // A path, not a command line: splitting on whitespace would cut `/Users/John Doe/bin/zsh` to `john`. + const spawnedShellBasename = spawnedShellProcess.split(/[\\/]/).pop()?.toLowerCase() ?? '' + return confirmPosixShellOwnsForeground( + shellPid, + (command) => executableBasename(command) === spawnedShellBasename + ) +} + +/** + * POSIX: the shallowest shell under a PTY's root process (the root itself, or the login(1) + * wrapper's shell on macOS) holds the terminal's foreground, and nothing in the tree is stopped. + * For a host that knows the root but not which shell it spawned (the terminal daemon). + */ +export function confirmPaneShellForegroundProcess(rootPid: number): Promise { + return confirmPosixShellOwnsForeground(rootPid, () => true) +} + +async function confirmPosixShellOwnsForeground( + rootPid: number, + isPaneShell: (command: string) => boolean +): Promise { try { const index = getProcessTableIndex(await getFreshShellForegroundSnapshot()) - const root = index.byPid.get(shellPid) + const root = index.byPid.get(rootPid) if (!root) { return false } - const tree = [{ ...root, depth: 0 }, ...collectDescendantsFromIndex(index, shellPid)] - // A path, not a command line: splitting on whitespace would cut `/Users/John Doe/bin/zsh` to `john`. - const spawnedShellBasename = spawnedShellProcess.split(/[\\/]/).pop()?.toLowerCase() ?? '' + const tree = [{ ...root, depth: 0 }, ...collectDescendantsFromIndex(index, rootPid)] const foregroundShell = tree - .filter( - (row) => - executableBasename(row.command) === spawnedShellBasename && - isShellProcess(commandExecutable(row.command)) - ) + .filter((row) => isShellProcess(commandExecutable(row.command)) && isPaneShell(row.command)) .sort((left, right) => left.depth - right.depth)[0] if (tree.some((row) => row.depth > 0 && row.stat.includes('T'))) { return false } - const confirmed = foregroundShell?.stat.includes('+') === true - return confirmed + return foregroundShell?.stat.includes('+') === true } catch { return false } diff --git a/src/main/providers/shell-foreground-proof-fresh-capture.test.ts b/src/main/providers/shell-foreground-proof-fresh-capture.test.ts deleted file mode 100644 index 3bf6a1ca287a..000000000000 --- a/src/main/providers/shell-foreground-proof-fresh-capture.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { ProcessTableRow } from '../../shared/process-table-snapshot' -import { createProcessTableSnapshotReader } from '../../shared/process-table-snapshot-reader' -import { CommandEndAgentExitVerifier } from '../runtime/command-end-agent-exit-verifier' -import { resolveRemoteForegroundEvidence } from './agent-foreground-process-batch' -import { proveDaemonShellForeground } from './shell-foreground-proof' - -// The daemon's evidence comes from a shared process-table capture whose age runs from the start of -// a `ps` that takes time. A capture the check itself triggers after the command end must prove the -// exit on the first ask; one already cached from before the exit must not. - -const SHELL_PID = 100 -const PS_MS = 60 - -const shellOnly: ProcessTableRow[] = [ - { - pid: SHELL_PID, - ppid: 1, - pgid: SHELL_PID, - tpgid: SHELL_PID, - tty: '/dev/pts/3', - startTime: 'shell-birth', - stat: 'Ss+', - command: '/bin/zsh' - } -] - -function daemonPane() { - const ps = vi.fn(async () => { - await new Promise((resolve) => setTimeout(resolve, PS_MS)) - return shellOnly - }) - const reader = createProcessTableSnapshotReader({ runPs: ps, now: Date.now }) - const reconcileEndedProcess = vi.fn() - const verifier = new CommandEndAgentExitVerifier({ - readLiveRowAnchors: () => - new Map([['tab:pane', { receivedAt: 1, agentType: 'codex', providerSessionId: 's' }]]), - checkHookAgentPresence: async () => null, - proveShellForeground: (_ptyId, notCapturedBefore) => - proveDaemonShellForeground({ - ptyId: 'pty-1', - incarnationId: 'inc-1', - notCapturedBefore, - platform: 'linux', - // The daemon's own confirm only proves a shell after a full-screen exit. - confirmShellForeground: async () => false, - inspectProcess: async () => { - const { value, capturedAgeMs } = await reader.getSnapshotWithAge() - return { - foregroundProcess: null, - hasChildProcesses: false, - foregroundProcessEvidence: resolveRemoteForegroundEvidence( - { rootPid: SHELL_PID, fallbackProcess: 'zsh' }, - { - ptyId: 'pty-1', - ptyIncarnationId: 'inc-1', - authorityGeneration: 'daemon', - observationEpoch: ps.mock.calls.length, - capturedAgeMs, - platform: 'linux' - }, - value - ) - } - } - }), - reconcileEndedProcess, - now: () => performance.now() - }) - return { ps, reader, verifier, reconcileEndedProcess } -} - -afterEach(() => { - vi.useRealTimers() -}) - -describe('a daemon exit proof from the shared process-table capture', () => { - it('clears on the first ask when the check triggers the capture', async () => { - const { ps, verifier, reconcileEndedProcess } = daemonPane() - - verifier.onCommandEnd('pty-1') - - await vi.waitFor(() => expect(reconcileEndedProcess).toHaveBeenCalledTimes(1), { - timeout: 400, - interval: 10 - }) - expect(ps).toHaveBeenCalledTimes(1) - }) - - it('asks again when the capture it is served began before the command end', async () => { - const { ps, reader, verifier, reconcileEndedProcess } = daemonPane() - const pollerCapture = reader.getSnapshotWithAge() - - // The command end lands while another pane's poll is mid-`ps`. - await new Promise((resolve) => setTimeout(resolve, PS_MS / 2)) - verifier.onCommandEnd('pty-1') - await pollerCapture - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(reconcileEndedProcess).not.toHaveBeenCalled() - - await vi.waitFor(() => expect(reconcileEndedProcess).toHaveBeenCalledTimes(1), { - timeout: 2_000, - interval: 20 - }) - expect(ps).toHaveBeenCalledTimes(2) - }) -}) diff --git a/src/main/providers/shell-foreground-proof.test.ts b/src/main/providers/shell-foreground-proof.test.ts index c550957a7463..4d6afb8a84a4 100644 --- a/src/main/providers/shell-foreground-proof.test.ts +++ b/src/main/providers/shell-foreground-proof.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence' import { proveDaemonShellForeground, @@ -120,8 +120,14 @@ describe('an inspection the host never answered', () => { }) describe('the terminal daemon shell proof', () => { - const daemon = (platform: NodeJS.Platform, confirmed: boolean) => - proveDaemonShellForeground({ + const daemon = ( + platform: NodeJS.Platform, + confirmed: boolean, + paneShellInFront = true, + evidence: RemoteForegroundEvidence = live('codex', 200) + ) => { + const confirmPaneShellForeground = vi.fn(async () => paneShellInFront) + const proof = proveDaemonShellForeground({ ptyId: PTY, incarnationId: INCARNATION, platform, @@ -129,17 +135,32 @@ describe('the terminal daemon shell proof', () => { inspectProcess: async () => ({ foregroundProcess: null, hasChildProcesses: false, - foregroundProcessEvidence: live(null, 100) - }) + foregroundProcessEvidence: evidence + }), + confirmPaneShellForeground }) + return { proof, confirmPaneShellForeground } + } + + it("trusts the daemon's own confirm first", async () => { + await expect(daemon('linux', true, false).proof).resolves.toBe('shell') + }) + + it("then reads this host's own process table under the root the evidence names", async () => { + // The evidence, possibly from a capture before the exit, still names the agent: the fresh read decides. + const { proof, confirmPaneShellForeground } = daemon('darwin', false, true) + await expect(proof).resolves.toBe('shell') + expect(confirmPaneShellForeground).toHaveBeenCalledWith(100) + await expect(daemon('darwin', false, false).proof).resolves.toBe('other') + }) - it("trusts the daemon's own confirm, then its evidence", async () => { - await expect(daemon('linux', true)).resolves.toBe('shell') - await expect(daemon('linux', false)).resolves.toBe('shell') + it('reads no answer from evidence it cannot admit', async () => { + const stale = { ...live(null, 100), ptyIncarnationId: 'replacement' } + await expect(daemon('linux', false, true, stale).proof).resolves.toBe('unread') }) it('cannot tell on Windows, where the daemon has no foreground evidence', async () => { - await expect(daemon('win32', false)).resolves.toBe('unprovable') + await expect(daemon('win32', false).proof).resolves.toBe('unprovable') }) it('rejects when the daemon cannot be reached', async () => { @@ -151,7 +172,8 @@ describe('the terminal daemon shell proof', () => { confirmShellForeground: async () => { throw new Error('daemon socket closed') }, - inspectProcess: async () => ({ foregroundProcess: null, hasChildProcesses: false }) + inspectProcess: async () => ({ foregroundProcess: null, hasChildProcesses: false }), + confirmPaneShellForeground: async () => true }) ).rejects.toThrow('daemon socket closed') }) diff --git a/src/main/providers/shell-foreground-proof.ts b/src/main/providers/shell-foreground-proof.ts index ba7f06c0576f..5d4d131d267a 100644 --- a/src/main/providers/shell-foreground-proof.ts +++ b/src/main/providers/shell-foreground-proof.ts @@ -1,5 +1,6 @@ import { admitRemoteForegroundEvidence } from '../../shared/remote-foreground-evidence-admission' import { isClientOnlyUnverifiableInspection } from '../../shared/terminal-process-inspection' +import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence' import type { PtyProcessInspection } from './pty-process-inspection' /** @@ -28,18 +29,14 @@ const OTHER_IN_FRONT_REASONS = new Set([ 'ambiguous_foreground_group' ]) -/** Reads the fenced foreground evidence a terminal daemon or SSH relay inspection carries. */ -export function shellForegroundProofFromInspection( +type LiveEvidence = Extract + +/** The fenced live evidence a daemon or relay inspection carries for this PTY incarnation, or the + * proof its answer already settles. */ +function readLiveEvidence( inspection: PtyProcessInspection, - expected: { - ptyId: string - incarnationId: string | null - requestStartedAtMonotonic: number - notCapturedBefore?: number - /** A host on this machine (the terminal daemon): its reply arrives as it is sent. */ - replyIsLocal?: boolean - } -): ShellForegroundProof { + expected: { ptyId: string; incarnationId: string | null; requestStartedAtMonotonic: number } +): LiveEvidence | ShellForegroundProof { // Why throw: a client-only verdict (transport loss) is no answer from the host at all. if (isClientOnlyUnverifiableInspection(inspection)) { throw new Error(`execution host gave no answer: ${inspection.reason}`) @@ -47,39 +44,48 @@ export function shellForegroundProofFromInspection( if (!('foregroundProcessEvidence' in inspection) || !inspection.foregroundProcessEvidence) { return 'unprovable' } - const receivedAtMonotonic = performance.now() const evidence = admitRemoteForegroundEvidence(inspection.foregroundProcessEvidence, { expectedPtyId: expected.ptyId, expectedIncarnationId: expected.incarnationId, requestStartedAtMonotonic: expected.requestStartedAtMonotonic, - receivedAtMonotonic, + receivedAtMonotonic: performance.now(), lastAuthorityGeneration: null, lastObservationEpoch: -1 }) if (!evidence) { return 'unread' } - // Why: a shared capture that began before the command end still shows the exiting agent in - // front. `capturedAgeMs` runs from the capture's start to its reply, so subtracting it from the - // reply's send time gives the start: a local reply is sent as it arrives; a remote one was sent - // at the earliest when the request left, which only errs early. - const replySentAt = expected.replyIsLocal - ? receivedAtMonotonic - : expected.requestStartedAtMonotonic - if ( - expected.notCapturedBefore !== undefined && - replySentAt - evidence.capturedAgeMs < expected.notCapturedBefore - ) { - return 'unread' - } if (evidence.verdict === 'unverifiable') { if (evidence.reason === 'windows_ssh_foreground_unavailable') { return 'unprovable' } return OTHER_IN_FRONT_REASONS.has(evidence.reason) ? 'other' : 'unread' } - if (evidence.verdict !== 'live') { - return 'other' + return evidence.verdict === 'live' && evidence.fence.platform === 'posix' ? evidence : 'other' +} + +/** Reads an SSH relay's fenced foreground evidence, whose PTY root is the shell itself. */ +export function shellForegroundProofFromInspection( + inspection: PtyProcessInspection, + expected: { + ptyId: string + incarnationId: string | null + requestStartedAtMonotonic: number + notCapturedBefore?: number + } +): ShellForegroundProof { + const evidence = readLiveEvidence(inspection, expected) + if (typeof evidence === 'string') { + return evidence + } + // Why: a shared capture that began before the command end still shows the exiting agent in + // front. `capturedAgeMs` runs from the capture's start to the reply, which left the relay no + // earlier than the request did, so this bound can only err early. + if ( + expected.notCapturedBefore !== undefined && + expected.requestStartedAtMonotonic - evidence.capturedAgeMs < expected.notCapturedBefore + ) { + return 'unread' } // Why TEMPORARY: this evidence cannot see a stopped job, so a Ctrl+Z'd agent reads as `shell`. return evidence.processName === null && @@ -89,15 +95,19 @@ export function shellForegroundProofFromInspection( : 'other' } -/** The terminal daemon's own confirm proves a shell only after a full-screen exit, so its fenced - * evidence is read next; that evidence is POSIX-only, so a Windows daemon cannot tell. */ +/** + * The terminal daemon's own confirm proves a shell only after a full-screen exit. Otherwise its + * fenced evidence names the PTY's root, and this host, which is the daemon's, reads its own process + * table fresh: on macOS the root is login(1) and the shell its child in another process group, so + * the evidence's root-is-the-shell test never holds there. A Windows daemon cannot tell. + */ export async function proveDaemonShellForeground(args: { ptyId: string incarnationId: string | null - notCapturedBefore?: number platform: NodeJS.Platform confirmShellForeground: () => Promise inspectProcess: () => Promise + confirmPaneShellForeground: (rootPid: number) => Promise }): Promise { if (await args.confirmShellForeground()) { return 'shell' @@ -106,13 +116,15 @@ export async function proveDaemonShellForeground(args: { return 'unprovable' } const requestStartedAtMonotonic = performance.now() - return shellForegroundProofFromInspection(await args.inspectProcess(), { + const evidence = readLiveEvidence(await args.inspectProcess(), { ptyId: args.ptyId, incarnationId: args.incarnationId, - requestStartedAtMonotonic, - replyIsLocal: true, - ...(args.notCapturedBefore !== undefined ? { notCapturedBefore: args.notCapturedBefore } : {}) + requestStartedAtMonotonic }) + if (typeof evidence === 'string') { + return evidence + } + return (await args.confirmPaneShellForeground(evidence.fence.shellPid)) ? 'shell' : 'other' } /** An SSH relay has no shell confirm; its fenced evidence answers, keyed to the relay's own PTY id. */ diff --git a/src/main/runtime/command-end-agent-exit-daemon.test.ts b/src/main/runtime/command-end-agent-exit-daemon.test.ts index 51ca53ab7fdd..9faf0bafeb45 100644 --- a/src/main/runtime/command-end-agent-exit-daemon.test.ts +++ b/src/main/runtime/command-end-agent-exit-daemon.test.ts @@ -5,6 +5,7 @@ import type * as ProcessTableSnapshotReader from '../../shared/process-table-sna import type { SubprocessHandle } from '../daemon/session-subprocess-handle' import { TerminalHost } from '../daemon/terminal-host' import { proveDaemonShellForeground } from '../providers/shell-foreground-proof' +import { confirmPaneShellForegroundProcess } from '../providers/agent-foreground-process' import { endCommand, expectEveryReaderSawTheClear, @@ -45,7 +46,9 @@ vi.mock('../../shared/process-table-snapshot-reader', async (importOriginal) => const startedAt = Date.now() await new Promise((resolve) => setTimeout(resolve, 40)) return { rows: processTable.rows, capturedAgeMs: Date.now() - startedAt } - } + }, + // Main's own fresh read of this machine's process table. + getFreshShellForegroundSnapshot: async () => processTable.rows })) vi.mock('../git/worktree', () => { @@ -64,26 +67,45 @@ vi.mock('../git/worktree', () => { } }) -const SHELL_PID = 99_999 +const ROOT_PID = 99_999 +const LOGIN_SHELL_PID = 99_990 const AGENT_PID = 100_100 -/** The pane's process table: its zsh alone in front, or a Codex process group in front of it. */ -function paneProcesses(front: 'shell' | 'codex'): ProcessTableRow[] { +/** + * The pane's process table: its zsh alone in front, or a Codex process group in front of it. On + * macOS the daemon spawns the shell under `/usr/bin/login` for TCC attribution, so the PTY's root + * is login and the shell is its child in its own process group. + */ +function paneProcesses( + front: 'shell' | 'codex', + root: 'shell' | 'macos-login' = 'shell' +): ProcessTableRow[] { const tty = '/dev/pts/7' - const foregroundPgid = front === 'shell' ? SHELL_PID : AGENT_PID - const shell = { - pid: SHELL_PID, + const shellPid = root === 'shell' ? ROOT_PID : LOGIN_SHELL_PID + const foregroundPgid = front === 'shell' ? shellPid : AGENT_PID + const login = { + pid: ROOT_PID, ppid: 1, - pgid: SHELL_PID, + pgid: ROOT_PID, + tpgid: foregroundPgid, + tty, + startTime: 'login-birth', + stat: 'Ss', + command: '/usr/bin/login -flpq qa /bin/bash --noprofile --norc -p -c exec' + } + const shell = { + pid: shellPid, + ppid: root === 'shell' ? 1 : ROOT_PID, + pgid: shellPid, tpgid: foregroundPgid, tty, startTime: 'shell-birth', stat: front === 'shell' ? 'Ss+' : 'Ss', - command: '/bin/zsh' + command: root === 'shell' ? '/bin/zsh' : '-zsh' } const codex = { pid: AGENT_PID, - ppid: SHELL_PID, + ppid: shellPid, pgid: AGENT_PID, tpgid: foregroundPgid, tty, @@ -91,14 +113,14 @@ function paneProcesses(front: 'shell' | 'codex'): ProcessTableRow[] { stat: 'S+', command: 'node /opt/homebrew/bin/codex' } - return front === 'shell' ? [shell] : [shell, codex] + return [...(root === 'macos-login' ? [login] : []), shell, ...(front === 'codex' ? [codex] : [])] } function createSubprocess(): { handle: SubprocessHandle; emit: (data: string) => void } { let onData: ((data: string) => void) | null = null let onExit: ((code: number) => void) | null = null const handle: SubprocessHandle = { - pid: SHELL_PID, + pid: ROOT_PID, getForegroundProcess: vi.fn(() => 'zsh'), // The fresh process read exists, but the daemon only asks it after a full-screen exit. confirmShellForeground: vi.fn(async () => true), @@ -158,9 +180,6 @@ async function launchDaemonCodexPane( proveDaemonShellForeground({ ptyId: id, incarnationId: options?.expectedIncarnationId ?? null, - ...(options?.notCapturedBefore !== undefined - ? { notCapturedBefore: options.notCapturedBefore } - : {}), platform, confirmShellForeground: () => terminalHost.confirmShellForeground(id), inspectProcess: () => @@ -169,7 +188,8 @@ async function launchDaemonCodexPane( options?.expectedIncarnationId ? { expectedIncarnationId: options.expectedIncarnationId } : undefined - ) + ), + confirmPaneShellForeground: confirmPaneShellForegroundProcess }) } }) @@ -247,14 +267,13 @@ describe('a normal-buffer agent on a terminal-daemon pane', () => { ) }) - it('re-asks when the shared capture predates the exit, and clears on the fresh one', async () => { + it('is not held by a shared capture from before the exit: a fresh read here decides', async () => { const daemonPane = await launchDaemonCodexPane('pty-daemon-stale-capture') // A poll's capture from just before the exit, served from the cache 300 ms later. processTable.captures = [{ rows: paneProcesses('codex'), capturedAgeMs: 300 }] processTable.rows = paneProcesses('shell') await runCommandToItsEnd(daemonPane, 'daemon fact') - expect(liveRow(daemonPane.host.server, daemonPane.pane.paneKey)?.state).toBe('done') await vi.waitFor( () => @@ -263,7 +282,44 @@ describe('a normal-buffer agent on a terminal-daemon pane', () => { daemonPane.host.readers, daemonPane.pane.paneKey ), - { timeout: 2_000, interval: 50 } + { timeout: 400, interval: 20 } + ) + }) + + // QA G4 (macOS, default daemon backend): a parked pane's Gemini quit, zsh was in front, and Done + // stayed on every surface, also after a later command end. + for (const path of ['shell bytes', 'daemon fact'] as const) { + it(`on macOS, under the login wrapper, quits to the shell: the row clears (${path})`, async () => { + const daemonPane = await launchDaemonCodexPane(`pty-daemon-login-${path.replace(' ', '-')}`) + processTable.rows = paneProcesses('shell', 'macos-login') + + await runCommandToItsEnd(daemonPane, path) + + await vi.waitFor( + () => + expectEveryReaderSawTheClear( + daemonPane.host.server, + daemonPane.host.readers, + daemonPane.pane.paneKey + ), + { timeout: 400, interval: 20 } + ) + }) + } + + it('on macOS, under the login wrapper, an agent still in front keeps its row', async () => { + const daemonPane = await launchDaemonCodexPane('pty-daemon-login-live') + processTable.rows = paneProcesses('codex', 'macos-login') + + await runCommandToItsEnd(daemonPane, 'daemon fact') + await vi.waitFor(() => expect(processTable.reads).toBe(1), { timeout: 400, interval: 20 }) + await new Promise((resolve) => setTimeout(resolve, 100)) + + expectNoReaderLostTheRow( + daemonPane.host.server, + daemonPane.host.readers, + daemonPane.pane.paneKey, + 'done' ) }) }) From 0df09a54943196870d09aa7de22b8785641f998b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:35:03 -0700 Subject: [PATCH 18/19] fix(agent-status): narrow the daemon's fence to POSIX before reading its root c37fee53d0c read the fence's root pid without narrowing the fence's platform, which tc:node rejects. --- src/main/providers/shell-foreground-proof.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/providers/shell-foreground-proof.ts b/src/main/providers/shell-foreground-proof.ts index 5d4d131d267a..af17c61e40af 100644 --- a/src/main/providers/shell-foreground-proof.ts +++ b/src/main/providers/shell-foreground-proof.ts @@ -121,8 +121,8 @@ export async function proveDaemonShellForeground(args: { incarnationId: args.incarnationId, requestStartedAtMonotonic }) - if (typeof evidence === 'string') { - return evidence + if (typeof evidence === 'string' || evidence.fence.platform !== 'posix') { + return typeof evidence === 'string' ? evidence : 'other' } return (await args.confirmPaneShellForeground(evidence.fence.shellPid)) ? 'shell' : 'other' } From 05b6c07d6a2aa657b340f1b0a0e537f856182e66 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:42:35 -0700 Subject: [PATCH 19/19] test(agent-status): wait on the daemon pane's verdict, not a wall-clock window The keep-row cases now wait for main's own process-table read, and the clear cases rely on a single inspect to show no re-ask was needed. --- .../command-end-agent-exit-daemon.test.ts | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/main/runtime/command-end-agent-exit-daemon.test.ts b/src/main/runtime/command-end-agent-exit-daemon.test.ts index 9faf0bafeb45..7d171adee9c3 100644 --- a/src/main/runtime/command-end-agent-exit-daemon.test.ts +++ b/src/main/runtime/command-end-agent-exit-daemon.test.ts @@ -27,10 +27,12 @@ const processTable = vi.hoisted( rows: ProcessTableRow[] captures: { rows: ProcessTableRow[]; capturedAgeMs: number }[] reads: number + freshReads: number } => ({ rows: [], captures: [], - reads: 0 + reads: 0, + freshReads: 0 }) ) vi.mock('../../shared/process-table-snapshot-reader', async (importOriginal) => ({ @@ -48,7 +50,10 @@ vi.mock('../../shared/process-table-snapshot-reader', async (importOriginal) => return { rows: processTable.rows, capturedAgeMs: Date.now() - startedAt } }, // Main's own fresh read of this machine's process table. - getFreshShellForegroundSnapshot: async () => processTable.rows + getFreshShellForegroundSnapshot: async () => { + processTable.freshReads += 1 + return processTable.rows + } })) vi.mock('../git/worktree', () => { @@ -156,6 +161,7 @@ afterEach(async () => { processTable.rows = [] processTable.captures = [] processTable.reads = 0 + processTable.freshReads = 0 vi.restoreAllMocks() }) @@ -209,6 +215,13 @@ async function launchDaemonCodexPane( return { host, pane, emit: subprocess.emit } } +/** Waits for main's own process-table read, which decides the verdict, then lets it land. */ +async function settledOnTheFirstAsk(): Promise { + await vi.waitFor(() => expect(processTable.freshReads).toBe(1), { timeout: 2_000, interval: 20 }) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(processTable.reads).toBe(1) +} + /** The same bytes reach the daemon's scanner and main's command-end path. */ async function runCommandToItsEnd(daemonPane: DaemonPane, path: CommandEndPath): Promise { // Codex renders inline in the normal screen buffer: no alternate-screen episode. @@ -232,7 +245,7 @@ describe('a normal-buffer agent on a terminal-daemon pane', () => { daemonPane.host.readers, daemonPane.pane.paneKey ), - { timeout: 400, interval: 20 } + { timeout: 2_000, interval: 20 } ) expect(processTable.reads).toBe(1) }) @@ -242,8 +255,7 @@ describe('a normal-buffer agent on a terminal-daemon pane', () => { processTable.rows = paneProcesses('codex') await runCommandToItsEnd(daemonPane, path) - await vi.waitFor(() => expect(processTable.reads).toBe(1), { timeout: 400, interval: 20 }) - await new Promise((resolve) => setTimeout(resolve, 100)) + await settledOnTheFirstAsk() expectNoReaderLostTheRow( daemonPane.host.server, @@ -282,7 +294,7 @@ describe('a normal-buffer agent on a terminal-daemon pane', () => { daemonPane.host.readers, daemonPane.pane.paneKey ), - { timeout: 400, interval: 20 } + { timeout: 2_000, interval: 20 } ) }) @@ -302,7 +314,7 @@ describe('a normal-buffer agent on a terminal-daemon pane', () => { daemonPane.host.readers, daemonPane.pane.paneKey ), - { timeout: 400, interval: 20 } + { timeout: 2_000, interval: 20 } ) }) } @@ -312,8 +324,7 @@ describe('a normal-buffer agent on a terminal-daemon pane', () => { processTable.rows = paneProcesses('codex', 'macos-login') await runCommandToItsEnd(daemonPane, 'daemon fact') - await vi.waitFor(() => expect(processTable.reads).toBe(1), { timeout: 400, interval: 20 }) - await new Promise((resolve) => setTimeout(resolve, 100)) + await settledOnTheFirstAsk() expectNoReaderLostTheRow( daemonPane.host.server,