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.
This commit is contained in:
Brennan Benson
2026-10-02 04:04:12 -07:00
parent 3054018722
commit 29e38f61cb
2 changed files with 76 additions and 6 deletions
@@ -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<RemoteForegroundEvidence>) {
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')
})
})
@@ -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)
) {