From bf758b955e2e24daa229cbcb7e685f3dcd697433 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Mon, 31 Aug 2026 11:27:58 -0700 Subject: [PATCH] fix(ssh): preserve process evidence polling semantics --- .../agent-foreground-process-batch.ts | 23 ++++++++++++++++--- src/relay/pty-handler.ts | 8 ++----- ...ent-completion-no-evidence-cadence.test.ts | 14 +++++++++++ .../agent-completion-process-monitor.ts | 3 ++- .../pty-connection/terminal-keydown-fit.ts | 7 +++--- src/shared/process-table-snapshot.ts | 2 +- 6 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/main/providers/agent-foreground-process-batch.ts b/src/main/providers/agent-foreground-process-batch.ts index a12910264af..093ef003ddc 100644 --- a/src/main/providers/agent-foreground-process-batch.ts +++ b/src/main/providers/agent-foreground-process-batch.ts @@ -1,4 +1,9 @@ -import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { + isAgentForegroundWrapperProcess, + isExpectedAgentProcess, + recognizeAgentProcessFromCommandLine +} from '../../shared/agent-process-recognition' +import { getFirstCommandToken } from '../../shared/command-token-scanner' import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent' import type { ForegroundProcessEvidence } from '../../shared/foreground-process-evidence' import { @@ -103,7 +108,19 @@ export function resolveAgentForegroundProcessesFromIndex( reason: 'no_controlling_tty' } } - const candidates = (rowsByOwner.get(root.pid) ?? []).filter((row) => row.pgid === root.tpgid) + const allCandidates = rowsByOwner.get(root.pid) ?? [] + const foregroundCandidates = allCandidates.filter((row) => row.pgid === root.tpgid) + const fallbackProcess = request.fallbackProcess + const wrapperFallback = + typeof fallbackProcess === 'string' && isAgentForegroundWrapperProcess(fallbackProcess) + const candidates = wrapperFallback + ? foregroundCandidates.filter((candidate) => + isExpectedAgentProcess(getFirstCommandToken(candidate.command), fallbackProcess) + ) + : foregroundCandidates + if (wrapperFallback && candidates.length !== 1) { + return { available: true, processName: null } + } let bestCandidate: (ProcessTableRow & { depth: number }) | null = null let bestName: ReturnType = null for (const candidate of candidates) { @@ -119,7 +136,7 @@ export function resolveAgentForegroundProcessesFromIndex( if (bestCandidate && bestName) { return { available: true, - processName: resolveOuterWrapperForegroundProcess(bestName, bestCandidate, candidates) + processName: resolveOuterWrapperForegroundProcess(bestName, bestCandidate, allCandidates) } } return { available: true, processName: null } diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 7c68f6655fb..c99129199ab 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -13,7 +13,6 @@ import { resolveProcessCwd, processHasChildren, getForegroundProcessName, - getForegroundProcessNameFromProcessTable, isProcessAlive, listShellProfiles } from './pty-shell-utils' @@ -2286,13 +2285,10 @@ export class PtyHandler { this.reapExitedPty(managed) continue } + // Reuse batched correlation; per-PTY tree scans recreate O(PTY × rows) work. const title = (evidenceRows - ? getForegroundProcessNameFromProcessTable( - [...evidenceRows], - managed.pty.pid, - managed.pty.process || null - ) + ? (evidenceResults[entryIndex]?.processName ?? managed.pty.process ?? null) : await getForegroundProcessName(managed.pty.pid, managed.pty.process || null)) || 'shell' const foregroundProcessEvidence = process.platform !== 'win32' diff --git a/src/renderer/src/components/terminal-pane/agent-completion-no-evidence-cadence.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-no-evidence-cadence.test.ts index 2d5afedbbe2..fa7c564e069 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-no-evidence-cadence.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-no-evidence-cadence.test.ts @@ -124,6 +124,20 @@ describe('agent completion no-evidence inspection cadence', () => { expect(inspectProcess).toHaveBeenCalledTimes(4) }) + it('does not re-arm no-evidence scans for output from hidden panes', async () => { + const inspectProcess = vi.fn(async () => processResult(null, false)) + const { coordinator } = createCoordinator(inspectProcess, { + shouldPollProcessCadence: () => false, + shouldPollNoEvidenceProcessCadence: () => false + }) + + coordinator.startProcessTracking() + coordinator.observeOutputActivity() + await vi.advanceTimersByTimeAsync(60_000) + + expect(inspectProcess).not.toHaveBeenCalled() + }) + it('escalates to the hot cadence when PTY output appears mid-interval', async () => { const inspectProcess = vi.fn(async () => processResult(null, false)) const { coordinator } = createCoordinator(inspectProcess) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts b/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts index 7e519e60325..7a7efc36185 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts @@ -202,7 +202,8 @@ export function createAgentCompletionProcessMonitor({ state.lastForegroundAgent !== null || (options.shouldPollProcessCadence?.() !== false && options.shouldPollNoEvidenceProcessCadence?.() !== false) || - (state.lastPaneActivityAt !== null && + (options.shouldPollProcessCadence?.() !== false && + state.lastPaneActivityAt !== null && Date.now() - state.lastPaneActivityAt < NO_EVIDENCE_ACTIVITY_HOT_WINDOW_MS) ) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts index 4536b8a73d1..947ead70c31 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts @@ -229,12 +229,11 @@ export function installTerminalKeydownFit(session: ConnectPanePtySession): void }), shouldPollProcessCadence: () => isAgentTaskCompleteTrackingEnabled() && session.deps.isVisibleRef.current, - shouldPollNoEvidenceProcessCadence: () => - !isRemoteExecutionHostPtyId(session.transport.getPtyId()), isProcessInspectionCostly: () => { // Why: local Windows inspection forks a powershell.exe whole-process-table - // CIM scan per poll (~10-40x heavier than POSIX `ps`). Remote authorities - // use the zero-idle cadence gate above instead. + // CIM scan per poll (~10-40x heavier than POSIX `ps`). Keep the no-evidence + // cadence enabled until inventory evidence is consumed by this renderer; + // mixed-version relays may omit the optional field. if (!navigator.userAgent.includes('Windows')) { return false } diff --git a/src/shared/process-table-snapshot.ts b/src/shared/process-table-snapshot.ts index e3e3840345b..ba88afb595b 100644 --- a/src/shared/process-table-snapshot.ts +++ b/src/shared/process-table-snapshot.ts @@ -322,7 +322,7 @@ const strictReader = createProcessTableSnapshotReader({ }) /** - * Run (or reuse a recent) `ps -axo pid=,ppid=,stat=,command=` scan and return + * Run (or reuse a recent) `ps -axo` process-table scan and return * its parsed rows. Per-process singleton: the relay and local main processes * each dedupe their own scans and share a single parse per TTL window. */