Conversation
|
Review from jc: two competing adversarial reviewers, then a verifier that tried to refute each finding. Verdict: changes needed. The stdin half works on the host runtime. The group-kill half has two problems.
Refuted: "no wall-clock timeout on foreground bash". That's real, but it predates this PR and is #414's job. |
…s pid test (PRI-3243) jc's review of obra#413 found two problems: 1. `detached: true` orphans the command tree when a subagent is killed or shut down. A detached foreground bash spawn is the leader of its OWN process group, distinct from the agent process's own group, so no signal aimed at the agent (or its group, from outside) ever reaches it. If the agent process then exits, the detached tree is simply reparented to init with no one left to signal it. Fix: track every detached command's pid in a new registry (process-group-registry.ts) and have main.ts's shutdown() call killAllTrackedProcessGroups() — SIGTERM every tracked group, poll for the whole group (not just the tracked pid) to empty out, then SIGKILL any survivor — before process.exit(). Reproduced the orphan first as a failing E2E test (agent-process.shutdown-process-group.e2e.test.ts): SIGTERMing a real spawned agent process while it runs a foreground bash pipeline whose first stage traps (ignores) SIGTERM leaves that stage alive under init on pre-fix main.ts; passes after the fix. Also covered at the unit level (process-group-registry.test.ts): tracking/untracking, and SIGKILL escalation for a TERM-ignoring tracked group. 2. The process-group regression test in bash.test.ts was vacuous: `echo $ > pidfile` (bare `$`) writes a literal `$` character, so `parseInt` gives NaN, `toBeDefined()` accepts it, and `process.kill(NaN, 0)` throws synchronously before ever reaching the OS — the test passed on the old, unfixed bash.ts too. Fixed to `echo $$` (bash's actual pid), asserts `Number.isInteger`, and treats only ESRCH as "dead". Verified: fails on pre-obra#413 main (the orphan survives — stillAlive: true) and passes on this branch. Gates: packages/agent unit suite (366 files / 3660 tests passed, 35 skipped), npm run lint, npm run typecheck — all clean.
…aped descendants; configurable default (PRI-3251) jc's review of obra#414 found: 1. Important: settling cleared the SIGKILL grace timer. host.ts passes context.signal straight into node's child_process.spawn({signal}), which fires its own abort-driven kill-and-error-the-child handling the instant the AbortSignal fires — independent of whether the real OS process has exited. That rejects `completion` almost immediately (observed ~1ms), and the old clearPendingTimers() unconditionally cancelled the still-pending SIGKILL timer on any settle. A TERM-ignoring process (trap) never got the SIGKILL and survived. Fix: split the timer-clearing so only the foreground-timeout-detection timer is cancelled on settle; the SIGKILL grace timer is left to run to completion (killProcessOrGroup's ESRCH is caught if there's truly nothing left) and is unref()'d so it can never itself keep the process alive. Reproduced first as a failing test: abort a `trap '' TERM; sleep 30` — settles in ~1ms, and the trapped process is still alive 3s later on 68a730c; fixed version confirms it's actually dead (ESRCH). 2. Important: same vacuous pid tests as obra#413 (bare `echo $` instead of `echo $$`). Fixed the same way: `$$`, `Number.isInteger`, ESRCH-only "dead" check. 3. Important: the timeout didn't bound a descendant that escaped the process group entirely (setsid) and still held the stdout/stderr pipes — waiting on stdout/stderr 'end' would hang for that descendant's whole lifetime. Fix: once the SIGKILL grace period elapses, stop waiting — destroy the streams and force the call to settle with whatever's already captured, bounding the call by timeoutMs (or abort) + KILL_GRACE_MS. New test: `(setsid sleep 30 &) ; sleep 30` with timeoutMs 1000 times out on old code (15s test timeout) and settles in ~2s on the fix. 4. Jesse's call on the default: configurable per-instance (LACE_BASH_FOREGROUND_TIMEOUT_MS, same env-var-with-clamped-fallback pattern as delegate.ts's LACE_WORKSPACE_MAX_PER_PARENT), default 600s instead of a hardcoded 60s that hard-killed every foreground call omitting timeoutMs (builds, installs, clones, long test runs). Tool description now reflects the configured value and the env var name. New test: a lowered env-configured default (500ms) is honored for a command that omits timeoutMs; times out (15s) on old code (which ignores the env var and uses the old 60s hardcoded default) and passes in ~0.5s+grace on the fix. Per-call override bounds (1s-600s) are unchanged; the per-call ceiling and the new default happen to be the same value (600s) — the ceiling was already generous, and Jesse's call was to give omitted-timeoutMs calls that same generous budget rather than the old conservative 60s. Gates: packages/agent unit suite, npm run lint, npm run typecheck.
…e (PRI-3250) Depends on obra#413 for the stdin option itself. ## Problem obra#413 (PRI-3243) gave RuntimeProcessOptions a stdin?: 'ignore' | 'pipe' option, defaulted to 'ignore', so a foreground bash spawn doesn't hang on a command that unexpectedly reads stdin (e.g. head with no file args). bash.ts passes stdin: 'ignore' explicitly. But that only reaches the host runtime (HostProcessRunner.start() in host.ts). The container runtime's ProjectedContainerProcessRunner.start() (tools/runtime/projected-container.ts) is the RuntimeProcessRunner implementation used whenever a tool executes inside a container, and it never read opts.stdin at all: optionsFor() doesn't forward it, and start() always calls containerManager.execStream(), which always spawns its underlying docker/container/plane CLI with -i and a piped stdin (see docker-container.ts's, apple-container.ts's and plane-runtime.ts's execStream() implementations). Nothing ever wrote to or closed that pipe, so a container-run command reading stdin because it got zero file args would hang forever, same shape as PRI-3243, just one layer down and unaffected by that fix. This is the container-runtime half PRI-3250 calls out as left out of obra#413's scope. Some execStream consumers genuinely need the piped stdin (an in-container MCP server driven interactively via RuntimeStdioClientTransport-equivalent plumbing, or ContainerExecFileSystem/ContainerExecNetworkClient's own low-level exec-with-stdin paths, which already write + end the pipe correctly and are unaffected). This fix only changes the ProjectedContainerProcessRunner.start()/exec() path that backs the bash tool (and other RuntimeProcessRunner callers) in container mode. ## Fix - ProjectedContainerProcessRunner.start() now ends the execStream handle's stdin immediately (with an error listener to swallow EPIPE) whenever opts.stdin !== 'pipe' (the default), mirroring HostProcessRunner's 'ignore' semantics without needing to change the three container runtimes' own execStream() signatures. - The returned RuntimeProcessHandle.stdin is undefined in that case, so a caller can't be misled into writing to an already-ended stream; it's only the live handle when the caller opted into stdin: 'pipe'. ## Tests Added to packages/agent/src/tools/runtime/__tests__/projected-container.test.ts: - 'ends the container stdin pipe immediately when the caller does not opt into it' — asserts end() is called once, the stream is writableEnded, and the returned handle.stdin is undefined. Fails against the pre-fix code (end() never called). - 'does not end the container stdin pipe when the caller opts into stdin: pipe' — asserts the opposite for an MCP-server-shaped stdin: 'pipe' caller. Gates: full packages/agent test suite (3656 passed, 35 skipped, 3 pre-existing failures unrelated to this change — confirmed by running them in isolation on this same worktree before this commit: resource-resolver.test.ts fails because this fresh worktree has no built dist/, and track-compaction.fixedpoint.test.ts's one failure was a parallel-run timing flake that passes standalone), npm run lint, npm run typecheck — all clean.
|
Addressed both findings in 9cc0a9f (rebased #414/#415 on top):
Didn't touch the container-runtime stdin gap (#415, still out of scope here) or the missing wall-clock timeout (#414's job) per your refutation. |
|
The stdin half of this PR is now in #418 (#418), on its own branch from current main, with abort/timeout behavior left as on main. This PR stays open for the process-group / kill-model rework only. Before it can land it has to fix the subagent orphaning review found: when a parent group-kills a subagent, the 500ms window is shorter than the bash tool's 2s self-reap, and the foreground command's detached group sits outside the subagent's own group, so the in-flight command tree and its background children survive as orphans. |
…I-3243) (#418) The host process runner spawned every child with stdin as an open pipe that nothing wrote to or closed. A command that falls back to reading stdin, such as `head -8 $(find ... | head -1)` when find matches nothing, blocked until killed by hand. That is the 47-minute Dex wedge. Add RuntimeProcessOptions.stdin ('ignore' | 'pipe'). The host runner now defaults to 'ignore' (/dev/null, immediate EOF) and only opens a pipe when asked. RuntimeStdioClientTransport, which talks to MCP servers over stdio, asks for 'pipe'. The bash tool and background shell jobs pass 'ignore' explicitly. The projected container runner does not honor the option yet; the doc comment says so. Abort and timeout handling is unchanged: the bash tool still kills its direct child only. Split out of #413; the process-group half of that PR moves to a follow-up.
…l window (PRI-3243) This PR now covers only the detached-process-group / kill-model rework. The stdin fix shipped separately as obra#418; obra#414 (foreground timeout) and obra#415 (container-runtime stdin) are already on main. This is rebuilt from scratch on top of that main, not rebased. jc's review of the previous version of this PR found the detached-group fix incomplete: a subagent kill only gives the subagent 500ms (job-control.ts's killJob/killAllRunningJobs, both called with waitMs: 500, forceKill: true) before SIGKILLing it outright. The prior attempt's shutdown()-time reap used a 2s SIGTERM->SIGKILL grace period — comfortably past that 500ms deadline, so a TERM-ignoring descendant of a detached foreground bash command would still be mid-poll when the parent's SIGKILL ended the subagent process outright, abandoning the detached group exactly as before. Fix, same shape as before but re-timed to fit the real deadline: - bash.ts spawns its foreground command detached (POSIX) again and kills the whole process group (kill(-pid)), not just the shell, on both abort and timeout. - process-group-registry.ts tracks every detached command's pid; main.ts's shutdown() calls killAllTrackedProcessGroups() as the very first thing it does (before workspace teardown, before anything else that can hang), SIGTERMing every tracked group, polling for it to empty out, and SIGKILLing any survivor. - The grace period for that escalation is now 150ms, not 2s -- far under the parent's 500ms deadline with room for signal-delivery and poll-loop latency, so the reap reliably finishes before the parent's SIGKILL can land on the subagent process itself. Why this design and not the others considered: - A pgid-file/handshake so the PARENT tracks and kills descendant groups itself would work too, but needs a new IPC surface between parent and child and still has to solve the same "how fast can this finish" problem -- it doesn't remove the timing constraint, just moves the deadline to a different process. Reaping from the subagent's own SIGTERM handler needs no protocol change: the subagent already gets the SIGTERM, and it already owns the process-group-registry that named these pids. - Not detaching at all would lose the ability to kill a pipeline (e.g. sleep | cat) from a plain abort inside the SAME process, which is the behavior obra#414's review already validated for this PR; dropping it would regress that. - A larger grace period is what jc's review actually found broken: it has to be short specifically because the parent's budget is short. Tests: - New: agent-process.shutdown-process-group.e2e.test.ts. Spawns a real lace-agent process, gives it a foreground bash command with BOTH a plain `&` background child and a foreground pipeline whose first stage traps (ignores) SIGTERM, then kills the agent process the same way job-control.ts's killJob does -- SIGTERM, wait only 500ms, SIGKILL if still running -- and asserts neither descendant survives. - Fails on plain main (no detached spawn at all: the bash-tool child is never signaled when the agent process exits, so it's simply reparented to init). - Fails on main + a detached-only spawn with no registry/shutdown reap (confirmed by temporarily reverting just the main.ts change): the detached group is abandoned to init the moment the agent process exits, since nothing ever signals a group outside the agent's own. - Passes with the full fix. - New: process-group-registry.test.ts (tracking/untracking, SIGKILL escalation for a TERM-ignoring group within the 150ms/500ms budget, and no-op on an empty or already-untracked registry). - bash.test.ts: added the process-group abort test (pipeline member killed on abort, not just the shell) and the no-unhandled-rejection-on-abort test. Gates: packages/agent full suite (`npm run test`) -- 374 files / 3851 tests passed, 35 skipped (unit phase) + 48 files / 276 tests passed, 20 skipped (E2E phase), 0 failed. `npm run lint`, `npm run typecheck` -- both clean. No orphan processes left on the host after the suite run.
0cb2d2b to
20a4539
Compare
|
Rebuilt at Addressed your latest finding (the 500ms-vs-2s-grace race): Went with reaping in the subagent's own SIGTERM handler rather than a parent-side pgid-file/handshake: the handshake still has to solve the identical "fast enough" problem, just on the other end of the pipe, and needs a new IPC surface to do it; the subagent already gets the SIGTERM and already owns the registry that named these pids, so no new protocol was needed. Didn't drop New E2E test ( Full |
|
Review from jc on It does fix the foreground-group-kill and stdin-end cases. But it adds two regressions over main (items 1 and 2), and item 2 is a design question: once bash detaches its groups, the parent's group SIGKILL no longer reaches the command tree, so cleanup depends on the subagent's JS SIGTERM handler running. The E2E test models a non-detached subagent, which isn't the real path. 1. [important] Evidence: I reproduced it with a real dist/main.js spawned detached, the way subagent-spawn.ts:58 does, and killed it with the real dist killJob({waitMs:500, forceKill:true}). Command: Suggested fix (small): Drop the entry only when the group is actually empty. On completion, probe with kill(-pgid,0) and keep the entry while members remain. Sweep lazily on later track calls and at shutdown. A pgid can't be reused while it still has members, so this is safe against pid reuse. Add an E2E case for a background job left by a finished call. 2. [important] Evidence: Repro: in-flight Suggested fix (design): Needs a design decision. Either the parent learns the descendant pgids (a pgid file or handshake the parent reaps in killJob after its SIGKILL), or the pipeline stays in the subagent's group and the tree gets killed some other way on in-process abort. At minimum, document this as an accepted tradeoff in the PR and module doc. 3. [important] Evidence: Repro, cancel-then-kill (cancel, 1500ms, killJob): trap stage ALIVE on both PR and main. Reviewer B also showed it dies with a 2.7s wait, which isolates the cause to the 2s timer racing process exit. Suggested fix (small): Fixed by the same change as the untrack finding: keep the group tracked until it is empty, so shutdown()'s reap still SIGKILLs a trap stage left after an abort. Add an E2E case for cancel then kill at 1500ms. 4. [minor] Evidence: subagent-spawn.ts:54-58 sets Suggested fix (small): Spawn the agent detached in the E2E test and kill it with the real killJob. Correct the module doc and the stale job-control.ts comment. 5. [minor] Evidence: I replaced Suggested fix (trivial): Drop the manual untrack() and assert the count reaches 0 after completion settles (with the group-empty semantics from the first fix, assert on group emptiness). |
The detached-process-group registry untracked a pgid as soon as the one pid it was given (the shell bash.ts spawned directly) exited, using that process's own completion promise as the removal signal. A plain `cmd &` backgrounds a grandchild in the same group that outlives the shell: the shell returns almost immediately after launching it, so the entry was dropped from the registry long before the background job was actually done. killAllTrackedProcessGroups() then had nothing left to signal for that survivor, so a background child of a foreground bash-tool call could outlive the agent process entirely. Remove the completion-driven auto-untracking. A tracked pgid is now removed only once its process group is observed empty (groupIsEmpty), checked lazily: opportunistically whenever a new pgid is tracked, and during the kill sweep itself after the SIGTERM/SIGKILL escalation has had its chance to empty the group out. A pgid with live members can't be reused by the OS for something unrelated, so deferring removal this way carries no pid-reuse risk, and the registry still can't grow without bound over the life of a long-running agent process. Adds a regression test that spawns `sleep 30 &` through the real bash tool, lets the shell exit, runs the kill sweep, and asserts the background process is dead -- it fails against the prior behavior and passes with this fix. Also updates the existing process-group-registry unit tests for the new (no-completion-arg) trackProcessGroup signature and adds coverage for the lazy-removal behavior directly. Does not change the detached-process-group's own reachability from a force-killed parent (a separate, still-open design question); see the PR description for a follow-up note on that trade-off.
…(jc finding obra#4) jc's finding obra#4 on 20a4539: the E2E test's killTheWayTheParentDoes called plain proc.kill() on a non-detached spawnAgentProcess child, but a real subagent is spawned detached (subagent-spawn.ts:58) and killed via job-control.ts's real group kill (process.kill(-pid, ...)). The mismatch let the test pass for the wrong reason and hid finding #1 (the untrack-on- completion regression, fixed in 0cfa772): with a foreground pipeline that blocks until killed, the shell's completion promise never resolves early enough for the bug to matter either way. Fix: - spawnAgentProcess (test helper) gains a `detached` option, spawning the agent the same way subagent-spawn.ts spawns a real subagent. - The E2E test now spawns detached and kills with the real job-control.ts killJob (wrapped in a minimal JobState), not a hand-rolled approximation. - Both orphan shapes are now backgrounded with `&` (redirecting their own stdout/stderr so they don't hold the agent's output pipe open) so the bash tool call itself returns before the kill -- the shape finding #1 actually needs: a background job left by a call that has already returned. - Corrected the stale "subagents are not spawned detached" claim in process-group-registry.ts's module doc and job-control.ts's group-kill fallback comment; both predated subagent-spawn.ts's detached spawn and were flagged by jc as the likely source of the test's false premise. TDD: copied the corrected test (+ helper) into a scratch worktree at 20a4539 (the commit immediately before the untrack-on-completion fix) and confirmed it fails there -- `expect(isAlive(trapPid)).toBe(false)` gets `true` (orphan survives), reproducing finding #1 under the now-realistic detached+real-killJob methodology. Reapplied on top of 0cfa772 (this branch) plus this change: passes, 5/5 runs, no orphan processes left behind either way (confirmed and cleaned up manually after the red runs). Does not touch the reg2 design question (parent SIGKILL reachability into the bash tool's own detached group) -- split out per jc's finding #2. Full packages/agent suite: 422 files / 4131 tests passed, 0 failed (55 skipped, pre-existing). ent-protocol and cli suites green. Lint, typecheck (whole monorepo), and prettier --check all clean.
Problem
dex-sen was wedged for ~47 minutes on
head -8 $(find … | head -1): thefindcame back empty, soheadgot zero file args, fell back to readingstdin, and blocked forever. That stdin fix shipped separately as #418.
This PR now covers only the remaining half: the detached process-group
and kill-model rework. #414 (foreground bash timeout) and #415
(container-runtime stdin) are also already on
main; this branch isrebuilt from scratch on top of current
main, not rebased forward.Root cause (this PR's half)
Aborting or timing out a foreground
bashtool call only ever signaled thespawned
/bin/bashprocess's own pid(
packages/agent/src/tools/implementations/bash.ts). When bash doesn'texec-replace itself (e.g. a pipeline like
sleep 30 | cat, where bash forksone process per stage), a plain kill only kills the shell — the other
pipeline members become orphans (reparented to init) and keep running
forever. The background job path (
shell-job.ts/job-control.ts)already spawns detached and kills via
process.kill(-pid, …)(the processgroup); this PR brings the foreground bash tool call up to the same
standard.
jc's review found the first attempt at this incomplete: making the
foreground spawn
detachedputs it in its OWN process group, separate fromthe agent process's group. That's necessary for the fix (an abort inside
the SAME process can now reach the whole pipeline), but it also means
nothing signals that group if the AGENT process itself is killed or exits —
it's simply abandoned to init. Worse: the parent's actual kill path for a
subagent (
job-control.ts'skillJob/killAllRunningJobs, both calledwith
waitMs: 500, forceKill: true) only gives the subagent 500ms beforeSIGKILLing it outright. My first fix's own shutdown-time reap used a 2s
SIGTERM→SIGKILL grace period for that — comfortably past the parent's real
500ms deadline, so a TERM-ignoring descendant of the detached command would
still be mid-poll when the parent's SIGKILL landed on the subagent process
itself, abandoning the detached group exactly as before.
Fix
bash.ts's foreground spawn isdetached: trueon POSIX again; itsabort and timeout paths kill the whole process group
(
process.kill(-pid, …)), not just the shell, with a fallback tosignaling the direct child if group-kill fails.
process-group-registry.tstracks every detached command's pid.main.ts'sshutdown()callskillAllTrackedProcessGroups()as thevery first thing it does — before workspace teardown, before
anything else that can hang — SIGTERMing every tracked group, polling for
it to empty out, and SIGKILLing any survivor.
parent's 500ms deadline, with room to spare for signal-delivery and
poll-loop latency, so the reap reliably finishes before the parent's
SIGKILL can land on the subagent process itself.
Design alternatives considered (jc's review named these)
but needs a new IPC surface between parent and child, and still has to
solve the identical "how fast can this finish" problem — it just moves
the deadline to a different process instead of removing it. Reaping from
the subagent's own SIGTERM handler needs no protocol change: the subagent
already receives the SIGTERM, and it already owns the registry that named
these pids.
a plain in-process abort, which is the behavior jc's review of the first
version of this PR already validated; dropping it regresses that.
it has to be short specifically because the parent's kill budget is
short.
I picked the smallest change that (a) passes the orphan-reproduction test
below, (b) keeps the pipeline-abort behavior validated earlier in review,
and (c) fits inside the parent's real 500ms kill window.
Tests
packages/agent/src/__tests__/agent-process.shutdown-process-group.e2e.test.ts:spawns a real
lace-agentprocess, gives it a foreground bash commandwith BOTH a plain
&background child and a foreground pipeline whosefirst stage traps (ignores) SIGTERM, then kills the agent process the
same way
job-control.ts'skillJobactually does — SIGTERM, wait only500ms, SIGKILL if still running — and asserts neither descendant
survives.
main: no detached spawn at all, so the bash-toolchild is never signaled when the agent process exits; it's simply
reparented to init.
main+ a detached-only spawn with no registry/shutdownreap (verified by temporarily reverting just the
main.tschange):the detached group is abandoned to init the moment the agent process
exits, since nothing signals a group outside the agent's own.
process-group-registry.test.ts: tracking/untracking, SIGKILLescalation for a TERM-ignoring group within the 150ms/500ms budget, and
no-op behavior on an empty or already-untracked registry.
bash.test.ts: added the process-group abort test (a non-shell pipelinemember is actually killed on abort, not just the shell) and a test that
aborting a foreground call leaves no unhandled rejection behind.
Gates
packages/agentfull suite (npm run test): 374 files / 3851 testspassed, 35 skipped (unit phase) + 48 files / 276 tests passed, 20 skipped
(E2E phase), 0 failed.
npm run lint,npm run typecheck: both clean. Noorphan processes left on the host after the suite run (checked with
psafter every manual repro run too).
Follow-up fix: a background
cmd &child could still outlive the shellReview also found a second, narrower gap in
process-group-registry.tsitself: it untracked a pgid as soon as the ONE pid it was given (the shell
bash.tsspawns directly) exited, using that process's own completionpromise as the removal signal. A plain
cmd &backgrounds a grandchild inthe same group that outlives the shell — the shell returns almost
immediately after launching it — so the registry entry was dropped long
before the background job was actually done.
killAllTrackedProcessGroups()then had nothing left to signal for that survivor by the time anyone called
it, so a background child of a foreground
bashtool call could outlivethe agent process entirely, exactly the class of bug this registry exists
to prevent.
Fixed by dropping the completion-driven auto-untracking. A tracked pgid is
now removed only once its process group is actually observed empty
(
groupIsEmpty), checked lazily: opportunistically whenever a new pgid istracked, and again during the kill sweep itself, after the SIGTERM/SIGKILL
escalation has had its chance to empty the group out. A pgid with live
members can't be reused by the OS for something unrelated, so deferring
removal this way carries no pid-reuse risk, and the registry still can't
grow without bound over an agent process's lifetime.
Added a regression test that spawns
sleep 30 &through the realbashtool, lets the shell exit, runs the kill sweep, and asserts the background
process is dead — it fails against the prior behavior and passes with this
fix.
Known trade-off (not fixed here)
A detached process group is only reachable from the subagent process that
spawned it. If the PARENT force-kills the subagent (SIGKILL, bypassing its
normal SIGTERM-then-wait shutdown path) before the subagent's own shutdown
reap runs, the detached group is abandoned to init with no one left to
signal it — the same failure mode this PR otherwise closes, just from a
different trigger. Closing that gap needs the parent to know about (and be
able to kill) pgids it didn't itself spawn, which is a bigger design
question than this PR's scope; a follow-up should have the subagent
register its tracked pgids with the parent so the parent can reap them
directly if it ever has to skip the subagent's own cooperative shutdown.
Left open
detached/group-kill gap as the host runtime did before fix(agent): bash tool spawns no longer hang on an inherited stdin (PRI-3243) #418/this PR;
not touched here — same as noted in fix(agent): bash tool spawns no longer hang on an inherited stdin (PRI-3243) #418.
bashcall is fix(agent): enforce a foreground bash timeout (PRI-3251) #414's job(already on
main); this PR's timeout-path kill now also targets thewhole process group, but the timeout mechanism itself is unchanged.
reap runs) — see that section for the proposed follow-up.
Still draft pending another look at the re-timed grace period and the new
orphan-reproduction E2E test.