diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 2d9dcf0fd89..4a4689d1708 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -9351,7 +9351,7 @@ "invariant": "Shift+Enter key recognition follows the client OS, while emitted bytes follow the active application and PTY host: active Kitty keyboard protocol authorizes CSI-u on every host; otherwise Windows hosts use Esc+CR except when allowlisted Droid ownership or fresh routing-trusted Droid process evidence authorizes CSI-u, and non-Windows hosts use Esc+CR. The active SSH connection, remote-runtime PTY owner, or live local session outranks later worktree ownership changes, and missing host-platform metadata falls back to the client. Every new command and PTY replacement revokes stale agent routing until current evidence settles; split or reused panes cannot inherit sibling or prior-PTY authority; unrelated keys perform no host, agent, protocol, or ConPTY lookup.", "oracle": "Renderer tests assert exact bytes across client/PTY-host combinations, including Kitty keyboard protocol active and inactive on Windows and non-Windows hosts, and prove SSH identity, the environment encoded in an active remote-runtime PTY id, and live local-session identity outrank current worktree ownership, while unavailable platform metadata falls back to the client. A live linux-arm64 SSH PTY independently captured Esc+CR with KKP inactive and CSI-u with KKP active. Fresh Windows process evidence authorizes Droid bytes only after a recognized global candidate is intersected with the exact ConPTY console process list; detached descendants, helper failure/timeout/root-only fallback, stale PTY exit/rebind results, typed text, and hook/OSC output fail closed. A new OSC 133;C immediately publishes a routing-neutral generation, including during D-to-C races. In cmd.exe/Git Bash/custom shells without OSC 133, accepted inferred commands start the same fresh generation; accepted submit/interrupt, title exit, focus, and visibility revoke trusted Droid bytes while one bounded confirmation runs. Detach preserves the live PTY's source shell override so WSL/native classification cannot change with the current default. Ordinary fast shell commands use cached/no-scan paths; routing-relevant confirmation bypasses cached process snapshots and keeps the bounded three-read ladder. Daemon protocol v21 persists only an allowlisted launchAgent; warm reattach uses it as a display/confirmation hint and restores routing only after current process proof. The Windows Electron test focuses the real xterm textarea and records exact renderer-to-main PTY writes.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/providers/windows-console-attached-processes.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts", "pnpm run test:e2e -- tests/e2e/terminal-shortcuts.spec.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts" ], @@ -9366,7 +9366,9 @@ "src/main/ipc/pty-runtime-kill-and-exit.test.ts", "src/main/providers/agent-foreground-process.test.ts", "src/main/providers/local-pty-provider-foreground-process.test.ts", + "src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts", "src/main/providers/windows-console-attached-processes.test.ts", + "src/main/providers/windows-pty-job-membership.test.ts", "src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts", "src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts", "src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts", @@ -9412,6 +9414,23 @@ "the bounded timeout kills the child helper exactly once" ] }, + { + "file": "src/main/providers/windows-pty-job-membership.test.ts", + "assertions": [ + "never spawns a child process to answer", + "refuses an answer that does not contain the shell", + "is asked with the pty handle, because a bare pid cannot find the job" + ] + }, + { + "file": "src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts", + "assertions": [ + "stops holding a dead agent once the job answer is only a superset", + "restores the idle refresh backoff once the dead identity is gone", + "never expires an identity while scans stay degraded", + "never expires an identity while the job answer is unverifiable" + ] + }, { "file": "src/main/providers/local-pty-provider-foreground-process.test.ts", "assertions": [ @@ -9562,7 +9581,7 @@ "date": "2026-07-10", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/providers/windows-console-attached-processes.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts", "result": "passed", "durationSeconds": 8.71, "summary": "Twenty-two focused test files passed (1206 tests) on current main, covering fresh-scan ordering/deduplication, exact ConPTY membership and detached-child rejection, stale PTY guards, daemon v21 launch ownership with current-process confirmation, no-OSC command/exit recovery, preserved detach shell classification, unavailable evidence, routing-neutral command generations, exact shortcut bytes, and the composed daemon-plus-tracker contract; the Windows Electron byte test remains platform-gated and is not counted as local macOS evidence." @@ -9624,7 +9643,7 @@ "oracle": "A quarter-circle Claude task title authorizes without a provider lookup only on the exact PTY incarnation carrying verified managed-Claude launch identity; the same title in a bare pane, with an unverified launch hint, or after PTY incarnation replacement is refused while current Claude activity remains a working signal. Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/quarter-circle-title-send-authorization.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx" + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx" ], "testFiles": [ "src/main/runtime/quarter-circle-title-send-authorization.test.ts", @@ -9633,7 +9652,9 @@ "src/main/ipc/pty-runtime-kill-and-exit.test.ts", "src/main/providers/agent-foreground-process.test.ts", "src/main/providers/local-pty-provider-foreground-process.test.ts", + "src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts", "src/main/providers/windows-console-attached-processes.test.ts", + "src/main/providers/windows-pty-job-membership.test.ts", "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts", "src/main/daemon/pty-subprocess.test.ts", "src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts", @@ -9728,7 +9749,7 @@ "date": "2026-07-11", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx", "result": "passed", "durationSeconds": 9.58, "summary": "Ten focused test files passed (1183 tests), covering runtime confirmation and PTY revalidation, guarded RPC zero-write behavior, PTY controller routing, local/daemon fresh scans, exact ConPTY membership, and unchanged renderer note routing." diff --git a/src/main/daemon/pty-subprocess-cwd-cancel-identity.test.ts b/src/main/daemon/pty-subprocess-cwd-cancel-identity.test.ts index 44c70f75116..de384576d4f 100644 --- a/src/main/daemon/pty-subprocess-cwd-cancel-identity.test.ts +++ b/src/main/daemon/pty-subprocess-cwd-cancel-identity.test.ts @@ -42,8 +42,8 @@ vi.mock('../providers/agent-foreground-process', () => ({ } })) -vi.mock('../providers/windows-conpty-process-membership', () => ({ - readWindowsConptyProcessIds: () => Promise.resolve(new Set([12345])) +vi.mock('../providers/windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: () => Promise.resolve(new Set([12345])) })) import { createPtySubprocess } from './pty-subprocess' diff --git a/src/main/daemon/pty-subprocess-env-inheritance.test.ts b/src/main/daemon/pty-subprocess-env-inheritance.test.ts index 53846b5071b..3a88a8ce6b8 100644 --- a/src/main/daemon/pty-subprocess-env-inheritance.test.ts +++ b/src/main/daemon/pty-subprocess-env-inheritance.test.ts @@ -70,8 +70,8 @@ vi.mock('../providers/agent-foreground-process', () => ({ // fake timers; default to "shell-only" so the degraded-scan guard falls through // to its existing retirement logic (the degraded-scan behavior itself is // covered in pty-subprocess-foreground-degraded-scan.test.ts). -vi.mock('../providers/windows-conpty-process-membership', () => ({ - readWindowsConptyProcessIds: () => Promise.resolve(new Set([12345])) +vi.mock('../providers/windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: () => Promise.resolve(new Set([12345])) })) import { createPtySubprocess } from './pty-subprocess' 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 ed0144ec4cb..f65b951747a 100644 --- a/src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts +++ b/src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts @@ -37,8 +37,8 @@ vi.mock('../providers/agent-foreground-process', () => ({ resolveAgentForegroundProcessMock(...args) })) -vi.mock('../providers/windows-conpty-process-membership', () => ({ - readWindowsConptyProcessIds: (...args: unknown[]) => readConptyMock(...args) +vi.mock('../providers/windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: (...args: unknown[]) => readConptyMock(...args) })) import { createPtySubprocess } from './pty-subprocess' @@ -127,7 +127,7 @@ describe('daemon pty foreground degraded-scan handling', () => { expect(readConptyMock).not.toHaveBeenCalled() }) - it('keeps a cached agent when a scan finds no agent but the console still has a child', async () => { + it('keeps a cached agent, for a bounded time, when the job still has a descendant', async () => { resolveAgentForegroundProcessMock .mockResolvedValueOnce({ available: true, processName: 'claude' }) .mockResolvedValue({ available: true, processName: null }) @@ -139,6 +139,78 @@ describe('daemon pty foreground degraded-scan handling', () => { expect(await readForegroundAt(handle, 2_500)).toBe('claude') }) + it('stops holding a dead agent once the job answer is only a superset', async () => { + // The WSL shape: job [shell, detached plumbing] forever, so `size > 1` used + // to veto retirement outright and pin an exited agent's name for the life of + // the pane. Job membership is a SUPERSET of the console -- it cannot tell a + // working agent from a leftover -- so age decides instead. + resolveAgentForegroundProcessMock + .mockResolvedValueOnce({ available: true, processName: 'claude' }) + .mockResolvedValue({ available: true, processName: null }) + readConptyMock.mockReturnValue(new Set([12345, 999])) + const { handle } = await spawnWindowsShell() + + await readForegroundAt(handle, 0) + expect(await readForegroundAt(handle, 2_500)).toBe('claude') + await readForegroundAt(handle, 40_000) // this refresh clears the cache + expect(await readForegroundAt(handle, 40_100)).toBe('powershell.exe') + }) + + it('restores the idle refresh backoff once the dead identity is gone', async () => { + // A non-null cache makes idleNoEvidenceShell false, which pins retryMs at + // the 1s TTL. Never clearing the cache therefore also meant scanning the + // process table every second, forever, on an idle pane. + resolveAgentForegroundProcessMock + .mockResolvedValueOnce({ available: true, processName: 'claude' }) + .mockResolvedValue({ available: true, processName: null }) + readConptyMock.mockReturnValue(new Set([12345, 999])) + const { handle } = await spawnWindowsShell() + + await readForegroundAt(handle, 0) + await readForegroundAt(handle, 40_000) // identity expires here + const scansAfterExpiry = resolveAgentForegroundProcessMock.mock.calls.length + + await readForegroundAt(handle, 41_000) + expect(resolveAgentForegroundProcessMock.mock.calls.length).toBe(scansAfterExpiry) + + await readForegroundAt(handle, 60_000) + expect(resolveAgentForegroundProcessMock.mock.calls.length).toBeGreaterThan(scansAfterExpiry) + }) + + it('never expires an identity a scan keeps recognizing', async () => { + resolveAgentForegroundProcessMock.mockResolvedValue({ available: true, processName: 'claude' }) + readConptyMock.mockReturnValue(new Set([12345, 999])) + const { handle } = await spawnWindowsShell() + + await readForegroundAt(handle, 0) + expect(await readForegroundAt(handle, 40_000)).toBe('claude') + expect(await readForegroundAt(handle, 120_000)).toBe('claude') + }) + + it('never expires an identity while scans stay degraded', async () => { + // Age must only advance on positive "I looked and found no agent" evidence. + resolveAgentForegroundProcessMock + .mockResolvedValueOnce({ available: true, processName: 'claude' }) + .mockResolvedValue({ available: false, processName: null }) + readConptyMock.mockReturnValue(new Set([12345, 999])) + const { handle } = await spawnWindowsShell() + + await readForegroundAt(handle, 0) + expect(await readForegroundAt(handle, 120_000)).toBe('claude') + }) + + it('never expires an identity while the job answer is unverifiable', async () => { + // ssh-execution-boundary.md: loss of contact is not evidence of death. + resolveAgentForegroundProcessMock + .mockResolvedValueOnce({ available: true, processName: 'claude' }) + .mockResolvedValue({ available: true, processName: null }) + readConptyMock.mockReturnValue(null) + const { handle } = await spawnWindowsShell() + + await readForegroundAt(handle, 0) + expect(await readForegroundAt(handle, 120_000)).toBe('claude') + }) + it('retires a cached agent when a scan finds no agent and the console is shell-only', async () => { resolveAgentForegroundProcessMock .mockResolvedValueOnce({ available: true, processName: 'claude' }) diff --git a/src/main/daemon/pty-subprocess-foreground-identity.test.ts b/src/main/daemon/pty-subprocess-foreground-identity.test.ts index 67967f02d5b..e74f1dd47f7 100644 --- a/src/main/daemon/pty-subprocess-foreground-identity.test.ts +++ b/src/main/daemon/pty-subprocess-foreground-identity.test.ts @@ -69,8 +69,8 @@ vi.mock('../providers/agent-foreground-process', () => ({ // fake timers; default to "shell-only" so the degraded-scan guard falls through // to its existing retirement logic (the degraded-scan behavior itself is // covered in pty-subprocess-foreground-degraded-scan.test.ts). -vi.mock('../providers/windows-conpty-process-membership', () => ({ - readWindowsConptyProcessIds: () => Promise.resolve(new Set([12345])) +vi.mock('../providers/windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: () => Promise.resolve(new Set([12345])) })) import { createPtySubprocess } from './pty-subprocess' diff --git a/src/main/daemon/pty-subprocess-git-credential-guard.test.ts b/src/main/daemon/pty-subprocess-git-credential-guard.test.ts index 746db79287a..ac6365f79b7 100644 --- a/src/main/daemon/pty-subprocess-git-credential-guard.test.ts +++ b/src/main/daemon/pty-subprocess-git-credential-guard.test.ts @@ -69,8 +69,8 @@ vi.mock('../providers/agent-foreground-process', () => ({ // fake timers; default to "shell-only" so the degraded-scan guard falls through // to its existing retirement logic (the degraded-scan behavior itself is // covered in pty-subprocess-foreground-degraded-scan.test.ts). -vi.mock('../providers/windows-conpty-process-membership', () => ({ - readWindowsConptyProcessIds: () => Promise.resolve(new Set([12345])) +vi.mock('../providers/windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: () => Promise.resolve(new Set([12345])) })) import { createPtySubprocess } from './pty-subprocess' diff --git a/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts b/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts index 0a19bc55a06..89f922b8834 100644 --- a/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts +++ b/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts @@ -69,8 +69,8 @@ vi.mock('../providers/agent-foreground-process', () => ({ // fake timers; default to "shell-only" so the degraded-scan guard falls through // to its existing retirement logic (the degraded-scan behavior itself is // covered in pty-subprocess-foreground-degraded-scan.test.ts). -vi.mock('../providers/windows-conpty-process-membership', () => ({ - readWindowsConptyProcessIds: () => Promise.resolve(new Set([12345])) +vi.mock('../providers/windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: () => Promise.resolve(new Set([12345])) })) import { createPtySubprocess } from './pty-subprocess' diff --git a/src/main/daemon/pty-subprocess-managed-agent-env.test.ts b/src/main/daemon/pty-subprocess-managed-agent-env.test.ts index ed63b84afec..b28ecef5743 100644 --- a/src/main/daemon/pty-subprocess-managed-agent-env.test.ts +++ b/src/main/daemon/pty-subprocess-managed-agent-env.test.ts @@ -69,8 +69,8 @@ vi.mock('../providers/agent-foreground-process', () => ({ // fake timers; default to "shell-only" so the degraded-scan guard falls through // to its existing retirement logic (the degraded-scan behavior itself is // covered in pty-subprocess-foreground-degraded-scan.test.ts). -vi.mock('../providers/windows-conpty-process-membership', () => ({ - readWindowsConptyProcessIds: () => Promise.resolve(new Set([12345])) +vi.mock('../providers/windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: () => Promise.resolve(new Set([12345])) })) import { createPtySubprocess } from './pty-subprocess' diff --git a/src/main/daemon/pty-subprocess-windows-shell-launch.test.ts b/src/main/daemon/pty-subprocess-windows-shell-launch.test.ts index 8dd0b420008..1b25645d3f8 100644 --- a/src/main/daemon/pty-subprocess-windows-shell-launch.test.ts +++ b/src/main/daemon/pty-subprocess-windows-shell-launch.test.ts @@ -69,8 +69,8 @@ vi.mock('../providers/agent-foreground-process', () => ({ // fake timers; default to "shell-only" so the degraded-scan guard falls through // to its existing retirement logic (the degraded-scan behavior itself is // covered in pty-subprocess-foreground-degraded-scan.test.ts). -vi.mock('../providers/windows-conpty-process-membership', () => ({ - readWindowsConptyProcessIds: () => Promise.resolve(new Set([12345])) +vi.mock('../providers/windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: () => Promise.resolve(new Set([12345])) })) import { createPtySubprocess } from './pty-subprocess' diff --git a/src/main/daemon/pty-subprocess-wsl-launch.test.ts b/src/main/daemon/pty-subprocess-wsl-launch.test.ts index ffc042d3509..5a14b5b8c8b 100644 --- a/src/main/daemon/pty-subprocess-wsl-launch.test.ts +++ b/src/main/daemon/pty-subprocess-wsl-launch.test.ts @@ -72,8 +72,8 @@ vi.mock('../providers/agent-foreground-process', () => ({ // fake timers; default to "shell-only" so the degraded-scan guard falls through // to its existing retirement logic (the degraded-scan behavior itself is // covered in pty-subprocess-foreground-degraded-scan.test.ts). -vi.mock('../providers/windows-conpty-process-membership', () => ({ - readWindowsConptyProcessIds: () => Promise.resolve(new Set([12345])) +vi.mock('../providers/windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: () => Promise.resolve(new Set([12345])) })) import { createPtySubprocess } from './pty-subprocess' diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 0e05fd44041..3252b1b280b 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -72,8 +72,8 @@ vi.mock('../providers/agent-foreground-process', () => ({ // fake timers; default to "shell-only" so the degraded-scan guard falls through // to its existing retirement logic (the degraded-scan behavior itself is // covered in pty-subprocess-foreground-degraded-scan.test.ts). -vi.mock('../providers/windows-conpty-process-membership', () => ({ - readWindowsConptyProcessIds: () => Promise.resolve(new Set([12345])) +vi.mock('../providers/windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: () => Promise.resolve(new Set([12345])) })) import { createPtySubprocess, checkPtySpawnHealth } from './pty-subprocess' diff --git a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts index cd560be753f..83fab615ec0 100644 --- a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts +++ b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts @@ -2,7 +2,7 @@ import type * as pty from 'node-pty' import { win32 as pathWin32 } from 'node:path' import { getAgentForegroundContextPaths } from '../../providers/agent-foreground-context-paths' import { resolveAgentForegroundProcessWithAvailability } from '../../providers/agent-foreground-process' -import { readWindowsConptyProcessIds } from '../../providers/windows-conpty-process-membership' +import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership' import { readWindowsConsoleAttachedProcessIds } from '../../providers/windows-console-attached-processes' import { isAgentForegroundWrapperProcess, @@ -16,6 +16,11 @@ import { parsePtySessionId } from '../pty-session-id' const FOREGROUND_AGENT_CACHE_TTL_MS = 1000 const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000 const WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS = 15_000 +// Why 30s: the job answers a SUPERSET of the console, so "something else is +// alive" cannot tell a working agent from a console-detached leftover. Age is +// the tiebreak, and only successful scans that found no agent advance it (an +// unavailable scan returns before this). 5x the renderer's 6s confirm ladder. +const WINDOWS_DETACHED_DESCENDANT_IDENTITY_MAX_AGE_MS = 30_000 const SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS = 10_000 const STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS = 5_000 @@ -122,13 +127,16 @@ export function createPtyForegroundProcessTracker(args: { } foregroundRefreshInFlight = true lastForegroundRefreshStartedAt = now - const retireStaleForegroundIdentity = (): void => { + const retireStaleForegroundIdentity = ({ onlyWhenAged = false } = {}): void => { const currentFallbackProcess = getFallbackProcess() + const identityAgeMs = + cachedAgentForeground === null ? 0 : Date.now() - cachedAgentForeground.refreshedAt if ( fallbackIsShell && !getActiveStartupAgent() && currentFallbackProcess !== null && - isShellProcess(currentFallbackProcess) + isShellProcess(currentFallbackProcess) && + (!onlyWhenAged || identityAgeMs > WINDOWS_DETACHED_DESCENDANT_IDENTITY_MAX_AGE_MS) ) { cachedAgentForeground = null startupAgentForeground = null @@ -152,11 +160,18 @@ export function createPtyForegroundProcessTracker(args: { if (process.platform === 'win32' && fallbackIsShell && cachedAgentForeground !== null) { // Job, not console: "is anything besides the shell alive?" needs no // console attachment, so it needs no forked helper (#10857). - const paneProcessIds = readWindowsConptyProcessIds(proc) - if (paneProcessIds === null || paneProcessIds.size > 1) { + const paneProcessIds = readWindowsPtyJobProcessIds(proc) + // Unverifiable is never exit proof (ssh-execution-boundary.md): hold. + if (paneProcessIds === null) { return } - retireStaleForegroundIdentity() + // A superset answer is not proof of life -- a WSL pane keeps + // console-detached plumbing in its job, so `size > 1` stays true + // forever and used to veto retirement outright. That pinned a dead + // agent's name (#9258's bug, new mechanism) and, because a non-null + // cache makes idleNoEvidenceShell false, pinned the refresh at 1s + // and defeated the 15s idle backoff. Let age settle it instead. + retireStaleForegroundIdentity({ onlyWhenAged: paneProcessIds.size > 1 }) return } retireStaleForegroundIdentity() 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 5676279b5cb..4011f4ff650 100644 --- a/src/main/providers/local-pty-provider-foreground-process.test.ts +++ b/src/main/providers/local-pty-provider-foreground-process.test.ts @@ -10,7 +10,7 @@ const { spawnMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, - readWindowsConptyProcessIdsMock, + readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, wslUncDirectoryExistsMock, @@ -24,7 +24,7 @@ const { spawnMock: vi.fn(), prepareMacosTccLoginShellMock: vi.fn(), resolveAgentForegroundProcessMock: vi.fn(), - readWindowsConptyProcessIdsMock: vi.fn(), + readWindowsPtyJobProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), isWslAvailableAsyncMock: vi.fn(), wslUncDirectoryExistsMock: vi.fn(), @@ -84,8 +84,8 @@ vi.mock('./agent-foreground-process', () => ({ resolveAgentForegroundProcessMock(...args) })) -vi.mock('./windows-conpty-process-membership', () => ({ - readWindowsConptyProcessIds: (...args: unknown[]) => readWindowsConptyProcessIdsMock(...args) +vi.mock('./windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args) })) vi.mock('../wsl', () => ({ @@ -137,7 +137,7 @@ describe('LocalPtyProvider', () => { writeFileSyncMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, - readWindowsConptyProcessIdsMock, + readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, wslUncDirectoryExistsMock, @@ -236,7 +236,7 @@ describe('LocalPtyProvider', () => { processName: 'claude' }) // A child beyond the shell is still attached to this console. - readWindowsConptyProcessIdsMock.mockReturnValue(new Set([12345, 999])) + readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999])) const { id } = await provider.spawn({ cols: 80, rows: 24 }) // First call establishes the agent identity via the scan. @@ -254,7 +254,7 @@ describe('LocalPtyProvider', () => { available: true, processName: 'claude' }) - readWindowsConptyProcessIdsMock.mockReturnValue(new Set([12345])) + readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345])) const { id } = await provider.spawn({ cols: 80, rows: 24 }) await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') @@ -268,7 +268,7 @@ describe('LocalPtyProvider', () => { resolveAgentForegroundProcessMock .mockResolvedValueOnce({ available: true, processName: 'claude' }) .mockResolvedValue({ available: true, processName: null }) - readWindowsConptyProcessIdsMock.mockReturnValue(null) + readWindowsPtyJobProcessIdsMock.mockReturnValue(null) const { id } = await provider.spawn({ cols: 80, rows: 24 }) await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') @@ -282,7 +282,7 @@ describe('LocalPtyProvider', () => { resolveAgentForegroundProcessMock .mockResolvedValueOnce({ available: true, processName: 'claude' }) .mockResolvedValue({ available: true, processName: null }) - readWindowsConptyProcessIdsMock.mockReturnValue(new Set([12345])) + readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345])) const { id } = await provider.spawn({ cols: 80, rows: 24 }) await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') diff --git a/src/main/providers/local-pty-provider-io-events.test.ts b/src/main/providers/local-pty-provider-io-events.test.ts index 83cf268a7f3..edc747ba694 100644 --- a/src/main/providers/local-pty-provider-io-events.test.ts +++ b/src/main/providers/local-pty-provider-io-events.test.ts @@ -10,7 +10,7 @@ const { spawnMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, - readWindowsConptyProcessIdsMock, + readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, wslUncDirectoryExistsMock, @@ -24,7 +24,7 @@ const { spawnMock: vi.fn(), prepareMacosTccLoginShellMock: vi.fn(), resolveAgentForegroundProcessMock: vi.fn(), - readWindowsConptyProcessIdsMock: vi.fn(), + readWindowsPtyJobProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), isWslAvailableAsyncMock: vi.fn(), wslUncDirectoryExistsMock: vi.fn(), @@ -84,8 +84,8 @@ vi.mock('./agent-foreground-process', () => ({ resolveAgentForegroundProcessMock(...args) })) -vi.mock('./windows-conpty-process-membership', () => ({ - readWindowsConptyProcessIds: (...args: unknown[]) => readWindowsConptyProcessIdsMock(...args) +vi.mock('./windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args) })) vi.mock('../wsl', () => ({ @@ -137,7 +137,7 @@ describe('LocalPtyProvider', () => { writeFileSyncMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, - readWindowsConptyProcessIdsMock, + readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, wslUncDirectoryExistsMock, diff --git a/src/main/providers/local-pty-provider-session-inventory.test.ts b/src/main/providers/local-pty-provider-session-inventory.test.ts index 2abe881354e..e94a3f98bfa 100644 --- a/src/main/providers/local-pty-provider-session-inventory.test.ts +++ b/src/main/providers/local-pty-provider-session-inventory.test.ts @@ -10,7 +10,7 @@ const { spawnMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, - readWindowsConptyProcessIdsMock, + readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, wslUncDirectoryExistsMock, @@ -24,7 +24,7 @@ const { spawnMock: vi.fn(), prepareMacosTccLoginShellMock: vi.fn(), resolveAgentForegroundProcessMock: vi.fn(), - readWindowsConptyProcessIdsMock: vi.fn(), + readWindowsPtyJobProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), isWslAvailableAsyncMock: vi.fn(), wslUncDirectoryExistsMock: vi.fn(), @@ -84,8 +84,8 @@ vi.mock('./agent-foreground-process', () => ({ resolveAgentForegroundProcessMock(...args) })) -vi.mock('./windows-conpty-process-membership', () => ({ - readWindowsConptyProcessIds: (...args: unknown[]) => readWindowsConptyProcessIdsMock(...args) +vi.mock('./windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args) })) vi.mock('../wsl', () => ({ @@ -137,7 +137,7 @@ describe('LocalPtyProvider', () => { writeFileSyncMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, - readWindowsConptyProcessIdsMock, + readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, wslUncDirectoryExistsMock, diff --git a/src/main/providers/local-pty-provider-shell-readiness.test.ts b/src/main/providers/local-pty-provider-shell-readiness.test.ts index c4621382e66..01b94816029 100644 --- a/src/main/providers/local-pty-provider-shell-readiness.test.ts +++ b/src/main/providers/local-pty-provider-shell-readiness.test.ts @@ -10,7 +10,7 @@ const { spawnMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, - readWindowsConptyProcessIdsMock, + readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, wslUncDirectoryExistsMock, @@ -24,7 +24,7 @@ const { spawnMock: vi.fn(), prepareMacosTccLoginShellMock: vi.fn(), resolveAgentForegroundProcessMock: vi.fn(), - readWindowsConptyProcessIdsMock: vi.fn(), + readWindowsPtyJobProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), isWslAvailableAsyncMock: vi.fn(), wslUncDirectoryExistsMock: vi.fn(), @@ -84,8 +84,8 @@ vi.mock('./agent-foreground-process', () => ({ resolveAgentForegroundProcessMock(...args) })) -vi.mock('./windows-conpty-process-membership', () => ({ - readWindowsConptyProcessIds: (...args: unknown[]) => readWindowsConptyProcessIdsMock(...args) +vi.mock('./windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args) })) vi.mock('../wsl', () => ({ @@ -137,7 +137,7 @@ describe('LocalPtyProvider', () => { writeFileSyncMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, - readWindowsConptyProcessIdsMock, + readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, wslUncDirectoryExistsMock, diff --git a/src/main/providers/local-pty-provider-shutdown.test.ts b/src/main/providers/local-pty-provider-shutdown.test.ts index 8e2f13bd1cd..902fa62aaf3 100644 --- a/src/main/providers/local-pty-provider-shutdown.test.ts +++ b/src/main/providers/local-pty-provider-shutdown.test.ts @@ -10,7 +10,7 @@ const { spawnMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, - readWindowsConptyProcessIdsMock, + readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, wslUncDirectoryExistsMock, @@ -24,7 +24,7 @@ const { spawnMock: vi.fn(), prepareMacosTccLoginShellMock: vi.fn(), resolveAgentForegroundProcessMock: vi.fn(), - readWindowsConptyProcessIdsMock: vi.fn(), + readWindowsPtyJobProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), isWslAvailableAsyncMock: vi.fn(), wslUncDirectoryExistsMock: vi.fn(), @@ -84,8 +84,8 @@ vi.mock('./agent-foreground-process', () => ({ resolveAgentForegroundProcessMock(...args) })) -vi.mock('./windows-conpty-process-membership', () => ({ - readWindowsConptyProcessIds: (...args: unknown[]) => readWindowsConptyProcessIdsMock(...args) +vi.mock('./windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args) })) vi.mock('../wsl', () => ({ @@ -142,7 +142,7 @@ describe('LocalPtyProvider', () => { writeFileSyncMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, - readWindowsConptyProcessIdsMock, + readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, wslUncDirectoryExistsMock, diff --git a/src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts b/src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts index 01aec7c7f4b..bfeb75e44d9 100644 --- a/src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts +++ b/src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts @@ -10,7 +10,7 @@ const { spawnMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, - readWindowsConptyProcessIdsMock, + readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, wslUncDirectoryExistsMock, @@ -24,7 +24,7 @@ const { spawnMock: vi.fn(), prepareMacosTccLoginShellMock: vi.fn(), resolveAgentForegroundProcessMock: vi.fn(), - readWindowsConptyProcessIdsMock: vi.fn(), + readWindowsPtyJobProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), isWslAvailableAsyncMock: vi.fn(), wslUncDirectoryExistsMock: vi.fn(), @@ -84,8 +84,8 @@ vi.mock('./agent-foreground-process', () => ({ resolveAgentForegroundProcessMock(...args) })) -vi.mock('./windows-conpty-process-membership', () => ({ - readWindowsConptyProcessIds: (...args: unknown[]) => readWindowsConptyProcessIdsMock(...args) +vi.mock('./windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args) })) vi.mock('../wsl', () => ({ @@ -138,7 +138,7 @@ describe('LocalPtyProvider', () => { writeFileSyncMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, - readWindowsConptyProcessIdsMock, + readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, wslUncDirectoryExistsMock, diff --git a/src/main/providers/local-pty-provider-spawn-env.test.ts b/src/main/providers/local-pty-provider-spawn-env.test.ts index f12de81360f..3ba1471410a 100644 --- a/src/main/providers/local-pty-provider-spawn-env.test.ts +++ b/src/main/providers/local-pty-provider-spawn-env.test.ts @@ -12,7 +12,7 @@ const { spawnMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, - readWindowsConptyProcessIdsMock, + readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, wslUncDirectoryExistsMock, @@ -26,7 +26,7 @@ const { spawnMock: vi.fn(), prepareMacosTccLoginShellMock: vi.fn(), resolveAgentForegroundProcessMock: vi.fn(), - readWindowsConptyProcessIdsMock: vi.fn(), + readWindowsPtyJobProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), isWslAvailableAsyncMock: vi.fn(), wslUncDirectoryExistsMock: vi.fn(), @@ -86,8 +86,8 @@ vi.mock('./agent-foreground-process', () => ({ resolveAgentForegroundProcessMock(...args) })) -vi.mock('./windows-conpty-process-membership', () => ({ - readWindowsConptyProcessIds: (...args: unknown[]) => readWindowsConptyProcessIdsMock(...args) +vi.mock('./windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args) })) vi.mock('../wsl', () => ({ @@ -139,7 +139,7 @@ describe('LocalPtyProvider', () => { writeFileSyncMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, - readWindowsConptyProcessIdsMock, + readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, wslUncDirectoryExistsMock, diff --git a/src/main/providers/local-pty-provider-spawn-session.test.ts b/src/main/providers/local-pty-provider-spawn-session.test.ts index 96340a8436c..0fce65a21b2 100644 --- a/src/main/providers/local-pty-provider-spawn-session.test.ts +++ b/src/main/providers/local-pty-provider-spawn-session.test.ts @@ -10,7 +10,7 @@ const { spawnMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, - readWindowsConptyProcessIdsMock, + readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, wslUncDirectoryExistsMock, @@ -24,7 +24,7 @@ const { spawnMock: vi.fn(), prepareMacosTccLoginShellMock: vi.fn(), resolveAgentForegroundProcessMock: vi.fn(), - readWindowsConptyProcessIdsMock: vi.fn(), + readWindowsPtyJobProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), isWslAvailableAsyncMock: vi.fn(), wslUncDirectoryExistsMock: vi.fn(), @@ -84,8 +84,8 @@ vi.mock('./agent-foreground-process', () => ({ resolveAgentForegroundProcessMock(...args) })) -vi.mock('./windows-conpty-process-membership', () => ({ - readWindowsConptyProcessIds: (...args: unknown[]) => readWindowsConptyProcessIdsMock(...args) +vi.mock('./windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args) })) vi.mock('../wsl', () => ({ @@ -137,7 +137,7 @@ describe('LocalPtyProvider', () => { writeFileSyncMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, - readWindowsConptyProcessIdsMock, + readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, wslUncDirectoryExistsMock, diff --git a/src/main/providers/local-pty-provider-test-harness.ts b/src/main/providers/local-pty-provider-test-harness.ts index d1e9587d2f8..cf95ae1d962 100644 --- a/src/main/providers/local-pty-provider-test-harness.ts +++ b/src/main/providers/local-pty-provider-test-harness.ts @@ -23,7 +23,7 @@ export type LocalPtyProviderMocks = { writeFileSyncMock: Mock prepareMacosTccLoginShellMock: Mock resolveAgentForegroundProcessMock: Mock - readWindowsConptyProcessIdsMock: Mock + readWindowsPtyJobProcessIdsMock: Mock killWithDescendantSweepMock: Mock isWslAvailableAsyncMock: Mock wslUncDirectoryExistsMock: Mock @@ -95,8 +95,8 @@ export function applyLocalPtyProviderMockDefaults(mocks: LocalPtyProviderMocks): processName: fallbackProcess }) ) - mocks.readWindowsConptyProcessIdsMock.mockReset() - mocks.readWindowsConptyProcessIdsMock.mockReturnValue(null) + mocks.readWindowsPtyJobProcessIdsMock.mockReset() + mocks.readWindowsPtyJobProcessIdsMock.mockReturnValue(null) mocks.isWslAvailableAsyncMock.mockReset() mocks.isWslAvailableAsyncMock.mockResolvedValue(true) mocks.wslUncDirectoryExistsMock.mockReset() diff --git a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts index 5f95812d5ae..4d524eb483e 100644 --- a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts +++ b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts @@ -10,7 +10,7 @@ const { spawnMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, - readWindowsConptyProcessIdsMock, + readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, wslUncDirectoryExistsMock, @@ -24,7 +24,7 @@ const { spawnMock: vi.fn(), prepareMacosTccLoginShellMock: vi.fn(), resolveAgentForegroundProcessMock: vi.fn(), - readWindowsConptyProcessIdsMock: vi.fn(), + readWindowsPtyJobProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), isWslAvailableAsyncMock: vi.fn(), wslUncDirectoryExistsMock: vi.fn(), @@ -85,8 +85,8 @@ vi.mock('./agent-foreground-process', () => ({ resolveAgentForegroundProcessMock(...args) })) -vi.mock('./windows-conpty-process-membership', () => ({ - readWindowsConptyProcessIds: (...args: unknown[]) => readWindowsConptyProcessIdsMock(...args) +vi.mock('./windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args) })) vi.mock('../wsl', () => ({ @@ -139,7 +139,7 @@ describe('LocalPtyProvider', () => { writeFileSyncMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, - readWindowsConptyProcessIdsMock, + readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, wslUncDirectoryExistsMock, diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 68ae5411e77..3d9ad4fe7c7 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -64,10 +64,10 @@ import { resolveStableForegroundProcess } from './stable-foreground-process' import { getAgentForegroundContextPaths } from './agent-foreground-context-paths' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { killWithDescendantSweep } from '../pty-descendant-termination' -import { readWindowsConptyProcessIds } from './windows-conpty-process-membership' +import { readWindowsPtyJobProcessIds } from './windows-pty-job-membership' import { readWindowsConsoleAttachedProcessIds } from './windows-console-attached-processes' import { terminatePtyJob } from '../windows/windows-pty-job' -import { canConfirmAgentFromConsolePresence } from './windows-console-foreground' +import { canRevalidateCachedAgentWithoutScan } from './windows-cached-agent-revalidation' import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd' @@ -1415,10 +1415,10 @@ export class LocalPtyProvider implements IPtyProvider { // so needs no forked helper (#10857). if ( process.platform === 'win32' && - canConfirmAgentFromConsolePresence(cachedAgent, fallbackProcess) + canRevalidateCachedAgentWithoutScan(cachedAgent, fallbackProcess) ) { try { - const paneProcessIds = readWindowsConptyProcessIds(proc) + const paneProcessIds = readWindowsPtyJobProcessIds(proc) if (ptyProcesses.get(id) !== proc) { return null } @@ -1447,7 +1447,7 @@ export class LocalPtyProvider implements IPtyProvider { const resolvedAgent = resolution.processName ? recognizeAgentProcessFromCommandLine(resolution.processName) : null - // Why: incomplete snapshot + unavailable console probe isn't exit proof; only shell-only membership may clear the cache. + // Why: incomplete snapshot + unavailable job read isn't exit proof; only shell-only membership may clear the cache. const stable = resolveStableForegroundProcess( paneMembershipUnavailable && resolvedAgent === null ? { ...resolution, available: false } diff --git a/src/main/providers/windows-cached-agent-revalidation.test.ts b/src/main/providers/windows-cached-agent-revalidation.test.ts new file mode 100644 index 00000000000..fe6ed56b8d1 --- /dev/null +++ b/src/main/providers/windows-cached-agent-revalidation.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { canRevalidateCachedAgentWithoutScan } from './windows-cached-agent-revalidation' + +describe('canRevalidateCachedAgentWithoutScan', () => { + it('is true for a cached agent when node-pty only names the shell (a scan would run)', () => { + expect(canRevalidateCachedAgentWithoutScan('claude', 'powershell.exe')).toBe(true) + expect(canRevalidateCachedAgentWithoutScan('codex', 'cmd.exe')).toBe(true) + }) + + it('is false when node-pty already names a recognized agent (no scan needed)', () => { + // Nothing to save here — the fast no-scan path already returns the agent. + expect(canRevalidateCachedAgentWithoutScan('claude', 'claude')).toBe(false) + }) + + it('is false for a generic wrapper that may outlive the cached agent', () => { + expect(canRevalidateCachedAgentWithoutScan('claude', 'node.exe')).toBe(false) + }) + + it('is false when no agent has been recognized yet (identity must be established first)', () => { + expect(canRevalidateCachedAgentWithoutScan(null, 'powershell.exe')).toBe(false) + }) + + it('is false when there is no fallback process name', () => { + expect(canRevalidateCachedAgentWithoutScan('claude', null)).toBe(false) + }) +}) diff --git a/src/main/providers/windows-console-foreground.ts b/src/main/providers/windows-cached-agent-revalidation.ts similarity index 75% rename from src/main/providers/windows-console-foreground.ts rename to src/main/providers/windows-cached-agent-revalidation.ts index 77b5bfe4f6d..fbc22d61a46 100644 --- a/src/main/providers/windows-console-foreground.ts +++ b/src/main/providers/windows-cached-agent-revalidation.ts @@ -1,7 +1,7 @@ import { isShellProcess } from '../../shared/shell-process-detection' -/** Whether ConPTY membership can revalidate a cached agent without a process scan. */ -export function canConfirmAgentFromConsolePresence( +/** Whether job membership can revalidate a cached agent without a process scan. */ +export function canRevalidateCachedAgentWithoutScan( cachedAgentName: string | null, fallbackProcess: string | null ): boolean { diff --git a/src/main/providers/windows-console-foreground.test.ts b/src/main/providers/windows-console-foreground.test.ts deleted file mode 100644 index f13ef2ae556..00000000000 --- a/src/main/providers/windows-console-foreground.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { canConfirmAgentFromConsolePresence } from './windows-console-foreground' - -describe('canConfirmAgentFromConsolePresence', () => { - it('is true for a cached agent when node-pty only names the shell (a scan would run)', () => { - expect(canConfirmAgentFromConsolePresence('claude', 'powershell.exe')).toBe(true) - expect(canConfirmAgentFromConsolePresence('codex', 'cmd.exe')).toBe(true) - }) - - it('is false when node-pty already names a recognized agent (no scan needed)', () => { - // Nothing to save here — the fast no-scan path already returns the agent. - expect(canConfirmAgentFromConsolePresence('claude', 'claude')).toBe(false) - }) - - it('is false for a generic wrapper that may outlive the cached agent', () => { - expect(canConfirmAgentFromConsolePresence('claude', 'node.exe')).toBe(false) - }) - - it('is false when no agent has been recognized yet (identity must be established first)', () => { - expect(canConfirmAgentFromConsolePresence(null, 'powershell.exe')).toBe(false) - }) - - it('is false when there is no fallback process name', () => { - expect(canConfirmAgentFromConsolePresence('claude', null)).toBe(false) - }) -}) diff --git a/src/main/providers/windows-conpty-process-membership.test.ts b/src/main/providers/windows-pty-job-membership.test.ts similarity index 67% rename from src/main/providers/windows-conpty-process-membership.test.ts rename to src/main/providers/windows-pty-job-membership.test.ts index f6111d047dd..be60fd3f6f3 100644 --- a/src/main/providers/windows-conpty-process-membership.test.ts +++ b/src/main/providers/windows-pty-job-membership.test.ts @@ -11,11 +11,11 @@ vi.mock('node:child_process', async (importOriginal) => ({ fork: forkMock })) -import { readWindowsConptyProcessIds } from './windows-conpty-process-membership' +import { readWindowsPtyJobProcessIds } from './windows-pty-job-membership' const pty = (pid = 100): IPty => ({ pid }) as unknown as IPty -describe('readWindowsConptyProcessIds', () => { +describe('readWindowsPtyJobProcessIds', () => { it('never spawns a child process to answer', () => { // The whole point. node-pty answers console membership by FORKING a helper, // and Orca asked on a foreground poll, per pane -- hundreds of hidden @@ -27,7 +27,7 @@ describe('readWindowsConptyProcessIds', () => { forkMock.mockClear() for (let read = 0; read < 50; read += 1) { - readWindowsConptyProcessIds(pty(), { listJobProcessIds }) + readWindowsPtyJobProcessIds(pty(), { listJobProcessIds }) } expect(forkMock).not.toHaveBeenCalled() @@ -35,14 +35,14 @@ describe('readWindowsConptyProcessIds', () => { }) it('reports the shell alone, which is what lets a stale agent be retired', () => { - const membership = readWindowsConptyProcessIds(pty(), { listJobProcessIds: () => [100] }) + const membership = readWindowsPtyJobProcessIds(pty(), { listJobProcessIds: () => [100] }) expect(membership).toEqual(new Set([100])) expect(membership?.size).toBe(1) }) it('reports descendants, which is what keeps a live agent cached', () => { - const membership = readWindowsConptyProcessIds(pty(), { + const membership = readWindowsPtyJobProcessIds(pty(), { listJobProcessIds: () => [100, 200, 300] }) @@ -56,11 +56,11 @@ describe('readWindowsConptyProcessIds', () => { // null is never evidence that processes died // (docs/reference/ssh-execution-boundary.md). An empty list means the tree // is gone, which this function has never been the one to report. - expect(readWindowsConptyProcessIds(pty(), { listJobProcessIds: () => pids })).toBeNull() + expect(readWindowsPtyJobProcessIds(pty(), { listJobProcessIds: () => pids })).toBeNull() }) it('drops nonsense pids rather than trusting the whole answer', () => { - const membership = readWindowsConptyProcessIds(pty(), { + const membership = readWindowsPtyJobProcessIds(pty(), { listJobProcessIds: () => [100, 0, -1, 1.5, 200] }) @@ -69,6 +69,31 @@ describe('readWindowsConptyProcessIds', () => { }) describe('why the filter does NOT use this', () => { + it('refuses an answer that does not contain the shell', () => { + // Shell exited, a descendant is still up. Size 1 -- but reading that as + // "the shell is alone, retire the agent" inverts the truth. The forked + // probe this replaced required the root in the raw list; so does this. + const membership = readWindowsPtyJobProcessIds(pty(100), { + listJobProcessIds: () => [200] + }) + + expect(membership).toBeNull() + }) + + it('is asked with the pty handle, because a bare pid cannot find the job', () => { + // ptyJobTarget reads node-pty's private `_pty` id off the object and pairs + // it with proc.pid; the native side refuses on a mismatch. Passing proc.pid + // instead of proc types fine at some call sites but makes every pane report + // unverifiable, so pin the argument. + const listJobProcessIds = vi.fn(() => [100]) + const proc = pty(100) + + readWindowsPtyJobProcessIds(proc, { listJobProcessIds }) + + expect(listJobProcessIds).toHaveBeenCalledWith(proc) + expect(listJobProcessIds).not.toHaveBeenCalledWith(100) + }) + it('documents that job membership keeps console-detached descendants', () => { // The candidate filter in windows-agent-foreground-process.ts exists to DROP // a descendant that left the console (`Start-Process droid`, a GUI child). @@ -81,7 +106,7 @@ describe('why the filter does NOT use this', () => { // vs console [69908,40980] -- the job is a superset. Harmless for the // `size > 1` callers, wrong for the filter. const detachedChild = 104068 - const membership = readWindowsConptyProcessIds(pty(40980), { + const membership = readWindowsPtyJobProcessIds(pty(40980), { listJobProcessIds: () => [40980, detachedChild] }) diff --git a/src/main/providers/windows-conpty-process-membership.ts b/src/main/providers/windows-pty-job-membership.ts similarity index 76% rename from src/main/providers/windows-conpty-process-membership.ts rename to src/main/providers/windows-pty-job-membership.ts index a17610632dd..c0b20680d66 100644 --- a/src/main/providers/windows-conpty-process-membership.ts +++ b/src/main/providers/windows-pty-job-membership.ts @@ -1,7 +1,7 @@ import type { IPty } from 'node-pty' import { listPtyJobProcessIds } from '../windows/windows-pty-job' -type WindowsConptyMembershipDeps = { +type WindowsPtyJobMembershipDeps = { listJobProcessIds?: (proc: IPty) => readonly number[] | null } @@ -31,9 +31,9 @@ type WindowsConptyMembershipDeps = { * an unpatched node-pty, a non-ConPTY terminal, or a tree no longer tracked. * It is never evidence that processes died. */ -export function readWindowsConptyProcessIds( +export function readWindowsPtyJobProcessIds( proc: IPty, - deps: WindowsConptyMembershipDeps = {} + deps: WindowsPtyJobMembershipDeps = {} ): ReadonlySet | null { const pids = (deps.listJobProcessIds ?? listPtyJobProcessIds)(proc) if (!pids) { @@ -45,8 +45,10 @@ export function readWindowsConptyProcessIds( membership.add(pid) } } - // Why reject an empty set: the job answering with no members at all is not - // the shell-alone case callers decide on -- it means the tree is gone, which - // this function has never been the one to report. - return membership.size > 0 ? membership : null + // Why require the shell: callers read a size-1 set as "the shell is alone, so + // retire the agent". A set of one pid that is NOT the shell means the shell + // already exited with a descendant still up -- the opposite state, and one the + // forked probe this replaced refused outright (it required the root in the raw + // list). Reporting unverifiable keeps that refusal. + return membership.has(proc.pid) ? membership : null }