fix(windows): revalidate PTY liveness from the job object, not a forked helper (#16419)

* fix(windows): answer console membership from the job object, not a forked helper

node-pty answers "which processes are attached to this pane's console?" by
FORKING a helper, because GetConsoleProcessList must run from a process
attached to that console. Orca asked on a foreground poll, per pane, so each
read spawned a conpty_console_list_agent -- hundreds of hidden processes
exhausting RAM within minutes, respawning as fast as they were killed (#10857).

QueryInformationJobObject has no console-attachment constraint: any process
holding the job handle can ask. Orca already creates that job per PTY, and
listPtyJobProcessIds has exposed it since the W1/W2 work with zero callers.
One syscall, no children.

Semantics the three call sites rely on are preserved: a root-only set still
proves the shell is alone (so a stale agent can be retired), and size > 1 still
proves something is running under it. The single difference is that a
descendant detached from the console stays in the job -- which widens the set,
the conservative direction for every caller.

Also fixes the third call site, which returned { available: false } whenever
membership was unavailable AND a recognized agent existed -- i.e. exactly while
an agent was running. Membership only ever narrowed the candidate list, so an
unavailable answer now leaves it unfiltered instead of failing the whole
resolution.

The no-fork test is asserted through a module-level vi.mock of
node:child_process. A vi.spyOn of a require()'d child_process does not
intercept the module's own import binding: the first version of that test
passed with a fork() deliberately reintroduced.

* fix(windows): keep console attachment for the candidate filter

Readiness review caught that this PR changed two different questions as if they
were one, and the repo's own plan doc had already said so:

  "The job is the wrong set here -- it would re-admit precisely the detached
   process the filter exists to drop."  (windows-wsl-root-cause-plan.html, Use B)

The two uses:

- Use A, `size > 1` at local-pty-provider and the daemon tracker -- "is anything
  in this pane besides the shell?". The job answers this, in-process and with no
  fork. Unchanged from the previous commit.
- Use B, the candidate filter -- "which of these are ATTACHED TO THIS CONSOLE?".
  Its whole job is dropping a descendant that detached, and the job object keeps
  those, so answering it from the job makes the filter a no-op in its motivating
  case: a detached `Start-Process droid` would be granted byte authority, and a
  detached sibling would make an attached agent look ambiguous.

Use B goes back to GetConsoleProcessList, in its own module named for what it
answers, with its fail-closed null restored. That path is not the #10857 storm:
it runs only when a recognized agent candidate already exists, not on every
foreground poll. Bounding it to one pooled supervised helper is the remaining
half, and per the plan doc either half alone takes #10857 from unbounded to one.

My earlier claim that widening membership is "the conservative direction for
every caller" was wrong -- true for Use A, backwards for Use B. The hardware run
did not catch it because I measured a WSL pane, where the superset is harmless,
and never a detached GUI child, which is the divergence.

* fix: restore the coverage and ratchets the module split dropped

Round 2 of review. Two blockers, both from moving the forking code to a new
file without moving what guarded it.

- The child_process import ratchet was RED: windows-console-attached-processes.ts
  imports node:child_process and was unlisted, and the old entry was stale. I
  never ran that suite -- lint and the providers/daemon tests both pass without
  it, which is exactly the gap the ratchet exists to close. Entry repointed;
  count unchanged at 159.
- The forking module had ZERO tests. Its 11 assertions -- bounded timeout,
  single kill, spawn error, malformed message, helper-pid removal -- were in the
  file that now answers a different question, so the module that actually caused
  #10857 was shipping untested. Moved with the code.

Also: nothing pinned the round-1 fix itself. No test drove console attachment to
null and asserted the fail-closed result, so re-deleting that branch would have
gone green. Now covered, and verified to fail when the branch is removed.

Cleanups the split left behind: `consoleMembershipUnavailable`/`consoleProcessIds`
renamed to `pane*` where they now hold job membership, the duplicated
`WindowsConptyMembershipDeps` type name, comments still describing the console
on the job path, and eight reliability-gate paths pointing at the moved tests.

* 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.

* fix(windows): bound the provider's job short-circuit too

The previous commit fixed the daemon retire path and left the identical bug in
the local provider, which I found while asking the reviewer to check for it.

local-pty-provider.ts returned the cached agent early on `size > 1` and that
early return skips the scan at the bottom of getForegroundProcess -- the ONLY
code that can delete ptyLastRecognizedForeground. So on a WSL pane, whose job
always holds console-detached plumbing, the short-circuit was permanent and the
identity could never be cleared. Same failure, second location, and the daemon
fix did nothing for it because this path never calls retireStaleForegroundIdentity.

The cache was a bare Map<id, name> with no timestamp, so bounding it needs one.
Added ptyLastRecognizedForegroundAt, stamped only when the recognized name
actually changes, and paired with every existing delete including pane teardown
so the new map cannot outlive the old one.

The 30s threshold now lives in windows-cached-agent-revalidation.ts rather than
being duplicated: that module already answers "can we revalidate this cached
agent without a scan", and the max age is the other half of that question.

Also renamed two tests that still said "ConPTY console presence" while driving a
job read. Re-conflating those two sets by name is how this PR got its first two
review rounds wrong.

* fix(windows): stamp the provider cache on every confirmation, not on change

My own previous commit was wrong, and wrong in the direction #9258 exists to
prevent. Review caught it; the test in this commit reproduces it first.

I stamped ptyLastRecognizedForegroundAt only when the recognized name CHANGED.
That makes the value the time of first recognition, so the age measures how long
the agent has been running rather than how long since we last confirmed it. For
a live agent recognized as the same name every cycle the stamp never moved, the
age crossed 30s and stayed there, and the short-circuit died permanently.

Two consequences, the second serious:
- every getForegroundProcess call on a >30s-old agent pane ran the whole-table
  scan, defeating the exact optimization the branch exists for;
- with the short-circuit off, one available-but-agentless snapshot was enough to
  delete a LIVE agent's identity, because paneMembershipUnavailable is false in
  this state so the degraded-scan substitution does not engage. That is the false
  "agent done" this code's own comment warns about.

The daemon path was already right -- it re-stamps refreshedAt on every positive
recognition -- so the same constant meant two different things in the two files.
Now both mean "time since we last saw the agent", which turns the bound from
"disable the short-circuit after 30s" into "force one revalidating scan every
30s": ~16-31ms per pane per 30s via the native process table.

Test asserts the scan count stops incrementing after the revalidation, and fails
against the stamp-on-change form.

Also correct the shared docstring, which had dropped the invariant the whole
design rests on, and stop calling this a WSL bug: the trigger is a persistent
console-detached job member plus a fallback that reads as a shell. wsl.exe is
not in SHELL_NAMES, so a plain WSL pane does not even reach this code -- WSL is
just where it was measured.

* refactor(windows): shrink the job-membership path

Elegance pass. No behaviour change -- all three mutation checks still bind
(restoring the size>1 veto, stamping only on name change, dropping the root
requirement each turn their tests red).

- windows-pty-job-membership.ts 54 -> 31 lines. A deps object carrying one
  optional function became a defaulted parameter, the accumulate loop became a
  filter, and the docblock lost two thirds of its bulk.

  It also lost a claim that was simply false: it said a widened set "is the
  conservative direction for every caller: it keeps a live agent rather than
  retiring it early". For the retire caller, never retiring IS the failure --
  that is the bug this stack just fixed, still being described as a feature
  three commits later.

- One local `identityOlderThan(ms)` in the tracker replaces two hand-rolled
  `Date.now() - refreshedAt` comparisons, one of which I had added.

- The provider's two parallel maps collapse into one Map<id, {name, at}>.
  Parallel maps meant every delete site had to remember its sibling, in three
  places; the reviewer flagged the leak risk and I fixed it by pairing them,
  which leaves the hazard for the next person. One map removes the class.

Comments trimmed to the load-bearing sentence throughout, per AGENTS.md.

* fix(windows): preserve foreground cache age evidence

* fix(windows): anchor cached agent identity to the pid that proved it

The job short-circuit and retirement veto only knew 'something besides the
shell is alive', so a detached leftover pinned a dead agent's name for the
30s age bound, and 30s of incomplete-but-successful scans could retire a
live one. The scan already knows which row proved the name: carry that pid
through the resolution, and judge the cache against the job with it --
membership of a known pid in a complete, inescapable job list is proof of
life (restamp, never expire), and its absence is proof of exit (retire now,
leftovers notwithstanding). Unanchored identities keep the age-bound
superset behavior.

* fix(windows): anchor the reported process, and let a scan refute a recycled pid

Review findings on the pid anchor:

1. The anchor followed the LEAF that proved a collapsed name: 'omp' reported,
   pi's pid stored. Pi exiting or restarting under a live OMP then read as the
   wrapper's exit -- retiring the identity before a scan that (degraded) may
   miss OMP, a false 'agent done'. resolveOuterWrapperForegroundIdentity now
   carries the pid of the process the name belongs to.

2. A bare numeric pid can be recycled inside the pane's job, making membership
   falsely confirm a dead identity indefinitely. Command lines are immutable,
   so a scan row holding the anchor pid without recognizing as an agent proves
   a different process: the resolution reports it (anchorPidForeign) and both
   consumers retire immediately. A query-denied row (command falls back to the
   image name) stays inconclusive -- never grounds to drop a live agent.

* fix(windows): find a recycled anchor pid in the full table, not the ppid walk

A squatter that inherited the pane job from a leftover whose creator then
exited is orphaned out of the shell-rooted descendant projection, so the
foreign-anchor refutation never saw its row. Pluck the anchor pid's row from
the same whole-table snapshot instead; a job member holding the pid is in the
table even when no ppid chain reaches it.

* fix(windows): survive an agent restart, and refute a squatter by name

Two review findings on the exit verdicts:

1. 'exited' deleted the cache before the scan, so an agent restarting under a
   new pid plus a degraded scan at that instant reported the shell -- a false
   'agent done'. Only the shell standing alone is decisive now; an anchor
   leaving a job that still has members downgrades to unanchored, age-bounded
   evidence and lets the scan decide. The daemon tracker keeps immediate
   retirement: its verdict path only runs after an available scan already
   found no agent.

2. The foreign-anchor refutation treated any recognized row as 'ours'. A pid
   recycled by a DIFFERENT agent now compares against the cached name the
   anchor is supposed to prove.
This commit is contained in:
Neil
2026-08-25 20:32:54 -07:00
committed by GitHub
parent 5e5457983a
commit a1ec0479e2
40 changed files with 1321 additions and 265 deletions
+35 -14
View File
@@ -10874,7 +10874,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-conpty-process-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 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"
],
@@ -10889,7 +10889,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/providers/windows-conpty-process-membership.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",
@@ -10928,13 +10930,30 @@
]
},
{
"file": "src/main/providers/windows-conpty-process-membership.test.ts",
"file": "src/main/providers/windows-console-attached-processes.test.ts",
"assertions": [
"the fixed node-pty helper returns validated integer console membership containing the PTY root",
"root-only fallback, malformed/missing-root messages, spawn errors, and a silent helper timeout all fail closed",
"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": [
@@ -11082,13 +11101,13 @@
],
"evidenceRuns": [
{
"date": "2026-07-10",
"date": "2026-08-25",
"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-conpty-process-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",
"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."
"durationSeconds": 3.88,
"summary": "Twenty-six focused test files passed (489 tests) on the PR head, 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."
}
],
"runtimeBudget": {
@@ -11147,7 +11166,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-conpty-process-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"
"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",
@@ -11156,7 +11175,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/providers/windows-conpty-process-membership.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",
@@ -11207,7 +11228,7 @@
"assertions": ["fresh confirmation is discarded when its owning local PTY exits"]
},
{
"file": "src/main/providers/windows-conpty-process-membership.test.ts",
"file": "src/main/providers/windows-console-attached-processes.test.ts",
"assertions": [
"exact ConPTY console membership comes from the fixed node-pty helper",
"malformed, incomplete, timed-out, and spawn-error membership reads fail closed"
@@ -11248,13 +11269,13 @@
"summary": "The focused file passed 9 tests, including current-incarnation managed-Claude authorization with zero foreground calls and fail-closed bare, unverified, and replacement-incarnation quarter-circle titles."
},
{
"date": "2026-07-11",
"date": "2026-08-25",
"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-conpty-process-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",
"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."
"durationSeconds": 13.74,
"summary": "Twelve focused test files passed (1351 tests, 1 skipped), 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."
}
],
"runtimeBudget": {
@@ -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: () => 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: () => 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'
@@ -84,7 +84,7 @@ describe('daemon pty foreground degraded-scan handling', () => {
isPwshAvailableMock.mockReturnValue(false)
resolveAgentForegroundProcessMock.mockReset()
readConptyMock.mockReset()
readConptyMock.mockResolvedValue(null)
readConptyMock.mockReturnValue(null)
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
userDataPath = mkdtempSync(join(tmpdir(), 'daemon-pty-degraded-scan-test-'))
process.env.ORCA_USER_DATA_PATH = userDataPath
@@ -127,11 +127,11 @@ 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 })
readConptyMock.mockResolvedValue(new Set([12345, 999])) // child still attached
readConptyMock.mockReturnValue(new Set([12345, 999])) // child still attached
const { handle } = await spawnWindowsShell()
await readForegroundAt(handle, 0)
@@ -139,11 +139,137 @@ 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 an anchored agent immediately when its pid leaves the job, despite a leftover', async () => {
// With an anchor pid the detached-leftover shape no longer pins a dead
// agent for the age bound: the anchor missing from a complete job read is
// proof of exit, leftovers notwithstanding.
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({
available: true,
processName: 'claude',
processId: 999
})
.mockResolvedValue({ available: true, processName: null })
readConptyMock.mockReturnValue(new Set([12345, 999]))
const { handle } = await spawnWindowsShell()
await readForegroundAt(handle, 0)
// Agent 999 exits; detached plumbing 777 keeps the job larger than the shell.
readConptyMock.mockReturnValue(new Set([12345, 777]))
await readForegroundAt(handle, 1_000) // refresh sees the anchor gone and clears
expect(await readForegroundAt(handle, 1_100)).toBe('powershell.exe')
})
it('never retires an anchored agent the job still holds, even when scans miss it', async () => {
// The anchor pid alive in the job is proof of life: agentless-but-available
// scans past the age bound restamp instead of retiring a working agent.
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({
available: true,
processName: 'claude',
processId: 999
})
.mockResolvedValue({ available: true, processName: null })
readConptyMock.mockReturnValue(new Set([12345, 999]))
const { handle } = await spawnWindowsShell()
await readForegroundAt(handle, 0)
await readForegroundAt(handle, 40_000) // pre-fix: this refresh cleared the cache
expect(await readForegroundAt(handle, 40_100)).toBe('claude')
expect(await readForegroundAt(handle, 120_000)).toBe('claude')
})
it('retires an anchored agent when the scan proves its pid was recycled', async () => {
// Squatter reuse: the pid survives in the job, but the scan shows it now
// runs a non-agent. Proof of life must yield to proof of a different process.
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({ available: true, processName: 'claude', processId: 999 })
.mockResolvedValue({ available: true, processName: null, anchorPidForeign: true })
readConptyMock.mockReturnValue(new Set([12345, 999]))
const { handle } = await spawnWindowsShell()
await readForegroundAt(handle, 0)
await readForegroundAt(handle, 1_000) // refresh sees the foreign anchor and clears
expect(await readForegroundAt(handle, 1_100)).toBe('powershell.exe')
})
it('retires a cached agent when a scan finds no agent and the console is shell-only', async () => {
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({ available: true, processName: 'claude' })
.mockResolvedValue({ available: true, processName: null })
readConptyMock.mockResolvedValue(new Set([12345]))
readConptyMock.mockReturnValue(new Set([12345]))
const { handle } = await spawnWindowsShell()
await readForegroundAt(handle, 0)
@@ -156,7 +282,7 @@ describe('daemon pty foreground degraded-scan handling', () => {
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({ available: true, processName: 'claude' })
.mockResolvedValue({ available: true, processName: null })
readConptyMock.mockResolvedValue(null)
readConptyMock.mockReturnValue(null)
const { handle } = await spawnWindowsShell()
await readForegroundAt(handle, 0)
@@ -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: () => 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: () => 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: () => 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: () => 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: () => 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: () => new Set([12345])
}))
import { createPtySubprocess } from './pty-subprocess'
+2 -2
View File
@@ -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: () => new Set([12345])
}))
import { createPtySubprocess, checkPtySpawnHealth } from './pty-subprocess'
@@ -0,0 +1,24 @@
import { win32 as pathWin32 } from 'node:path'
function normalizeForegroundProcessName(processName: string | null | undefined): string | null {
const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? ''
if (!trimmed || trimmed === 'xterm-256color') {
return null
}
return trimmed.split(/[\\/]/).pop() || null
}
/**
* node-pty's reported foreground name, normalized to a bare executable.
* Windows ConPTY can report nothing useful; fall back to the spawned shell.
*/
export function resolveFallbackForegroundProcess(
processName: string | null | undefined,
shellPath: string
): string | null {
const normalized = normalizeForegroundProcessName(processName)
if (normalized || process.platform !== 'win32') {
return normalized
}
return normalizeForegroundProcessName(pathWin32.basename(shellPath))
}
@@ -1,15 +1,23 @@
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 {
judgeCachedAgentJobEvidence,
WINDOWS_DETACHED_DESCENDANT_IDENTITY_MAX_AGE_MS
} from '../../providers/windows-cached-agent-revalidation'
import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership'
import { readWindowsConsoleAttachedProcessIds } from '../../providers/windows-console-attached-processes'
import {
isAgentForegroundWrapperProcess,
recognizeAgentProcess,
type RecognizedAgentProcess
} from '../../../shared/agent-process-recognition'
import { shouldInspectOuterWrapperForegroundProcess } from '../../../shared/foreground-wrapper-agent'
import {
shouldInspectOuterWrapperForegroundName,
shouldInspectOuterWrapperForegroundProcess
} from '../../../shared/foreground-wrapper-agent'
import { isShellProcess } from '../../../shared/shell-process-detection'
import { resolveFallbackForegroundProcess } from './foreground-fallback-process'
import { parsePtySessionId } from '../pty-session-id'
const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
@@ -18,29 +26,7 @@ const WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS = 15_000
const SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS = 10_000
const STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS = 5_000
function normalizeForegroundProcessName(processName: string | null | undefined): string | null {
const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? ''
if (!trimmed || trimmed === 'xterm-256color') {
return null
}
return trimmed.split(/[\\/]/).pop() || null
}
function resolveFallbackForegroundProcess(
processName: string | null | undefined,
shellPath: string
): string | null {
const normalized = normalizeForegroundProcessName(processName)
if (normalized || process.platform !== 'win32') {
return normalized
}
return normalizeForegroundProcessName(pathWin32.basename(shellPath))
}
function shouldInspectOuterWrapperFallback(processName: string | null): boolean {
const recognized = recognizeAgentProcess(processName)
return recognized !== null && shouldInspectOuterWrapperForegroundProcess(recognized)
}
type CachedAgentForeground = { processName: string; pid: number | null; refreshedAt: number }
export type PtyForegroundProcessTracker = {
recordOutput(data: string): void
@@ -59,7 +45,8 @@ export function createPtyForegroundProcessTracker(args: {
}): PtyForegroundProcessTracker {
const proc = args.process
let lastOutputAt = 0
let cachedAgentForeground: { processName: string; refreshedAt: number } | null = null
// `pid` anchors the identity to the row that proved it (null when ambiguous).
let cachedAgentForeground: CachedAgentForeground | null = null
const contextPaths = getAgentForegroundContextPaths({
cwd: args.cwd,
worktreeId: parsePtySessionId(args.sessionId).worktreeId
@@ -91,7 +78,7 @@ export function createPtyForegroundProcessTracker(args: {
fallbackProcess !== null &&
(isShellProcess(fallbackProcess) ||
isAgentForegroundWrapperProcess(fallbackProcess) ||
shouldInspectOuterWrapperFallback(fallbackProcess) ||
shouldInspectOuterWrapperForegroundName(fallbackProcess) ||
process.platform !== 'win32')
const scheduleRefresh = (fallbackProcess: string | null): void => {
@@ -121,45 +108,76 @@ export function createPtyForegroundProcessTracker(args: {
}
foregroundRefreshInFlight = true
lastForegroundRefreshStartedAt = now
const retireStaleForegroundIdentity = (): void => {
const identityOlderThan = (ms: number): boolean =>
cachedAgentForeground !== null && Date.now() - cachedAgentForeground.refreshedAt > ms
const retireStaleForegroundIdentity = ({ onlyWhenAged = false } = {}): void => {
const currentFallbackProcess = getFallbackProcess()
if (
fallbackIsShell &&
!getActiveStartupAgent() &&
currentFallbackProcess !== null &&
isShellProcess(currentFallbackProcess)
isShellProcess(currentFallbackProcess) &&
(!onlyWhenAged || identityOlderThan(WINDOWS_DETACHED_DESCENDANT_IDENTITY_MAX_AGE_MS))
) {
cachedAgentForeground = null
startupAgentForeground = null
} else if (
cachedAgentForeground !== null &&
Date.now() - cachedAgentForeground.refreshedAt > FOREGROUND_AGENT_CACHE_TTL_MS &&
identityOlderThan(FOREGROUND_AGENT_CACHE_TTL_MS) &&
currentFallbackProcess !== null &&
isAgentForegroundWrapperProcess(currentFallbackProcess)
) {
cachedAgentForeground = null
}
}
const anchor = cachedAgentForeground
void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, {
contextPaths
contextPaths,
...(anchor?.pid != null
? { anchorProcessId: anchor.pid, anchorProcessName: anchor.processName }
: {})
})
.then<string | void>(({ processName, available }) => {
.then<string | void>(({ processName, processId, available, anchorPidForeign }) => {
if (args.isDead() || !available) {
return
}
if (!processName || !recognizeAgentProcess(processName)) {
if (process.platform === 'win32' && fallbackIsShell && cachedAgentForeground !== null) {
return readWindowsConptyProcessIds(proc.pid).then((consoleProcessIds) => {
if (args.isDead() || consoleProcessIds === null || consoleProcessIds.size > 1) {
// Job, not console: needs no console attachment, so no fork (#10857).
const verdict = judgeCachedAgentJobEvidence({
jobProcessIds: readWindowsPtyJobProcessIds(proc),
shellPid: proc.pid,
anchorProcessId: cachedAgentForeground.pid,
identityAgeMs: Date.now() - cachedAgentForeground.refreshedAt
})
// Unverifiable is never exit proof (ssh-execution-boundary.md): hold.
if (verdict === 'unavailable') {
return
}
if (verdict === 'confirmed' || verdict === 'recheck') {
if (anchorPidForeign === true) {
// The scan proved the pid recycled to a non-agent: retire now.
retireStaleForegroundIdentity()
return
}
// The anchor pid is still in the job: the scan lost the row, not
// the agent. Restamp so a live agent never ages out (#9258).
cachedAgentForeground = { ...cachedAgentForeground, refreshedAt: Date.now() }
return
}
if (verdict === 'exited' || verdict === 'anchor-exited') {
// Safe mid-restart: an available scan already found no agent.
retireStaleForegroundIdentity()
})
return
}
// Unanchored superset evidence cannot tell a working agent from a
// leftover; the age bound settles it.
retireStaleForegroundIdentity({ onlyWhenAged: true })
return
}
retireStaleForegroundIdentity()
return
}
cachedAgentForeground = { processName, refreshedAt: Date.now() }
cachedAgentForeground = { processName, pid: processId ?? null, refreshedAt: Date.now() }
startupAgentForeground = null
return processName
})
@@ -192,7 +210,11 @@ export function createPtyForegroundProcessTracker(args: {
fallbackRecognition !== null &&
shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)
if (fallbackProcess && fallbackRecognition && !inspectOuterWrapper) {
cachedAgentForeground = { processName: fallbackProcess, refreshedAt: Date.now() }
cachedAgentForeground = {
processName: fallbackProcess,
pid: null,
refreshedAt: Date.now()
}
startupAgentForeground = null
return fallbackProcess
}
@@ -247,7 +269,8 @@ export function createPtyForegroundProcessTracker(args: {
...(process.platform === 'win32'
? {
forceProcessScan: true,
readWindowsConptyProcessIds: () => readWindowsConptyProcessIds(proc.pid)
readWindowsConsoleAttachedProcessIds: () =>
readWindowsConsoleAttachedProcessIds(proc.pid)
}
: {})
}
@@ -259,6 +282,7 @@ export function createPtyForegroundProcessTracker(args: {
if (recognized) {
cachedAgentForeground = {
processName: recognized.processName,
pid: resolution.processId ?? null,
refreshedAt: Date.now()
}
startupAgentForeground = null
@@ -52,14 +52,41 @@ describe('Pi Windows foreground recognition', () => {
getAllProcessesMock.mockImplementation((cb: (snapshot: unknown) => void) => {
cb(withSelf(rows))
})
const readWindowsConptyProcessIds = vi.fn(async () => new Set([100, 101]))
const readWindowsConsoleAttachedProcessIds = vi.fn(async () => new Set([100, 101]))
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'node.exe', {
fresh: true,
readWindowsConptyProcessIds
readWindowsConsoleAttachedProcessIds
})
).resolves.toEqual({ available: true, processName: 'pi' })
expect(readWindowsConptyProcessIds).toHaveBeenCalledTimes(1)
).resolves.toEqual({ available: true, processName: 'pi', processId: 101 })
expect(readWindowsConsoleAttachedProcessIds).toHaveBeenCalledTimes(1)
})
it('anchors a collapsed omp name to the omp pid, not the embedded pi leaf', async () => {
// Pi restarts under a live OMP; an anchor on pi's pid would read that as
// OMP's exit and fire a false "agent done" when the next snapshot degrades.
const rows = [
{ pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' },
{ pid: 101, ppid: 100, name: 'omp.exe', commandLine: 'omp' },
{
pid: 102,
ppid: 101,
name: 'node.exe',
commandLine:
'node.exe C:\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js'
}
]
getAllProcessesMock.mockImplementation((cb: (snapshot: unknown) => void) => {
cb(withSelf(rows))
})
const readWindowsConsoleAttachedProcessIds = vi.fn(async () => new Set([100, 101, 102]))
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
fresh: true,
readWindowsConsoleAttachedProcessIds
})
).resolves.toEqual({ available: true, processName: 'omp', processId: 101 })
})
})
@@ -407,6 +407,96 @@ describe('resolveAgentForegroundProcess', () => {
).resolves.toEqual({ available: true, processName: null })
})
it('reports a foreign anchor when its pid now runs an unrecognized command', async () => {
// Pid reuse inside the pane's job: the row proves a different process
// (command lines are immutable), so job membership must stop confirming it.
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{ pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' },
{ pid: 999, ppid: 100, name: 'node.exe', commandLine: 'node server.js' }
])
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
anchorProcessId: 999
})
).resolves.toEqual({
available: true,
processName: 'powershell.exe',
anchorPidForeign: true
})
})
it('detects a foreign anchor even when the squatter is orphaned out of the descendant walk', async () => {
// The recycled pid's creator exited, so the row is not a ppid-descendant of
// the shell — but it can still be the job member holding the anchor pid.
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{ pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' },
{ pid: 999, ppid: 500, name: 'node.exe', commandLine: 'node server.js' }
])
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
anchorProcessId: 999
})
).resolves.toEqual({
available: true,
processName: 'powershell.exe',
anchorPidForeign: true
})
})
it('flags an anchor recycled by a DIFFERENT agent as foreign', async () => {
// The squatter recognizes as an agent — just not the cached one.
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{ pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' },
{ pid: 999, ppid: 500, name: 'node.exe', commandLine: 'node /usr/bin/codex' }
])
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
anchorProcessId: 999,
anchorProcessName: 'claude'
})
).resolves.toEqual({
available: true,
processName: 'powershell.exe',
anchorPidForeign: true
})
})
it('does not flag an anchor whose row still recognizes as the cached agent', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{ pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' },
{ pid: 999, ppid: 500, name: 'node.exe', commandLine: 'node C:\\npm\\claude' }
])
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
anchorProcessId: 999,
anchorProcessName: 'claude'
})
).resolves.toEqual({ available: true, processName: 'powershell.exe' })
})
it('treats a query-denied anchor row as inconclusive, never foreign', async () => {
// A denied query yields command === name; the agent may just be unreadable.
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{ pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' },
{ pid: 999, ppid: 100, name: 'node.exe', commandLine: '' }
])
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
anchorProcessId: 999
})
).resolves.toEqual({ available: true, processName: 'powershell.exe' })
})
it('treats a Windows snapshot missing the requested shell as unavailable', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
@@ -508,9 +598,39 @@ describe('resolveAgentForegroundProcess', () => {
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
fresh: true,
readWindowsConptyProcessIds: async () => new Set([100, 101])
readWindowsConsoleAttachedProcessIds: async () => new Set([100, 101])
})
).resolves.toEqual({ available: true, processName: 'droid' })
).resolves.toEqual({
available: true,
processName: 'droid',
processId: 101
})
})
it('fails closed when console attachment cannot be read', async () => {
// The filter exists to DROP descendants that left the console. If it cannot
// tell which ones those are, publishing the unfiltered list would grant a
// detached process the pane's identity. #16419 briefly made this fall open
// and nothing caught it, because no test drove the null.
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{ pid: 200, ppid: 199, name: 'powershell.exe', commandLine: 'powershell.exe' },
{ pid: 201, ppid: 200, name: 'droid.exe', commandLine: 'droid' },
{ pid: 202, ppid: 200, name: 'agy.exe', commandLine: 'agy' }
])
const reader = vi.fn(async () => null)
const resolution = await resolveAgentForegroundProcessWithAvailability(200, 'powershell.exe', {
fresh: true,
readWindowsConsoleAttachedProcessIds: reader
})
expect(reader).toHaveBeenCalledTimes(1)
// available:false is the fail-closed signal; the wrapper then substitutes
// the shell fallback rather than publishing an unverified agent identity.
expect(resolution.available).toBe(false)
expect(resolution.processName).toBe('powershell.exe')
})
it('recognizes a Windows shell-rooted agent when only one candidate matches the worktree path', async () => {
@@ -655,15 +775,19 @@ describe('resolveAgentForegroundProcess', () => {
commandLine: 'droid'
}
])
const readWindowsConptyProcessIds = vi.fn(async () => new Set([100, 101, 999]))
const readWindowsConsoleAttachedProcessIds = vi.fn(async () => new Set([100, 101, 999]))
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
fresh: true,
readWindowsConptyProcessIds
readWindowsConsoleAttachedProcessIds
})
).resolves.toEqual({ available: true, processName: 'droid' })
expect(readWindowsConptyProcessIds).toHaveBeenCalledTimes(1)
).resolves.toEqual({
available: true,
processName: 'droid',
processId: 101
})
expect(readWindowsConsoleAttachedProcessIds).toHaveBeenCalledTimes(1)
})
it('excludes a detached Windows Droid descendant from byte authority', async () => {
@@ -686,7 +810,7 @@ describe('resolveAgentForegroundProcess', () => {
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
fresh: true,
readWindowsConptyProcessIds: async () => new Set([100, 999])
readWindowsConsoleAttachedProcessIds: async () => new Set([100, 999])
})
).resolves.toEqual({ available: true, processName: 'powershell.exe' })
})
@@ -701,14 +825,14 @@ describe('resolveAgentForegroundProcess', () => {
commandLine: 'powershell.exe'
}
])
const readWindowsConptyProcessIds = vi.fn(async () => new Set([100, 999]))
const readWindowsConsoleAttachedProcessIds = vi.fn(async () => new Set([100, 999]))
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
fresh: true,
readWindowsConptyProcessIds
readWindowsConsoleAttachedProcessIds
})
).resolves.toEqual({ available: true, processName: 'powershell.exe' })
expect(readWindowsConptyProcessIds).not.toHaveBeenCalled()
expect(readWindowsConsoleAttachedProcessIds).not.toHaveBeenCalled()
})
})
+14 -1
View File
@@ -16,6 +16,14 @@ export type { AgentForegroundResolutionOptions } from './windows-agent-foregroun
export type AgentForegroundProcessResolution = {
available: boolean
processName: string | null
/**
* Windows: pid of the process a recognized name belongs to — a liveness
* anchor callers may check against the pane's job. Absent when the name is a
* fallback, ambiguous, or resolved on POSIX (where `+` already marks it).
*/
processId?: number
/** Windows: the scan proved the caller's `anchorProcessId` is now a non-agent. */
anchorPidForeign?: boolean
}
function collectDescendants<Row extends { pid: number; ppid: number }>(
@@ -86,7 +94,12 @@ export async function resolveAgentForegroundProcessWithAvailability(
resolution.processName ??
(options.forceProcessScan && recognizeAgentProcessFromCommandLine(fallbackProcess)
? null
: fallbackProcess)
: fallbackProcess),
// The anchor only travels with the name it proved, never with a fallback.
...(resolution.processName !== null && resolution.processId !== undefined
? { processId: resolution.processId }
: {}),
...(resolution.anchorPidForeign ? { anchorPidForeign: true } : {})
}
}
@@ -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,
@@ -228,15 +228,15 @@ describe('LocalPtyProvider', () => {
await expect(foreground).resolves.toBeNull()
})
it('confirms a still-active agent from ConPTY console presence without a whole-table scan', async () => {
it('confirms a still-active agent from job membership without a whole-table scan', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.process = 'powershell.exe'
resolveAgentForegroundProcessMock.mockResolvedValue({
available: true,
processName: 'claude'
})
// A child beyond the shell is still attached to this console.
readWindowsConptyProcessIdsMock.mockResolvedValue(new Set([12345, 999]))
// A descendant beyond the shell is still in the pane's job.
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
const { id } = await provider.spawn({ cols: 80, rows: 24 })
// First call establishes the agent identity via the scan.
@@ -247,14 +247,111 @@ describe('LocalPtyProvider', () => {
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(1)
})
it('falls through to the scan when the ConPTY console shows only the shell', async () => {
it('stops trusting job membership once the cached agent goes stale', async () => {
// The daemon path is not the only one that short-circuited on `size > 1`.
// Here the early return skips the scan that is the ONLY thing able to
// clear ptyLastRecognizedForeground, so on a WSL pane -- whose job always
// holds console-detached plumbing -- the identity was pinned for good.
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.process = 'powershell.exe'
resolveAgentForegroundProcessMock.mockResolvedValue({
available: true,
processName: 'claude'
})
readWindowsConptyProcessIdsMock.mockResolvedValue(new Set([12345]))
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
vi.useFakeTimers({ toFake: ['Date'] })
try {
vi.setSystemTime(1_000_000)
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(1)
// Past the bound, the superset answer stops standing in for a scan.
vi.setSystemTime(1_000_000 + 40_000)
resolveAgentForegroundProcessMock.mockResolvedValue({
available: true,
processName: 'powershell.exe'
})
await expect(provider.getForegroundProcess(id)).resolves.toBe('powershell.exe')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('does not refresh the agent age after a degraded revalidation scan', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.process = 'powershell.exe'
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({ available: true, processName: 'claude' })
.mockResolvedValueOnce({ available: false, processName: null })
.mockResolvedValue({ available: true, processName: 'powershell.exe' })
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
vi.useFakeTimers({ toFake: ['Date'] })
try {
vi.setSystemTime(1_000_000)
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
vi.setSystemTime(1_000_000 + 40_000)
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
vi.setSystemTime(1_000_000 + 41_000)
await expect(provider.getForegroundProcess(id)).resolves.toBe('powershell.exe')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(3)
} finally {
vi.useRealTimers()
}
})
it('keeps short-circuiting a live agent instead of scanning on every call', async () => {
// The age bound must mean "time since we last confirmed the agent", not
// "how long the agent has run". Stamping only on a CHANGE of name makes a
// steadily-recognized agent age out permanently: the short-circuit dies
// after 30s, every call runs the whole-table scan, and one available-but-
// agentless snapshot then deletes a LIVE agent -- the false "agent done"
// #9258 exists to prevent.
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.process = 'powershell.exe'
resolveAgentForegroundProcessMock.mockResolvedValue({
available: true,
processName: 'claude'
})
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
vi.useFakeTimers({ toFake: ['Date'] })
try {
vi.setSystemTime(1_000_000)
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(1)
// Past the bound: one revalidating scan is expected, and it re-confirms.
vi.setSystemTime(1_000_000 + 40_000)
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
// That confirmation must restart the clock, so the short-circuit resumes.
vi.setSystemTime(1_000_000 + 45_000)
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('falls through to the scan when the job holds only the shell', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.process = 'powershell.exe'
resolveAgentForegroundProcessMock.mockResolvedValue({
available: true,
processName: 'claude'
})
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345]))
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
@@ -268,7 +365,7 @@ describe('LocalPtyProvider', () => {
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({ available: true, processName: 'claude' })
.mockResolvedValue({ available: true, processName: null })
readWindowsConptyProcessIdsMock.mockResolvedValue(null)
readWindowsPtyJobProcessIdsMock.mockReturnValue(null)
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
@@ -276,13 +373,149 @@ describe('LocalPtyProvider', () => {
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
})
it('retires the cached agent after verified shell-only membership and a no-agent scan', async () => {
it('retires an anchored agent immediately when its pid leaves the job, despite a leftover', async () => {
// Fix for stale-identity-behind-a-leftover: with an anchor pid, a detached
// descendant surviving in the job no longer stands in for the dead agent
// until the age bound -- the missing anchor is proof of exit right now.
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
mockProc.process = 'powershell.exe'
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({
available: true,
processName: 'claude',
processId: 999
})
.mockResolvedValue({ available: true, processName: null })
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
// Agent 999 exits; a detached leftover 777 keeps the job larger than the shell.
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 777]))
await expect(provider.getForegroundProcess(id)).resolves.toBeNull()
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
})
it('never expires an anchored agent the job still holds, even when scans miss it', async () => {
// Fix for false removal of a live agent: the anchor pid in the job is
// proof of life, so >30s of agentless-but-successful scans no longer
// retire a working agent -- and the confirmation restamps the clock.
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
mockProc.process = 'powershell.exe'
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({
available: true,
processName: 'claude',
processId: 999
})
.mockResolvedValue({ available: true, processName: null })
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
vi.useFakeTimers({ toFake: ['Date'] })
try {
vi.setSystemTime(1_000_000)
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
// Past the bound: one drift-recheck scan runs, finds nothing, and the
// live anchor outranks the incomplete snapshot.
vi.setSystemTime(1_000_000 + 40_000)
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
// The proof of life restamped the clock, so the short-circuit resumes.
vi.setSystemTime(1_000_000 + 45_000)
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
// Much later again: recheck, still alive, still claude.
vi.setSystemTime(1_000_000 + 80_000)
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(3)
} finally {
vi.useRealTimers()
}
})
it('holds a restarting agent across a degraded scan instead of reporting an exit', async () => {
// The anchor pid died but another job member remains -- possibly the
// agent's restarted successor under a new pid. A degraded scan at that
// instant must not fire a false "agent done"; the next available scan
// re-recognizes and re-anchors.
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.process = 'powershell.exe'
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({ available: true, processName: 'claude', processId: 999 })
.mockResolvedValueOnce({ available: false, processName: null })
.mockResolvedValue({ available: true, processName: 'claude', processId: 1000 })
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
vi.useFakeTimers({ toFake: ['Date'] })
try {
vi.setSystemTime(1_000_000)
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
// Restart: 999 exits, successor 1000 joins the job; scan degrades.
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 1000]))
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
// Downgraded evidence ages out; the recheck scan re-anchors the successor.
vi.setSystemTime(1_000_000 + 40_000)
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(3)
// Re-anchored on pid 1000: the short-circuit resumes.
vi.setSystemTime(1_000_000 + 45_000)
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(3)
} finally {
vi.useRealTimers()
}
})
it('drops an anchored agent when the drift recheck proves the pid was recycled', async () => {
// The anchor pid stays in the job (a squatter reused it) but the scan
// proves the pid now runs a non-agent: proof of life must not apply.
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.process = 'powershell.exe'
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({ available: true, processName: 'claude', processId: 999 })
.mockResolvedValue({ available: true, processName: null, anchorPidForeign: true })
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
vi.useFakeTimers({ toFake: ['Date'] })
try {
vi.setSystemTime(1_000_000)
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
vi.setSystemTime(1_000_000 + 40_000)
await expect(provider.getForegroundProcess(id)).resolves.toBeNull()
// The recheck scan received the anchor to test against.
expect(resolveAgentForegroundProcessMock).toHaveBeenLastCalledWith(
mockProc.pid,
'powershell.exe',
expect.objectContaining({ anchorProcessId: 999 })
)
} finally {
vi.useRealTimers()
}
})
it('retires the cached agent after verified shell-only membership and a no-agent scan', async () => {
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
mockProc.process = 'powershell.exe'
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({ available: true, processName: 'claude' })
.mockResolvedValue({ available: true, processName: null })
readWindowsConptyProcessIdsMock.mockResolvedValue(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.mockResolvedValue(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,
+80 -24
View File
@@ -64,9 +64,13 @@ 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,
judgeCachedAgentJobEvidence
} 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'
@@ -120,7 +124,12 @@ const pendingLocalPtySpawns = new Map<string, Set<PendingLocalPtySpawn>>()
const ptyShellName = new Map<string, string>()
const ptyAgentForegroundContextPaths = new Map<string, string[]>()
// Why: remember the last recognized agent foreground so a degraded scan doesn't report the shell and look like an exit.
const ptyLastRecognizedForeground = new Map<string, string>()
// `pid` anchors the identity to the row that proved it (null when ambiguous);
// `at` is the last confirmation, so unanchored job evidence -- only a superset -- cannot hold it forever.
const ptyLastRecognizedForeground = new Map<
string,
{ name: string; pid: number | null; at: number }
>()
const ptyTerminalHandle = new Map<string, string>()
const ptyWorktreeId = new Map<string, string>()
const ptyInitialCwd = new Map<string, string>()
@@ -1406,24 +1415,47 @@ export class LocalPtyProvider implements IPtyProvider {
proc.process || null,
ptyShellName.get(id)
)
const cachedAgent = ptyLastRecognizedForeground.get(id) ?? null
let consoleMembershipUnavailable = false
// Why: console membership preserves a live cached agent without the whole-table scan (incomplete under Windows load).
const cachedEntry = ptyLastRecognizedForeground.get(id)
const cachedAgent = cachedEntry?.name ?? null
let paneMembershipUnavailable = false
let cachedAgentAliveInJob = false
// Why: job membership preserves a live cached agent without the whole-table
// scan (incomplete under Windows load). Job, not console: this asks "is
// anything besides the shell alive?", which needs no console attachment and
// so needs no forked helper (#10857).
if (
process.platform === 'win32' &&
canConfirmAgentFromConsolePresence(cachedAgent, fallbackProcess)
canRevalidateCachedAgentWithoutScan(cachedAgent, fallbackProcess)
) {
try {
const consoleProcessIds = await readWindowsConptyProcessIds(proc.pid)
const paneProcessIds = readWindowsPtyJobProcessIds(proc)
if (ptyProcesses.get(id) !== proc) {
return null
}
if (consoleProcessIds !== null && consoleProcessIds.size > 1 && cachedAgent !== null) {
const verdict = judgeCachedAgentJobEvidence({
jobProcessIds: paneProcessIds,
shellPid: proc.pid,
anchorProcessId: cachedEntry?.pid ?? null,
identityAgeMs: Date.now() - (cachedEntry?.at ?? 0)
})
if (verdict === 'confirmed' || verdict === 'unproven') {
return cachedAgent
}
consoleMembershipUnavailable = consoleProcessIds === null
if (verdict === 'exited') {
// The shell stands alone in a complete, inescapable job list: no
// successor is possible, so the identity retires before the scan.
ptyLastRecognizedForeground.delete(id)
} else if (verdict === 'anchor-exited' && cachedEntry) {
// The recognized process died but another member remains -- a
// leftover, or a restarted successor. Keep the name as unanchored,
// age-bounded evidence and let this cycle's scan decide: deleting
// here made a degraded scan read a mid-restart agent as an exit.
ptyLastRecognizedForeground.set(id, { ...cachedEntry, pid: null })
}
cachedAgentAliveInJob = verdict === 'recheck'
paneMembershipUnavailable = verdict === 'unavailable'
} catch {
consoleMembershipUnavailable = true
paneMembershipUnavailable = true
}
}
try {
@@ -1431,7 +1463,10 @@ export class LocalPtyProvider implements IPtyProvider {
proc.pid,
fallbackProcess,
{
contextPaths: ptyAgentForegroundContextPaths.get(id)
contextPaths: ptyAgentForegroundContextPaths.get(id),
...(cachedEntry?.pid != null
? { anchorProcessId: cachedEntry.pid, anchorProcessName: cachedEntry.name }
: {})
}
)
// Why: the scan can outlive PTY teardown/id reuse; stale results must not resurrect cache for a foreign id.
@@ -1439,20 +1474,40 @@ export class LocalPtyProvider implements IPtyProvider {
return null
}
// Why: a degraded scan reporting shell-as-foreground fires a false "agent done"; keep last recognized agent instead.
const lastRecognizedAgent = ptyLastRecognizedForeground.get(id) ?? null
const lastRecognizedAgent = ptyLastRecognizedForeground.get(id)?.name ?? null
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.
const stable = resolveStableForegroundProcess(
consoleMembershipUnavailable && resolvedAgent === null
// A recycled anchor pid keeps job membership truthful but the identity
// dead; the scan proving the pid now runs a non-agent settles it.
const anchorContradicted = resolution.anchorPidForeign === true
// Why: incomplete snapshot + unavailable job read isn't exit proof; and an
// anchor pid still alive in the job outranks a snapshot that lost its row.
const stableResolution =
(paneMembershipUnavailable || cachedAgentAliveInJob) &&
!anchorContradicted &&
resolvedAgent === null
? { ...resolution, available: false }
: resolution,
lastRecognizedAgent
)
if (stable.lastRecognizedAgent) {
ptyLastRecognizedForeground.set(id, stable.lastRecognizedAgent)
} else {
: resolution
const stable = resolveStableForegroundProcess(stableResolution, lastRecognizedAgent)
if (stable.lastRecognizedAgent && stableResolution.available) {
// Only a positive recognition restarts the age bound.
ptyLastRecognizedForeground.set(id, {
name: stable.lastRecognizedAgent,
pid:
stable.lastRecognizedAgent === resolution.processName
? (resolution.processId ?? null)
: null,
at: Date.now()
})
} else if (stable.lastRecognizedAgent && cachedAgentAliveInJob && !anchorContradicted) {
// The anchor pid in the job is proof of life; restamp so the
// short-circuit resumes instead of scanning on every call.
const entry = ptyLastRecognizedForeground.get(id)
if (entry) {
ptyLastRecognizedForeground.set(id, { ...entry, at: Date.now() })
}
} else if (!stable.lastRecognizedAgent) {
ptyLastRecognizedForeground.delete(id)
}
return stable.processName
@@ -1461,7 +1516,7 @@ export class LocalPtyProvider implements IPtyProvider {
return null
}
// Why: an inspection error is a degraded read; fall back to last recognized agent (null reads as an exit).
return ptyLastRecognizedForeground.get(id) ?? null
return ptyLastRecognizedForeground.get(id)?.name ?? null
}
}
@@ -1480,7 +1535,8 @@ export class LocalPtyProvider implements IPtyProvider {
...(process.platform === 'win32'
? {
forceProcessScan: true,
readWindowsConptyProcessIds: () => readWindowsConptyProcessIds(proc.pid)
readWindowsConsoleAttachedProcessIds: () =>
readWindowsConsoleAttachedProcessIds(proc.pid)
}
: {})
}
@@ -6,12 +6,12 @@ import {
type RecognizedAgentProcess
} from '../../shared/agent-process-recognition'
import {
resolveOuterWrapperForegroundProcess,
resolveOuterWrapperForegroundIdentity,
shouldInspectOuterWrapperForegroundProcess
} from '../../shared/foreground-wrapper-agent'
import { isShellProcess } from '../../shared/shell-process-detection'
import {
queryWindowsProcessDescendants,
queryWindowsPaneProcessInventory,
type WindowsProcessCandidate,
type WindowsProcessRow
} from './windows-foreground-process-rows'
@@ -23,12 +23,34 @@ export type AgentForegroundResolutionOptions = {
/** Force confirmation scans even when node-pty reports a recognized name. */
forceProcessScan?: boolean
/** Lazily proves which global descendants still belong to this ConPTY. */
readWindowsConptyProcessIds?: () => Promise<ReadonlySet<number> | null>
readWindowsConsoleAttachedProcessIds?: () => Promise<ReadonlySet<number> | null>
/**
* A caller's cached liveness anchor. When a scan row holds this pid but no
* longer recognizes as the cached agent, the pid was recycled by a different
* process (command lines are immutable): the resolution reports it foreign.
*/
anchorProcessId?: number
/** The cached agent name the anchor pid is supposed to prove. */
anchorProcessName?: string
}
export type WindowsAgentForegroundResolution = {
available: boolean
processName: string | null
/**
* Pid of the process the name belongs to — the liveness anchor a caller may
* check against the pane's job. The OUTER wrapper's pid when the name
* collapsed onto one (its embedded leaf may exit first). Absent when the
* name came from a fallback or when sibling leaves left no single anchor.
*/
processId?: number
/** True when the scan proves `anchorProcessId` now belongs to a non-agent. */
anchorPidForeign?: boolean
}
type WindowsForegroundIdentity = {
processName: string | null
processId?: number
}
export function shouldInspectWindowsAgentForeground(fallbackProcess: string): boolean {
@@ -55,13 +77,14 @@ export async function resolveWindowsAgentForegroundProcessWithAvailability(
fallbackProcess: string,
options: AgentForegroundResolutionOptions
): Promise<WindowsAgentForegroundResolution> {
const candidates = await queryWindowsProcessDescendants(
shellPid,
options.fresh === true ? { fresh: true } : {}
)
if (!candidates) {
const inventory = await queryWindowsPaneProcessInventory(shellPid, {
...(options.fresh === true ? { fresh: true } : {}),
...(options.anchorProcessId !== undefined ? { anchorPid: options.anchorProcessId } : {})
})
if (!inventory) {
return { available: false, processName: null }
}
const candidates = inventory.candidates
// Resolve membership before applying the global ambiguity rule. A detached
// agent can otherwise make an attached Droid look ambiguous and suppress
// the only identity that is actually able to receive this PTY's input.
@@ -71,20 +94,35 @@ export async function resolveWindowsAgentForegroundProcessWithAvailability(
options.contextPaths
)
let filteredCandidates = candidates
if (hasRecognizedCandidate && options.readWindowsConptyProcessIds) {
const conptyProcessIds = await options.readWindowsConptyProcessIds()
if (!conptyProcessIds) {
if (hasRecognizedCandidate && options.readWindowsConsoleAttachedProcessIds) {
// Why console attachment and not the job: this filter exists to DROP a
// descendant that detached from the console, and the job still contains
// those by design. Answering it from the job would re-admit precisely what
// the filter is for -- granting byte authority to a detached `Start-Process
// droid`, or making an attached agent look ambiguous.
const consoleProcessIds = await options.readWindowsConsoleAttachedProcessIds()
if (!consoleProcessIds) {
return { available: false, processName: null }
}
filteredCandidates = candidates.filter((candidate) => conptyProcessIds.has(candidate.pid))
filteredCandidates = candidates.filter((candidate) => consoleProcessIds.has(candidate.pid))
}
// From the FULL table, not the ppid projection: an orphaned job member (its
// creator exited) leaves the descendant walk yet can hold a recycled pid.
const anchorRow = inventory.anchorRow
const anchorRecognized = anchorRow === null ? null : recognizeWindowsProcessCandidate(anchorRow)
const anchorPidForeign =
anchorRow !== null &&
(anchorRecognized !== null
? // A recognized row is foreign when it names a DIFFERENT agent.
options.anchorProcessName !== undefined &&
anchorRecognized.processName !== options.anchorProcessName
: // A query-denied row falls back to command === name; that is
// inconclusive (the agent may just be unreadable), never foreign.
anchorRow.command !== anchorRow.name)
return {
available: true,
processName: resolveWindowsProcessName(
filteredCandidates,
fallbackProcess,
options.contextPaths
)
...resolveWindowsForegroundIdentity(filteredCandidates, fallbackProcess, options.contextPaths),
...(anchorPidForeign ? { anchorPidForeign: true } : {})
}
}
@@ -105,11 +143,11 @@ function windowsCandidatesContainRecognizedAgent(
)
}
function resolveWindowsProcessName(
function resolveWindowsForegroundIdentity(
candidates: readonly WindowsProcessCandidate[],
fallbackProcess: string,
contextPaths: readonly string[] | undefined
): string | null {
): WindowsForegroundIdentity {
if (isShellProcess(fallbackProcess)) {
return resolveShellForegroundProcessFromWindowsCandidates(candidates, contextPaths)
}
@@ -128,15 +166,15 @@ function resolveWindowsProcessName(
recognizeAgentProcessFromCommandLine(candidate.command) ??
recognizeAgentProcessFromCommandLine(candidate.name)
if (recognized) {
return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates)
return resolveOuterWrapperForegroundIdentity(recognized, candidate, candidates)
}
return null
return { processName: null }
}
function resolveShellForegroundProcessFromWindowsCandidates(
candidates: readonly WindowsProcessCandidate[],
contextPaths: readonly string[] | undefined
): string | null {
): WindowsForegroundIdentity {
const recognizedCandidates = createRecognizedWindowsProcessCandidates(candidates, contextPaths)
const contextCandidates = recognizedCandidates.filter((candidate) => candidate.contextMatch)
if (contextCandidates.length > 0) {
@@ -149,14 +187,14 @@ function resolveWrapperForegroundProcessFromWindowsCandidates(
candidates: readonly WindowsProcessCandidate[],
allCandidates: readonly WindowsProcessCandidate[],
contextPaths: readonly string[] | undefined
): string | null {
): WindowsForegroundIdentity {
const contextCandidates = createRecognizedWindowsProcessCandidates(
candidates,
contextPaths
).filter((candidate) => candidate.contextMatch)
return contextCandidates.length > 0
? resolveRecognizedWindowsProcessCandidates(contextCandidates, allCandidates)
: null
: { processName: null }
}
type RecognizedWindowsProcessCandidate = WindowsProcessRow & {
@@ -190,9 +228,9 @@ function createRecognizedWindowsProcessCandidates(
function resolveRecognizedWindowsProcessCandidates(
recognizedCandidates: readonly RecognizedWindowsProcessCandidate[],
allCandidates: readonly WindowsProcessCandidate[]
): string | null {
): WindowsForegroundIdentity {
if (recognizedCandidates.length === 0) {
return null
return { processName: null }
}
const candidatesByPid = new Map(allCandidates.map((candidate) => [candidate.pid, candidate]))
const leafCandidates = recognizedCandidates.filter(
@@ -203,14 +241,24 @@ function resolveRecognizedWindowsProcessCandidates(
windowsCandidateIsAncestor(candidate, other, candidatesByPid)
)
)
const leafProcessNames = new Set(
leafCandidates.map((candidate) =>
resolveOuterWrapperForegroundProcess(candidate.recognized, candidate, allCandidates)
)
const leafIdentities = leafCandidates.map((candidate) =>
resolveOuterWrapperForegroundIdentity(candidate.recognized, candidate, allCandidates)
)
const leafProcessNames = new Set(leafIdentities.map((identity) => identity.processName))
// Why: Windows lacks a cheap PTY foreground marker like POSIX '+'. A single
// recognized lineage leaf is strong enough; sibling agent leaves are not.
return leafProcessNames.size === 1 ? [...leafProcessNames][0] : null
if (leafProcessNames.size !== 1) {
return { processName: null }
}
// The anchor is the process the NAME belongs to — the outer wrapper when the
// leaf collapsed onto one, else the leaf itself. An embedded leaf can exit
// and restart under a live wrapper; its pid must not stand for the wrapper's.
const anchorProcessIds = new Set(leafIdentities.map((identity) => identity.processId))
return {
processName: [...leafProcessNames][0],
// Distinct anchors agreeing on one name still leave no single liveness anchor.
...(anchorProcessIds.size === 1 ? { processId: [...anchorProcessIds][0] } : {})
}
}
function windowsCandidateIsAncestor(
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest'
import {
canRevalidateCachedAgentWithoutScan,
judgeCachedAgentJobEvidence,
WINDOWS_DETACHED_DESCENDANT_IDENTITY_MAX_AGE_MS
} 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)
})
})
describe('judgeCachedAgentJobEvidence', () => {
const SHELL = 12345
const AGENT = 999
const FRESH = 1_000
const AGED = WINDOWS_DETACHED_DESCENDANT_IDENTITY_MAX_AGE_MS + 1
const judge = (
jobProcessIds: ReadonlySet<number> | null,
anchorProcessId: number | null,
identityAgeMs: number
) =>
judgeCachedAgentJobEvidence({
jobProcessIds,
shellPid: SHELL,
anchorProcessId,
identityAgeMs
})
it('is unavailable without a job answer, never exit proof', () => {
expect(judge(null, AGENT, FRESH)).toBe('unavailable')
expect(judge(null, null, AGED)).toBe('unavailable')
})
it('confirms a fresh anchored identity whose pid is still in the job', () => {
expect(judge(new Set([SHELL, AGENT]), AGENT, FRESH)).toBe('confirmed')
})
it('asks for a drift recheck once an anchored identity ages, without retiring it', () => {
expect(judge(new Set([SHELL, AGENT]), AGENT, AGED)).toBe('recheck')
})
it('downgrades an anchored identity when its pid leaves a job that still has members', () => {
// The survivor is a detached leftover OR the agent's restarted successor;
// only a scan can tell, so the verdict must not be a hard exit.
expect(judge(new Set([SHELL, 777]), AGENT, FRESH)).toBe('anchor-exited')
})
it('retires any identity when the shell stands alone', () => {
expect(judge(new Set([SHELL]), AGENT, FRESH)).toBe('exited')
expect(judge(new Set([SHELL]), null, FRESH)).toBe('exited')
})
it('bounds unanchored superset evidence by age', () => {
expect(judge(new Set([SHELL, 777]), null, FRESH)).toBe('unproven')
expect(judge(new Set([SHELL, 777]), null, AGED)).toBe('expired')
})
it('treats a shell-pid anchor as unanchored', () => {
// The shell being alive proves nothing about the agent.
expect(judge(new Set([SHELL, 777]), SHELL, FRESH)).toBe('unproven')
})
})
@@ -0,0 +1,86 @@
import { isShellProcess } from '../../shared/shell-process-detection'
/**
* How long a cached agent identity may survive on job evidence alone.
*
* The job is a SUPERSET of the console: it keeps console-detached descendants,
* so "something besides the shell is alive" cannot tell a working agent from a
* leftover. Any pane that keeps one -- and whose fallback name reads as a shell
* -- would otherwise pin a dead agent's name forever (#9258's bug, reached by a
* new route). Age is the tiebreak.
*
* The invariant both callers must preserve: every successful recognition resets
* the clock, so this can only expire an identity no scan has confirmed for this
* long. It is never a timeout on a live agent.
*
* 5x the renderer's 6s confirm ladder, and bounded above by the fact that a
* stale cache also pins the refresh at the 1s TTL until it clears.
*/
export const WINDOWS_DETACHED_DESCENDANT_IDENTITY_MAX_AGE_MS = 30_000
/** Whether job membership can revalidate a cached agent without a process scan. */
export function canRevalidateCachedAgentWithoutScan(
cachedAgentName: string | null,
fallbackProcess: string | null
): boolean {
return (
cachedAgentName !== null &&
fallbackProcess !== null &&
// Why: a generic wrapper may outlive the agent; only the shell fallback is
// the known unreliable Windows exit signal this cache is allowed to bridge.
isShellProcess(fallbackProcess)
)
}
export type WindowsCachedAgentJobVerdict =
/** Anchor pid alive in the job, recently confirmed: identity stands, no scan. */
| 'confirmed'
/** Anchor pid alive but past the age bound: scan for drift; a silent scan keeps it. */
| 'recheck'
/** The shell alone in a complete job read: no successor is possible, retire. */
| 'exited'
/**
* The anchor pid left the job but another member remains — a leftover, or
* the agent's restarted successor. The name may only survive as unanchored,
* age-bounded evidence; an available scan settles it (a degraded one must
* not read as an exit).
*/
| 'anchor-exited'
/** No anchor; a non-shell member exists and the bound has not elapsed: identity stands. */
| 'unproven'
/** No anchor and the bound elapsed: the superset answer stops standing in for a scan. */
| 'expired'
/** No job answer: unverifiable per ssh-execution-boundary.md, never exit proof. */
| 'unavailable'
/**
* Weigh a cached agent identity against the pane's job membership.
*
* The job list is complete when non-null (the native read grows its buffer
* until every pid fits), so an anchor pid it lacks has provably exited — a job
* is inescapable once joined. Without an anchor the job is only a superset of
* the console (it keeps console-detached descendants), so `size > 1` cannot
* tell a working agent from a leftover and the age bound decides instead.
*/
export function judgeCachedAgentJobEvidence(args: {
jobProcessIds: ReadonlySet<number> | null
shellPid: number
anchorProcessId: number | null
identityAgeMs: number
}): WindowsCachedAgentJobVerdict {
if (args.jobProcessIds === null) {
return 'unavailable'
}
const withinAgeBound = args.identityAgeMs <= WINDOWS_DETACHED_DESCENDANT_IDENTITY_MAX_AGE_MS
// A shell-pid "anchor" proves nothing about a child; treat it as unanchored.
if (args.anchorProcessId !== null && args.anchorProcessId !== args.shellPid) {
if (!args.jobProcessIds.has(args.anchorProcessId)) {
return args.jobProcessIds.size <= 1 ? 'exited' : 'anchor-exited'
}
return withinAgeBound ? 'confirmed' : 'recheck'
}
if (args.jobProcessIds.size <= 1) {
return 'exited'
}
return withinAgeBound ? 'unproven' : 'expired'
}
@@ -1,6 +1,15 @@
/*
* Coverage for the FORKING console-attachment reader.
*
* These assertions moved here with the code when the job-object reader took
* over the polling path (#16419). They cover the module that caused #10857 --
* the bounded timeout, the single kill, spawn errors, malformed messages and
* helper-pid removal -- so none of it is left untested just because the file
* it used to live in now answers a different question.
*/
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import { readWindowsConptyProcessIds } from './windows-conpty-process-membership'
import { readWindowsConsoleAttachedProcessIds } from './windows-console-attached-processes'
function forkWith(event: 'message' | 'error' | 'none', value?: unknown, pid?: number) {
const child = new EventEmitter() as EventEmitter & {
@@ -24,12 +33,12 @@ function forkWith(event: 'message' | 'error' | 'none', value?: unknown, pid?: nu
return { child, forkProcess: forkProcess as never }
}
describe('readWindowsConptyProcessIds', () => {
describe('readWindowsConsoleAttachedProcessIds', () => {
it('returns exact console membership from the fixed node-pty helper', async () => {
const { forkProcess } = forkWith('message', [999, 101, 202, 303], 999)
await expect(
readWindowsConptyProcessIds(101, {
readWindowsConsoleAttachedProcessIds(101, {
forkProcess,
resolveAgentPath: () => '/fixed/node-pty/conpty_console_list_agent.js'
})
@@ -49,31 +58,33 @@ describe('readWindowsConptyProcessIds', () => {
['unavailable helper pid', [101, 202], undefined]
])('fails closed for %s', async (_label, processIds, helperPid) => {
const { forkProcess } = forkWith('message', processIds, helperPid)
await expect(readWindowsConptyProcessIds(101, { forkProcess })).resolves.toBeNull()
await expect(readWindowsConsoleAttachedProcessIds(101, { forkProcess })).resolves.toBeNull()
})
it('returns root-only membership when only the helper and shell are attached', async () => {
const { forkProcess } = forkWith('message', [999, 101], 999)
await expect(readWindowsConptyProcessIds(101, { forkProcess })).resolves.toEqual(new Set([101]))
await expect(readWindowsConsoleAttachedProcessIds(101, { forkProcess })).resolves.toEqual(
new Set([101])
)
})
it('reports membership excluding the helper when a real child is attached', async () => {
const { forkProcess } = forkWith('message', [999, 101, 202], 999)
await expect(readWindowsConptyProcessIds(101, { forkProcess })).resolves.toEqual(
await expect(readWindowsConsoleAttachedProcessIds(101, { forkProcess })).resolves.toEqual(
new Set([101, 202])
)
})
it('handles helper spawn errors without an unhandled child error', async () => {
const { forkProcess } = forkWith('error')
await expect(readWindowsConptyProcessIds(101, { forkProcess })).resolves.toBeNull()
await expect(readWindowsConsoleAttachedProcessIds(101, { forkProcess })).resolves.toBeNull()
})
it('kills a silent helper at the bounded timeout', async () => {
vi.useFakeTimers()
try {
const { child, forkProcess } = forkWith('none')
const result = readWindowsConptyProcessIds(101, { forkProcess, timeoutMs: 10 })
const result = readWindowsConsoleAttachedProcessIds(101, { forkProcess, timeoutMs: 10 })
await vi.advanceTimersByTimeAsync(10)
await expect(result).resolves.toBeNull()
expect(child.kill).toHaveBeenCalledTimes(1)
@@ -89,7 +100,7 @@ describe('readWindowsConptyProcessIds', () => {
child.kill.mockImplementation(() => {
queueMicrotask(() => child.emit('error', new Error('kill failed')))
})
const result = readWindowsConptyProcessIds(101, { forkProcess, timeoutMs: 10 })
const result = readWindowsConsoleAttachedProcessIds(101, { forkProcess, timeoutMs: 10 })
await vi.advanceTimersByTimeAsync(10)
await expect(result).resolves.toBeNull()
expect(child.listenerCount('error')).toBe(0)
@@ -4,7 +4,7 @@ const CONPTY_PROCESS_LIST_TIMEOUT_MS = 3_000
type ProcessListMessage = { consoleProcessList?: unknown }
type WindowsConptyMembershipDeps = {
type WindowsConsoleAttachedProcessDeps = {
forkProcess?: typeof fork
resolveAgentPath?: () => string
timeoutMs?: number
@@ -15,12 +15,25 @@ function resolveNodePtyConsoleListAgent(): string {
}
/**
* Returns normalized console membership, or null when the probe is unavailable.
* A root-only set proves the shell is alone because successful raw results include the helper.
* Processes ATTACHED TO THIS PANE'S CONSOLE, or null when unavailable.
*
* Distinct from job membership on purpose. `GetConsoleProcessList` must be
* called from a process attached to that console, and a process can hold only
* one console at a time -- which is why node-pty answers it from a separate
* process, and why this still forks.
*
* Only the candidate FILTER may use this. That filter exists to drop a
* descendant which detached from the console (`Start-Process`, a GUI child), and
* the job object deliberately still contains those, so the job cannot answer it
* -- see docs/windows-wsl-root-cause-plan.html, "Use B".
*
* This is not the fork storm in #10857: it runs only when a recognized agent
* candidate already exists, not on every foreground poll. Bounding it to one
* pooled, supervised helper is the remaining half of that fix.
*/
export function readWindowsConptyProcessIds(
export function readWindowsConsoleAttachedProcessIds(
rootPid: number,
deps: WindowsConptyMembershipDeps = {}
deps: WindowsConsoleAttachedProcessDeps = {}
): Promise<ReadonlySet<number> | null> {
if (!Number.isSafeInteger(rootPid) || rootPid <= 0) {
return Promise.resolve(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)
})
})
@@ -1,15 +0,0 @@
import { isShellProcess } from '../../shared/shell-process-detection'
/** Whether ConPTY membership can revalidate a cached agent without a process scan. */
export function canConfirmAgentFromConsolePresence(
cachedAgentName: string | null,
fallbackProcess: string | null
): boolean {
return (
cachedAgentName !== null &&
fallbackProcess !== null &&
// Why: a generic wrapper may outlive the agent; only the shell fallback is
// the known unreliable Windows exit signal this cache is allowed to bridge.
isShellProcess(fallbackProcess)
)
}
@@ -40,6 +40,23 @@ export async function queryWindowsProcessDescendants(
rootPid: number,
options: { fresh?: boolean } = {}
): Promise<WindowsProcessCandidate[] | null> {
return (await queryWindowsPaneProcessInventory(rootPid, options))?.candidates ?? null
}
export type WindowsPaneProcessInventory = {
candidates: WindowsProcessCandidate[]
/**
* Full-table row for `anchorPid`. From the whole snapshot, not the ppid
* projection: a pane-job member whose creator exited is orphaned out of the
* descendant walk yet can still hold a recycled anchor pid.
*/
anchorRow: WindowsProcessRow | null
}
export async function queryWindowsPaneProcessInventory(
rootPid: number,
options: { fresh?: boolean; anchorPid?: number } = {}
): Promise<WindowsPaneProcessInventory | null> {
let rows: WindowsProcessRow[]
try {
const native =
@@ -55,7 +72,13 @@ export async function queryWindowsProcessDescendants(
if (!rows.some((row) => row.pid === rootPid)) {
return null
}
return collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth)
return {
candidates: collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth),
anchorRow:
options.anchorPid !== undefined
? (rows.find((row) => row.pid === options.anchorPid) ?? null)
: null
}
}
/** Test-only: clear the shared snapshot so one case's rows never serve the next. */
@@ -0,0 +1,107 @@
import type * as ChildProcess from 'node:child_process'
import { describe, expect, it, vi } from 'vitest'
import type { IPty } from 'node-pty'
// Module-level, so it intercepts the module's own import binding. A spyOn of a
// require()'d child_process does not: the first version of this test passed
// even with a fork() reintroduced, which is the failure it exists to catch.
const forkMock = vi.hoisted(() => vi.fn())
vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal<typeof ChildProcess>()),
fork: forkMock
}))
import { readWindowsPtyJobProcessIds } from './windows-pty-job-membership'
const pty = (pid = 100): IPty => ({ pid }) as unknown as IPty
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
// conpty_console_list_agent processes until the machine ran out of memory
// (#10857). Killing them changed nothing; the next poll spawned more.
// QueryInformationJobObject needs no console attachment, so this is one
// syscall and zero children.
const listJobProcessIds = vi.fn(() => [100, 200])
forkMock.mockClear()
for (let read = 0; read < 50; read += 1) {
readWindowsPtyJobProcessIds(pty(), listJobProcessIds)
}
expect(forkMock).not.toHaveBeenCalled()
expect(listJobProcessIds).toHaveBeenCalledTimes(50)
})
it('reports the shell alone, which is what lets a stale agent be retired', () => {
const membership = readWindowsPtyJobProcessIds(pty(), () => [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 = readWindowsPtyJobProcessIds(pty(), () => [100, 200, 300])
expect(membership?.size).toBe(3)
})
it.each([
['no job support or an untracked tree', null],
['an empty job, which is not the shell-alone case', []]
])('reports unverifiable for %s', (_case, pids) => {
// 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(readWindowsPtyJobProcessIds(pty(), () => pids)).toBeNull()
})
it('drops nonsense pids rather than trusting the whole answer', () => {
const membership = readWindowsPtyJobProcessIds(pty(), () => [100, 0, -1, 1.5, 200])
expect(membership).toEqual(new Set([100, 200]))
})
})
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), () => [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).
// The job still contains those by design, so answering that filter from the
// job would re-admit exactly what it is for -- granting byte authority to a
// pane no agent owns, or making an attached agent look ambiguous.
// docs/windows-wsl-root-cause-plan.html calls this out as "Use B".
//
// Measured on Windows 11 against a real WSL pane: job [40980,104068,4888,69908]
// vs console [69908,40980] -- the job is a superset. Harmless for the
// `size > 1` callers, wrong for the filter.
const detachedChild = 104068
const membership = readWindowsPtyJobProcessIds(pty(40980), () => [40980, detachedChild])
expect(membership?.has(detachedChild)).toBe(true)
})
})
@@ -0,0 +1,34 @@
import type { IPty } from 'node-pty'
import { listPtyJobProcessIds } from '../windows/windows-pty-job'
/**
* Processes still running under a pane, or null when there is no answer.
*
* Read from the pane's Win32 job object. `GetConsoleProcessList` would need
* console attachment, which is why node-pty answers it by forking a helper, and
* why asking on a foreground poll exhausted memory (#10857).
* `QueryInformationJobObject` has no such constraint: one syscall, no children.
*
* The job is a SUPERSET of the console -- it keeps console-detached descendants
* -- so `size > 1` is not proof of life, only absence of proof of absence.
* `size === 1` (the shell alone) is decisive, and so is membership of a KNOWN
* pid: the list is complete when non-null and a job is inescapable once joined,
* so an anchored identity can be confirmed or retired exactly
* (judgeCachedAgentJobEvidence). Callers must bound only the unanchored rest.
*
* Null means unverifiable per docs/reference/ssh-execution-boundary.md, never
* that processes died.
*/
export function readWindowsPtyJobProcessIds(
proc: IPty,
listJobProcessIds: (proc: IPty) => readonly number[] | null = listPtyJobProcessIds
): ReadonlySet<number> | null {
const pids = listJobProcessIds(proc)
if (!pids) {
return null
}
const membership = new Set(pids.filter((pid) => Number.isSafeInteger(pid) && pid > 0))
// Without the shell, a size-1 set would read as "shell alone, retire" when it
// means the opposite. The forked probe this replaced refused the same way.
return membership.has(proc.pid) ? membership : null
}
@@ -118,7 +118,7 @@ src/main/plugins/plugin-host-process.ts
src/main/ports/port-scan-command-execution.ts
src/main/providers/macos-login-session-pty-probe.ts
src/main/providers/process-cwd.ts
src/main/providers/windows-conpty-process-membership.ts
src/main/providers/windows-console-attached-processes.ts
src/main/pty-descendant-termination.ts
src/main/pty/posix-pty-foreground-group.ts
src/main/pty/posix-pty-process-groups.ts
+22 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { resolveOuterWrapperForegroundProcess } from './foreground-wrapper-agent'
import {
resolveOuterWrapperForegroundIdentity,
resolveOuterWrapperForegroundProcess
} from './foreground-wrapper-agent'
describe('resolveOuterWrapperForegroundProcess', () => {
const omp = { agent: 'omp' as const, processName: 'omp' }
@@ -52,4 +55,22 @@ describe('resolveOuterWrapperForegroundProcess', () => {
])
).toBe('pi')
})
it('carries the wrapper pid with a collapsed name, so liveness tracks the wrapper', () => {
// Anchoring 'omp' to pi's pid would read a pi restart as omp's exit.
expect(
resolveOuterWrapperForegroundIdentity(pi, { pid: 102, ppid: 101, command: 'pi' }, [
{ pid: 102, ppid: 101, command: 'pi' },
{ pid: 101, ppid: 100, command: 'omp' }
])
).toEqual({ processName: 'omp', processId: 101 })
})
it('keeps the winner pid when nothing collapses', () => {
const barePi = { pid: 101, ppid: 100, command: 'pi' }
expect(resolveOuterWrapperForegroundIdentity(pi, barePi, [barePi])).toEqual({
processName: 'pi',
processId: 101
})
})
})
+25 -2
View File
@@ -1,4 +1,5 @@
import {
recognizeAgentProcess,
recognizeAgentProcessFromCommandLine,
type RecognizedAgentProcess
} from './agent-process-recognition'
@@ -18,6 +19,12 @@ export function shouldInspectOuterWrapperForegroundProcess(
return process.agent === 'pi'
}
/** Same gate for a bare process name, recognizing it first. */
export function shouldInspectOuterWrapperForegroundName(processName: string | null): boolean {
const recognized = recognizeAgentProcess(processName)
return recognized !== null && shouldInspectOuterWrapperForegroundProcess(recognized)
}
/**
* Collapse a foreground read onto its outermost same-title-group ancestor.
* Why: OMP embeds Pi, while depth alone cannot distinguish wrappers from sibling jobs.
@@ -27,13 +34,28 @@ export function resolveOuterWrapperForegroundProcess(
winnerCandidate: ForegroundAgentCandidate,
descendants: readonly ForegroundAgentCandidate[]
): string {
return resolveOuterWrapperForegroundIdentity(winner, winnerCandidate, descendants).processName
}
/**
* Same collapse, keeping the pid of the process the name belongs to.
* Why: a liveness anchor must follow the REPORTED process — anchoring the
* outer wrapper's name to the embedded leaf's pid reads the leaf's exit as
* the wrapper's.
*/
export function resolveOuterWrapperForegroundIdentity(
winner: RecognizedAgentProcess,
winnerCandidate: ForegroundAgentCandidate,
descendants: readonly ForegroundAgentCandidate[]
): { processName: string; processId: number } {
const winnerGroup = getSyntheticAgentTitleProfile(winner.agent)?.titleIdentityGroup
if (!winnerGroup) {
return winner.processName
return { processName: winner.processName, processId: winnerCandidate.pid }
}
const candidatesByPid = new Map(descendants.map((candidate) => [candidate.pid, candidate]))
const seen = new Set<number>([winnerCandidate.pid])
let outerProcessName = winner.processName
let outerProcessId = winnerCandidate.pid
let parentPid = winnerCandidate.ppid
while (!seen.has(parentPid)) {
seen.add(parentPid)
@@ -49,8 +71,9 @@ export function resolveOuterWrapperForegroundProcess(
getSyntheticAgentTitleProfile(recognized.agent)?.titleIdentityGroup === winnerGroup
) {
outerProcessName = recognized.processName
outerProcessId = candidate.pid
}
parentPid = candidate.ppid
}
return outerProcessName
return { processName: outerProcessName, processId: outerProcessId }
}