test(pty): pin the unavailable arm's twin, and eight neighbouring refusal arms

A gate ablation found the `unavailable` arm of the completion monitor free
while its structurally identical out-of-contract twin was pinned. After an
`unavailable` sample the pending exit could stop being discarded and nothing
would notice: a pending exit would survive the gap and be confirmed by the next
positive sample, so the two "consecutive" samples that dispatch a completion
would be separated by a read that observed nothing. `unavailable` is strictly
less evidence than an out-of-contract verdict, so if either arm deserves the
re-arm it is this one. Its backoff charge was free for the same reason and is
pinned here too, or an unreachable host gets polled at the active tier forever.

Also pinned, each red with that arm reverted alone: the exit-1-is-not-absence
timed-out check on both pgrep and ps, the ps spawn-failure staying
unverifiable, `processName: null` from a completed scan reading as positive
absence, an unowned pty reading as positive absence, and a pty replaced
mid-scan staying unverifiable.

`!result.signal` in subprocessRanAndMatchedNothing is left unpinned on
purpose: Node reports `code` and `signal` mutually exclusively, so
`code === 1` with a signal is not an input production emits, and a test for it
would assert an unreachable branch.
This commit is contained in:
Brennan Benson
2026-08-28 07:46:57 -07:00
parent abb7f33020
commit a4d69d7b45
4 changed files with 214 additions and 0 deletions
@@ -84,6 +84,21 @@ describe('classifyLocalPtyChildProcesses', () => {
).toEqual({ hasChildProcesses: false, evidence: { verdict: 'exited' } })
})
it('reads a completed scan that observed nothing as positive absence', () => {
// The canonical completion input: the agent exited, the scan ran to
// completion and resolved no foreground process, and node-pty's title is
// back to the shell. `processName: null` is the observation, not an
// absent one, so this is the one shape that may say exited.
expect(
classifyLocalPtyChildProcesses({
procPresent: true,
titleRead: { ok: true, title: 'zsh' },
shell: 'zsh',
foreground: { verdict: 'observed', processName: null }
})
).toEqual({ hasChildProcesses: false, evidence: { verdict: 'exited' } })
})
it('reads a shell title under a degraded scan as unverifiable', () => {
// node-pty's POSIX title read silently falls back to the spawned shell
// when the native read fails, so under scan distress "title == shell"
@@ -204,6 +204,51 @@ describe('LocalPtyProvider.inspectProcess evidence', () => {
})
})
it('reports a pty the provider no longer owns as positive absence', async () => {
// The provider owns this table, so a missing entry is a reaped PTY rather
// than a failed probe. It has to stay a positive observation: the monitor
// refuses to complete on anything it cannot read, so publishing
// `unverifiable` here would strand the pane's last completion forever.
await expect(provider.inspectProcess('pty-the-provider-never-owned')).resolves.toEqual({
foregroundProcess: null,
hasChildProcesses: false,
processEvidence: {
foreground: { verdict: 'observed', processName: null },
children: { verdict: 'exited' }
}
})
})
it('marks a pty replaced mid-scan unverifiable instead of an exit', async () => {
const { id } = await provider.spawn({ cols: 80, rows: 24, sessionId: 'pane-1' })
await recognizeCodex(id)
mockProc.process = 'zsh'
resolveAgentForegroundProcessMock.mockImplementationOnce(async () => {
// The scan outlives the pane: the PTY exits and the same caller session
// id is reattached to a fresh node-pty while the scan is still running.
exitCb?.({ exitCode: 0 })
const replacement = createLocalPtyMockProcess({
get: () => exitCb,
set: (cb) => {
exitCb = cb
}
})
replacement.process = 'zsh'
spawnMock.mockReturnValue(replacement)
await provider.spawn({ cols: 80, rows: 24, sessionId: 'pane-1' })
return { available: true, processName: 'zsh' }
})
// The stale scan answered about a process that is no longer this pane's.
// Its `null` name predates the evidence contract and reads as an exit.
await expect(provider.inspectProcess(id)).resolves.toMatchObject({
processEvidence: {
foreground: { verdict: 'unverifiable', reason: 'pty replaced during inspection' }
}
})
})
it('treats a successful Windows job confirmation as an observation', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.process = 'powershell.exe'