mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
fix(windows): let a superset job answer expire instead of vetoing retirement
Round 3. The job read had reintroduced #9258's bug by a new mechanism.
`size > 1` returned unconditionally, so any pane holding a console-detached
descendant never retired its cached agent. A WSL pane always holds some: the
measurement in this PR's own test recorded job [40980,104068,4888,69908] against
console [69908,40980], i.e. console said "shell alone, retire" while the job said
"three others alive, keep". #9258's third commit describes the identical failure
from the other direction -- a bare shell reading as [helper, shell] "looked like
it still had a child ... the foreground refresh held the exited agent's identity
indefinitely" -- and that is what came back.
It bites because the read branch that serves the cached name across a Windows
shell fallback is deliberately untimed: #9258 made it so on the stated assumption
that "the background refresh authoritatively retires it". Removing the retire
authority left the identity with no bound at all. Second-order: a non-null cache
makes idleNoEvidenceShell false, which pins the refresh at the 1s TTL, so an idle
WSL pane also scanned the process table every second forever.
A TTL on the read would have been the wrong fix -- untimed is deliberate, because
on Windows the fallback name is structurally uninformative. Instead the job answer
is treated as what it is: a SUPERSET of the console, which cannot tell a working
agent from a leftover. Proof of absence retires immediately (size 1, unchanged);
an inconclusive answer ages out at 30s; unverifiable (null) still holds forever
per ssh-execution-boundary.md. Only successful scans that found no agent advance
the clock -- a degraded scan returns before this -- so the fix cannot expire an
agent it simply failed to see.
Also from review:
- Restore the root requirement the forked probe had. Without it a set of one
non-root pid -- shell gone, descendant alive -- read as "shell alone, retire",
inverting the truth.
- Rename to windows-pty-job-membership.ts / readWindowsPtyJobProcessIds. The old
name still said ConPTY console while reading the job, and conflating those two
sets is precisely the bug aee07c24aa reverted. Same for
windows-console-foreground.ts, which guards a job read now.
- Gate the two files that had no coverage: the job read and the retire path.
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -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 {
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
+33
-8
@@ -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]
|
||||
})
|
||||
|
||||
+9
-7
@@ -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<number> | 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
|
||||
}
|
||||
Reference in New Issue
Block a user