fix(ssh): measure pane idleness in the unit the sweep's kill operates on

The orphan-relay-PTY sweep authorizes `pty.shutdown { immediate: true }`, which
runs `forceKillPosixPtyProcessGroups`: collect every process group on the pane's
tty, then `killpg` each one. The blast radius is therefore (groups on the tty) x
(members of those groups, wherever they are). The idleness evidence measured only
the first factor, so three shapes read as idle and were SIGKILLed:

- with job control off (`set +m`) a background job keeps the SHELL's pgid, so the
  tty carries exactly one process group and that group is running the user's build;
- a child that drops the controlling terminal (`ioctl(TIOCNOTTY)` without `setsid`)
  keeps the pgid, reports `tpgid == -1`, and never appears in `ps -t <tty>`;
- a double-forked grandchild keeps the pgid and tty but reparents to pid 1, so the
  `ppid` walk cannot reach it and the named-process backstop never fires.

`shellOwnsEveryTtyProcessGroup` now also requires the shell's own process group to
hold no other member anywhere in the table, indexed in the same single pass. A
pids-per-tty set would catch the first and third but not the second, which is why
the count is pgid-wide rather than tty-scoped. The wire field keeps its tty-shaped
name: the value only ever became stricter, so an old client skips more, never less.

Second, unrelated-in-mechanism but same file family: `foregroundSkipReason` summed
`capturedAgeMs + evidenceAgeSinceListingMs` without validating either. A non-numeric
`capturedAgeMs` makes the sum `NaN`, and `NaN > 5000` is false, so a malformed record
PASSED the freshness gate and proceeded toward the stop — the one place in the file
that defaulted toward kill. Nothing validated it on this path
(`mapSshPtyProcessList` checks the ownership fields and spreads the rest through;
`PtyProcessListAdmission` is not on the sweep path). It now runs
`isForegroundProcessEvidence` and fails closed.

Verified on real Linux, not only in mocks: a container drives `bash -i` on a real
pty, builds each construction, runs the real publisher and planner, and then calls
the real `forceKillPosixPtyProcessGroups`. Before, all three published
`shellOwnsEveryTtyProcessGroup: true`, planned SWEEP, and the planted pid was gone
after the signal. After, all three skip and survive, and an idle shell is still
reclaimed.

