From 5709037f4cab3a3f4ba28131c3b90a707cc8a201 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:47:20 -0700 Subject: [PATCH] Continue a chat after the terminal agent exits (#33759) * fix(native-chat): a chat sends again once the terminal's agent exits on macOS The terminal-writer check required the pane's root process to be a shell, but macOS panes spawn under login(1), which stays as the shell's parent, so the check refused every send until the terminal closed. Census the shell login started instead. * Reconcile current terminal writer evidence before continuation * Share fresh foreground confirmation and prove hosted login census * Restore shell process identity import in foreground tracker * Correct process fixture identity and polling callback contracts * Align decisive Windows inspection fixture expectations * Complete foreground inspection Session fixture --- .github/workflows/macos-updater-tests.yml | 11 +- ...ubprocess-foreground-degraded-scan.test.ts | 22 ++ ...y-subprocess-spawn-file-foreground.test.ts | 5 +- .../foreground-process-tracker.ts | 57 +++-- src/main/daemon/session-subprocess-handle.ts | 2 +- src/main/daemon/session.ts | 6 +- ...terminal-host-non-agent-foreground.test.ts | 1 + .../terminal-host-process-inspection.test.ts | 200 ++++++++++++++++ .../terminal-host-process-inspection.ts | 9 +- .../local-pty-foreground-inspection.ts | 47 ++-- ...al-pty-provider-foreground-process.test.ts | 220 +++++++++++++++++- src/main/providers/local-pty-provider.ts | 4 +- src/main/providers/macos-tcc-login-shell.ts | 2 +- .../providers/pty-foreground-confirmation.ts | 87 +++++++ .../windows-cached-agent-revalidation.ts | 27 +++ .../structured-agent-cli-writers.test.ts | 192 ++++++++++++++- .../runtime/structured-agent-cli-writers.ts | 17 +- 17 files changed, 834 insertions(+), 75 deletions(-) create mode 100644 src/main/providers/pty-foreground-confirmation.ts diff --git a/.github/workflows/macos-updater-tests.yml b/.github/workflows/macos-updater-tests.yml index 9ef1180300d..ed7d1595a73 100644 --- a/.github/workflows/macos-updater-tests.yml +++ b/.github/workflows/macos-updater-tests.yml @@ -15,6 +15,10 @@ on: - 'src/main/window/main-window-state-lifecycle*' - 'src/main/window/dashboard-popout-window*' - 'src/packages/process-host/**' + - 'src/main/runtime/structured-agent-cli-writers*' + - 'src/main/providers/macos-tcc-login-shell*' + - 'src/shared/process-table-snapshot*' + - 'src/shared/darwin-process-table-capture*' - 'src/shared/update-status-types.ts' - 'pnpm-lock.yaml' workflow_dispatch: @@ -29,7 +33,7 @@ concurrency: jobs: updater: if: github.event_name != 'pull_request' || github.event.pull_request.draft != true - runs-on: blacksmith-6vcpu-macos-15 + runs-on: macos-15 timeout-minutes: 20 steps: - uses: actions/checkout@v6 @@ -55,3 +59,8 @@ jobs: src/main/startup/main-process-quit-update-veto.test.ts src/main/window/main-window-state-lifecycle.test.ts src/main/window/dashboard-popout-window.test.ts + - name: Prove login-backed terminal writer census on the disposable runner + env: + ORCA_MACOS_LOGIN_CENSUS_CI: '1' + ORCA_BACKGROUND_LAUNCH: '1' + run: pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/structured-agent-cli-writers.test.ts diff --git a/src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts b/src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts index cee9a07f157..22a02dfa9c1 100644 --- a/src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts +++ b/src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts @@ -123,6 +123,28 @@ describe('daemon pty foreground degraded-scan handling', () => { return { proc, handle } } + it('keeps raw confirmation separate from a known detached writer decision', async () => { + resolveAgentForegroundProcessMock + .mockResolvedValueOnce({ available: true, processName: 'claude', processId: 999 }) + .mockResolvedValue({ available: true, processName: 'powershell.exe' }) + readConptyMock.mockReturnValue(new Set([12345, 999])) + const { handle } = await spawnWindowsShell() + await readForegroundAt(handle, 0) + if (!handle.confirmForegroundProcess) { + throw new Error('fixture confirmer missing') + } + vi.setSystemTime(BASE_TIME_MS + 40_000) + resolveAgentForegroundProcessMock.mockClear() + expect(await handle.confirmForegroundProcess({ decision: true })).toBe('claude') + expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(1) + expect(await handle.confirmForegroundProcess()).toBe('powershell.exe') + expect(resolveAgentForegroundProcessMock).toHaveBeenLastCalledWith( + 12345, + 'powershell.exe', + expect.not.objectContaining({ anchorProcessId: 999 }) + ) + }) + it('keeps a cached agent across a degraded (timed-out) scan', async () => { resolveAgentForegroundProcessMock .mockResolvedValueOnce({ available: true, processName: 'claude' }) diff --git a/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts b/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts index 35f330078cc..98966e11123 100644 --- a/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts +++ b/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts @@ -267,7 +267,7 @@ it('uses Windows job membership and the native process table for ordinary childr const handle = createHandle() expect(await handle.confirmForegroundProcess?.()).toBe('vim.exe') expect(await inspect(handle)).toMatchObject({ - foregroundProcess: 'vim.exe', + foregroundProcess: 'zsh', hasChildProcesses: true }) expect(readStrict).not.toHaveBeenCalled() @@ -277,8 +277,9 @@ it('keeps missing Windows job membership unverifiable', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') resolveWindows.mockResolvedValue({ available: true, processName: null }) members.mockReturnValue(null) + expect(await createHandle().confirmForegroundProcess?.()).toBeNull() expect(await inspect(createHandle())).toMatchObject({ - foregroundProcess: null, + foregroundProcess: 'zsh', hasChildProcesses: true, childProcessEvidence: 'unverifiable' }) diff --git a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts index f6d3bdf0987..c39e6a9eecb 100644 --- a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts +++ b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts @@ -1,5 +1,6 @@ import type * as pty from 'node-pty' import { ptyShellProcessId } from '../../windows/windows-pty-job' +import * as foreground from '../../providers/pty-foreground-confirmation' import { getAgentForegroundContextPaths } from '../../providers/agent-foreground-context-paths' import { confirmPtyShellForeground } from './pty-shell-foreground-confirmation' import { @@ -11,7 +12,6 @@ import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership' -import { readWindowsConsoleAttachedProcessIds } from '../../providers/windows-console-attached-processes' import { isAgentForegroundWrapperProcess, recognizeAgentProcess, @@ -43,7 +43,7 @@ export type PtyForegroundProcessTracker = { markDead(): void /** rawFallback bypasses cached identity and process-table refreshes for cheap polling. */ getForegroundProcess(options?: { rawFallback?: boolean; cachedOnly?: boolean }): string | null - confirmForegroundProcess(): Promise + confirmForegroundProcess(context?: { decision?: boolean }): Promise confirmShellForeground(): Promise } @@ -266,45 +266,44 @@ export function createPtyForegroundProcessTracker(args: { return null } }, - confirmForegroundProcess: async () => { + confirmForegroundProcess: async (context) => { if (args.isDead() || !proc.pid) { return null } + const decision = process.platform === 'win32' && context?.decision === true + const anchor = cachedAgentForeground try { const fallbackProcess = getFallbackProcess() - const fallbackRecognition = recognizeAgentProcess(fallbackProcess) - if ( - !fallbackProcess || - (fallbackRecognition !== null && - process.platform !== 'win32' && - !shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) || - (process.platform !== 'win32' && !shouldInspectFallback(fallbackProcess)) - ) { + if (!foreground.shouldConfirmPtyForeground(fallbackProcess, decision)) { return fallbackProcess } - const resolution = await resolveForeground(proc.pid, fallbackProcess, { + const { resolution, processName: name } = await foreground.confirmFreshPtyForeground({ + process: proc, + fallbackProcess, contextPaths, - fresh: true, - ...(process.platform === 'win32' - ? { - forceProcessScan: true, - readWindowsConsoleAttachedProcessIds: () => - readWindowsConsoleAttachedProcessIds(proc.pid) - } - : {}) + decision, + anchor, + anchorAt: anchor?.refreshedAt }) - if (args.isDead() || !resolution.available) { + if (args.isDead()) { return null } - const processName = - recognizeAgentProcess(resolution.processName)?.processName ?? resolution.processName - if (shouldCachePtyForeground(processName, staticName)) { - cacheAgentForeground(processName, resolution.processId ?? null) - return processName + if (!resolution.available && !decision) { + return null } - cachedAgentForeground = null - startupAgentForeground = null - return resolution.processName + const recognized = recognizeAgentProcess(resolution.processName) + const processName = decision ? name : (recognized?.processName ?? resolution.processName) + const cacheName = decision ? (recognized?.processName ?? null) : processName + if ( + resolution.available && + shouldCachePtyForeground(cacheName, decision ? false : staticName) + ) { + cacheAgentForeground(cacheName, resolution.processId ?? null) + } else if (!decision || processName !== anchor?.processName) { + cachedAgentForeground = null + startupAgentForeground = null + } + return processName } catch { return null } diff --git a/src/main/daemon/session-subprocess-handle.ts b/src/main/daemon/session-subprocess-handle.ts index 2ebe6b969eb..a99d23e7583 100644 --- a/src/main/daemon/session-subprocess-handle.ts +++ b/src/main/daemon/session-subprocess-handle.ts @@ -14,7 +14,7 @@ export type SubprocessHandle = { /** cachedOnly must neither query the process nor schedule inspection. */ getForegroundProcess(options?: { rawFallback?: boolean; cachedOnly?: boolean }): string | null /** Await process-table evidence captured after this confirmation request. */ - confirmForegroundProcess?(): Promise + confirmForegroundProcess?(context?: { decision?: boolean }): Promise /** Proves a fresh post-boundary PTY process tree contains only the shell. */ confirmShellForeground?(): Promise /** Skip stdin command delivery when shell arguments already delivered it. */ diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 0a4f4d495ea..a4428e4228d 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -243,8 +243,10 @@ export class Session { return this.subprocess.getForegroundProcess(options) } - async confirmForegroundProcess(): Promise { - return this.subprocess.confirmForegroundProcess?.() ?? this.subprocess.getForegroundProcess() + async confirmForegroundProcess(context?: { decision?: boolean }): Promise { + return ( + this.subprocess.confirmForegroundProcess?.(context) ?? this.subprocess.getForegroundProcess() + ) } confirmShellForeground(): Promise { diff --git a/src/main/daemon/terminal-host-non-agent-foreground.test.ts b/src/main/daemon/terminal-host-non-agent-foreground.test.ts index 434622ecc08..02cd30af700 100644 --- a/src/main/daemon/terminal-host-non-agent-foreground.test.ts +++ b/src/main/daemon/terminal-host-non-agent-foreground.test.ts @@ -47,6 +47,7 @@ async function inspect(rawName: string, command: string | null) { pid: 100, incarnationId: 'incarnation-1', isAlive: true, + confirmForegroundProcess: async () => rawName, inspectChildProcesses: () => 'unverifiable' as const, getForegroundProcess: () => rawName } as unknown as Session, diff --git a/src/main/daemon/terminal-host-process-inspection.test.ts b/src/main/daemon/terminal-host-process-inspection.test.ts index fd7000eb57c..f12679d84fe 100644 --- a/src/main/daemon/terminal-host-process-inspection.test.ts +++ b/src/main/daemon/terminal-host-process-inspection.test.ts @@ -3,6 +3,35 @@ import { describe, expect, it, vi } from 'vitest' import type { SubprocessHandle } from './session-subprocess-handle' import { TerminalHost } from './terminal-host' +import type { IPty } from 'node-pty' +import type { WindowsProcessRow } from '../windows/windows-process-table' +import { createPtyForegroundProcessTracker } from './pty-subprocess/foreground-process-tracker' +import { inspectSpawnFileWindowsChildProcesses } from './pty-subprocess/spawn-file-child-processes' +import { inspectionShowsShellAlone } from '../runtime/run-terminal-shell-alone' +import { + assertCliConversationWritersExited, + type CliWriterHost +} from '../runtime/structured-agent-cli-writers' +const { fullTable, identityTable, jobMembers, consoleMembers } = vi.hoisted(() => ({ + fullTable: vi.fn(), + identityTable: vi.fn(), + jobMembers: vi.fn(), + consoleMembers: vi.fn() +})) +vi.mock('../windows/windows-process-table', () => ({ + readWindowsProcessTable: fullTable, + readWindowsProcessTableFresh: fullTable, + readWindowsProcessIdentityTable: identityTable, + readWindowsProcessIdentityTableFresh: identityTable +})) +vi.mock('../providers/windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: jobMembers, + isWindowsPtyJobReadable: () => true +})) +vi.mock('../providers/windows-console-attached-processes', () => ({ + readWindowsConsoleAttachedProcessIds: consoleMembers +})) + function createSubprocess(): SubprocessHandle { let onExit: ((code: number) => void) | null = null return { @@ -71,6 +100,177 @@ describe('TerminalHost process inspection', () => { } }) + it.each([false, true])( + 'uses fresh anchored decisions through Session (static=%s)', + async (staticName) => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.useFakeTimers({ toFake: ['Date'] }) + const nativeRow = ( + pid: number, + ppid: number, + name: string, + command = name + ): WindowsProcessRow => ({ pid, ppid, name, command }) + const shell = nativeRow(100, 1, 'powershell.exe') + const orphan = nativeRow(120, 999, 'claude.exe', 'claude.exe --resume conversation') + const daemon = nativeRow(130, 1, 'ssh-agent.exe', 'ssh-agent.exe -D') + const cases: [string, boolean, WindowsProcessRow[], boolean, string?][] = [ + ['D1', false, [shell, daemon], true], + ['known young', true, [shell, orphan], false], + ['known old', true, [shell, orphan], false, 'old'], + ['foreign anchor', true, [shell, { ...daemon, pid: 120 }], true], + ['anchor absent', true, [shell, daemon], true], + ['anchor exited', true, [shell], true], + ['degraded incomplete', false, [shell, daemon], false, 'both'], + ['Session exits during foreground read', false, [shell, daemon], false, 'exit'] + ] + try { + for (const [name, anchored, rows, admits, variant] of cases) { + vi.setSystemTime(1000) + let dead = false + const base = createSubprocess() + const proc: IPty = { + pid: 100, + cols: 80, + rows: 24, + process: 'powershell.exe', + handleFlowControl: false, + onData: () => ({ dispose() {} }), + onExit: () => ({ dispose() {} }), + resize: vi.fn(), + clear: vi.fn(), + write: vi.fn(), + kill: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + } + Object.assign(proc, { processNameIsSpawnFile: staticName }) + const tracker = createPtyForegroundProcessTracker({ + process: proc, + shellPath: 'powershell.exe', + sessionId: 'decision', + startupAgentRecognition: null, + isDead: () => dead + }) + const getForegroundProcess = vi.fn(tracker.getForegroundProcess) + const host = new TerminalHost({ + spawnSubprocess: () => ({ + ...base, + pid: 100, + processNameIsSpawnFile: staticName, + getForegroundProcess, + confirmForegroundProcess: tracker.confirmForegroundProcess, + inspectChildProcesses: (fresh, ignoreDetached) => + inspectSpawnFileWindowsChildProcesses(proc, shell.name, fresh, ignoreDetached), + onExit: (callback) => + base.onExit((code) => { + dead = true + tracker.markDead() + callback(code) + }) + }) + }) + try { + const created = await host.createOrAttach({ + sessionId: 'decision', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + fullTable + .mockReset() + .mockResolvedValue([ + shell, + { ...orphan, ppid: 100 }, + ...(anchored ? [] : [nativeRow(121, 100, 'claude.exe')]) + ]) + consoleMembers.mockResolvedValue(new Set([100, 120, 121])) + expect(await host.confirmForegroundProcess('decision')).toBe('claude') + vi.setSystemTime(variant === 'old' ? 41_000 : 2000) + fullTable.mockReset().mockImplementation(async () => { + if (variant === 'both') { + throw new Error('denied') + } + if (variant === 'exit') { + base.kill() + } + return rows + }) + identityTable.mockReset().mockResolvedValue(variant === 'both' ? [shell] : rows) + jobMembers.mockReturnValue(new Set(rows.map((row) => row.pid))) + consoleMembers.mockResolvedValue(new Set([100])) + getForegroundProcess.mockClear() + const terminal = () => ({ + pane: { + ptyId: 'decision', + incarnationId: created.incarnationId, + identity: 'decision' + } + }) + let alone: boolean | undefined + const writer: CliWriterHost = { + rows: () => [], + saved: () => [], + observation: () => null, + sleeping: () => false, + terminal, + links: () => [ + { + agent: 'claude', + id: 'conversation', + paneOwned: true, + worktreeId: 'workspace', + terminal + } + ], + shellAlone: async () => { + const inspected = await host.inspectProcess('decision', { + expectedIncarnationId: created.incarnationId, + scanChildProcesses: true, + ignoreDetached: true + }) + alone = inspectionShowsShellAlone(inspected) + return alone + } + } + const run = () => assertCliConversationWritersExited(writer, 'claude', 'conversation') + const result = expect(run()) + await (admits + ? result.resolves.toBeUndefined() + : result.rejects.toMatchObject({ refusal: { code: 'agent_session_conflict' } })) + expect(alone, name).toBe(admits) + expect(fullTable, name).toHaveBeenCalledTimes(1) + expect(getForegroundProcess, name).not.toHaveBeenCalled() + if (name === 'D1') { + for (const count of [2, 20]) { + fullTable.mockClear() + identityTable.mockClear() + for (let i = 0; i < count; i++) { + await run() + } + expect(fullTable).toHaveBeenCalledTimes(count) + expect(identityTable).toHaveBeenCalledTimes(count) + } + expect( + inspectionShowsShellAlone( + await host.inspectProcess('decision', { ignoreDetached: false }) + ) + ).toBe(false) + } + } finally { + await host.dispose() + } + } + } finally { + vi.useRealTimers() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + } + ) + it('returns unverifiable when the expected incarnation is stale', async () => { const host = new TerminalHost({ spawnSubprocess: () => createSubprocess() }) try { diff --git a/src/main/daemon/terminal-host-process-inspection.ts b/src/main/daemon/terminal-host-process-inspection.ts index 41f52d20e0a..9aa786c6afd 100644 --- a/src/main/daemon/terminal-host-process-inspection.ts +++ b/src/main/daemon/terminal-host-process-inspection.ts @@ -80,7 +80,8 @@ export async function inspectTerminalHostProcess(args: { } args.onTier?.('full') - let foregroundProcess = session.getForegroundProcess() + const windowsDecision = process.platform === 'win32' && args.steadyState !== true + let foregroundProcess = windowsDecision ? null : session.getForegroundProcess() let childProcessEvidence: PtyChildProcessVerdict | undefined = session.processNameIsSpawnFile ? 'unverifiable' : undefined @@ -88,8 +89,10 @@ export async function inspectTerminalHostProcess(args: { if (!incarnationMatches) { evidence = unverifiableEvidence(args, session, 'incarnation_mismatch') } else if (process.platform === 'win32') { - if (session.processNameIsSpawnFile) { - foregroundProcess = await session.confirmForegroundProcess() + if (windowsDecision || session.processNameIsSpawnFile) { + foregroundProcess = await session.confirmForegroundProcess( + windowsDecision ? { decision: true } : undefined + ) } // A steady-state poll never decides adoption or close, so it may read the cached process table. childProcessEvidence = await session.inspectChildProcesses( diff --git a/src/main/providers/local-pty-foreground-inspection.ts b/src/main/providers/local-pty-foreground-inspection.ts index cf1ec9bc172..d7f378e27df 100644 --- a/src/main/providers/local-pty-foreground-inspection.ts +++ b/src/main/providers/local-pty-foreground-inspection.ts @@ -6,6 +6,7 @@ import { getStrictProcessTableSnapshotWithAge } from '../../shared/process-table-snapshot-reader' import { confirmShellForegroundProcess } from './agent-foreground-process' +import { confirmFreshPtyForeground } from './pty-foreground-confirmation' import { createPtyForegroundResolver, ptyProcessNameIsSpawnFile @@ -30,7 +31,6 @@ import { canRevalidateCachedAgentWithoutScan, judgeCachedAgentJobEvidence } from './windows-cached-agent-revalidation' -import { readWindowsConsoleAttachedProcessIds } from './windows-console-attached-processes' import { isWindowsPtyJobReadable, readWindowsPtyJobProcessIds } from './windows-pty-job-membership' /** Retired masters still report the spawn name; inspect the descriptor first so unreadability cannot prove idle. */ @@ -241,34 +241,47 @@ async function readPosixSteadyState( } } -export async function confirmLocalPtyForegroundProcess(id: string): Promise { +export async function confirmLocalPtyForegroundProcess( + id: string, + decision = false +): Promise { const proc = ptyProcesses.get(id) if (!proc) { return null } try { - const resolution = await createPtyForegroundResolver(proc)( - proc.pid, - resolveForegroundFallbackProcess( + const windowsDecision = decision && process.platform === 'win32' + const cached = ptyLastRecognizedForeground.get(id) + const anchor = cached ? { processName: cached.name, pid: cached.pid } : null + const { resolution, processName } = await confirmFreshPtyForeground({ + process: proc, + fallbackProcess: resolveForegroundFallbackProcess( ptyProcessNameIsSpawnFile(proc) ? (getPtyShellName(id) ?? null) : proc.process || null, getPtyShellName(id) ), - { - contextPaths: ptyAgentForegroundContextPaths.get(id), - fresh: true, - ...(process.platform === 'win32' - ? { - forceProcessScan: true, - readWindowsConsoleAttachedProcessIds: () => - readWindowsConsoleAttachedProcessIds(proc.pid) - } - : {}) - } - ) + contextPaths: ptyAgentForegroundContextPaths.get(id), + decision: windowsDecision, + anchor, + anchorAt: cached?.at + }) // Why: a fresh scan can outlive this PTY id; never publish identity from an exited or same-id-reusing session. if (ptyProcesses.get(id) !== proc) { return null } + if (windowsDecision) { + const recognized = recognizeAgentProcessFromCommandLine(resolution.processName ?? '') + if (resolution.available && recognized) { + ptyLastRecognizedForeground.set(id, { + name: recognized.processName, + pid: resolution.processId ?? null, + at: Date.now(), + steady: null + }) + } else if (processName !== anchor?.processName) { + ptyLastRecognizedForeground.delete(id) + } + return processName + } return resolution.available ? resolution.processName : null } catch { return null diff --git a/src/main/providers/local-pty-provider-foreground-process.test.ts b/src/main/providers/local-pty-provider-foreground-process.test.ts index 7374356215a..67155991f00 100644 --- a/src/main/providers/local-pty-provider-foreground-process.test.ts +++ b/src/main/providers/local-pty-provider-foreground-process.test.ts @@ -1,7 +1,22 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { assert, beforeEach, describe, expect, it, vi } from 'vitest' import type * as MacosTccLoginShell from './macos-tcc-login-shell' +import type * as ForegroundProcess from './agent-foreground-process' +import type { WindowsProcessRow } from '../windows/windows-process-table' +import { + ptyLastRecognizedForeground, + ptyProcesses, + ptyIncarnations +} from './local-pty-provider-state' +import { inspectionShowsShellAlone } from '../runtime/run-terminal-shell-alone' +import { + assertCliConversationWritersExited, + type CliWriterHost +} from '../runtime/structured-agent-cli-writers' const { + fullTable, + identityTable, + consoleMembers, existsSyncMock, statSyncMock, accessSyncMock, @@ -16,6 +31,9 @@ const { wslUncDirectoryExistsAsyncMock, createShellPromptReadinessProbeMock } = vi.hoisted(() => ({ + fullTable: vi.fn(), + identityTable: vi.fn(), + consoleMembers: vi.fn(), existsSyncMock: vi.fn(), statSyncMock: vi.fn(), accessSyncMock: vi.fn(), @@ -76,7 +94,8 @@ vi.mock('./windows-powershell-executable', () => ({ getWindowsCmdPath: () => CMD_ABS })) -vi.mock('./agent-foreground-process', () => ({ +vi.mock('./agent-foreground-process', async (importOriginal) => ({ + ...(await importOriginal()), resolveAgentForegroundProcessWithAvailability: (...args: unknown[]) => resolveAgentForegroundProcessMock(...args) })) @@ -86,6 +105,16 @@ vi.mock('./windows-pty-job-membership', () => ({ isWindowsPtyJobReadable: () => true })) +vi.mock('../windows/windows-process-table', () => ({ + readWindowsProcessTable: fullTable, + readWindowsProcessTableFresh: fullTable, + readWindowsProcessIdentityTable: identityTable, + readWindowsProcessIdentityTableFresh: identityTable +})) +vi.mock('./windows-console-attached-processes', () => ({ + readWindowsConsoleAttachedProcessIds: consoleMembers +})) + vi.mock('../wsl', () => ({ parseWslPath: (path: string) => { const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/) @@ -187,6 +216,193 @@ describe('LocalPtyProvider', () => { }) }) + it.each([false, true])( + 'reconciles real Windows decision evidence (static=%s)', + async (staticName) => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const actual = await vi.importActual('./agent-foreground-process') + resolveAgentForegroundProcessMock.mockImplementation( + actual.resolveAgentForegroundProcessWithAvailability + ) + mockProc.pid = 100 + mockProc.process = 'powershell.exe' + Object.assign(mockProc, { processNameIsSpawnFile: staticName }) + const { id, incarnationId } = await provider.spawn({ + cols: 80, + rows: 24, + shellOverride: 'powershell.exe' + }) + assert(incarnationId, 'fixture incarnation missing') + const nativeRow = ( + pid: number, + ppid: number, + name: string, + command = name + ): WindowsProcessRow => ({ pid, ppid, name, command, creationTimeMs: pid }) + const shell = nativeRow(100, 1, 'powershell.exe') + const orphan = nativeRow(120, 999, 'claude.exe', 'claude.exe --resume conversation') + const daemon = nativeRow(130, 1, 'ssh-agent.exe', 'ssh-agent.exe -D') + type EvidenceOptions = { + age?: number + detailed?: WindowsProcessRow[] + identities?: WindowsProcessRow[] + scanFailed?: boolean + jobFailed?: boolean + consoleFailed?: boolean + childFailed?: boolean + } + const cases: [string, number | null, WindowsProcessRow[], boolean, EvidenceOptions?][] = [ + ['C1', null, [shell, daemon], true], + ['live young anchor', 120, [shell, orphan], false], + ['live old anchor', 120, [shell, orphan], false, { age: 40_000 }], + ['anchor identity missing', 120, [shell, orphan], false, { detailed: [shell] }], + ['anchor query denied', 120, [shell, nativeRow(120, 999, 'unknown.exe', '')], false], + ['anchored scan failed', 120, [shell, orphan], false, { scanFailed: true }], + ['job unavailable', 120, [shell, orphan], false, { jobFailed: true }], + ['anchor absent', 120, [shell, daemon], true], + ['anchor just exited', 120, [shell], true], + [ + 'foreign anchor', + 120, + [shell, nativeRow(120, 999, 'ssh-agent.exe', 'ssh-agent.exe -D')], + true + ], + ['attached agent', null, [shell, { ...orphan, ppid: 100 }], false], + ['ordinary background job', null, [shell, nativeRow(130, 100, 'sleep.exe')], false], + ['incomplete identity', null, [shell, daemon], false, { identities: [shell] }], + ['console unavailable', null, [shell, daemon], false, { consoleFailed: true }], + ['child read failed', null, [shell, daemon], false, { childFailed: true }], + ['unanchored degraded complete', null, [shell, daemon], true, { scanFailed: true }], + [ + 'unanchored degraded incomplete', + null, + [shell, daemon], + false, + { scanFailed: true, identities: [shell] } + ] + ] + vi.useFakeTimers({ toFake: ['Date'] }) + try { + const seed = (pid: number | null) => + ptyLastRecognizedForeground.set(id, { name: 'claude', pid, at: 1000, steady: null }) + for (const [name, anchor, rows, admits, input = {}] of cases) { + vi.setSystemTime(1000 + (input.age ?? 1000)) + seed(anchor) + readWindowsPtyJobProcessIdsMock.mockReturnValue( + input.jobFailed ? null : new Set(rows.map((row) => row.pid)) + ) + fullTable.mockReset().mockResolvedValue(input.detailed ?? rows) + if (input.scanFailed) { + fullTable.mockRejectedValue(new Error('denied')) + } + identityTable.mockReset().mockResolvedValue(input.identities ?? rows) + if (input.childFailed) { + identityTable.mockRejectedValue(new Error('denied')) + } + consoleMembers.mockResolvedValue(input.consoleFailed ? null : new Set([100])) + let alone: boolean | undefined + const host: CliWriterHost = { + rows: () => [ + { + paneKey: 'source', + connectionId: null, + worktreeId: 'workspace', + agentType: 'claude', + state: 'done', + prompt: '', + receivedAt: 1, + stateStartedAt: 1, + providerSession: { key: 'session_id', id: 'conversation' } + } + ], + saved: () => [], + links: () => [], + sleeping: () => false, + observation: () => ({ + current: () => true, + topology: 'pane-owned', + presence: async () => null + }), + terminal: () => ({ pane: { ptyId: id, incarnationId, identity: id + incarnationId } }), + shellAlone: async () => { + const observed = await provider.inspectProcess(id, { + expectedIncarnationId: incarnationId, + scanChildProcesses: true, + ignoreDetached: true + }) + alone = inspectionShowsShellAlone(observed) + return alone + } + } + const result = expect(assertCliConversationWritersExited(host, 'claude', 'conversation')) + await (admits + ? result.resolves.toBeUndefined() + : result.rejects.toMatchObject({ refusal: { code: 'agent_session_conflict' } })) + expect(alone, name).toBe(admits) + expect(fullTable, name).toHaveBeenCalledTimes(1) + expect(identityTable.mock.calls.length, name).toBe( + input.jobFailed || rows.length === 1 ? 0 : 1 + ) + } + fullTable.mockReset().mockResolvedValue([shell, daemon]) + identityTable.mockReset().mockResolvedValue([shell, daemon]) + consoleMembers.mockResolvedValue(new Set([100])) + readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([100, 130])) + for (const count of [2, 20]) { + fullTable.mockClear() + identityTable.mockClear() + for (let i = 0; i < count; i++) { + seed(null) + expect( + inspectionShowsShellAlone(await provider.inspectProcess(id, { ignoreDetached: true })) + ).toBe(true) + } + expect(fullTable).toHaveBeenCalledTimes(count) + expect(identityTable).toHaveBeenCalledTimes(count) + } + seed(null) + fullTable.mockClear() + expect(await provider.getForegroundProcess(id)).toBe('claude') + await expect( + provider.inspectProcess(id, { steadyState: true, ignoreDetached: true }) + ).resolves.toMatchObject({ foregroundProcess: 'claude' }) + expect(fullTable).not.toHaveBeenCalled() + expect( + inspectionShowsShellAlone(await provider.inspectProcess(id, { ignoreDetached: false })) + ).toBe(false) + seed(120) + fullTable.mockResolvedValue([shell, orphan]) + identityTable.mockResolvedValue([shell, orphan]) + readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([100, 120])) + expect(await provider.confirmForegroundProcess(id)).not.toBe('claude') + for (const replacement of ['object', 'incarnation']) { + const original = ptyProcesses.get(id) + if (!original) { + throw new Error('fixture PTY missing') + } + fullTable.mockImplementationOnce(async () => { + if (replacement === 'object') { + ptyProcesses.set(id, { ...original }) + } else { + ptyIncarnations.set(id, 'replacement') + } + return [shell] + }) + await expect( + provider.inspectProcess(id, { + expectedIncarnationId: incarnationId, + ignoreDetached: true + }) + ).resolves.toMatchObject({ childProcessEvidence: 'unverifiable' }) + ptyProcesses.set(id, original) + ptyIncarnations.set(id, incarnationId) + } + } finally { + vi.useRealTimers() + } + } + ) + describe('getForegroundProcess', () => { it('returns the process name', async () => { const { id } = await provider.spawn({ cols: 80, rows: 24 }) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 9e098e68e62..e8b6c27ec88 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -157,7 +157,9 @@ export class LocalPtyProvider implements IPtyProvider { childProcessEvidence: 'unverifiable' } } - const foregroundProcess = await getLocalPtyForegroundProcess(id) + const foregroundProcess = await (process.platform === 'win32' && options?.steadyState !== true + ? confirmLocalPtyForegroundProcess(id, true) + : getLocalPtyForegroundProcess(id)) // A steady-state poll never decides adoption or close, so it may read the cached process table. const childProcessEvidence = await inspectLocalPtyChildProcesses( id, diff --git a/src/main/providers/macos-tcc-login-shell.ts b/src/main/providers/macos-tcc-login-shell.ts index 3546eb0b600..db37b75358a 100644 --- a/src/main/providers/macos-tcc-login-shell.ts +++ b/src/main/providers/macos-tcc-login-shell.ts @@ -9,7 +9,7 @@ import { export type { LoginPreflightOutcome } from './macos-login-session-pty-probe' -const MACOS_LOGIN_PATH = '/usr/bin/login' +export const MACOS_LOGIN_PATH = '/usr/bin/login' const MACOS_BASH_PATH = '/bin/bash' const MACOS_PRINTF_PATH = '/usr/bin/printf' const LOGIN_SHELL_TRAMPOLINE = 'export SHELL="$1"; shift; exec -l -- "$@"' diff --git a/src/main/providers/pty-foreground-confirmation.ts b/src/main/providers/pty-foreground-confirmation.ts new file mode 100644 index 00000000000..fb07622c5db --- /dev/null +++ b/src/main/providers/pty-foreground-confirmation.ts @@ -0,0 +1,87 @@ +import type { IPty } from 'node-pty' +import { recognizeAgentProcess } from '../../shared/agent-process-recognition' +import { shouldInspectOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent' +import { ptyShellProcessId } from '../windows/windows-pty-job' +import { createPtyForegroundResolver } from '../daemon/pty-subprocess/spawn-file-foreground-process' +import { + resolveAgentForegroundProcessWithAvailability, + type AgentForegroundResolutionOptions, + type AgentForegroundProcessResolution +} from './agent-foreground-process' +import { + judgeCachedAgentJobEvidence, + reconcileWindowsAgentDecision, + type WindowsCachedAgentJobVerdict +} from './windows-cached-agent-revalidation' +import { readWindowsConsoleAttachedProcessIds } from './windows-console-attached-processes' +import { isWindowsPtyJobReadable, readWindowsPtyJobProcessIds } from './windows-pty-job-membership' + +/** Execute fresh confirmation; owners retain cache publication and lifetime fences. */ +export async function confirmFreshPtyForeground(args: { + process: IPty + fallbackProcess: string | null + contextPaths: AgentForegroundResolutionOptions['contextPaths'] + decision: boolean + anchor: { processName: string; pid: number | null } | null + anchorAt?: number +}): Promise<{ resolution: AgentForegroundProcessResolution; processName: string | null }> { + const proc = args.process + const shellPid = ptyShellProcessId(proc) ?? proc.pid + let verdict: WindowsCachedAgentJobVerdict = 'unavailable' + if (args.decision) { + try { + verdict = judgeCachedAgentJobEvidence({ + jobProcessIds: readWindowsPtyJobProcessIds(proc), + jobSupported: isWindowsPtyJobReadable(), + shellPid, + anchorProcessId: args.anchor?.pid ?? null, + identityAgeMs: Date.now() - (args.anchorAt ?? 0) + }) + } catch { + /* A failed job read cannot release a known anchor. */ + } + } + let resolution: AgentForegroundProcessResolution + try { + const resolve = args.decision + ? resolveAgentForegroundProcessWithAvailability + : createPtyForegroundResolver(proc) + resolution = await resolve(proc.pid, args.fallbackProcess, { + contextPaths: args.contextPaths, + fresh: true, + ...(args.decision && args.anchor?.pid != null + ? { anchorProcessId: args.anchor.pid, anchorProcessName: args.anchor.processName } + : {}), + ...(process.platform === 'win32' + ? { + forceProcessScan: true, + readWindowsConsoleAttachedProcessIds: () => + readWindowsConsoleAttachedProcessIds(proc.pid) + } + : {}) + }) + } catch { + resolution = { available: false, processName: null } + } + return { + resolution, + processName: args.decision + ? reconcileWindowsAgentDecision(resolution, verdict, args.anchor, shellPid) + : resolution.available + ? resolution.processName + : null + } +} + +/** Known direct agent names need no POSIX scan; Windows decisions still require fresh evidence. */ +export function shouldConfirmPtyForeground(fallback: string | null, decision: boolean): boolean { + if (!fallback) { + return decision + } + const recognized = recognizeAgentProcess(fallback) + return ( + process.platform === 'win32' || + recognized === null || + shouldInspectOuterWrapperForegroundProcess(recognized) + ) +} diff --git a/src/main/providers/windows-cached-agent-revalidation.ts b/src/main/providers/windows-cached-agent-revalidation.ts index 6a355f16f10..803e312e5fc 100644 --- a/src/main/providers/windows-cached-agent-revalidation.ts +++ b/src/main/providers/windows-cached-agent-revalidation.ts @@ -1,4 +1,6 @@ import { isShellProcess } from '../../shared/shell-process-detection' +import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import type { AgentForegroundProcessResolution } from './agent-foreground-process' /** Unanchored job evidence may outlive console agents; cap it at five renderer confirm ladders (30s). * Refresh on every positive recognition so the limit never times out a confirmed live agent. */ @@ -58,3 +60,28 @@ export function judgeCachedAgentJobEvidence(args: { } return withinAgeBound ? 'unproven' : 'expired' } + +/** Decision authority comes from fresh recognition or a known anchor not yet proved gone. */ +export function reconcileWindowsAgentDecision( + resolution: AgentForegroundProcessResolution, + verdict: WindowsCachedAgentJobVerdict, + anchor: { processName: string; pid: number | null } | null, + shellPid: number +): string | null { + if (resolution.available && recognizeAgentProcessFromCommandLine(resolution.processName ?? '')) { + return resolution.processName + } + if ( + anchor?.pid != null && + anchor.pid !== shellPid && + recognizeAgentProcessFromCommandLine(anchor.processName) && + !resolution.anchorPidForeign && + (verdict === 'confirmed' || + verdict === 'recheck' || + verdict === 'unavailable' || + verdict === 'unsupported') + ) { + return anchor.processName + } + return resolution.available ? resolution.processName : null +} diff --git a/src/main/runtime/structured-agent-cli-writers.test.ts b/src/main/runtime/structured-agent-cli-writers.test.ts index d39ca3493b6..61ab4182680 100644 --- a/src/main/runtime/structured-agent-cli-writers.test.ts +++ b/src/main/runtime/structured-agent-cli-writers.test.ts @@ -1,3 +1,4 @@ +import type * as ProcessTableReader from '../../shared/process-table-snapshot-reader' import { hostSleepKey } from './terminal/stop/worktree-terminal-sleep-key' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' @@ -7,13 +8,14 @@ import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resu import type { EnrichedAgentHookEventPayload } from '../agent-hooks/server/server-types' import { assertCliConversationWritersExited, + posixCliWriterShellAlone, type CliWriterHost } from './structured-agent-cli-writers' import { OrcaRuntimeService } from './orca-runtime' import type { RuntimeStore } from './runtime-store-contract' -import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' import { getDefaultWorkspaceSession } from '../../shared/constants' import { bindPaneFirst } from './bind-first-pane.test-fixture' +import { asProfileSessionStore } from './profile-session-store.test-fixture' import { OPEN_CODE_WRITER_TOPOLOGY, tmuxCliWriterEvent @@ -95,6 +97,108 @@ function writerLayoutStore(): RuntimeStore { beforeEach(() => vi.clearAllMocks()) describe('execution-host conversation writer inspection', () => { + it('looks through the macOS login wrapper to the shell it started', async () => { + // Captured from a real macOS pane: login(1) stays as the shell's parent. + const wrapped = [ + { + pid: 10, + ppid: 1, + stat: 'Ss', + command: '/usr/bin/login -flpq user /bin/bash --noprofile --norc -p -c orca-tcc-login' + }, + { pid: 11, ppid: 10, stat: 'S+', command: '-/bin/zsh -l' } + ] + snapshot.mockResolvedValue(wrapped) + await expect(posixCliWriterShellAlone(10)).resolves.toBe(true) + for (const rows of [ + [wrapped[0]], + [...wrapped, { ...wrapped[1], pid: 13 }], + [wrapped[0], { ...wrapped[1], command: 'claude' }], + [wrapped[0], { ...wrapped[1], stat: 'Ss' }] + ]) { + snapshot.mockResolvedValue(rows) + await expect(posixCliWriterShellAlone(10)).resolves.toBe(false) + } + }) + + it.skipIf( + process.platform !== 'darwin' || + process.env.GITHUB_ACTIONS !== 'true' || + process.env.RUNNER_ENVIRONMENT !== 'github-hosted' || + process.env.ORCA_MACOS_LOGIN_CENSUS_CI !== '1' + )( + 'censuses a real login PTY on a disposable hosted Mac', + async () => { + expect(process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL).toBeUndefined() + const login = await import('../providers/macos-tcc-login-shell') + expect(await login.prepareMacosTccLoginShell()).toMatchObject({ + ok: true, + reason: 'accepted' + }) + const wrapped = login.wrapShellSpawnForMacosTccAttribution('/bin/bash', [ + '--noprofile', + '--norc', + '-i' + ]) + expect(wrapped.file).toBe('/usr/bin/login') + const real = await vi.importActual( + '../../shared/process-table-snapshot-reader' + ) + snapshot.mockImplementation(real.getFreshShellForegroundSnapshot) + const { spawn } = await import('node-pty') + const proc = spawn(wrapped.file, wrapped.args, { + name: 'xterm-256color', + cols: 80, + rows: 24, + env: { PATH: '/usr/bin:/bin', TERM: 'xterm-256color' } + }) + let exited = false + const subscription = proc.onExit(() => { + exited = true + }) + const idle = () => posixCliWriterShellAlone(proc.pid) + const rows = () => real.getFreshShellForegroundSnapshot() + try { + await expect.poll(idle, { timeout: 15000 }).toBe(true) + const initial = await rows() + expect(initial.find((row) => row.pid === proc.pid)?.command).toMatch(/^\/usr\/bin\/login /) + const children = initial.filter((row) => row.ppid === proc.pid) + expect(children).toHaveLength(1) + expect(children[0].command).toMatch(/bash/) + expect(children[0].stat).toContain('+') + for (const kind of ['foreground', 'background', 'suspended']) { + proc.write(kind === 'foreground' ? '/bin/sleep 30\r' : '/bin/sleep 30 & job=$!\r') + const jobObserved = async (stopped = false) => + (await rows()).some( + (row) => + row.ppid === children[0].pid && + row.command === '/bin/sleep 30' && + (!stopped || row.stat.includes('T')) + ) + await expect.poll(() => jobObserved(), { timeout: 15000 }).toBe(true) + if (kind === 'suspended') { + proc.write('kill -STOP "$job"\r') + await expect.poll(() => jobObserved(true), { timeout: 15000 }).toBe(true) + } + expect(await idle()).toBe(false) + proc.write( + kind === 'foreground' + ? '\x03' + : 'kill -CONT "$job"; kill "$job"; wait "$job"; unset job\r' + ) + await expect.poll(idle, { timeout: 15000 }).toBe(true) + } + } finally { + // Only this shell's recorded job is signalled; login exits through its own shell. + proc.write('\x03') + proc.write('if [ -n "$job" ]; then kill -CONT "$job"; kill "$job"; wait "$job"; fi; exit\r') + await expect.poll(() => exited, { timeout: 15000 }).toBe(true) + subscription.dispose() + } + }, + 120000 + ) + it('accepts exact exited or unchanged shell-only proof, never loss of evidence', async () => { await expect(inspection('unverifiable').run()).rejects.toMatchObject(refusal) for (const cause of ['missing-row', 'sleeping', 'failed-scan', 'unknown-topology'] as const) { @@ -403,11 +507,26 @@ describe('execution-host conversation writer inspection', () => { await expect(noRows.run()).rejects.toMatchObject(refusal) }) - it('inspects the terminal a conversation leaves, whatever its rows now say', async () => { + it.each([ + ['darwin', true], + ['darwin', false], + ['linux', false] + ] as const)('inspects %s CLI consumers (login=%s)', async (platform, wrapped) => { const leaf = '11111111-1111-4111-8111-111111111111' const pane = `tab-a:${leaf}` let rows: AgentStatusIpcPayload[] = [] - const store = ORCA_LAYOUT_OWNER_IS_MAIN ? writerLayoutStore() : null + const store = writerLayoutStore() + const sessions = asProfileSessionStore(store, 'local') + // The hook shell captures $PPID, the CLI, before starting its helper shell. + const cliProcess = { + pid: 82801, + platform, + startTime: + platform === 'darwin' + ? 'Mon Oct 26 03:00:00 2026' + : '11111111-2222-4333-8444-555555555555:123456' + } + probe.mockResolvedValue('exited') class WriterRuntime extends OrcaRuntimeService { readonly sleepStates = this.terminalSleepStateByHostWorktree } @@ -416,7 +535,7 @@ describe('execution-host conversation writer inspection', () => { readHookCliWriterObservation: () => ({ current: () => true, topology: 'pane-owned', - presence: async () => null + presence: async () => probe(cliProcess) }) }) const register = (tabId: string, incarnationId?: string, isWsl?: boolean) => { @@ -428,7 +547,9 @@ describe('execution-host conversation writer inspection', () => { register('tab-a', 'i1') // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only listProcesses. runtime.setPtyController({ - listProcesses: async () => [{ id: 'pty', rootProcessId: 10, incarnationId: 'i1' }] + listProcesses: async () => [ + { id: 'pty', rootProcessId: wrapped ? 82718 : 82760, incarnationId: 'i1' } + ] } as never) const event = (agentType: string, extra?: Partial) => ({ paneKey: pane, @@ -437,6 +558,9 @@ describe('execution-host conversation writer inspection', () => { stateStartedAt: 1, providerSession: { key: 'session_id' as const, id: 'conversation' }, payload: { state: 'done' as const, prompt: '', agentType }, + ...(agentType === 'claude' + ? { agentPresence: { agent: 'claude' as const, process: cliProcess } } + : {}), ...extra }) const report = (agentType: string, extra?: Partial) => @@ -445,16 +569,40 @@ describe('execution-host conversation writer inspection', () => { agent: 'claude' | 'opencode', source?: { workspaceId: string; paneKey: string } ) => runtime.assertStructuredAdoptionTerminalWritersExited(agent, 'conversation', source) - const shell = { pid: 10, ppid: 1, stat: 'Ss+', command: '/bin/zsh' } + const shell = { + pid: 82760, + ppid: wrapped ? 82718 : 1, + pgid: 82760, + tpgid: 82760, + stat: 'S+', + command: platform === 'darwin' ? '-/bin/zsh -l' : '/bin/bash' + } + const launch = wrapped + ? [ + { + pid: 82718, + ppid: 46400, + pgid: 82718, + tpgid: 82760, + stat: 'Ss', + command: + '/usr/bin/login -flpq user /bin/bash --noprofile --norc -p -c orca-tcc-login /bin/zsh /bin/zsh -l' + } + ] + : [] const census = async (run: () => Promise) => { // A prompt's zpty worker left the session; any suspended or background job counts, whatever its name. const worker = [ + ...launch, shell, - { pid: 20, ppid: 10, stat: 'Ss+', command: '-zsh -i' }, + { pid: 20, ppid: 82760, stat: 'Ss+', command: '-zsh -i' }, { pid: 21, ppid: 20, stat: 'S+', command: 'git status' } ] - for (const stat of ['T', 'SN']) { - snapshot.mockResolvedValue([...worker, { pid: 22, ppid: 10, stat, command: 'agent-alias' }]) + for (const stat of ['S+', 'T', 'SN']) { + snapshot.mockResolvedValue([ + ...worker, + { pid: 82801, ppid: 82760, stat, command: 'agent-alias' } + ]) await expect(run()).rejects.toMatchObject(refusal) } snapshot.mockResolvedValue(worker) @@ -480,18 +628,40 @@ describe('execution-host conversation writer inspection', () => { runtime.sleepStates.delete(hostSleepKey('local', 'workspace')) } runtime.sleepStates.clear() - // A row restored after restart has no observed identity; the pane's current PTY decides. + // The current row rechecks the exited CLI identity before inspecting its PTY. rows = [{ ...row, paneKey: pane, agentType: 'claude' }] await census(() => check('claude')) + const saved: SleepingAgentSessionRecord = { + paneKey: pane, + worktreeId: 'workspace', + agent: 'claude', + providerSession: { key: 'session_id', id: 'conversation' }, + prompt: '', + state: 'done', + origin: 'live', + capturedAt: 1, + updatedAt: 1 + } + sessions.set( + { ...sessions.get('local'), sleepingAgentSessionsByPaneKey: { [pane]: saved } }, + 'local' + ) + await census(() => check('claude')) rows = [] + await census(() => check('claude')) + sessions.set({ ...sessions.get('local'), sleepingAgentSessionsByPaneKey: {} }, 'local') + expect(probe).toHaveBeenCalledWith(cliProcess) // Paused, then a second run overwrote the row and the pane moved: the link is the execution's. report('claude') + rows = [{ ...row, paneKey: pane, agentType: 'claude' }] + await census(() => check('claude')) + rows = [{ ...rows[0], providerSession: { key: 'session_id', id: 'later-run' } }] report('claude', { providerSession: { key: 'session_id', id: 'later-run' } }) register('tab-b', 'i1') await census(() => check('claude')) // The agent's own process can outlive the terminal's tree, as in detached tmux or nohup. report('claude', { - agentPresence: { agent: 'claude', process: { pid: 12, platform: 'darwin', startTime: 's' } } + agentPresence: { agent: 'claude', process: cliProcess } }) for (const verdict of ['live', 'unverifiable', 'exited'] as const) { probe.mockResolvedValueOnce(verdict) diff --git a/src/main/runtime/structured-agent-cli-writers.ts b/src/main/runtime/structured-agent-cli-writers.ts index 7435c86085f..5805f7d1eb6 100644 --- a/src/main/runtime/structured-agent-cli-writers.ts +++ b/src/main/runtime/structured-agent-cli-writers.ts @@ -10,6 +10,7 @@ import type { CliWriterObservation } from '../agent-hooks/server/cli-writer-obse import { getFreshShellForegroundSnapshot } from '../../shared/process-table-snapshot-reader' import { collectDescendantsFromIndex, getProcessTableIndex } from '../../shared/process-table-index' import { isShellProcess } from '../../shared/shell-process-detection' +import { MACOS_LOGIN_PATH } from '../providers/macos-tcc-login-shell' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { AgentProcessIdentity } from '../../shared/agent-process-presence' import { probeAgentProcessPresence } from '../../shared/agent-process-presence-probe' @@ -48,14 +49,20 @@ export type CliWriterHost = { /** Unlike foreground ownership, any job left in the shell's session refuses, background and suspended too. */ export async function posixCliWriterShellAlone(rootPid: number): Promise { const index = getProcessTableIndex(await getFreshShellForegroundSnapshot()) + const executable = (command: string) => command.trim().replace(/^-/, '').split(/\s+/, 1)[0] ?? '' const root = index.byPid.get(rootPid) - const executable = root?.command.trim().replace(/^-/, '').split(/\s+/, 1)[0] ?? '' + // macOS panes spawn under login(1), which stays as the shell's parent; it is launch plumbing. + const launched = + root && executable(root.command) === MACOS_LOGIN_PATH + ? (index.childrenByPpid.get(rootPid) ?? []) + : undefined + const shell = launched ? (launched.length === 1 ? launched[0] : undefined) : root return ( - !!root && - isShellProcess(executable) && - root.stat.includes('+') && + !!shell && + isShellProcess(executable(shell.command)) && + shell.stat.includes('+') && // A session leader below the shell has left this terminal (a prompt's zpty worker); its jobs never do. - collectDescendantsFromIndex(index, rootPid, (row) => row.stat.includes('s')).length === 0 + collectDescendantsFromIndex(index, shell.pid, (row) => row.stat.includes('s')).length === 0 ) }