diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index b962df41edc5..a4929877d968 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -83,6 +83,41 @@ 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. 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, 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, 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. +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. 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. + ## 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/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/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-row-removal-notifies-readers.test.ts b/src/main/agent-hooks/server-row-removal-notifies-readers.test.ts new file mode 100644 index 000000000000..f3cc7779f486 --- /dev/null +++ b/src/main/agent-hooks/server-row-removal-notifies-readers.test.ts @@ -0,0 +1,245 @@ +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 (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', () => { + 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) + + 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() + } + }) + + it('stays silent for a pane that held no row', async () => { + const server = await startServer() + try { + const seen = recordReaderNotifications(server) + + server.retirePaneAuthority(PANE) + + expect(seen.windowClears).toEqual([]) + expect(seen.subscriberClears).toEqual([]) + } 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 = () => + server.attestCompatibilityAuthority({ + paneKey: PANE, + launchTokenHash: createHash('sha256').update('launch-token').digest('hex'), + connectionId: CONNECTION, + terminalProvenance: 'current_runtime' + }) + expect(attest()).not.toBeNull() + + runtimeTokenHash = null + server.retirePaneAuthority(PANE, undefined, { authorityOnly: true }) + + expect(liveRow(server)?.state).toBe('working') + expect(attest()).toBeNull() + expect(seen.windowClears).toEqual([]) + expect(seen.subscriberClears).toEqual([]) + // 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, 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() + } + }) +}) + +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.reconcileEndedProcessForPaneKeys([PANE], { + preserveResumeIdentity: true, + armedRowReceivedAt: liveRow(server)!.receivedAt + }) + + 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 8cda3426629f..2803f8be3bcb 100644 --- a/src/main/agent-hooks/server/server-authority-evidence.ts +++ b/src/main/agent-hooks/server/server-authority-evidence.ts @@ -8,7 +8,43 @@ 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 record of a 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 + } + + /** 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 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) : 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 || + this.persistedAuthorityCommitmentsByPaneKey.get(ownerPaneKey)?.launchTokenHash === + launchTokenHash || + this.hydratedLaunchTokenHashByPaneKey.get(ownerPaneKey) === launchTokenHash + return live ? event : { ...event, launchToken: undefined } + } + attestCompatibilityAuthority(candidate: { paneKey: string launchTokenHash: string @@ -63,7 +99,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 3bd289d9c551..6be726b38567 100644 --- a/src/main/agent-hooks/server/server-authority-fences.ts +++ b/src/main/agent-hooks/server/server-authority-fences.ts @@ -8,8 +8,20 @@ import type { } from './server-types' export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuthorityAliases { + /** 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, + /** 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) + return + } const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) const previousFence = this.retiredPaneFencesByKey.get(ownerPaneKey) const paneKeys = new Set([paneKey, ownerPaneKey]) @@ -70,6 +82,12 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth 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..d5a0cae4a7a1 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,6 +27,22 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen return { ...resumeIdentity, providerSessionOnly: true, retainedForLiveness: true } } + /** The launch token lives on in the shell's environment after its command ends, so its authority + * 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 + // 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() + } + } + /** 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, @@ -115,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 @@ -131,21 +140,27 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen if (!this.hasLiveClaimsForPaneKey(resolvedPaneKey)) { continue } + 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.providerSessionOnly) + ) { + 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 (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-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts index 74a37c441f29..ad6b4bc4199b 100644 --- a/src/main/agent-hooks/server/server-persistence.ts +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -46,8 +46,13 @@ 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 }, + { requireVoucher: true } + ).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 +60,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..8e850bab6038 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -35,6 +35,8 @@ import type { AgentHookStatusChangeEntry, AgentHookStatusFreshnessObservation, AgentPromptSentDedupeEntry, + EndedAgentSession, + EndedProcessReconcileOptions, EnrichedAgentHookEventPayload, NormalizedLocalHook, PaneKeyAliasEntry, @@ -149,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 @@ -183,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( @@ -267,7 +275,7 @@ export abstract class AgentHookServerState { abstract reconcileEndedProcessForPaneKeys( paneKeys: Iterable, - options?: { preserveResumeIdentity?: boolean; endedPresence?: AgentProcessPresence } + options?: EndedProcessReconcileOptions & { endedPresence?: AgentProcessPresence } ): number protected abstract clearPaneState( @@ -282,6 +290,10 @@ export abstract class AgentHookServerState { protected abstract hydrateLastStatusFromDisk(): void protected abstract captureHydratedAuthorityCommitments(): void protected abstract recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void + protected abstract withLiveLaunchToken( + event: T, + options?: { requireVoucher?: boolean } + ): 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..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 && @@ -104,9 +122,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, @@ -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-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-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 79b4e569cdd6..394c61ac9b5b 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -90,6 +90,19 @@ 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 + * 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/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..05431dd3aef2 --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-shell-proof.test.ts @@ -0,0 +1,95 @@ +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 } +} + +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 335c641da627..77df73ed1095 100644 --- a/src/main/daemon/daemon-pty-process-inspection.ts +++ b/src/main/daemon/daemon-pty-process-inspection.ts @@ -9,6 +9,13 @@ import { } from './types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' import { clientOnlyUnverifiableInspection } from '../../shared/terminal-process-inspection' +import { + proveDaemonShellForeground, + type ShellForegroundProof, + 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. @@ -91,6 +98,38 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot } } + async proveShellForeground( + id: string, + options?: ShellForegroundProofOptions + ): Promise { + if (this.protocolVersion < COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION) { + return 'unprovable' + } + const expectedIncarnationId = options?.expectedIncarnationId + return proveDaemonShellForeground({ + ptyId: id, + incarnationId: expectedIncarnationId ?? null, + platform: process.platform, + 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), + confirmPaneShellForeground: confirmPaneShellForegroundProcess + }) + } + 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..9f7bd9509b4a 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -9,6 +9,10 @@ import type { PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' +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' @@ -204,6 +208,13 @@ export class DaemonPtyRouter implements IPtyProvider { return (await this.adapterFor(id).confirmShellForeground?.(id)) ?? false } + proveShellForeground( + id: string, + options?: ShellForegroundProofOptions + ): 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..5e0d64e533ca 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -6,6 +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, + ShellForegroundProofOptions +} from '../../../providers/shell-foreground-proof' import type { PtyRuntimeControllerDeps } from './controller-deps' import { writeRefused, @@ -161,6 +165,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?: ShellForegroundProofOptions +): 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/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 6c0e9bbad227..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) => - agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), buildAgentHookPtyEnv: () => isAgentStatusHooksEnabled(profileStore.getSettings()) ? agentHookServer.buildPtyEnv() : {}, // Why the dedupe here and not in the instance: `apply` closes and reconstructs 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/local-pty-provider-shell-proof.test.ts b/src/main/providers/local-pty-provider-shell-proof.test.ts new file mode 100644 index 000000000000..843d29bd3c1c --- /dev/null +++ b/src/main/providers/local-pty-provider-shell-proof.test.ts @@ -0,0 +1,28 @@ +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.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-posix')).resolves.toBe('other') + } + ) +}) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 9c2f3c80c9cd..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 { @@ -24,10 +25,12 @@ import { clearPtyState, pendingLocalPtySpawns, ptyProcesses, + ptyShellPath, resetLoadGeneration, type DataCallback, type ExitCallback } from './local-pty-provider-state' +import type { ShellForegroundProof } from './shell-foreground-proof' import { clearLocalPtyBuffer, closeLocalPtyStartupQueryAuthority, @@ -170,6 +173,14 @@ export class LocalPtyProvider implements IPtyProvider { return confirmLocalPtyShellForeground(id) } + async proveShellForeground(id: string): Promise { + // Why: a WSL pane's root is wsl.exe, and the guest's processes are out of this host's reach. + if (pathWin32.basename(ptyShellPath.get(id) ?? '').toLowerCase() === 'wsl.exe') { + 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..209003558713 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, 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' @@ -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?: ShellForegroundProofOptions + ) => 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..4d6afb8a84a4 --- /dev/null +++ b/src/main/providers/shell-foreground-proof.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, vi } 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, notCapturedBefore?: number) { + const requestStartedAtMonotonic = performance.now() + return shellForegroundProofFromInspection( + { + foregroundProcess: null, + hasChildProcesses: false, + ...(evidence ? { foregroundProcessEvidence: evidence } : {}) + }, + { + ptyId: PTY, + incarnationId: INCARNATION, + requestStartedAtMonotonic, + ...(notCapturedBefore !== undefined ? { notCapturedBefore } : {}) + } + ) +} + +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 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' }], + ['evidence past the admission age', { ...live(null, 100), capturedAgeMs: 5_000 }] + ] + 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][] = [ + [ + '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, + paneShellInFront = true, + evidence: RemoteForegroundEvidence = live('codex', 200) + ) => { + const confirmPaneShellForeground = vi.fn(async () => paneShellInFront) + const proof = proveDaemonShellForeground({ + ptyId: PTY, + incarnationId: INCARNATION, + platform, + confirmShellForeground: async () => confirmed, + inspectProcess: async () => ({ + foregroundProcess: null, + hasChildProcesses: false, + 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('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).proof).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 }), + 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 new file mode 100644 index 000000000000..af17c61e40af --- /dev/null +++ b/src/main/providers/shell-foreground-proof.ts @@ -0,0 +1,149 @@ +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' + +/** + * 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 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' | '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' +]) + +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 } +): 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}`) + } + 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) { + 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' + } + 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 && + 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. 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 + platform: NodeJS.Platform + confirmShellForeground: () => Promise + inspectProcess: () => Promise + confirmPaneShellForeground: (rootPid: number) => Promise +}): Promise { + if (await args.confirmShellForeground()) { + return 'shell' + } + if (args.platform === 'win32') { + return 'unprovable' + } + const requestStartedAtMonotonic = performance.now() + const evidence = readLiveEvidence(await args.inspectProcess(), { + ptyId: args.ptyId, + incarnationId: args.incarnationId, + requestStartedAtMonotonic + }) + if (typeof evidence === 'string' || evidence.fence.platform !== 'posix') { + return typeof evidence === 'string' ? evidence : 'other' + } + 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. */ +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.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..59691366f35c 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -2,7 +2,12 @@ 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 { + proveRelayShellForeground, + type ShellForegroundProof, + type ShellForegroundProofOptions +} from './shell-foreground-proof' import { createSshPtyAppliedSizeReader } from './ssh-pty-applied-size' import type { RemoteCliBridgeEnv, @@ -70,6 +75,13 @@ export class SshPtyProvider implements IPtyProvider { id: string, options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean } ): Promise => this.rpcOperations.inspectProcess(id, options) + proveShellForeground = ( + id: string, + 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/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-daemon.test.ts b/src/main/runtime/command-end-agent-exit-daemon.test.ts new file mode 100644 index 000000000000..7d171adee9c3 --- /dev/null +++ b/src/main/runtime/command-end-agent-exit-daemon.test.ts @@ -0,0 +1,336 @@ +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 { proveDaemonShellForeground } from '../providers/shell-foreground-proof' +import { confirmPaneShellForegroundProcess } from '../providers/agent-foreground-process' +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[] + captures: { rows: ProcessTableRow[]; capturedAgeMs: number }[] + reads: number + freshReads: number + } => ({ + rows: [], + captures: [], + reads: 0, + freshReads: 0 + }) +) +vi.mock('../../shared/process-table-snapshot-reader', async (importOriginal) => ({ + ...(await importOriginal()), + // 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 } + }, + // Main's own fresh read of this machine's process table. + getFreshShellForegroundSnapshot: async () => { + processTable.freshReads += 1 + return processTable.rows + } +})) + +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 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. 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 shellPid = root === 'shell' ? ROOT_PID : LOGIN_SHELL_PID + const foregroundPgid = front === 'shell' ? shellPid : AGENT_PID + const login = { + pid: ROOT_PID, + ppid: 1, + 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: root === 'shell' ? '/bin/zsh' : '-zsh' + } + const codex = { + pid: AGENT_PID, + ppid: shellPid, + pgid: AGENT_PID, + tpgid: foregroundPgid, + tty, + startTime: 'codex-birth', + stat: 'S+', + command: 'node /opt/homebrew/bin/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: 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), + 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 = [] + processTable.captures = [] + processTable.reads = 0 + processTable.freshReads = 0 + vi.restoreAllMocks() +}) + +/** An Orca-launched Codex pane whose PTY is a real daemon session. */ +async function launchDaemonCodexPane( + ptyId: string, + platform: NodeJS.Platform = 'linux' +): 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: { + // 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?.expectedIncarnationId + ? { expectedIncarnationId: options.expectedIncarnationId } + : undefined + ), + confirmPaneShellForeground: confirmPaneShellForegroundProcess + }) + } + }) + 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 } +} + +/** 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. + 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) + + // 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: 2_000, interval: 20 } + ) + expect(processTable.reads).toBe(1) + }) + + 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) + await settledOnTheFirstAsk() + + expectNoReaderLostTheRow( + daemonPane.host.server, + daemonPane.host.readers, + daemonPane.pane.paneKey, + 'done' + ) + }) + } + + 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 + ) + }) + + 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') + + await vi.waitFor( + () => + expectEveryReaderSawTheClear( + daemonPane.host.server, + daemonPane.host.readers, + daemonPane.pane.paneKey + ), + { timeout: 2_000, 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: 2_000, 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 settledOnTheFirstAsk() + + 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 new file mode 100644 index 000000000000..fa09f7382c12 --- /dev/null +++ b/src/main/runtime/command-end-agent-exit-verification.test.ts @@ -0,0 +1,478 @@ +import { createHash } from 'node:crypto' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ShellForegroundProof } from '../providers/shell-foreground-proof' +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.shellProof.mockResolvedValue('other') + 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("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') + 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.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.shellProof.mockResolvedValue('shell') + 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.shellProof.mockResolvedValue('shell') + 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.shellProof.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) + }) + + 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)) + ) + 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(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 = () => {} + host.shellProof.mockImplementationOnce( + () => new Promise((resolve) => (answer = resolve)) + ) + const pane = await launchAgentPane(host, 'pty-late-own-hook') + await claudeIsWorking(host, pane) + + await endCommand(host.runtime, pane.ptyId, 'shell bytes') + await new Promise((resolve) => setTimeout(resolve, 2)) + await claudeIsDone(host, pane) + answer('shell') + await settle() + + 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 () => { + const host = await wire() + 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) + + // 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('other') + await settle() + + expect(host.shellProof).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.shellProof).not.toHaveBeenCalled() + }) + + it('still drops launch authority at once: the token lives on in the shell', async () => { + const host = await wire() + 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') + ) + + host.runtime.emitDaemonPtyTransientFact(pane.ptyId, { + kind: 'command-finished', + exitCode: 0 + }) + + expect(host.runtime.readPaneLaunchAuthority(pane.paneKey)).toEqual({ launchTokenHash: null }) + }) +}) + +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') + + 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) + 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() + }) +}) + +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 new file mode 100644 index 000000000000..92e64a29ac38 --- /dev/null +++ b/src/main/runtime/command-end-agent-exit-verifier.ts @@ -0,0 +1,220 @@ +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), 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, 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 +} + +/** + * 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: + * - `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 a row was rewritten mid-check (often the exiting agent's own late hook). */ +const REWRITE_RECHECKS = 2 + +export async function verifyCommandEndAgentExit( + ptyId: string, + paneKeys: Iterable, + notCapturedBefore: number, + deps: Pick +): Promise { + const verdicts = await Promise.all( + Array.from(paneKeys, (paneKey) => deps.checkHookAgentPresence(paneKey).catch(() => null)) + ) + if (verdicts.includes('live')) { + return 'live' + } + let proof: ShellForegroundProof + try { + proof = await deps.proveShellForeground(ptyId, notCapturedBefore) + } catch { + return 'unread' + } + 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 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 { + 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 + running.wake?.() + return + } + this.run(ptyId).catch((error: unknown) => { + console.error('[agent-status] command-end exit check failed', { ptyId, error }) + }) + } + + /** 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: RunState = { rerun: true, cancelled: false, wake: null } + this.running.set(ptyId, state) + try { + while (state.rerun && !state.cancelled) { + state.rerun = false + await this.verifyOnce(ptyId, state) + } + } finally { + this.running.delete(ptyId) + } + } + + 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, verdict) + if (!rewritten || rechecks >= REWRITE_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 + } + } + } + + /** 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 ||= verdict === 'exited' || sameAgentSession(armed, now) + continue + } + try { + 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/command-end-host-wiring.test-fixture.ts b/src/main/runtime/command-end-host-wiring.test-fixture.ts new file mode 100644 index 000000000000..44066fe0fdf1 --- /dev/null +++ b/src/main/runtime/command-end-host-wiring.test-fixture.ts @@ -0,0 +1,228 @@ +import { expect, vi } from 'vitest' +import type { AgentStatusClearIpcPayload } from '../../shared/agent-status-types' +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 { + 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 (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}` + +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 on the pane's own shell; reject for a host that can't be reached. */ + shellProof: ReturnType Promise>> + teardown: () => void +} + +export async function wireCommandEndHost( + 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) { + 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, { + ...agentHookStatusStoreRuntimeDeps(server), + ...agentHookLaunchAuthorityRuntimeDeps(server) + }) + wireRuntimeLaunchAuthorityReader(server, runtime) + 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 shellProof = vi.fn<(ptyId: string) => Promise>(async () => 'shell') + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + proveShellForeground: (ptyId) => shellProof(ptyId), + ...options.controller + }) + return { + server, + runtime, + readers, + spawn, + shellProof, + 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', + incarnationId = `${ptyId}-incarnation` +): Promise<{ ptyId: string; paneKey: string; launchToken: string }> { + host.spawn.mockResolvedValueOnce({ id: ptyId, incarnationId }) + 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 + 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 + const paneKey = `${options.tabId}:${options.leafId}` + const pty = internals.recordPtyWorktree(ptyId, TEST_WORKTREE_ID, { + connected: true, + tabId: options.tabId, + paneKey, + incarnationId: `${ptyId}-incarnation`, + ...(options.connectionId ? { connectionId: options.connectionId } : {}), + ...(options.wslDistro ? { isWsl: true, wslDistro: options.wslDistro } : {}) + }) + 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-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..df5772d2c41a --- /dev/null +++ b/src/main/runtime/launch-token-liveness-after-command-end.test.ts @@ -0,0 +1,262 @@ +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.shellProof.mockResolvedValue('other') + 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 + }) +} + +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 { + runtimeRecords(host).ptysById.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('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') + 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/opencode-finished-session-authority.test.ts b/src/main/runtime/opencode-finished-session-authority.test.ts index ded271c06175..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' @@ -53,18 +58,30 @@ 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 + ? { + ...agentHookStatusStoreRuntimeDeps(server), + ...agentHookLaunchAuthorityRuntimeDeps(server) + } + : { + attestAgentHookCompatibilityAuthority: (candidate) => ({ + paneKey: candidate.paneKey, + source: 'current_hook' as const + }) + } + ) + if (server) { + wireRuntimeLaunchAuthorityReader(server, runtime) + } runtime.setPtyController({ spawn, write: () => true, @@ -91,8 +108,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 +189,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 +246,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 +298,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`, { 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..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) + 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 2e52e6f07483..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,6 +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, + 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. */ @@ -125,7 +130,55 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim } } - protected retirePtyAgentLaunchAuthority(ptyId: string): void { + /** 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) { + return null + } + return { + launchTokenHash: pty.launchToken + ? createHash('sha256').update(pty.launchToken).digest('hex') + : null + } + } + + protected readonly commandEndAgentExit = new CommandEndAgentExitVerifier({ + readLiveRowAnchors: (ptyId) => { + const anchors = new Map() + for (const paneKey of this.collectAgentStatusPaneKeysForPty(ptyId)) { + 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, 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 + * 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) { return @@ -142,7 +195,11 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim pty.launchIncarnationId = null pty.launchAgent = null for (const paneKey of paneKeys) { - this.retireAgentHookCompatibilityAuthorityFn?.(paneKey) + if (ended === 'command') { + this.retireAgentHookCompatibilityAuthorityFn?.(paneKey, { authorityOnly: true }) + } else { + this.retireAgentHookCompatibilityAuthorityFn?.(paneKey) + } } } 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..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) + this.settleAgentAtCommandEnd(ptyId) 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..df0581665638 100644 --- a/src/main/runtime/orca-runtime-on-pty-exit.ts +++ b/src/main/runtime/orca-runtime-on-pty-exit.ts @@ -56,8 +56,9 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte }) this.restoredOrchestrationAuthorityByPtyId.delete(ptyId) } else { - this.retirePtyAgentLaunchAuthority(ptyId) + 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/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index 37f8121f99ae..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, @@ -88,14 +91,16 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin }) => AgentHookAuthorityAttestation | null) | null - protected readonly retireAgentHookCompatibilityAuthorityFn: ((paneKey: string) => void) | null + protected readonly retireAgentHookCompatibilityAuthorityFn: + | ((paneKey: string, options?: { authorityOnly?: boolean }) => void) + | null protected readonly checkHookAgentPresenceFn: | ((paneKey: string) => Promise<'live' | 'unverifiable' | 'exited' | null>) | 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 ca95bf9dc270..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 @@ -81,11 +84,17 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { connectionId: string | null terminalProvenance: 'current_runtime' | 'restored' }) => AgentHookAuthorityAttestation | null - retireAgentHookCompatibilityAuthority?: (paneKey: string) => void + retireAgentHookCompatibilityAuthority?: ( + paneKey: string, + 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/runtime/orca-runtime-structured-status-sink-wiring.test.ts b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts index b931af574632..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 @@ -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)') @@ -77,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 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/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 60c1e18cf10a..18374f3e0f31 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -11,6 +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, + ShellForegroundProofOptions +} from '../providers/shell-foreground-proof' import type { WriteSettlement } from '../../shared/pty-write-settlement' import type { TerminalInputKind } from '../../shared/terminal-input-kind' @@ -122,6 +126,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?: ShellForegroundProofOptions + ): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resetInputModes?(ptyId: string): Promise diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index cfd0fa294020..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) => - agentHookServer.retirePaneAuthority(paneKey), - checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), - reconcileAgentStatusForEndedProcess: (paneKeys) => - agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), 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,6 +154,7 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { }) app.once('will-quit', () => sessionSearch?.dispose()) state.runtime = runtime + wireRuntimeLaunchAuthorityReader(agentHookServer, runtime) agentHookServer.subscribeEnrichedStatus((enriched) => recordObservedAgentStatusPaneIdentity(observedPaneIdentities, enriched.paneKey, runtime) ) 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 70b13e22a41f..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) } @@ -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. 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 b043abb40428..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 () => { @@ -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, { 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 88f199bb3a4e..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 @@ -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 = ( @@ -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) @@ -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) 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