Skip to content

fix(terminal): confirm the agent from ConPTY console presence to avoid false exits#9258

Merged
brennanb2025 merged 5 commits into
mainfrom
brennanb2025/win-foreground-console-list
Jul 19, 2026
Merged

fix(terminal): confirm the agent from ConPTY console presence to avoid false exits#9258
brennanb2025 merged 5 commits into
mainfrom
brennanb2025/win-foreground-console-list

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #9244. Fixes false "agent done" notifications on Windows by removing the whole-process-table scan from the hot path of foreground detection — including the daemon path, which is what actually runs on Windows.

Problem

Deciding an agent finished depends partly on "is the agent still the terminal's foreground process?" On Windows that answer comes from a whole-process-table Get-CimInstance Win32_Process PowerShell scan with a 3s timeout. Under load (many agents doing heavy work) the scan times out or returns an incomplete snapshot, so the completion coordinator reads the shell as the foreground, concludes the agent exited, and fires a false completion while it's still working — multiplied across panes into the notification flood.

#9244 addressed the timeout case in the local PTY provider, but Windows PTYs are hosted by the terminal daemon, so that change is inert on the default path. This PR fixes the primary (daemon) path.

Fix

Confirm the agent with a cheap, terminal-scoped ConPTY console-membership read (readWindowsConptyProcessIds, already vendored) instead of the whole-table scan:

  • Local + daemon foreground reads: while a recognized agent is active and node-pty only names the shell, a child still attached to the console means the agent is working → keep the agent and skip the whole-table scan; only a shell-only console falls through to the authoritative scan.
  • Daemon refresh: a degraded scan (available: false) no longer retires the identity; an authoritative no-agent scan is confirmed against the console read before retiring (an incomplete snapshot with a child still attached keeps the agent). Scoped to a shell fallback so the deliberate wrapper (npm-after-exit) retirement is unaffected; the membership read stays off the sync foreground read.
  • Console-membership off-by-one: the console-list helper attaches to the console to read it, so GetConsoleProcessList counts the helper's own forked process — a bare shell read as [helper, shell] and never looked shell-only, holding an exited agent's identity forever. The helper's own pid is now dropped before judging membership, so a real exit is surfaced promptly. This fixes both the daemon and local paths.

No-op off Windows; never worse than the pre-existing degraded-scan fallback.

Testing

  • Pure gate helper + real-function membership tests (helper-pid exclusion: [helper, shell] → {shell}, [helper, shell, agent] → {shell, agent}).
  • Daemon degraded-scan tests: keep-on-degraded, keep-on-incomplete-snapshot-with-console-child, retire-on-shell-only.
  • Local provider tests: console presence skips the scan; shell-only falls through.
  • Full src/main/daemon + src/main/providers suites (1319 tests) green; typecheck:node, oxlint, oxfmt clean.

Windows validation (real hardware, under CIM-timeout load)

  • False "agent done" under load: fixed — foreground stays on the agent (40/40) while it works; no false completion.
  • Real-exit detection: works — after a real exit the console reads shell-only, the identity is retired, and foreground returns the shell within ~2s.
  • Console-membership probe flipped as expected (bare shell → shell-only; shell + child → child present).

Supersedes the Windows behavior of #9244 (which remains a correct, no-regression improvement to the daemon-unavailable fallback path).

Reliability and performance contract

  • Invariant (agent-session.foreground-liveness): A recognized Windows agent remains foreground while evidence is degraded or inconclusive, and retires only after a no-agent scan plus verified shell-only ConPTY membership. Generic wrappers keep their existing retirement behavior.
  • Failure source: Windows CIM timeouts and incomplete whole-process snapshots under multi-agent load caused the completion coordinator to observe the shell and emit false completion.
  • Oracle: Deterministic daemon and local-provider tests prove keep-on-degraded, keep-on-incomplete-with-child, keep-on-membership-unavailable, retire-on-verified-shell-only, helper PID exclusion, wrapper isolation, and exact helper/scan counts. Physical Windows 11 validation proves live-agent retention and real-exit retirement.
  • Gate: The shared foreground/membership contract is exercised by terminal-input.windows-modified-enter-routing; a dedicated completion-notification gate is an accepted manifest gap for this PR.
  • Provider/platform coverage: Windows local and daemon paths are covered. macOS/Linux are runtime no-ops. SSH, WSL, remote-runtime, and mobile/relay do not execute this local ConPTY branch and are unaffected.
  • Performance budget: Whole-process scans retain the shared 500 ms snapshot dedupe and existing foreground cadence. Degraded scans add zero per-PTY membership forks; the exact ConPTY helper runs only after an authoritative no-agent Windows snapshot needs PTY-scoped confirmation. Every helper remains bounded by the existing 3 s timeout and kill cleanup.
  • Diagnostics: The evidence contract now distinguishes unavailable membership (null), verified shell-only membership ({shell}), and attached-child membership ({shell, child...}), so future failures can be classified without treating probe failure as exit proof.
  • Residual gap: Live validation covers Windows 11 hardware, while CI provides simulated Windows provider contracts rather than a real ConPTY membership artifact.