Residuals are written down at the predicate and in ssh-execution-boundary.md: the
capture is a snapshot (bounded by the evidence-age budget, not removed), and a
process the host's own `ps` cannot enumerate stays unobservable while `killpg`
still reaches it.
This commit is contained in:
Neil
2026-09-03 03:32:19 -07:00
parent f70f580627
commit dc3aa36ad3
6 changed files with 271 additions and 36 deletions
+15
View File
@@ -70,6 +70,21 @@ A verdict needs evidence from the host that owns the process. Apply these tests
Anything short of positive host evidence is `unverifiable`. Reporting it as `exited` is the error this document exists to prevent: it orphans live work and can cold-start a duplicate over the same worktree.
## Deciding a remote pane is idle
The orphan-PTY sweep is the one flow that turns an observation into a SIGKILL, so its idleness evidence has to be measured against the same thing the signal reaches. It is not the terminal.
`forceKillPosixPtyProcessGroups` (`src/main/pty/posix-pty-process-groups.ts`) collects every process group on the pane's tty and `killpg`s each one. The blast radius is therefore _(process groups on the tty) × (members of those groups, wherever they are)_, and the second factor is not bounded by the terminal at all. Two facts make that gap reachable:
- **Job control can be off.** With `set +m` a background job does not get its own process group — it keeps the shell's. `ps` then shows one process group on the tty, running a build. Nothing in a tty-shaped predicate can see it.
- **A group member can leave the terminal.** `ioctl(TIOCNOTTY)` without `setsid` drops the controlling terminal but keeps the pgid, so the process reports `tpgid == -1`, never appears in `ps -t <tty>`, and is still killed by `killpg(shellPgid)`. A double-forked grandchild similarly keeps the pgid while reparenting to pid 1, so no walk by `ppid` from the PTY root can name it either.
So `shellOwnsEveryTtyProcessGroup` (`src/main/providers/agent-foreground-process-batch.ts`) requires both measurements: every process group on the tty is the shell's own with none stopped, **and** the shell's own process group has no other member anywhere in the host's process table. The name is tty-shaped for wire-compatibility reasons only.
Two residuals remain, and neither is removable here. The capture is a snapshot, so work started between the `ps` and the signal is invisible — bounded by `RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS` on the reading side, not eliminated. And a process the host's own `ps` cannot enumerate (another PID namespace, `hidepid=2`, a table truncated by a permission boundary) is unobservable while `killpg` still reaches it.
The general rule this instantiates: **evidence must be measured in the unit the destructive action operates on.** Evidence in a different unit is `unverifiable` no matter how precise it looks.
## Reading artifacts instead of process state
Artifacts are stronger evidence than liveness signals, but they answer a narrower question than they appear to.
@@ -29,7 +29,10 @@ export type BatchedForegroundProcessResult = {
processName: string | null
reason?: string
/** Set only when the table was readable: every process group attached to this PTY's terminal is
* the shell's own, and none of them is stopped. Left absent when we could not observe it. */
* the shell's own, none of them is stopped, AND that group's only member is the shell itself.
* Left absent when we could not observe it. Keeps the tty-shaped name because it is on the wire
* (`ForegroundProcessEvidence`); the value only ever got stricter, so an old client reading it
* skips more, never less. */
shellOwnsEveryTtyProcessGroup?: boolean
}
@@ -62,30 +65,45 @@ export type BatchedForegroundProcessOptions = {
stats?: ProcessTableIndexStats
}
/** Which process groups occupy each controlling terminal, and which terminals hold a stopped
* process. */
type TtyOccupancy = {
/** The two units a forced stop can reach, indexed from one capture: which process groups occupy
* each controlling terminal (which terminals hold a stopped process), and how many rows belong to
* each process group anywhere on the host. */
type PaneOccupancy = {
processGroupsByTty: ReadonlyMap<number, ReadonlySet<number>>
stoppedTtys: ReadonlySet<number>
/** Rows per `pgid`, counted over the WHOLE table with no tty filter — that is the point of it.
* A member that shares the shell's group but has no controlling terminal is reachable by
* `killpg` and invisible to every tty-shaped index. */
rowsByProcessGroup: ReadonlyMap<number, number>
/** True when some row carried no `pgid`, so the group counts are incomplete and cannot support
* an idleness claim. */
processGroupsIncomplete: boolean
}
const ttyOccupancyByCapture = new WeakMap<readonly ProcessTableRow[], TtyOccupancy>()
const paneOccupancyByCapture = new WeakMap<readonly ProcessTableRow[], PaneOccupancy>()
/** Index the capture by controlling terminal.
/** Index the capture by controlling terminal and by process group.
*
* Keyed on `tpgid` because the snapshot carries no tty column and does not need one: a process
* group belongs to exactly one session, a session to at most one controlling terminal, so two
* rows reporting the same live `tpgid` are on the same tty. Memoized per capture, since the
* per-pane cadence poll and `pty.listProcesses` share one TTL-cached table. */
function getTtyOccupancy(rows: readonly ProcessTableRow[]): TtyOccupancy {
const cached = ttyOccupancyByCapture.get(rows)
* The tty half is keyed on `tpgid` because the snapshot carries no tty column and does not need
* one: a process group belongs to exactly one session, a session to at most one controlling
* terminal, so two rows reporting the same live `tpgid` are on the same tty. Memoized per capture,
* since the per-pane cadence poll and `pty.listProcesses` share one TTL-cached table. */
function getPaneOccupancy(rows: readonly ProcessTableRow[]): PaneOccupancy {
const cached = paneOccupancyByCapture.get(rows)
if (cached) {
return cached
}
const processGroupsByTty = new Map<number, Set<number>>()
const stoppedTtys = new Set<number>()
const rowsByProcessGroup = new Map<number, number>()
let processGroupsIncomplete = false
for (const row of rows) {
if (row.pgid === undefined || row.tpgid === undefined || row.tpgid <= 0) {
if (row.pgid === undefined) {
processGroupsIncomplete = true
continue
}
rowsByProcessGroup.set(row.pgid, (rowsByProcessGroup.get(row.pgid) ?? 0) + 1)
if (row.tpgid === undefined || row.tpgid <= 0) {
continue
}
let groups = processGroupsByTty.get(row.tpgid)
@@ -99,8 +117,13 @@ function getTtyOccupancy(rows: readonly ProcessTableRow[]): TtyOccupancy {
stoppedTtys.add(row.tpgid)
}
}
const occupancy: TtyOccupancy = { processGroupsByTty, stoppedTtys }
ttyOccupancyByCapture.set(rows, occupancy)
const occupancy: PaneOccupancy = {
processGroupsByTty,
stoppedTtys,
rowsByProcessGroup,
processGroupsIncomplete
}
paneOccupancyByCapture.set(rows, occupancy)
return occupancy
}
@@ -161,7 +184,7 @@ export function resolveAgentForegroundProcessesFromIndex(
}
}
const occupancy = getTtyOccupancy(index.rows)
const occupancy = getPaneOccupancy(index.rows)
return requests.map((request) => {
const root = lookupProcessTableIndex(index, (value) => value.byPid.get(request.rootPid))
if (!root) {
@@ -185,20 +208,40 @@ export function resolveAgentForegroundProcessesFromIndex(
reason: 'no_controlling_tty'
}
}
// The only host-observable "nothing is running here" signal, and it has to be read off the
// whole tty rather than off `tpgid === pgid`. A backgrounded `pnpm build &` and a Ctrl-Z'd
// editor both leave the shell owning the foreground group, byte-identical to an idle prompt;
// what separates them is a second process group attached to the pane's terminal. That is also
// exactly the blast radius of the stop this attests to — `forceKillPosixPtyProcessGroups`
// SIGKILLs every process group on the tty — so the evidence and the kill now measure the same
// thing. A reader may treat `false` as "busy" and must never treat absence as "idle".
// The only host-observable "nothing is running here" signal, and it takes TWO measurements
// because the stop it authorizes has two units. `forceKillPosixPtyProcessGroups` collects every
// process group on the pane's tty and then `killpg`s each one, so the blast radius is
// (groups on the tty) x (members of those groups, wherever they are). Neither half implies the
// other, so both are required:
//
// tty: a backgrounded `pnpm build &` and a Ctrl-Z'd editor both hand the terminal back, so
// the shell's row is byte-identical to an idle prompt. What separates them is a second
// process group attached to the pane's terminal.
// group: with job control off (`set +m`, common in non-interactive and dumb-terminal shells,
// and settable by the user at the prompt) a background job KEEPS the shell's pgid, so
// the tty shows one group and that group is running a build. Same for a child that
// drops the controlling terminal without `setsid` (`tpgid == -1`, absent from every
// tty index, still reachable by `killpg`) and for a double-forked grandchild that
// reparents to pid 1 and so never appears in the ppid walk below.
//
// Residual after both, written down because the predicate cannot see it: the capture is a
// snapshot, so work started between the `ps` and the signal is invisible — bounded, not
// removed, by RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS on the reading side; and a process the host's
// own `ps` cannot enumerate (another PID namespace, `hidepid=2`, a table truncated by a
// permission boundary) is unobservable here while `killpg` still reaches it.
//
// A reader may treat `false` as "busy" and must never treat absence as "idle".
const ttyProcessGroups = occupancy.processGroupsByTty.get(root.tpgid)
const shellOwnsEveryTtyProcessGroup =
root.tpgid === root.pgid &&
ttyProcessGroups !== undefined &&
ttyProcessGroups.size === 1 &&
ttyProcessGroups.has(root.pgid) &&
!occupancy.stoppedTtys.has(root.tpgid)
!occupancy.stoppedTtys.has(root.tpgid) &&
!occupancy.processGroupsIncomplete &&
// The root always counts itself, so exactly one row in its group means the group IS the
// shell — no separate leader check, and no set of pids retained per capture.
occupancy.rowsByProcessGroup.get(root.pgid) === 1
const allCandidates = rowsByOwner.get(root.pid) ?? []
const foregroundCandidates = allCandidates.filter((row) => row.pgid === root.tpgid)
const fallbackProcess = request.fallbackProcess
@@ -68,6 +68,44 @@ const CAPTURES = {
' 3159 3158 3159 3158 T sleep 300',
' 3160 1 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command='
]
},
/** `set +m; sleep 300 &`. With job control OFF the job does not get its own process group — it
* keeps the SHELL's pgid. So the tty carries exactly one process group, and that group is
* running a build. Reproduced independently on a real Ubuntu host through an Orca pane. */
setMinusMBackground: {
rootPid: 12,
table: [
' 1 0 1 -1 Ss /bin/bash /work/run.sh',
' 11 1 1 -1 S python3 /work/pty-scenario.py setm_background',
' 12 11 12 12 Ss+ bash -i',
' 13 12 12 12 S+ sleep 300',
' 14 11 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command='
]
},
/** A `set +m` job that drops its controlling terminal (`ioctl(TIOCNOTTY)` with no `setsid`). It
* keeps the shell's pgid, reports `tpgid == -1`, and is absent from `ps -t <tty>` and from every
* tty-keyed index — while `killpg(shellPgid)` still reaches it. */
nottyGroupMember: {
rootPid: 16,
table: [
' 1 0 1 -1 Ss /bin/bash /work/run.sh',
' 15 1 1 -1 S python3 /work/pty-scenario.py notty_member',
' 16 15 16 16 Ss+ bash -i',
' 17 16 16 -1 S python3 -c import fcntl,os,time;fd=os.open("/dev/tty",os.O_RDWR);fcntl.ioctl(fd,0x5422);os.close(fd);time.sleep(300)',
' 18 15 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command='
]
},
/** A `set +m` job that double-forks. pid 22 keeps the shell's pgid and tty but reparented to pid
* 1, so the ppid walk from `rootPid` never reaches it and it can never be named. */
doubleForkedGroupMember: {
rootPid: 20,
table: [
' 1 0 1 -1 Ss /bin/bash /work/run.sh',
' 19 1 1 -1 S python3 /work/pty-scenario.py double_fork',
' 20 19 20 20 Ss+ bash -i',
' 22 1 20 20 S+ python3 -c import os,sys,time;p=os.fork() if p: print("GRANDCHILD:%d"%p);sys.stdout.flush();os._exit(0) time.sleep(300)',
' 23 19 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command='
]
}
} as const
@@ -147,6 +185,15 @@ describe('what the host publishes about a pane, read by the sweep', () => {
expect(shellShape(CAPTURES.background)).toBe(shellShape(CAPTURES.idle))
expect(shellShape(CAPTURES.ctrlz)).toBe(shellShape(CAPTURES.idle))
expect(shellShape(CAPTURES.foreground)).not.toBe(shellShape(CAPTURES.idle))
// Same premise for the `set +m` captures, minus `ppid`: their harness keeps its parent alive
// rather than reparenting the shell to init, and the ppid is the one field of the shape the
// predicate never reads.
const paneShape = (capture: { rootPid: number; table: readonly string[] }): string =>
shellShape(capture).split(' ').slice(1).join(' ')
expect(paneShape(CAPTURES.setMinusMBackground)).toBe(paneShape(CAPTURES.idle))
expect(paneShape(CAPTURES.nottyGroupMember)).toBe(paneShape(CAPTURES.idle))
expect(paneShape(CAPTURES.doubleForkedGroupMember)).toBe(paneShape(CAPTURES.idle))
})
it('sweeps an idle shell', async () => {
@@ -191,6 +238,58 @@ describe('what the host publishes about a pane, read by the sweep', () => {
expect(skipReason(plan)).toBe('host does not attest an idle shell')
})
// The tty is not the unit the stop operates on. `forceKillPosixPtyProcessGroups` collects the
// groups on the tty and then `killpg`s each one, so anything sharing the shell's pgid dies with
// it — including members the tty index cannot see at all. All three captures below reproduce on
// real Linux: before the group-membership half of the predicate they published
// `shellOwnsEveryTtyProcessGroup: true`, planned a SWEEP, and the planted pid was GONE after the
// real `forceKillPosixPtyProcessGroups` call.
it('never sweeps a pane whose background job shares the shell pgid under `set +m`', async () => {
// pid 13 is `sleep 300` — stand in `pnpm build`. Its pgid IS the shell's, so the tty carries
// exactly one process group and the tty half of the predicate reads the pane as idle.
const rows = parseStrictProcessTableRows(CAPTURES.setMinusMBackground.table.join('\n'))
const tty = rows.filter((row) => row.tpgid === CAPTURES.setMinusMBackground.rootPid)
expect(new Set(tty.map((row) => row.pgid))).toEqual(new Set([12]))
expect(tty.map((row) => row.pid)).toEqual([12, 13])
const evidence = await publish(CAPTURES.setMinusMBackground)
expect(evidence).toMatchObject({ shellOwnsEveryTtyProcessGroup: false })
const plan = await planFor(CAPTURES.setMinusMBackground)
expect(plan.sweep).toEqual([])
expect(skipReason(plan)).toBe('host does not attest an idle shell')
})
it('never sweeps a pane whose group member dropped the controlling terminal', async () => {
// pid 17 kept the shell's pgid and called `ioctl(TIOCNOTTY)`, so it reports `tpgid == -1`,
// never appears in `ps -t <tty>`, and no tty-shaped index — not process groups, not pids —
// can observe it. `killpg(16)` reaches it regardless.
const rows = parseStrictProcessTableRows(CAPTURES.nottyGroupMember.table.join('\n'))
expect(rows.filter((row) => row.tpgid === 16).map((row) => row.pid)).toEqual([16])
expect(rows.filter((row) => row.pgid === 16).map((row) => row.pid)).toEqual([16, 17])
const evidence = await publish(CAPTURES.nottyGroupMember)
expect(evidence).toMatchObject({ shellOwnsEveryTtyProcessGroup: false })
const plan = await planFor(CAPTURES.nottyGroupMember)
expect(plan.sweep).toEqual([])
expect(skipReason(plan)).toBe('host does not attest an idle shell')
})
it('never sweeps a pane whose group member double-forked away from the shell', async () => {
// pid 22 reparented to pid 1, so the ppid walk from rootPid cannot reach it and the named-
// process backstop can never fire. It still holds the shell's pgid.
const rows = parseStrictProcessTableRows(CAPTURES.doubleForkedGroupMember.table.join('\n'))
expect(rows.find((row) => row.pid === 22)).toMatchObject({ ppid: 1, pgid: 20, tpgid: 20 })
const evidence = await publish(CAPTURES.doubleForkedGroupMember)
expect(evidence).toMatchObject({ processName: null, shellOwnsEveryTtyProcessGroup: false })
const plan = await planFor(CAPTURES.doubleForkedGroupMember)
expect(plan.sweep).toEqual([])
expect(skipReason(plan)).toBe('host does not attest an idle shell')
})
it('refuses an observation older than the pass it would authorize', async () => {
// Same idle capture that sweeps above; only its age differs. Staleness degrades to "leave it
// running", never to "stop it".
+11 -5
View File
@@ -17,14 +17,20 @@ export type ForegroundProcessEvidence =
| ({
verdict: 'live'
processName: string | null
/** True only when the host observed every process group attached to this PTY's terminal to be
* the shell's own, with none of them stopped — i.e. nothing is running in the pane, in the
* foreground OR the background, and nothing sits suspended.
/** True only when the host observed BOTH units a forced stop can reach to hold nothing but
* the shell: every process group attached to this PTY's terminal is the shell's own with
* none of them stopped, AND the shell's own process group has no other member anywhere on
* the host. I.e. nothing is running in the pane, in the foreground OR the background, and
* nothing sits suspended.
*
* Deliberately not `tpgid === pgid`: a job the user backgrounded with `&` and a job the user
* suspended with Ctrl-Z both hand the terminal back to the shell, so a foreground-only
* predicate reads them as idle. This one is measured against the same set of process groups
* a forced stop would SIGKILL.
* predicate reads them as idle. Deliberately not the tty alone either: with job control off
* (`set +m`) a background job keeps the shell's pgid, and a child that drops the controlling
* terminal leaves every tty index entirely — both are still inside `killpg`'s reach.
*
* The name is tty-shaped for wire reasons only. It shipped that way and old clients read it;
* the value has only ever become stricter, which makes an old client skip more, never less.
*
* False means something IS running, named or not. Absent from a host that predates the
* field, which is neither: a reader deciding whether the pane is idle must require `true`
@@ -146,6 +146,58 @@ describe('planRelayPtySweep', () => {
expect(reasonFor(plan, 'pty-1')).toBe('host attests another client created it')
})
// The age gate is the one comparison in the file that a malformed field defaults toward the
// kill: the sum goes `NaN`, and `NaN > budget` is FALSE, so the entry PASSES the freshness gate
// and proceeds toward the stop. Nothing validated this record on the sweep path —
// `mapSshPtyProcessList` checks the ownership fields and spreads the rest through.
it.each([
['missing', undefined],
['a string', '0' as unknown],
['NaN', Number.NaN],
['Infinity', Number.POSITIVE_INFINITY],
['negative', -1],
['fractional', 1.5]
])('never sweeps when the host stamped capturedAgeMs %s', (_label, capturedAgeMs) => {
const plan = planRelayPtySweep(
[
orphan({
foregroundProcessEvidence: {
...idleShell(),
capturedAgeMs
} as unknown as ForegroundProcessEvidence
})
],
context()
)
expect(plan.sweep).toEqual([])
expect(reasonFor(plan, 'pty-1')).toBe('host foreground observation is malformed')
})
it('never sweeps on an evidence record whose other host stamps are malformed', () => {
const plan = planRelayPtySweep(
[
orphan({
foregroundProcessEvidence: {
...idleShell(),
authorityGeneration: ''
} as ForegroundProcessEvidence
})
],
context()
)
expect(plan.sweep).toEqual([])
expect(reasonFor(plan, 'pty-1')).toBe('host foreground observation is malformed')
})
it('never sweeps when this client cannot compute an age budget', () => {
const plan = planRelayPtySweep([orphan()], context({ evidenceAgeSinceListingMs: Number.NaN }))
expect(plan.sweep).toEqual([])
expect(reasonFor(plan, 'pty-1')).toBe('sweep has no usable evidence-age budget')
})
it('never sweeps a PTY younger than the floor', () => {
const plan = planRelayPtySweep(
[orphan({ hostAgeMs: RELAY_PTY_SWEEP_MIN_AGE_MS - 1 })],
+27 -7
View File
@@ -1,4 +1,7 @@
import type { ForegroundProcessEvidence } from './foreground-process-evidence'
import {
isForegroundProcessEvidence,
type ForegroundProcessEvidence
} from './foreground-process-evidence'
/** Which relay PTYs a client may prove it orphaned, and therefore may stop (#9819).
*
@@ -104,9 +107,10 @@ export const RELAY_PTY_SWEEP_MAX_PER_PASS = 8
export const RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS = 5_000
/** The host's own answer to "is anything running in this pane?". Only a positive "no" clears the
* sweep; every other shape — an older host, an unreadable process table, an observation too old to
* describe now, a named foreground process, any other process group on the pane's terminal — is a
* reason to leave the process alone. */
* sweep; every other shape — an older host, a malformed record, an unreadable process table, an
* observation too old to describe now, a named foreground process, any other process group on the
* pane's terminal, any other member of the shell's own process group — is a reason to leave the
* process alone. */
function foregroundSkipReason(
evidence: ForegroundProcessEvidence | undefined,
context: RelayPtySweepContext
@@ -116,6 +120,20 @@ function foregroundSkipReason(
// observation is not the observation of absence.
return 'host published no foreground-process observation'
}
// The record reaches this decision straight off the wire — `mapSshPtyProcessList` validates the
// ownership fields and spreads the rest through, and `PtyProcessListAdmission` is not on the
// sweep path. Shape-check it here, because the age gate below is the one comparison in this file
// that a malformed field defaults toward the kill: a non-numeric `capturedAgeMs` makes the sum
// `NaN`, and `NaN > budget` is FALSE, so the entry would pass the freshness gate.
if (!isForegroundProcessEvidence(evidence)) {
return 'host foreground observation is malformed'
}
if (
!Number.isFinite(context.evidenceAgeSinceListingMs) ||
!Number.isFinite(context.maximumEvidenceAgeMs)
) {
return 'sweep has no usable evidence-age budget'
}
// Before anything is read out of it: an observation is only a claim about the instant it was
// taken. Age is checked on both verdicts because a stale `unverifiable` is no better.
if (evidence.capturedAgeMs + context.evidenceAgeSinceListingMs > context.maximumEvidenceAgeMs) {
@@ -130,9 +148,11 @@ function foregroundSkipReason(
return 'host observes a named foreground process'
}
if (evidence.shellOwnsEveryTtyProcessGroup !== true) {
// Something other than the shell's own process group is attached to the pane's terminal — a
// foreground command, a job backgrounded with `&`, a Ctrl-Z'd editor — or this host predates
// the field. The stop would SIGKILL that group, so none of those is a pane to reclaim.
// The host saw work inside the stop's blast radius: another process group on the pane's
// terminal (a foreground command, a job backgrounded with `&`, a Ctrl-Z'd editor), or another
// member of the shell's OWN process group (a `set +m` background job, a child that dropped the
// controlling terminal) — or this host predates the field. `killpg` reaches all of it, so none
// of those is a pane to reclaim.
return 'host does not attest an idle shell'
}
return null