From 29e38f61cbd3ebf96f26aeac01e2e2a23516f16a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:04:12 -0700 Subject: [PATCH] fix(agent-launch): judge the foreground-group proof by when its capture began, not how long ps took The age the host stamps on a process-group observation runs from the start of its whole-machine ps, so on a loaded Mac a capture begun after the read was asked for still read as older than 1 s and the proof was dropped. Count an observation whose capture began after the read was asked for, less the window a shared capture is reused across. --- .../runtime/launched-agent-foreground.test.ts | 69 +++++++++++++++++++ src/main/runtime/launched-agent-foreground.ts | 13 ++-- 2 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 src/main/runtime/launched-agent-foreground.test.ts diff --git a/src/main/runtime/launched-agent-foreground.test.ts b/src/main/runtime/launched-agent-foreground.test.ts new file mode 100644 index 00000000000..4f94a91e795 --- /dev/null +++ b/src/main/runtime/launched-agent-foreground.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence' +import { readLaunchedAgentForeground } from './launched-agent-foreground' + +/** A live observation naming `claude` in the terminal's foreground group. */ +function claudeInGroup(capturedAgeMs: number): RemoteForegroundEvidence { + return { + verdict: 'live', + processName: 'claude', + fence: { + platform: 'posix', + shellPid: 40100, + shellStartTime: 'Fri Oct 2 09:30:01 2026', + tty: 'ttys007', + foregroundPgid: 40210 + }, + authorityGeneration: 'gen-1', + observationEpoch: 1, + capturedAgeMs, + ptyId: 'pty-1', + ptyIncarnationId: 'inc-1' + } +} + +/** The fresh scan names the `sh` that leads the group, as it does behind a wrapper. */ +function controllerAnswering(inspect: () => Promise) { + return { + getForegroundProcess: async () => 'sh', + confirmForegroundProcess: async () => 'sh', + confirmShellForeground: async () => false, + inspectProcess: async () => ({ + foregroundProcess: 'sh', + hasChildProcesses: true, + foregroundProcessEvidence: await inspect() + }) + } +} + +const POSIX_LOCAL = { remote: false, windows: false } + +describe('the foreground group as proof of a launched agent', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + // On a loaded host the whole-machine capture can take seconds; it still describes the moment + // after the read was asked for. + it('counts a capture begun after the read was asked for, however long `ps` took', async () => { + const controller = controllerAnswering(async () => { + vi.advanceTimersByTime(1_800) + return claudeInGroup(1_800) + }) + + await expect( + readLaunchedAgentForeground(controller, POSIX_LOCAL, 'pty-1', 'claude') + ).resolves.toBe('agent') + }) + + it('takes the fresh read over a capture reused from before the read was asked for', async () => { + const controller = controllerAnswering(async () => claudeInGroup(1_500)) + + await expect( + readLaunchedAgentForeground(controller, POSIX_LOCAL, 'pty-1', 'claude') + ).resolves.toBe('shell') + }) +}) diff --git a/src/main/runtime/launched-agent-foreground.ts b/src/main/runtime/launched-agent-foreground.ts index c5fc1fa77ca..f7763ac4fc3 100644 --- a/src/main/runtime/launched-agent-foreground.ts +++ b/src/main/runtime/launched-agent-foreground.ts @@ -2,6 +2,7 @@ import { isExpectedAgentProcess, recognizeAgentProcess } from '../../shared/agent-process-recognition' +import { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } from '../../shared/process-table-snapshot' import { isShellProcess } from '../../shared/shell-process-detection' import type { TuiAgent } from '../../shared/tui-agent' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' @@ -14,9 +15,6 @@ import type { RuntimePtyController } from './runtime-pty-controller-contract' */ export type LaunchedAgentForeground = 'agent' | 'shell' | 'unknown' -/** Under the 1.5 s quiet a ready signal needs, so an observation this young postdates a crash. */ -const FOREGROUND_GROUP_EVIDENCE_MAX_AGE_MS = 1_000 - /** A login shell is reported as `-zsh`. */ function isLaunchShell(processName: string): boolean { return isShellProcess(processName.replace(/^-/, '')) @@ -33,8 +31,10 @@ function isLaunchedAgent(processName: string, agent: TuiAgent): boolean { * First the host's process-group observation, which only ever proves the agent: the launched agent * among the members of the terminal's foreground group finds it behind a wrapper that did not * `exec` it (a script, or the `/bin/sh` a tcsh or nu launch line runs), whose own name leads the - * group. It never proves a shell: a macOS pane's shell runs under `login`, so the group's root is - * not the shell, and a capture that ran out of time is no answer. + * group. It counts only from a capture begun after this read was asked for, less the window a + * shared capture is reused across, and never by how long `ps` took on a loaded host. It never + * proves a shell: a macOS pane's shell runs under `login`, so the group's root is not the shell, + * and a capture that ran out of time is no answer. * * Otherwise one fresh read of the terminal's foreground process: on a local POSIX host the scan * behind `confirmForegroundProcess`, and on an SSH host the relay's name, which it reads from the @@ -65,11 +65,12 @@ export async function readLaunchedAgentForeground( // An SSH pane has no such check, so on a Windows relay nothing proves a shell. return (await controller.confirmShellForeground?.(ptyId)) ? 'shell' : 'unknown' } + const askedAt = Date.now() const evidence = (await controller.inspectProcess?.(ptyId))?.foregroundProcessEvidence if ( evidence?.verdict === 'live' && evidence.fence.platform === 'posix' && - evidence.capturedAgeMs <= FOREGROUND_GROUP_EVIDENCE_MAX_AGE_MS && + Date.now() - evidence.capturedAgeMs >= askedAt - PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS && evidence.processName && isLaunchedAgent(evidence.processName, agent) ) {