Additional review verification: the 22-file reliability gate passed with 1,443 tests; full node/CLI/web typecheck, oxlint, reliability-manifest validation, and max-lines ratchet passed.

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 697718e0-579c-449f-9a1b-c86d33ebb964

📥 Commits

Reviewing files that changed from the base of the PR and between 1c05afb and 3a152c0.

📒 Files selected for processing (2)
  • src/main/providers/windows-conpty-process-membership.test.ts
  • src/main/providers/windows-conpty-process-membership.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/providers/windows-conpty-process-membership.test.ts

📝 Walkthrough

Walkthrough

Windows foreground-process detection now uses cached agent identity and ConPTY console membership to confirm active agents before running the full resolution scan. ConPTY results are validated and filtered to exclude the helper process. A new confirmation helper defines the fast-path conditions, while daemon refresh logic preserves cached identities during degraded scans and retires them only after shell-only confirmation. Tests cover membership filtering, fast confirmation, fallback scans, and identity retention.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it does not follow the required template and is missing several required sections. Add the required template sections: Summary, Screenshots (or 'No visual change'), Testing checklist, AI Review Report, Security Audit, and Notes.
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the Windows ConPTY foreground-detection fix and matches the main change.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@OrcaWin

OrcaWin commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Windows validation: the fix is correct but lands on a path that doesn't run on Windows-with-daemon

Validated on real Windows 11. Summary: the technique is sound, but as written this change does not engage on the default Windows configuration, so it does not fix the false "agent done" it targets. The same is true of the already-merged sibling change in local-pty-provider.ts.

The routing gap (root cause of "we can't see it engage live")

On Windows the terminal daemon hosts PTYs. initDaemonPtyProvider swaps the local provider for the daemon adapter (setLocalPtyProvider), so getForegroundProcess / confirmForegroundProcess route over the socket to the daemon and are resolved by src/main/daemon/pty-subprocess.ts — not local-pty-provider.ts. This PR only modifies local-pty-provider.ts, which on Windows is exercised only in daemon-unavailable fallback.

Direct proof (instrumented both paths, verified compiled into the running build, then drove a real agent):

  • local-pty-provider.getForegroundProcess0 invocations
  • daemon foreground path (pty-subprocess.ts) — 326 invocations

Every foreground resolution went through the daemon; the modified code never ran.

The false-exit reproduces on the daemon path

With a recognized agent genuinely working ("Running 1 shell command · 2m 16s", status "Working…"), under the load that times out the whole-table CIM scan (CIM 31/31 timed out, avg ~3175ms):

  • daemon getForegroundProcessshell (powershell.exe) 95/95
  • daemon confirmForegroundProcessnull 30/30 (not the agent)
  • the daemon's async scheduleAgentForegroundRefresh cleared the cached agent on the degraded scan (available === false, hadCachedAgent: true)

Mechanism: scheduleAgentForegroundRefresh (pty-subprocess.ts ~L986) ignores the scan's available flag; a timeout returns "no recognized agent," which clears cachedAgentForeground (~L1001), so the next getForegroundProcess returns the shell. The confirmation step can't save it either — resolveWindowsAgentForegroundProcessWithAvailability returns available: false and bails before the ConPTY console-membership read (windows-agent-foreground-process.ts:51), so the console read is unreachable under a CIM timeout. Downstream, getTerminalAgentStatus (orca-runtime.ts ~L11181) flips isRunningAgent to false via terminalHasShellForegroundProcess → false "agent done."

The good news: the technique is validated

The ConPTY console-membership read is reliable under exactly the load that times out the CIM scan. Standalone rig against the real readWindowsConptyProcessIds, under representative concurrent-scan load:

  • 20/20 non-null reads while the CIM scan degraded 8/20
  • console read avg ~1.4s / max ~1.9s (under its 3s cap), vs the 3s+ CIM timeout

So the approach is right — it just has to live in the daemon.

Where the fix belongs

Apply the same idea to the daemon's scheduleAgentForegroundRefresh (pty-subprocess.ts): (a) don't clear the cached agent when the scan is degraded (available === false) — mirrors the sibling local-pty-provider change; and (b) for the incomplete-snapshot case (available: true but the agent row is missing), gate the clear behind a console-presence read — mirrors this PR. The pure helpers (resolveStableForegroundProcess, canConfirmAgentFromConsolePresence) are reusable as-is; only the daemon's cache/refresh wiring is new.

Net: keep this change (correct, no-regression on the fallback path), but the Windows-with-daemon fix needs to move into pty-subprocess.ts for users to actually get it.

@AmethystLiang AmethystLiang self-assigned this Jul 18, 2026
@AmethystLiang
AmethystLiang self-requested a review July 18, 2026 16:47
@brennanb2025
brennanb2025 force-pushed the brennanb2025/win-foreground-console-list branch from 8b87868 to bf93202 Compare July 19, 2026 03:51
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Expanded to the primary Windows path (daemon)

Validation on real Windows showed the initial change never engaged: it lived in the local PTY provider, but Windows PTYs are hosted by the terminal daemon, whose own foreground-identity refresh is what runs by default. Instrumented hit counts confirmed the local-provider path was bypassed entirely.

Added the same protection to the daemon foreground path (pty-subprocess.ts), which is where the false exit actually originates — the refresh was retiring the cached agent on a timed-out/incomplete CIM scan:

  • A degraded scan (available: false) no longer retires the identity.
  • An authoritative scan that resolves no agent is confirmed against a ConPTY console-membership read before retiring — an incomplete snapshot with a child still attached keeps the agent; only a shell-only console retires it. Scoped to a shell fallback so the deliberate wrapper (npm-after-exit) retirement is unaffected. The membership read stays off the sync foreground read.
  • The sync foreground read serves the cached agent across a Windows shell fallback (an unreliable exit signal under ConPTY lag) until the refresh authoritatively retires it.

New daemon degraded-scan tests cover keep-on-degraded, keep-on-incomplete-snapshot-with-console-child, and retire-on-shell-only-console. Full daemon + providers suites green; typecheck/lint clean. Head is now bf9320257.

…d false exits

On Windows the foreground scan is a whole-process-table PowerShell fork
that, under load, exceeds its timeout or returns an incomplete snapshot —
the completion coordinator then reads the shell as the foreground and
fires a false "agent done" while the agent is still working.

While a recognized agent is still active, confirm it with a cheap ConPTY
console-membership read instead: a child process still attached to the
console means the agent is working, so keep it without the whole-table
scan. Fall through to the authoritative scan only when the console is
shell-only (the agent likely exited). No-op off Windows, and never worse
than the existing degraded-scan fallback.
…foreground path

Windows PTYs are hosted by the terminal daemon, whose foreground-identity
refresh (not the local provider) is what runs by default — it retired the
cached agent on a timed-out or incomplete CIM scan, so foreground reads fell
back to the shell and fired a false "agent done" while the agent was working.

Mirror the local-provider fix on the daemon path: a degraded scan
(available:false) no longer retires the identity; an authoritative scan that
resolves no agent is confirmed against a ConPTY console-membership read before
retiring (an incomplete snapshot with a child still attached keeps the agent);
and the sync foreground read serves the cached agent across a shell fallback on
Windows (an unreliable exit signal under load) until the background refresh
authoritatively retires it. The membership read stays off the sync path.
…PTY membership

The console-membership helper attaches to the console to read it, so
GetConsoleProcessList counts the helper's own forked process. A bare shell
therefore read as [helper, shell] and looked like it still had a child, so a
genuine shell-only console (an exited agent) was never detected — the foreground
refresh held the exited agent's identity indefinitely. Drop the helper's own pid
before judging membership; a remaining set of only the shell (or the
AttachConsole-failure fallback) is not child proof. Fixes real-exit detection on
both the daemon and local foreground paths.
@brennanb2025
brennanb2025 force-pushed the brennanb2025/win-foreground-console-list branch from bf93202 to 59c5cee Compare July 19, 2026 19:20
@OrcaWin

OrcaWin commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Windows re-validation: the relocation to the daemon path works — false-exit-under-load is genuinely fixed

Validated on real Windows 11. Instrumented the daemon (pty-subprocess.ts getForegroundProcess + the refresh .then console read), rebuilt the daemon bundle (confirmed the dev daemon runs this worktree's out/main/daemon-entry.js), and drove a real heavy agent task under the load that times out the whole-table CIM scan.

#1 — Fast path fires; no false "agent done" under load: PASS

With the agent genuinely working, under CIM load (CIM 23/23 timed out, avg ~3.1s):

  • daemon getForegroundProcessagent 70/70, shell 0/70 (previous revision was shell 95/95)
  • unreadAgentCompletionPanes (this pane) → never fired

Daemon decision log confirms the mechanism:

  • serve-cached-agent-on-Windows-shell-fallback 434× (sync path returns the agent, not the shell)
  • keep-on-degraded-scan (available:false) 42× — timed-out scans no longer retire the identity
  • retire calls: 0 while working

The primary bug — the false "agent done" when the CIM scan times out under load — is fixed on the path that actually runs on Windows (the terminal daemon).

#2 — Real exit detection: a regression was found in this revision, root-caused, and has since been fixed

On the revision first validated, after a real exit the daemon kept reporting the exited agent's name indefinitely (40s+, 0 retires). Root cause: the ConPTY console-membership helper attaches to the console to read it, so it counts its own probe process — a bare shell read as size 2 ([helperPid, shellPid]), so "shell-only" was never detectable and the retire guard never fired. Controlled probe at the time: bare shell → non-null size 2; shell+agent → size 3, with the membership check only rejecting length <= 1.

Fix direction (now landed): drop the helper's own PID before judging membership and treat "only the shell remaining" as no-child, in the shared helper so both the daemon and local-provider paths are covered — plus a real-function unit test for the [helper, shell] → null / [helper, shell, agent] → {shell, agent} cases the mocked tests couldn't reach. Re-validation of the exit path against the fixed head follows.

Net: keep this change — the false-exit-under-load is genuinely fixed on the daemon path, and the exit-detection regression it briefly introduced has been root-caused and corrected in the same shared helper.

@brennanb2025
brennanb2025 merged commit 764cada into main Jul 19, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants