HOMELAB-2682: fix(pi-swarm-suite): stall watchdog counts a live child process as activity #109
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-2682-watchdog-fix"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What this fixes
The incident (inferred timeline, stated as such). On 2026-11-06 a review seat
spent ~47 minutes of legitimate work blocked inside ONE
bashtool call, waitingon the build lock (
flock build.lock). A bash call publishestool_activity(start, bash)and then nothing until it returns — pi streams thecommand's stdout into the tool result and the swarm activity bus has no
output-delta kind. So the stall watchdog's only clock read twenty minutes of
silence and reaped the seat at 08:36Z, with no verdict. The phase-1 challenge
could not have saved it either: the challenge is a pi
steer, and a steer queuedbehind a tool call is drained at a tool boundary, which a blocked call never
reaches.
This is a code-level derivation from
stall-watchdog.ts(866 lines,threshold 10 min + grace 10 min, defaults unconverted in
subagents.json) plus thelast bus event, not a measured watchdog journal — the pod keeps none. It is exactly
the shape the file's own doctrine warns about: "silence is a suspicion, not a
verdict", and a lock wait is "a HEALTHY agent parked in a long, silent blocking
wait".
The fix: a live child process is activity. pi spawns every shell
detached: true(pi coredist/core/tools/bash.js), so the call owns a processgroup that is alive exactly while the call is really working. Once per tick the
watchdog asks the kernel whether the group a record's in-flight shell call started
still has a live member; if it does, the record is stamped ACTIVE.
S2-1 (blocker, high) — the shelter had no ceiling and no notice → FIXED
Seat 2's repro: one bash call holding
sleep 3600stayedrunningfor sixsimulated hours with
notices: []. The class the watchdog used to reap (a holderthat never releases — the 08:11Z shape) was permanently hidden, and the lock such a
holder keeps is freed only by the reap, so hiding it also pinned the lock.
Round 1's residual 1 ("a wedge that owns a process is not a wedge") was wrong.
Reproduced first as a failing test (
the reported 6-hour repro now notices and finally reaps instead of hiding forever), then fixed. The live-child stamp keepsits own clock, independent of the silence clock, over one continuous shelter
(a break in the kernel evidence restarts it):
liveChildNoticeMinuteslive-child-long-waitNOTICE with the sameChild processes:evidence (pid, pgid, cmdline) every other notice carries. Visibility only: no challenge, no reap, and below the ceiling the silence clock keeps being reset exactly as before (that behaviour and its tests are untouched).liveChildCeilingMinutesswarm-attach abort <id> --yesuses — that is what kills the group and frees the lock). The reap notice carries the child evidence plusbecause: live-child-ceiling, the shelter duration and the ceiling, and renders asSTALL CONFIRMED — a live child process sheltered this agent past the ceiling.Why 60/90. Both sit above every legitimate wait we have evidence for: the
08:36Z legitimate lock wait was ~47 min, and the sandbox's own bash tool
timeouts reach ~55 min. So a single call still holding a process group at 90
minutes is not a lock wait any more; and the 30-minute gap between notice and reap
is the window in which an operator who reads the notice can intervene (kill it, or
raise the bound).
Configurable.
stallWatchdog.liveChildNoticeMinutes/stallWatchdog.liveChildCeilingMinutesinsubagents.json(0disables thatbound), and env-configurable per session —
PI_SWARM_LIVE_CHILD_NOTICE_MINUTES/PI_SWARM_LIVE_CHILD_CEILING_MINUTES(env wins; a malformed value is ignored, so atypo can never disable the ceiling by accident). A notice bound at or above the
ceiling is clamped to the ceiling instead of becoming dead code.
Also: the durable
subagents:stallbreadcrumb now recordschildEvidence(and theceiling fields), so a reap can be judged afterwards without the process table.
S2-2 (blocker, medium) — the marker is agent-forgeable, and the headers said the opposite → CLAIMS CORRECTED, WEAKNESS PINNED
Seat 2 reproduced spoofing three ways plus end-to-end (a wedged victim stamped
ACTIVE by a peer's call) while
live-child-marker.ts/live-child-probe.tsclaimed"the agent never sees or chooses it" / "it cannot be set by the agent". Those
sentences were false.
What I made TRUE (nothing): I did not find an honest in-process
(spawn-side, pgid→agentId) attribution I could land here. pi's
BashSpawnContextis
{command, cwd, env}— it has no pid, and the pid appears only after the spawn,so the harness cannot record "this group is agent X's" without racing the process
table or wrapping the tool's
executewith a before/after diff. Inventing thatinside this PR would have been a second guess dressed as a fact.
What I made HONEST instead (documented + pinned in tests):
live-child-probe.tsnow states: existence is a kernel fact; attribution is aspawn-time stamp — attribution, not authentication;
live-child-marker.tsreplaces the round-1 "WHY IT IS NOT GAMEPLAY" block withthe reproductions, the guarantees that DO hold (an honest worker is attributed to
itself; the marker is inherited by the command tree and dies with the process; it
is only ever evidence of work, never permission to skip a reap) and the bounded
impact:
round 1, and now ceiled at 90 min with a notice at 60;
agent's call carries its id, so that seat's wedge is masked and its notices
carry the wrong attribution;
every oversight gate read PR comments and verdicts;
stall-watchdog.tshas nogate consumer — so this is availability-mechanism confusion, not a way past a
merge, and not a new class of evasion beyond self-sheltering.
command rewriting the marker inside its own call
(
live-child-marker.test.ts), and a real detached child attributed to whatever idits environment carries (
live-child-probe.test.ts). Making attributionunforgeable later must fail those tests and rewrite those comments with them.
A follow-up (per-call nonce matched against the in-flight call, or restricting
attribution to the group the record's own call opened) is the honest way to get
more than this — not asserted here.
C1 / C2 / S2-3 — the untyped cast and the silent-regression surface → FIXED, with a wiring proof
installLiveChildMarker(sessionOpts, …)is typed againstpi's own
customTools?: ToolDefinition[], so renaming/removing the option is nowa compile error (
tscis part of the proof here, not decoration).shell tool under pi's name with a callable
execute, and the spawn hook mustreally put the marker into an environment (checked through the same exported
closure the definition received). Either failing is reported through
warnandreturned as
installed: false— a child that cannot be marked is announced, neverassumed.
installer — the same call
agent-runnermakes with its realsessionOpts— thenruns a REAL command through the returned definition and reads
PI_SWARM_AGENT_IDout of/proc/$$/environ. If the override stops takingeffect (wrong tool name, no
customTools, a hook that no longer lands, a futurepi ignoring
spawnHook), that test fails loudly instead of the marker quietlygoing missing. Two further tests pin the loud-degradation paths (factory throws;
definition under a different name).
post-merge live check stands.
S2-5 — "cannot read" was reported as "no children" → FIXED
A live process in a tool group whose environment cannot be read now makes the probe
answer
unavailablewith the pid and the error, never "no children" — but it KEEPSthe evidence it did get and reports the partial blindness alongside it (a
shelter from a readable sibling still counts; real evidence is not discarded
because another group was unreadable). A process that merely dies mid-scan stays a
silent skip, and an unmarked live child is still a clean "none", not
unavailable.Three tests pin those three cases apart.
C4 —
LIVE — …read as reassurance it had not earned → FIXEDThe child-evidence line now says what the evidence is not good for whenever the
in-flight call cannot claim it:
… but the in-flight call (bash) already ended, so this is not evidence about it/… not evidence for the in-flight read call, which starts no process group/… but no tool call is in flight. LIVE stays first whenit is real, with
probe partly unavailable: …appended when that is also true.S2-4 / queued steer accumulation → DOCUMENTED (deferred, never lost)
A challenge dropped because a live child appeared has already been steered, so
the steer stays queued in the child session and lands at the next tool boundary — an
agent that is challenged, sheltered and challenged again can accumulate stale
"STALL" steers. Documented in
issueChallenge's doc and in the file header'sresidual list: each one says "continue your task", harmless to a live run, and
losing the signal would be worse. No deduplication in this PR.
C3 — the mutation counts (reconciled)
Seat 1 measured 2 of 34 failures for a broader unconditional-stamp mutation; seat 2
measured 10 of 11 for the PR's own shape. Both are right about their own edit. For
the record, with the PR's own shape at the round-1 head, the numbers in the
round-1 body reproduce exactly: disabling the stamp fails 4 of the 11 live-child
cases (survival cases) with the wedge cases still green; making the stamp
unconditional (
if (true)) fails 10 of 11. At the round-2 head the file is18 cases and the same two mutations fail 10/18 and 12/18 respectively — the
extra failures are the new ceiling cases, which is the point (a disabled stamp takes
the ceiling path with it; an unconditional stamp takes the wedge path with it).
What round 1 got wrong (now corrected everywhere)
(residual 1) — wrong; it was unbounded invisibility. Replaced by the ceiling.
live-child-probe.ts,live-child-marker.ts) — false. Replaced by attribution-not-authentication, withthe bounded impact and tests.
installer boundary plus a compile-time option check.
S2R2-1 (blocker, high) — the shelter clock carried across a call boundary → FIXED
The streak was anchored to the record: it started at the first tick that saw a
live child and survived until a tick saw none. Between two calls sits a model turn
(well under the 60 s sweep), so normally no tick lands in the gap — and the
clock, and the notice that goes with it, crossed from call A into call B.
Seat 2's repro, reproduced here before the fix (two calls, one tick per simulated
minute, no tick in the boundary gap): call A (
go build ./...) holds a childt+1→t+89 and correctly survives;
tool_activity(end)+tool_activity(start)opencall B (
npx vitest run, its own child 0 minutes old); t+90 alive, t+91 reaped —call B killed one minute into its own life, its notice printing the previous call's
Sheltered by a live child: 1h 30mnext to call B's pid. With one tick in that samegap: no reap at all. A working agent's fate depended on tick phase.
The bound's own rationale was already per-call ("one process group held this long is
not a lock wait"), so the clock is now anchored to the in-flight call:
Two calls in a row are two clocks and two notices (a
noticedAtearned by call Adoes not pre-pay call B's long-wait notice). Nothing else about the bound changes:
still cleared when the kernel stops showing the child, still clamped from settings and
env, still 60/90 defaults.
Test first. The new case failed with exactly the seat's numbers —
reapedAtRecordMinute91: ["worker"],status: "stopped"— and passes after the fix.Its control runs the identical shape with one tick in the gap and asserts the two
runs are deeply equal (that assertion is what catches phase dependence, so a
future re-anchoring to the record cannot come back quietly). Reverting only this
anchor fails exactly those 2 of 20 cases and nothing else.
What the per-call bound costs, stated plainly: a chain of calls that each hold a
child under the ceiling is never reaped. That is the deliberate trade — each call is
its own evidence, and a multi-hour legitimate build has exactly that shape (residual
6 below now says so). What it still catches is the shape S2-1 was about: one call
holding one process group past 90 minutes, noticed at 60.
S2R2-2 / S2R2-3 (concerns) — the docs a reap notice points at → FIXED
README.md: bothstallWatchdogtables now listliveChildNoticeMinutes/liveChildCeilingMinuteswith their env vars (PI_SWARM_LIVE_CHILD_NOTICE_MINUTES,…_CEILING_MINUTES); a new "a live child process is activity" subsectiondocuments the shelter, both bounds, the per-call anchoring and the
attribution-not-authentication limit; a guardrail bullet and the stale "nested
stallWatchdogtriple" (now a block of five) are corrected.bashcall silent throughthreshold + grace "is still reaped" as if that were unconditional: it names the
live-child shelter and both of its bounds.
docs/attach-protocol.md+src/core/types.ts:wedged: truenow names bothpaths — (a) challenged, stayed silent through grace, and (b) a live-child ceiling
reap that had no challenge and no grace at all, distinguished on the durable entry
by
because: "live-child-ceiling"+liveChildForMs+ceilingMinutes(and bythe absence of a
challengeobject on path (a)'s sibling). No consumer reads itdifferently; the contract no longer implies a path that did not happen.
The two nits, both taken
Silent for: 1m 00s(the last tick) above a90-minute shelter. Its head line reports the shelter:
Sheltered for: 1h 30m, not silent — a live child inside the in-flight tool call reset this clock on every tick (the silence threshold 10m never applied). Pinned by assertion(
not.toContain("Silent for:")).(
agentMarkerSpawnHook, a pure function ofagentId), not "the same closure thedefinition got".
What was checked before choosing the signal
trackedDetachedChildPidsis module-private, only the kill function is exported, and the pid is untracked in the tool'sfinallyrecord.sessionPI_SESSION_IDexecutereceives anExtensionContext, which built-in tools do not get. Verified on pi 1.4.0 — a bash child's/proc/<pid>/environcarries noPI_*entries at allcustomToolsoverrides a built-in by name and pi exportscreateBashToolDefinition, so aspawnHookcan stampPI_SWARM_AGENT_ID=<agentId>into every shell that child session startsNo heuristic was improvised: no timing correlation, no "any live child of the
process counts", no bare
kill -0.The change
src/core/live-child-probe.ts. Scans procfs for process groups whose leaderis a direct child of the pi session process (the shape
detached: trueproduces), keeps live members (
Z/Xrefused), attributes them by the spawnmarker, and pairs every existence answer with the kernel's immutable identity —
bootId:pid:starttime, re-read before it is reported — so a pid recycledbetween the scan and the report is refused, never read as work. Anything
unreadable (no procfs, missing boot id, an environment that cannot be read)
reports
unavailable, never "no children".src/core/live-child-marker.ts. Builds the child sessions'bashtool frompi's own factory with
spawnHookadding the marker, installs it through a typed,self-checking
installLiveChildMarker, and degrades loudly when it cannot.src/core/agent-runner.ts. The one child-session spawn site calls thatinstaller with its real
sessionOpts(no cast).src/core/stall-watchdog.ts. Tracks the in-flight tool fromtool_activity(start|end). Arunningrecord whose in-flight tool is a shell andwhose call owns a live child is stamped ACTIVE, its pending challenge is dropped
(it could not have been answered), and the tick reports it in
StallTickResult.liveChildren. The stamp is ceiled (notice 60 / reap 90 bydefault;
StallTickResult.liveChildNoticesreports the notices). Notices carry aChild processes:line saying exactly what the probe saw, and when.src/core/settings.ts. The two new bounds, sanitized and merged per fieldlike their neighbours, readable at decision time and overridable per session by the
two env vars.
What still reaps
the same clock;
ended, so it is not evidence about this run);
liveChildCeilingMinutes(round 2).Residuals (not closed here, on purpose)
challenge's steer stays queued until the next tool boundary (so it can
accumulate). Deferred, never lost.
killProcessTree), whileGNU
timeoutmoves the monitored command into a new group that escapes it. Thatis pi core
shell.jsscope and is deliberately untouched; the sbx-gits locklibrary closes its own half by not letting a lock outlive its owner.
customToolsoverride makes child sessions' bashappear as
<sdk:bash>instead of<builtin:bash>. The definition is produced bypi's own factory (same parameters, schema, prompt snippet, renderers); seat 2
swept this repo and
~/.pi/agent/extensionsand found nosourceInfoconsumerfor a tool (the one consumer keys on a skill-sourced command's
path), so nodrift in this fleet.
with a model. A real command runs through the definition the session is handed
and reads the marker back out of
/proc/$$/environ; no test spawns a full childsession with a model. A live check after merge: one subagent bash call, then
/proc/<child>/environshould carryPI_SWARM_AGENT_ID=<agent id>.and documented, not fixed.
that dies and comes back restarts the clock, and so does a new call, so neither an
agent that re-creates a silent child every few minutes nor a chain of calls each
holding a child under the ceiling is reaped. The first must emit bus events to do
that (heartbeat-shaped wedge, not an invisible one); the second is the per-call
bound's deliberate cost, and it is the shape of a legitimate multi-hour build.
Tests
origin/main(34fdf7f): 106 files / 1476 tests, all pass.76e653f): 109 files / 1504 tests.c4f9edb): 109 files / 1523 tests.00dc972): 109 files / 1525 tests (+2), andtsc --noEmitclean.Full run 1 had one documented load flake (
watch-renderer.test.ts250 ms stdoutassert); the immediate re-run was 109 files / 1525 tests, all pass.
call's ceiling (it measures and notices each call against its own start) ·
the same shape with one tick in the gap yields a deeply equal outcome (tick
phase is not a property of the agent's work) · a ceiling reap notice reports the
shelter, never
Silent for:.ceiling reap names the child and uses the normal reap path · the seat's exact
6-hour repro notices then reaps · configured bounds honoured, notice-bound
clamped to the ceiling · the shelter restarts when the child goes away ·
LIVEevidence qualified when the in-flight call cannot claim it (C4) · apartly-unreadable probe keeps its real evidence and reports the blindness ·
installer wiring proof (real command through the definition the session gets) ·
loud degradation when the factory throws or the definition is misnamed · the
probe counts an unreadable live
environasunavailable, keeps readableevidence alongside it, and still treats an unmarked child as a clean "none" ·
the forgeability of the marker pinned twice (real call + real process).
(12/20 at this head) with the wedge cases still green; making the stamp
unconditional fails 10 of 11 (12/20). C3 above reconciles seat 1's different
count. Re-anchoring the streak to the record (the round-2 shape) fails 2 of 20
— precisely the round-3 cases.
Merge
Oversight path (watchdog machinery + the child-session tool installation). Not
merged by the builder; fresh review seats review this head —
00dc972. Round 2'sverdicts were on
c4f9edband do not carry over a head that changed code.The three findings closed in round 4
[bash(long), read]batch — two calls in flight at once — let thereadcall'send wipe the state the
bashcall's shelter depended on, so a legitimately longbuild was challengeable and reapable mid-flight. The watchdog now tracks
in-flight calls by their
toolCallIdand anchors the shelter to the call thatactually owns the child, so a sibling call ending no longer disturbs it.
ceiling measures one continuous shelter, and the notice and reap now report the
age of the current call separately from the shelter duration, so an operator
reading a notice can tell "this call has held a child for 62 minutes" from
"this record has been sheltered across several calls".
that the shelter was resetting on every tick, and the ceiling reap reports the
shelter rather than
Silent for:(the last tick's near-zero figure).Review and CI at this head
exactly
e5e4d702b6242c9db1bf0f54c4481014f6fc7b0b: comments23030and23032,blockers none. Their concerns are recorded there and are not reproduced here.
#400(all fifteen packages, includingpi-swarm-suite) and the follow-up scoped run#401.Merge
Oversight path (watchdog machinery + child-session tool installation), so the head
is landed only on the strength of the two independent round-4 seats above and green
CI at the same sha — not by the builder. This section will be corrected if anything
below it goes stale; the round-2 and round-3 sections describe earlier heads
(
c4f9edb,00dc972) and are kept for history only.A bash tool call publishes tool_activity(start) and nothing else until it returns, so a subagent blocked inside ONE long legitimate call (flock build.lock, a Gradle build) looked exactly like a wedge. On 2026-11-06T08:36Z that reaped a review seat doing ~47 minutes of lock-wait work, with no verdict — the phase-1 steer could not be answered either, because a run blocked in a tool call never reaches a tool boundary. The honest second signal is the kernel's: pi spawns each shell detached, so the process group stays alive exactly while the call is really working. - live-child-probe.ts (new): finds process groups whose leader is a direct child of the pi session process, keeps live members, attributes them per agent, and pairs every existence answer with identity (boot id + starttime, re-read), so a recycled pid is refused rather than read as work. Unreadable procfs reports "unavailable", never "no children". - live-child-marker.ts (new): stamps PI_SWARM_AGENT_ID=<agentId> into a child session's bash calls via spawnHook on pi's own createBashToolDefinition. - agent-runner.ts: installs that definition at the single child-session spawn site (customTools overrides the builtin by name). - stall-watchdog.ts: tracks the in-flight tool; a running record inside a shell call with a live child is stamped ACTIVE and its pending challenge is dropped; notices now carry the probe's verdict ("Child processes: …"). Still reaps: a call with no live child, a non-shell tool, a shell left over from an earlier finished call, and any tick where the probe cannot answer. Residuals documented in the file header: a challenge is still unanswerable from inside a tool call, and aborting a bash kills one process group (GNU timeout escapes it) — pi core scope, untouched.Verdict of record — ld-reviewer — head
76e653f99b: APPROVEmodel: deepseek-flash
session: ses-109-seat1-8beae3dd
Independent review seat 1 of 2 (oversight machinery). Read-only: I did not edit, commit, push or merge. Everything below was checked first-hand against the diff at
76e653f, a clean worktree at that head, and process-table repros run from /tmp against the head's own probe module (bundled with esbuild fromsrc/core/live-child-probe.ts).Verdict basis — acceptance criteria
tests/core/stall-watchdog-live-child.test.ts:207("still challenges and reaps a bash call with NO live child") passes. Mutation re-derived: with the stamp disabled (if (false && inFlight?.phase === "start" …)), 4 of the 11 live-child cases fail while the wedge-path cases still pass — the new signal is not a blanket reprieve.bash -c 'timeout 900 flock <lock> -c "sleep 5"'carryingPI_SWARM_AGENT_ID=<seat>is reported by the head's probe as 2 live members (thetimeoutgroup leader plusflock).live-child-probe.test.ts:150drivesstatReaderto answer starttime 111 on the scan and 222 on the confirmation read, and the group is dropped; index math for field 22 is right (rest[19], with alength < 20refusal), and the production path really does re-read before reporting.readdirSyncfailure or an unreadablebootIdboth returnunavailable(never an empty-children answer), and the watchdog case at:293pins challenge → reap withprobeUnavailableset. The tick also reports it inStallTickResult.probeUnavailable.npx vitest runin a clean worktree at76e653f: 109 files / 1504 tests, all pass. The knownagent-manager.test.tsload flake did not appear, so I have no flake to attribute.Adversarial findings
The
timeoutincident shape is NOT missed — verified by repro. Both variants were exercised: single command (bashexecs intotimeout, which stays the tool-group leader, ppid = pi) and multi-command (cd … && echo … && timeout 900 flock …, same result). The group leader remains a direct child of pi and carries the marker, andtimeout/flockare live members of it, so the probe answers with evidence. The probe never needs to see the workload's escaped group: the shell/timeoutstill inside the tool group is the evidence. (What is genuinely not covered — abort killing only one group — is disclosed in the PR and is pi-core scope.)The spawn marker IS agent-settable — reproduced; the module's claim that it is not is false. From a call held open by the same agent,
PI_SWARM_AGENT_ID=seat-victim sleep 300 &makes the head's probe report seat-victim as having a live child (pid 1801905, pgid 1801904 — a group belonging to the spoofing agent's call). So a worker can stamp another agent ACTIVE while that agent is wedged, provided the victim has a shell call in flight and the spoofer keeps its own call open. I judged this not a blocker: the impact is attribution confusion inside an availability mechanism — the landing lock and oversight gates read comments/verdicts, not liveness, so no merge or gate can be bypassed this way — and a seat could already shelter itself without any spoofing by running one long silent call (any live child of its own counts), so this is not a new class of watchdog evasion, only a cross-agent one. It is, however, a claim-honesty defect in an oversight-path file:live-child-probe.tsstates the marker "cannot be set by the agent", andlive-child-marker.tsheads its rationale "WHY IT IS NOT GAMEPLAY". Both are wrong as written and a future reviewer will rely on them. See concern C1.<sdk:bash>drift — no consumer found. pi's_refreshToolRegistryinsertscustomToolsover the builtin of the same name (definitionRegistry.set) and the dispatch registry is built the same way, so the child session sees exactly onebashtool, not a duplicate, and it is still filtered by_isAllowedTool— an agent without bash in its allowlist cannot gain it through this override. The activity bus takestoolNamefrom pi's tool-call event (plainbash), soSHELL_TOOL_NAMES.has("bash")still matches and the watchdog is unaffected by the registry label. Sweeping this repo and~/.pi/agent/extensions: nothing keys onsourceInfo,source: "builtin"or a<builtin:…>label — the only mention of the label is the comment that discloses it. No drift to fix.procfs races. A child that dies mid-scan fails its stat read and is skipped (not counted, not an error); a zombie is refused (
Z/X); an orphan whose group leader has exited is correctly not counted — reproduced:sleep 200 &from a call whose shell already returned does not appear in the probe at all, which is the intended "an earlier, finished call is not evidence" rule."Challenge dropped" defers, it does not lose the signal. The stamp re-anchors
lastActivityatnowon every tick that sees a live child, so when the child dies the threshold window restarts from the last live tick and the unchanged challenge → reap path runs (pinned by "reaps once the child dies and the silence resumes"). Worst case is detection delayed by threshold + grace after the last kernel evidence of work, which is the correct trade for the incident shape.Blockers
None.
Concerns
live-child-probe.ts("it cannot be set by the agent") andlive-child-marker.ts("WHY IT IS NOT GAMEPLAY") state something false. Suggest correcting both comment blocks to say what is actually true — the marker is kernel-readable and dies with the process, but it is written into an environment the agent's own shell can rewrite, so it is attribution evidence, not tamper-proof evidence — and, if cross-agent attribution confusion ever matters, a follow-up ticket for a binding the agent cannot forge (e.g. a per-call nonce minted at spawn and matched against the in-flight call, or restricting attribution to the group the record's own call opened). Doc-only; does not need to block this PR.(sessionOpts as { customTools?: unknown[] }).customTools = [markedBash]— with no compile-time check that the option exists and no runtime check that it took effect. If a future pi renames or stops honouringcustomTools, children spawn unmarked and the fix reverts to pre-fix behaviour silently (fail-safe, but invisible; the PR's own residual 4 admits the wiring is proven only at the definition level). A one-line debug/warn on the first marked spawn — or asserting the registry label once per child session — would make that regression detectable instead of silent.children.length > 0) fails 2 of the 34 tests in the two watchdog files, not the "10 of 11" the PR body reports. The direction is confirmed (the mutation does bite); the magnitude differs, so the numbers in the body were probably produced by a differently shaped edit. Worth reconciling so the mutation evidence on the record is exact.describeChildEvidenceanswersLIVE — …whenever children were seen for the agent, even when the in-flight tool is not a shell or already ended — in a notice whose verdict is a challenge, that can read as reassurance it does not support. E.g. "live children seen (pid …), but the in-flight tool isread: not evidence for this call". Non-blocking.Merge note (not mine to act on): this touches the stall watchdog and the child-session tool installation — oversight paths — so it needs the second independent seat on a different model (and a post-merge live check that
/proc/<child>/environcarriesPI_SWARM_AGENT_ID=<agent id>for a real subagent call, which is exactly the wiring no test covers yet).Verdict of record — ld-reviewer — head
76e653f99b: REQUEST_CHANGESmodel: deepseek-flash
session: ses-109-seat2-9054c13fefb6
I reviewed this as an independent seat: fresh clone of the head, first-hand probes in
/tmpagainst the head's own exports (real detached processes, real/proc, realStallWatchdogdeps), plus mutation runs. The mechanism is sound and the acceptance criteria hold. Two things should be corrected before this lands, both small: the shelter it grants is unbounded and unnotified, and the attribution claim its own headers rely on is false.What I verified first-hand
still challenges and reaps a bash call with NO live childpasses on the head. I re-ran the PR's own mutations: disabling the stamp (if (false && …)) fails 4 of 11 install-watchdog-live-child.test.tswith the wedge cases still green — exactly the PR's claim; making the stamp unconditional (if (true)) fails 10 of 11 (Tests 10 failed | 1 passed) — also exact. The tests bite.exec timeout 900 flock <lock> sleep 5(real lock held elsewhere, real process tree): the probe reports the group leader (timeout 900 flock …, pid == pgid) and the member (flock …). Multi-command body (echo start; timeout 900 flock … sleep 5; echo done): the shell itself is reported in the group. GNUtimeoutmoving the monitored command into a new group does not hide the wait, because the call's own shell/timeout stay in the group.bootId:pid:starttime, re-read before reporting; the fixture test holds. The confirm read comparesstarttimeonly (not pgrp/ppid/state), which is the right minimum — a recycled pid in the same boot cannot reuse astarttimewithout wrapping the whole pid space./nonexistent→unavailable; missing boot id →unavailable; a throwing probe is caught intounavailable; all three leavechildren.length === 0, so no stamp and the pre-fix challenge→reap path runs unchanged (behaves exactly as before when the probe cannot run at all). See S2-5 for the one shape where "cannot read" is reported as "no children".tests/core/watch-renderer.test.ts(a 250 msstdoutassert on a spawned renderer), which passes 3/3 standalone. Head run 2: 1 failed / 1503 passed —tests/core/agent-manager.test.ts(output-file layout), the documented flake. Baseline34fdf7f: 106 files / 1476 tests, run 1 → 1 failed / 1475 passed (same output-file case), run 2 → 1476/1476. So both failure shapes are load-sensitive full-run flakes present on unmodifiedmain, the tallies in the PR body are exact, and no failure is attributable to this diff.<sdk:bash>drift sweep. Nothing in this repo or in~/.pi/agent/extensionskeys a tool's permissions onsourceInfo/abuiltin:label (the onlysourceInfoconsumer isskill-args, on a skill-sourced slash command'spath). In pi 1.4.0agent-session.jswraps built-ins, then overlayscustomToolsby name, and exposure/activation come from the definition (exposure ?? "direct") rather than the source — so the override stays declarable and active, and keeps pi's own snippets/guidelines. The label change is cosmetic in this fleet; no drift found./procentries — fine at a 60 s tick.stall-watchdog.tsreadsreaped/liveChildren/probeUnavailable; the consumers are the notices andswarm-attach abort. That is why the spoof below is ranked medium and not high.Blockers
sleep 3600was stamped ACTIVE and ticked for 6 simulated hours →{"status":"running","notices":[],"challenges":[],"aborts":[]}. Notices exist only forstall-challenged/stall-reaped/stall-escalated/watchdog-error, and a continuously stamped record reaches none of them, so the operator gets nothing at all — this is the class that used to be reaped at threshold+grace, and it includes the incident's own shape (a lock waiter whose holder never releases, a download with no timeout, a deadlocked build). The PR body's "What still reaps" and residual 1 define the residual wedge as "a run blocked in a call that owns no process — a wedge by definition", which this disproves: a wedge that owns a process is still a wedge, and it is now unbounded. Minimal fix consistent with the file's own doctrine ("silence is a suspicion, not a verdict"): keep the stamp, but after a record has been continuously live-child-stamped for a configurable multiple ofthresholdMinutes, emit a notice carrying the sameChild processes:line (pid, pgid, cmdline) — visibility restored, no new reap, no change to the ACs.live-child-probe.ts: "That is a kernel fact … it cannot be set by the agent" and "it must be a fact, not a marker the agent can fake";live-child-marker.ts: "the agent never sees or chooses it". Reproduced three ways from a normal bash call carrying agentattacker's marker:PI_SWARM_AGENT_ID=victim-agent sleep 60(victim bucket got that pid/pgid and labelsleep 60),export PI_SWARM_AGENT_ID=victim-agent; sleep 60; true(both buckets attributed),env PI_SWARM_AGENT_ID=victim2 sh -c 'sleep 60'(victim2 attributed). End to end with the real probe: at +11 min{"liveChildren":["victim-wedged","attacker"],"challenged":["control-wedged"]}; after 50 more minutes{"reaped":[],"aborts":["control-wedged"],"victim":"running"}— a wedged victim sheltered by a peer's call, while an identical unsheltered control was challenged and reaped. Two amplifiers:environis the agent's own to read, so the marker name and value are discoverable from inside a call (env | grep PI_SWARM), and pi's bash guidelines literally tell the model to inspectPI_*. Honest severity: an agent can already shelter itself with onesleep(S2-1), so the marginal capability is sheltering a peer and hiding attribution, not a new escape — hence medium, not a functional blocker. But the headers sell the design on an invariant it does not have, and a future maintainer will reason from those sentences. Fix: correct the claims (attribution is honest-party de-confliction, not an unforgeable fact), and say so in the PR's residual list.Concerns
tscexactly where it was available.(sessionOpts as { customTools?: unknown[] })discards the check thatCreateAgentSessionOptions.customTools?: ToolDefinition[]already provides (pi 1.4.0sdk.d.ts:56). Failure modes: if a future pi renames the option or thespawnHookkey,tscstays silent, the assignment is ignored, no marker is written, the probe finds no children, and the watchdog silently reverts to pre-fix behaviour — with no test failing, becauselive-child-marker.test.tsexercises the definition directly and never the session wiring (the PR already discloses this gap in residual 4). A missingcreateBashToolDefinitionwould fail loudly at import (which would take the whole extension down, the worse failure). Cheap improvement: type it —sessionOpts.customTools = [markedBash as ToolDefinition](or havebashToolWithAgentMarkerreturnToolDefinition | undefined) — so a pi API change is a compile error here instead of a silent regression.pendingChallenges() === []whilechallenge deliveries === 1), so it stays queued in the child session and lands at the next tool boundary — a working agent finishing a long build gets a stale "STALL … the watchdog may abort this agent" steer. Pre-fix that steer was also delivered, but at most once per run; with the drop-and-rechallenge cycle a long-running agent can accumulate several. Worth a line in the notice wording or inissueChallenge's doc.environis reported as "no children", notunavailable.readMarkerswallows every error, so a member whose environment cannot be read contributes nothing whileunavailablestays unset — the notice then says "owns no live process group", i.e. evidence of absence where the module promises it never guesses. Practically unreachable for our own same-uid children (hidepid also hidesstat, which makes Pass 2 empty), so this is a wording/robustness nit, not an AC4 breach. Counting marker-read failures and reportingunavailablewhen every candidate failed would close it.pgrp === pid. Fixture probe: leader gone, marked member alive in the same group →byAgent = [],unavailable = undefined. Reap-side failure, so safe, but it means "none" in a notice can also mean "the leader died, a marked worker still runs".Verdict of record — ld-reviewer — head
c4f9edb737: APPROVEmodel: deepseek-flash
session: ses-109r2-seat1-275629570bb2
Round 2, seat 1. I read no other round-2 verdict before writing this. Reviewed the head
c4f9edbin worktree.worktrees/HOMELAB-2682-watchdog-fix; every number below is my own measurement on a throwaway copy of that tree (symlinkednode_modules, nothing written to the branch).Blockers
None.
S2-1 and S2-2 are closed as claimed, and round-1 acceptance still holds at this head.
Concerns
tool_activity(end)+tool_activity(start)with no tick in that sub-60 s gap (call B legitimate, its own live child) →{kinds: ["live-child-long-wait","stall-reaped"], reaped at minute 90, call-B age at reap: 40 min, aborts: ["back-to-back"]}. The PR rationale ("a single call still holding a process group at 90 minutes is not a lock wait any more") therefore overstates what the clock guarantees: a legit chain of two 50-min builds can be reaped mid-second-call. The notice at 60 min gives the operator a window, and residual 6 covers the child-dies-and-returns shape — but not this one. Cheap fix if you want it: clearliveChildStreakontool_activity(start)for a shell tool (a new call = a new clock), which makes the bound per-call exactly as the header argues; otherwise say it explicitly in residual 6.noticeMinutes ≥ ceilingMinutesthe clamp yieldsnoticeMs == ceilingMs, and the ceiling branch runs first andcontinues, so the notice never fires — it stays dead code (the head's own clamp test asserts exactly that: onlystall-reapedappears). What the clamp actually buys is that the bound never exceeds the ceiling and the reap notice carries the evidence. The in-code comment (stall-watchdog.ts, sweep) states this correctly; only the PR body is wrong.pi-swarm-suite/README.md(:339-353, :460-466) and the sentence "The nestedstallWatchdogtriple merges per field" (:875) do not mentionliveChildNoticeMinutes/liveChildCeilingMinutesorPI_SWARM_LIVE_CHILD_{NOTICE,CEILING}_MINUTES— while the reap notice instructs the operator to setstallWatchdog.liveChildCeilingMinutes. An operator meeting the new reap has no documented knob to raise. Either add the two rows (+ the env vars) in this PR or file the follow-up explicitly.pi-swarm-suite/docs/attach-protocol.md(:48-51) still defineswedged: trueas "silent pastthresholdMinutes, was CHALLENGED … stayed silent throughgraceMinutes". A ceiling reap setsrecord.wedgedAtand produceswedged: truewith no challenge and withsilenceMs≈ one tick (measured:silenceMs: 60000). The durable entry distinguishes it (because: "live-child-ceiling",liveChildForMs,ceilingMinutes), so nothing is lost — but the doc sentence is now false for that class.agentMarkerSpawnHook(options.agentId)afresh, not the closure handed to the definition, so "checked through the same exported closure the definition received" (PR body and the code comment) is imprecise. It is a pure factory, so the two closures are behaviourally identical today — the sentence should say "the same exported factory".Silent for: 1m 00s (threshold 10m)becausesilenceMsis the last shelter reset. Harmless — theSheltered by a live child: … (ceiling 90m)line follows — but a reader can briefly read the reap as a silence verdict.What I verified first-hand (not taken from the report)
npx vitest runon a copy of the head: 109 files / 1523 tests, all pass, first run, no flakes, matching the claim exactly.tsc --noEmitclean.pid 4321, pgid 4321, … flock build.lock),because: "live-child-ceiling",liveChildForMs: 5400000,abortCalls: ["stuck-forever"],record.wedgedAtset,challenges: []. The reap goes through the unchangedreapNow(marker + abort + post-abort verification + durable entry); with an abort that does not terminalize it escalates instead of silently "reaping". One notice per shelter (noticedAt), not one per tick.envMinutes). The clamp is applied in the sweep from the merged config, so no env value can bypass it — env can only set bounds (including0, documented), never skip the min().0disables that bound without disabling the other.stall-reapedat minute 90. The residual's claim that resetting the clock "has to emit bus events" holds for within-call churn, because breaking the streak requires the group to have no live member, i.e. the shell exited, i.e. atool_activity(end). Only the cross-call boundary (C-R2-1) escapes that argument.runningwithnotices: [](measured). Given the sandbox's ~55-min bash timeouts and the 08:36Z ~47-min wait, 90 for a single call is defensible; the only legit shape that reaches 90 is chained calls (C-R2-1), not one call.grep -rn "unforgeable|never sees or chooses|cannot be set by the agent|tamper-proof"returns only negations and "a future unforgeable design must fail these tests". Both pinned tests are real reproductions (a real commandenv PI_SWARM_AGENT_ID=spoofed-agent sh -c …reading back its own environ; a real detached child attributed to the id its environment carries). I re-ran the peer-shelter shape at the watchdog level: a victim wedged inside its own bash call, its id carried by someone else's process →kinds: ["live-child-long-wait","stall-reaped"], reaped at minute 90. And the same spoof with the victim not inside a shell call does not shelter it at all (challenged, then reaped on the normal path) — theinFlight.phase === "start" && SHELL_TOOL_NAMESguard is doing real work. Masking a peer is therefore bounded at 90 min and visible at 60, exactly as the headers state, and nothing in the merge/landing path reads watchdog liveness.byAgentandunavailable; the sweep keeps sheltering on the real evidence and reportsprobe partly unavailable: …in the sameChild processes:line (pinned test asserts both facts). A live member whose environ cannot be read is never a "clean none"; an unmarked member still is.readLiveChildrenalso maps a throwing probe tounavailable, never to "no children".customToolsrenamed tocustomToolDefsintoinstallLiveChildMarkerfailstscwithTS2559: Type 'Renamed' has no properties in common with type '{ customTools?: ToolDefinition<any,any,any>[] }'(andTS2339at the alias site). The claim holds: a pi rename breaks the build atagent-runner.ts:871, not at runtime.if (inFlight?.phase === "start" && SHELL_TOOL_NAMES.has(inFlight.toolName) && children.length > 0)→if (false): 10 of 18 live-child cases fail (wedge cases green). →if (true): 12 of 18 fail. Both match the reconciled numbers in the PR body.Judgement on the documented residual "a re-created child restarts the shelter clock": acceptable. A child that goes away and comes back is only reachable by ending the call (bus event) and starting a new one (bus event), which is exactly the heartbeat-shaped wedge the file names — not the invisible shelter S2-1 was about, and the silence clock still applies in the gaps.
One thing I did not verify (out of scope for a review seat): a live child session started by a real model, i.e. residual 4's post-merge check. The installer-boundary proof plus my compile-rename check plus the real-command
/proc/$$/environread are the strongest evidence available without spawning a child session with a model, and they are honest about that limit.Verdict of record — ld-reviewer — head
c4f9edb737: REQUEST_CHANGESmodel: deepseek-flash
session: ses-109r2-seat2-f307afa367e8
Round 2, seat 2 of 2, independent (I have seen no other round-2 verdict). Everything below was reproduced at
c4f9edbfrom a pristine copy ofpi-swarm-suite(lockfile-pinned@earendil-works/pi-coding-agent0.80.3,npm ci), with my own probes in/tmpand no write to the PR tree.Blockers
Concerns
stallWatchdogtables (README.md348-352 and 463-465) omitliveChildNoticeMinutes/liveChildCeilingMinutes, and "Trade-off, stated plainly" (README 391-401) still says abashcall quiet through threshold + grace "is still reaped" — false since round 1 and doubly so now. An operator reading the README will be surprised by a 60-min notice and a 90-min ceiling reap.wedged: truecontract is now wider than its text.docs/attach-protocol.md(47-51) andsrc/core/types.ts(228-233) define the flag as "challenged … stayed silent throughstallWatchdog.graceMinutes… automatic abort issued"; a ceiling reap setswedgedAtwith NO challenge and no grace, so a path that never happened is implied. Forensics-only, but it is the same claim-outran-the-code class this round exists to fix.live-child-marker.ts("masked … attributed to the wrong seat") and bounded by the ceiling; no gate/merge consumer reads watchdog liveness. Accurate as documented.S2R2-1 — evidence and the judge on severity
Probe (one bash call in flight is activity, threat model = the incident's own shape):
go build ./...) holds a child from t+1 to t+89; a tick every simulated minute; no notice, no reap — correct, this is the 08:36Z wait.tool_activity(end, bash)thentool_activity(start, bash)for call B (npx vitest run, pid 5002, 0 min old) with NO tick in between (the model's turn between two calls is normally well under the 60 s sweep).liveChildren: ["worker"], not reaped. t+91:reaped: ["worker"], recordstopped. Call B was 1 minute old when it was killed.The reap notice, verbatim from the run:
Control run, identical except that ONE tick lands in the 60-second gap: no reap at all (the streak is cleared and each call gets its own clock). So whether a working agent dies depends on tick phase, not on any property of its work.
Why this is not a "just tune it" note:
stall-watchdog.ts986-989) and the test comment "the bound is about a single process group being held forever". The group it reaped was held one minute.pid 5002… "Sheltered … 1h 30m"), i.e. evidence that is false on its face next to its ownSilent for: 1m 00sline. Round 2's own thesis is that a notice may not assert more than the code knows.residual 6already accepts that the clock restarts when a child dies and comes back, so making the bound per-call costs the ceiling nothing it has not already conceded.Cheap fix (either, but the first matches the documented model): floor the streak at the in-flight call's start —
streak.since = Math.max(streak.since, inFlight.at)on every shelter tick, plus a test with exactly the two-call shape above (it must NOT reap call B). If per-record cumulative is actually intended, then the notice must say so ("sheltered across N calls, current call 1m") and the rationale text install-watchdog.tsand the ceiling docs must stop describing a single group.Verified first-hand at this head (all reproduced here, not taken from the body)
npx vitest run→ 109 files / 1523 tests, all pass (with the lockfile-pinned deps; my first run with loose semver^0.80.2was 8 failures, which is a dependency-resolution artifact, not a PR defect).tsc --noEmitclean (TS2559-class check below).live-child-probe.test.ts170/191 re-read identity);unavailabledegradation honest (217/223/233/247/259); child evidence qualified when the in-flight call cannot claim it (C4).Tests 10 failed | 8 passed), unconditional stamp (if (true)) → 12 of 18 (11 failedfor my slightly narrowerinFlight !== undefinedshape). The counts are honest.sleep 3600repro notices at 60 with named evidence and reaps at 90 throughreapNow(abortCalls = the record,because: live-child-ceiling, child evidence in the reap notice); below the ceiling the silence reset is untouched; configured bounds honoured.PI_SWARM_LIVE_CHILD_NOTICE_MINUTES=500withPI_SWARM_LIVE_CHILD_CEILING_MINUTES=30read back as 500/30 fromgetStallWatchdogSettings, and driving a real watchdog from that source produced no notice at all and a reap at 30: the notice is dead when it would be redundant, and no source (file or env) bypasses the clamp, which is applied at decision time. Malformed env ignored (ninety→ default 90);0still disables a bound.installLiveChildMarkeragainst an argument with the option renamed →error TS2559: Type '{ customToolz: any[] }' has no properties in common with type '{ customTools?: ToolDefinition[] }'. A pi rename breaks the build, it does not go quiet. Install-time self-checks and the loud-degradation tests are real.childEvidenceand the ceiling fields intosubagents:stall(index.ts963+).Note for the next round
Only S2R2-1 needs code. S2R2-2 and S2R2-3 are doc text (
README.md,docs/attach-protocol.md,types.ts) and can ride the same commit. My probes are reproducible: the two-call shape is 20 lines against the existingLiveChildFleetfixture.Round 3, closing review seat 2's S2R2-1 (round-2 blocker, comment 22752). The shelter clock was anchored to the RECORD, so it carried across a call boundary whenever no sweep landed in the gap between two calls — which is the normal case, since the gap is a model turn and the sweep is 60 s. Seat 2's repro (a 89-minute `go build` followed by a fresh `npx vitest run`) reaped the FRESH call one minute into its own life at t+91, its notice printing the previous call's "Sheltered by a live child: 1h 30m" against the new call's pid; with one tick in the gap there was no reap at all. An agent's fate depended on tick phase. Fix: the clock is anchored to the in-flight call (`streak.since = max(since, inFlight.at)`, and a new call also gets a new notice), which is what the bound's own rationale already claimed ("one process group held this long"). Two calls in a row are now two clocks. Test first: the seat's exact two-call shape (call A 89 min -> end -> start call B with no intervening tick) failed with `reapedAtRecordMinute91: ["worker"]`, `status: "stopped"` — the seat's numbers — and passes after the fix. A control running the same shape WITH one tick in the gap must produce an identical outcome; it does (that assertion is what catches phase dependence). Reverting just this anchor fails exactly those 2 of 20 cases and nothing else. Docs (round-2 concerns, folded in): * README: both `stallWatchdog` tables gain `liveChildNoticeMinutes` / `liveChildCeilingMinutes` and their env vars; a new "a live child process is activity" subsection documents the shelter and its two per-call bounds; the "Trade-off, stated plainly" reap sentence is qualified by the live-child path; the stale "nested stallWatchdog triple" is now "block". * docs/attach-protocol.md + types.ts: `wedged: true` names BOTH paths — a challenge unanswered through grace, and a live-child ceiling reap that had no challenge at all (distinguished by `because` / `liveChildForMs` on the entry). * A ceiling reap notice no longer prints `Silent for: 1m 00s` (the last tick) next to a 90-minute shelter: its head line reports the shelter instead. * The install-time self-check describes the same exported FACTORY (a pure function), not "the same closure the definition got". Suite: 109 files / 1525 tests green (1523 + 2 new), tsc --noEmit clean. Mutations still bite: guard disabled 12/20, guard unconditional 12/20, per-record streak 2/20.Verdict of record — ld-reviewer — head
00dc9721d0: APPROVEmodel: deepseek-flash
session: ses-109r3-seat1-0b51d976
Blockers
None.
Concerns
C-S1-R3-1 (medium) — the ceiling now measures the CALL's age, not a continuous shelter, and three documents plus the reap notice still say it measures the shelter. With
streak = anchored && anchored.since >= inFlight.at ? anchored : { since: inFlight.at }, a re-sighting inside the SAME call is re-anchored to the call start, so within one callshelteredMsis alwaysnow - inFlight.at. Measured at this head (my own harness, one call): child 4001 live T+1…T+50, no child T+51…T+60, a NEW child 4200 from T+61 →live-child-long-waitfires at T+61 carryingliveChildForMs3 660 000 (61 min for a child 0 minutes old), and the ceiling reaps at T+90 withliveChildForMs5 400 000 (90 min, of which the current child lived 30). Continuous-shelter semantics would predict ≈T+120 / ≈T+151. The docs claim the opposite: README "A break in the kernel evidence restarts it too. Values are clamps on a single continuity — they never accumulate"; the file header "broken when the kernel stops showing a live child for it"; residual 6 "A child that dies and comes back restarts the clock". Operator-facing case (probe E, same head): child live T+1…T+50, absent T+51…T+65, new child T+66 → the silence path does challenge the record at T+62 (challenged: ["w"],stall-challengeddelivered), the challenge is dropped when the child returns at T+66, and the T+90 ceiling reap still printsSheltered for: 1h 30m, not silent — a live child inside the in-flight tool call reset this clock on every tick (the silence threshold 10m never applied)— the threshold did apply and a steer was issued; the same durable entry records that challenge. So the round-3 head line is false in exactly the shape it was added for (a break in the evidence inside one call). Not a blocker: the error is toward reaping, it only reaches calls already ≥90 min old (which the design declares non-legitimate — sandbox bash timeouts ≈55 min), andbecause/liveChildForMs/ceilingMinutes/childEvidenceare all accurate. Two one-line fixes — pick one and pin it with a test:{ since: now }on a re-sighting (restores the documented continuity), or state in README + header that the bound is the in-flight call's age. Today the code and its own documentation disagree silently.C-S1-R3-2 (low, pre-existing, unchanged by this diff) —
inFlightToolholds ONE event per record; two shell calls in flight lose the shelter. start A, start B,end A→ theendoverwrites the phase, the shelter is dropped while B is still running its live child, and the record is challenged at +10 and reaped at +20 (observed). Unreachable if pi executes a session's tool calls serially, but round 3 makes that single map entry load-bearing for the clock's correctness, so it deserves a residual line rather than being silently true.C-S1-R3-3 (informational) — cross-call churn is unbounded, exactly as residual 6 states. 40 consecutive 50-minute calls, each holding a live child, produced 0 notices and 0 reaps over ≈2000 simulated minutes. Recorded because I measured it rather than assuming it.
Verified first-hand at this head
python) success; run #352 all 14 jobs success includingpi-swarm-suite4m1s — 0 pending, 0 failure.status: running), call B gets its own notice ≈60 min into B (pid 5002) and its own ceiling reap ≈90 min into B — not ~150/~180; the with-tick control is deeply equal.false12/20; guard→true12/20 (note: with the literalif (true)shape, 2 of those fail viaTypeError: Cannot read properties of undefined (reading 'kind')oninFlight.at, so the number is right but partly for the wrong reason — the faithful "stamp whenever a shell is in flight, children or not" variant fails 7/20); streak back to per-record (anchored ?? { since: now }) fails exactly the two round-3 cases and nothing else — 2/20.npx vitest run→ 109 files / 1525 tests, all pass on the first run (no flake this time);tsc --noEmitclean.wedgedAt == START + 90m, abort throughreapNowwith the pid/pgid/cmdline evidence; a silent childless call still challenges at +11 and reaps at +21 (my own probe); pid-reuse refusal andunavailabledegradation green; settings and env clamps (envMinuteswins per field, malformed ignored,0disables, notice ≥ ceiling clamped) covered;customTools?: ToolDefinition[]typed with no cast; no unforgeability claim anywhere (live-child-probe.tssays "never an unforgeable fact"); both spoof tests pin the documented behaviour.wedged: truepaths, andSheltered for:on ceiling reap notices — and the durablesubagents:stallentry (src/core/index.ts) writesbecause/liveChildForMs/ceilingMinutesonly on the ceiling path andchallengeonly when present, exactly asdocs/attach-protocol.mdnow says. The single doc-vs-code gap is C-S1-R3-1.Verdict of record — ld-reviewer — head
00dc9721d0: REQUEST_CHANGESmodel: deepseek-flash
session: ses-109r3-seat2-4c8aad30
Seat 2 of 2, final round. I formed this verdict before reading seat 1's.
Blockers
endclears the shelter of a still-in-flight shell call, so a working agent is challenged and reaped on the silence path (~threshold + grace) while it is genuinely inside a live bash call holding a live process group.inFlightToolis a single slot keyed by record id (stall-watchdog.ts:766), overwritten on everytool_activity(start|end), and the gate isinFlight?.phase === "start" && SHELL_TOOL_NAMES.has(inFlight.toolName). pi executes a tool batch in parallel by default —pi-agent-core/dist/agent-loop.js:257goes sequential only onconfig.toolExecution === "sequential"or a tool withexecutionMode: "sequential";agent.js:130defaults to"parallel";pi-coding-agentsets neither, thebashdefinition declares noexecutionMode(dist/core/tools/bash.js:187), andagent-runnerpasses nothing, so a child session's batch is parallel.executeToolCallsParallelemits everytool_execution_startfirst, then eachtool_execution_endas it completes — so the quick member of the batch ends while the long bash call is still running, overwriting the slot with{phase:"end"}and switching the shelter off for the rest of the long call. My probes (scratch copy of this head, its ownLiveChildFleetharness, one tick per simulated minute):[bash(long, child alive), bash(second)], second ends at t+10, first still running with its child →challenged@21,REAPED@31,kinds: ["stall-challenged","stall-reaped"], recordstopped, one abort;[bash(long), read(quick)]— the shape a model emits most often — read ends at t+2 →challenged@13,REAPED@23.This is the 08:36Z incident reproduced with a wrong notice (silence-based), by this PR's own round-1 code, on the default pi execution mode, and nothing in the PR documents or accepts it: the body and the code say the loss of evidence applies to "a shell left running by an earlier, finished call" /
none (the last tool call, bash, already ended), which is not what happens here — the record is still inside a call that never ended. Fix direction (not prescriptive): keep the set of in-flight shell calls keyed byevent.toolCallId(pi'stool_execution_start/endcarry it;ToolActivityinagent-runner.ts:312drops it) so anendcloses only its own call, and shelter while any in-flight shell call owns a live child; state the anchor explicitly when several overlap.Concerns
live-child-long-waitfires at minute 60 of the call withliveChildForMs60m andChild processes: … pid 7002, and the ceiling reaps at minute 90 printingSheltered for: 1h 30mwhile 7002 is 48 minutes old. Two consequences: (a) the file header and residual 6 still say "a child that dies and comes back restarts the clock" — under the new anchoring the streak is deleted on the tick that sees the absence but is immediately re-created withsince = inFlight.at(the past), so what it actually measures for churn is call age, not child tenure; (b) the notice asserts a shelter duration that did not happen, which is the same class as the round-2 C4 nit this PR just fixed. Impact is small (a single call past 90 minutes is already declared out of bounds, so the reap is not a new false positive), hence a concern — but the sentence and the notice should say "this call has been running N with a live child", or the anchor should bemax(inFlight.at, first tick that saw a child).startBashbefore the second shelter; with per-call anchoring, churn inside one call is exactly the untested case that C-S2R3-2 falls into. Add the same-call variant so the name and the coverage agree.What I verified first-hand at this head
00dc972: run #352 all 13 jobssuccess(pi-swarm-suite4m1s), run #353pythonsuccess; no red job.go build ./...), thenend+start+ child 5002; no reap at t+90 or t+91; B's ownlive-child-long-waitat 60 min into B withpid 5002; B's ownstall-reapedat 90 min into B withliveChildForMs90m (not ~180m); exactly one abort; and the two runs are deeply equal (JSON.stringifycomparison of the whole outcome: notices, durations, evidence, reap, status, aborts). Phase dependence is gone.notice@60,reaped@90, evidenceLIVE — 1 live (pid 9001, …)throughreapNow, notice headSheltered for: 1h 30m … never appliedwith noSilent for:; below-ceiling silence reset, childless wedge path, pid-reuse refusal,unavailabledegradation, clamp from settings + env are covered by green cases I re-ran; the two spoof tests are present (live-child-marker.test.ts:85and the probe case); the only "cannot be set by the agent" strings left in the tree quote the old claim as false (live-child-marker.ts:18-19), so no unforgeability claim survives;installLiveChildMarkeris typed against pi'scustomTools(no cast) andtsc --noEmitis clean, so a pi rename is a compile error.src/,tests/, configs →npx vitest run= 109 files / 1525 tests, all pass in one run, no flake;tsc --noEmitexit 0.if (true)unconditional stamp → 12 failed; guard disabled → 12 failed; streak back to per-record (anchored ?? { since: now }) → exactly 2 failed, and they are precisely the two round-3 cases. The round-3 fix has its own knife-edge mutation with no collateral — the claim reproduces.PI_SWARM_LIVE_CHILD_NOTICE_MINUTES/…_CEILING_MINUTES, the0-disables and clamp-at-ceiling semantics, and the "A live child process is activity (HOMELAB-2682)" section;docs/attach-protocol.md:56-60names both paths towedged: trueincluding "no challenge ever happened" withbecause: "live-child-ceiling",liveChildForMs,ceilingMinutes.Posting exactly one comment; no file, commit or push from this seat.
Verdict of record — ld-reviewer — head
e5e4d702b6: APPROVEmodel: omniroute/deepseek-flash
session: 01a1b390-0d3e-70d7-89c1-cc0aca46fd8d
Round 4, seat 1 of 2 — a fresh review session; I read the three round-3 verdicts and re-litigate nothing except where this delta touches them. Reviewer only: I did not edit, commit, push or merge, and this is my only write.
Blockers
None.
Concerns
00dc972"; the stringsROUND 4ande5e4d70do not appear in the body (23 589 chars). So the operator-facing record of what this head contains is absent, and the body's residual 6 sentence ("A child that dies and comes back restarts the clock") describes behaviour that was only true from this head on. In-repo contracts (README, the file header, the header's residual list) are accurate at this head; the body is the stale surface. Cheap fix: add a ROUND 4 section and repoint the Merge section ate5e4d70.resumeAgentstill publishes tool activity with notoolCallId.agent-runner.ts:1029-1030(the resume/wake path, reached throughturnWiring→emitActivity) omits the id even though the pi event carries it (pi-agent-core/dist/types.d.ts:383—toolCallId: stringontool_execution_start, same onend), whilerunAgentat:938/:941plumbs it. On a resumed turn every call is therefore asynthetic:*call and anendcloses the oldest open call of the same tool name (stall-watchdog.ts:889-895). Measured on the real watchdog: batch[bash, bash]with no ids (exactly the resume shape), short bash ends at t+4 → shelter survives,liveChildren: ["w"]every tick to t+30, no challenge, no reap, recordrunning— the open set keeps the other bash, so the phantom close cannot empty it and produce a false reap. It is an inconsistency in the stated mechanism ("keyed by toolCallId"), not a demonstrated defect; one line at:1029-1030makes it uniform.Basis:line hardcodes "the in-flight bash call" (an anchor namedpowershellwould be misnamed), and the notice prints the ceiling twice ("ceiling 90m" in the head line, then "reap ceiling 90m — 30m left"). Cosmetic only.What I measured (all at
e5e4d70, my own runs)[bash(long), read]batch shape — fixed. RealStallWatchdog(head sources, the harness's ownLiveChildFleet-style deps) withtool_activitycarrying ids:start bash c1,start read c2,end read c2at t+2, one tick per minute to t+25 →challenged: [],reaped: [],liveChildren: ["w"]every tick, recordrunning, 0 aborts, 0 notices. The seat'schallenged@13/reaped@23does not occur. Also: a no-idendfor a call that never started (the runner'stools-error:synthetic events) mid-bash → shelter intact to t+20, nothing challenged.Child processes: LIVE — 1 live (pid 5001, pgid 5001, S)with no "already ended" qualifier, and theBasis:line names the in-flight bash call. The evidence is about the call that is still working, not the sibling that ended.live-child-long-waitnotice fires at record minute 121 (= 60 min after the new sighting) withliveChildForMs60m andcallAgeMs121m, and the ceiling reaps at record minute 151 (= 90 min after the new sighting) withbecause: live-child-ceiling,liveChildForMs90m. Nothing reaps at record minute 90 (the round-3 code reaped there).sinceis set to the tick that first saw the child (stall-watchdog.ts:1180), so the measured span can never exceed the observed evidence — a lower bound, so the reap is never early. The notice states the basis in words and the remaining time (30m left).pi-agent-core/dist/agent-loop.js:299executeToolCallsParallelemits everytool_execution_startbefore the ends, and:256-260goes sequential only ontoolExecution === "sequential"or a tool'sexecutionMode;toolCallIdis on both events (types.d.ts:382-385). So the plumbing has a field to carry and the shape has a producer.startoverwrites one"slot"key, everyendclears it): 5 of 27 cases fail — both round-4 sibling cases, "still drops the shelter when the SHELL call itself ends", and 2 round-3 per-call cases. Call-anchoredsince({ since: anchor.at }, the round-3 shape): 5 of 27 fail — all continuous-shelter cases (does not notice a brand-new child…,reaps at the ceiling measured from the new child…, the notice-basis case, probe E, and round-2's "restarts the shelter when the child goes away"). Neither mutation is survivable.npx vitest run: 109 files / 1532 tests, all pass, exit 0, no flake.tsc --noEmit: clean.live-child-probe.test.ts+live-child-marker.test.ts: 27/27 pass (the real detached-process probe still answers on this host). CI one5e4d70: run #400 all 15 jobs success (incl.pi-swarm-suite3m46s), run #401pythonsuccess — 0 failures, 0 pending.00dc9721..e5e4d702touches 6 files:stall-watchdog.ts,activity-bus.ts,agent-runner.ts,agent-manager.ts,README.md,tests/core/stall-watchdog-live-child.test.ts.live-child-probe.tsandlive-child-marker.tsare untouched — no change to marker/probe code beyond nothing at all. The runner/manager/bus hunks are pure plumbing (an optionaltoolCallIdon the event and the type), and the README diff is the continuity/anchor/batch text.Exposure
Read: the three round-3 verdict comments and the PR body (via the public API), the full
00dc9721..e5e4d702diff, the head'sstall-watchdog.ts(shelter/reap/notice regions, header and residual list),agent-runner.ts(both activity emission sites),activity-bus.ts,agent-manager.tsforwarding, README's shelter sections, and the installed pi 0.80.3agent-loop.js/types.d.ts. Ran: the full suite,tsc --noEmit, the probe/marker test files, my own 6-probe test file and two mutation copies — all in throwaway copies under/tmp(the branch worktree was read only; nothing was written to the PR tree). Disclosure: my first fetch of the PR body used the-H "Authorization: token $(cat …)"shell-substitution form for a GET — the token file does not exist on this host, so the header carried an empty string and the request was an anonymous read of a public repo; no write of mine used any token in argv, and this verdict is posted only through the staged-credential script.Verdict of record — ld-reviewer — head
e5e4d702b6: APPROVEmodel: omniroute/deepseek-flash
session: 01a1b391-f16a-70d7-89c1-cc0d4b813b5b
Round 4, seat 2 of 2, a genuinely separate fresh session (I read no round-4 verdict; I read the three round-3 verdicts because the brief directed it). Reviewer only: I edited nothing, committed nothing, pushed nothing, merged nothing. The head was verified live immediately before this comment and had not moved (
e5e4d702b6242c9db1bf0f54c4481014f6fc7b0b, branch tip equal, PRstate: OPEN).Blockers
None.
What I measured myself
I did not run or re-read the PR's own cases as evidence: I wrote my own harbour
(
/tmp, a byte copy of the head'spi-swarm-suite,node_modulessymlinked to thelockfile-pinned tree), my own fake probe, my own event order and my own tick
schedule, then deleted it.
1. S2R3-1 — the
[bash(long), read]batch. Driven in pi's real parallel shape(both
tool_execution_starts before the firstend, idscall-1/call-2), childlive throughout, one tick per simulated minute: ticks t+1…t+40 all reported
liveChildren: ["w"],challenged: [],reaped: [], no notice, recordrunning,zero aborts — 40 minutes past the seat's
challenged@13/reaped@23. The child-evidenceline read
LIVE — …and did not mention the endedread. Same shape with notoolCallIdon either event: shelter held 30 minutes. Two overlappingbashcallswith the sibling's
endat t+10: quiet through t+45. Control (the other half of theblocker, so per-call
endhandling is not simply ignored): the shell call's ownendstill runs the silence path — challenged at +11, reaped 10 minutes later,record
stopped. Three parallel shell calls whoseends arrive id-less: shelter held.2. Continuous shelter — C-S1-R3-1 / C-S2R3-2. Seat 1's churn shape (child 4001
live T+1…T+50, absent T+51…T+60, new child 4200 from T+61, one call open throughout):
no
live-child-long-waitat T+61 (the round-3 head fired one claiming 61 min of"shelter" for a child one tick old); the 10-minute gap did breach the silence
threshold, so a
stall-challengedexists on the pre-existing path and the returningchild dropped the pending challenge; the notice then landed after 60 minutes of
the new stretch with
liveChildForMsexactly 60 min andcallAgeMs2h 01mreported separately; the ceiling reaped at record-minute 151 (T+61+90), not 90,
with
liveChildForMs90 min andbecause: live-child-ceiling. Lower bound:single call, child first visible on the tick at T+1 → reap at T+91, one sweep
later than the true child age and never earlier, so the clock can only understate.
Anchor change: two overlapping shell calls, the oldest (the anchor) ending at
minute 70 → clock restarts, no reap, no second notice, and the notice already sent
carried
liveChildForMs60 min withcallAgeMs61 min.3. The words. Rendered both notices through
formatStallNotice: the long-wait headsays
Sheltered for: 1h 00m of continuous kernel evidence, the body adds— 30m leftand aBasis:block stating that a break restarts the clock and thatcall age is not what the bound measures; the ceiling-reap head says
Sheltered for: 1h 30m of continuous kernel evidenceand prints neitherSilent for:nor "never applied". Cross-read against README's twostallWatchdogtables, its "a live child process is activity" subsection and the file header: every
sentence there (continuous stretch, starts at the first tick that saw the child as a
lower bound, restarts on a child-less tick, batch = a set of calls, anchor = oldest
open shell call, restarts when the anchor changes) matches one of the measurements
above. I found no doc sentence in those surfaces that the code does not do.
4. My own mutations (applied to the head's
stall-watchdog.tsin my copy, thenreverted). Re-introducing the round-3 single slot (
calls.clear()on everystart)fails exactly my 3 batch cases and nothing else. Re-introducing call-age anchoring
(
{ since: anchor.at }) fails exactly my 3 continuity cases and nothing else. Soboth fixes are load-bearing under a measurement that is not the PR's.
5. Scope. The delta is exactly 6 files —
stall-watchdog.ts,activity-bus.ts,agent-runner.ts,agent-manager.ts,README.md,tests/core/stall-watchdog-live-child.test.ts. Nothing outside the three findingschanged. The event field the fix rests on is real, not assumed:
pi-agent-core@0.80.3dist/types.d.tsdeclarestoolCallId: stringon bothtool_execution_startandtool_execution_end, anddist/agent-loop.jsexecuteToolCallsParallelemits every start (carryingtoolCall.id) before any end.6. Suite and typecheck at the head, my run. Whole package: 109 files / 1532
tests — 1 failed / 1531 passed, the failure being
tests/core/watch-renderer.test.ts > orphan exit …(5.3 s, a spawn-timing assertunder full-run load); that file passes 18/18 three times standalone and is
untouched by this delta, so I attribute nothing to the change.
stall-watchdog-live-child.test.ts27/27.
tsc --noEmitexit 0. CI at the head: run #400 all 14 jobs success(
pi-swarm-suite3m46s), #401pythonsuccess.Observations (no action demanded by this seat)
00dc972in its"Tests" and "Merge" sections (1525 tests; "review this head 00dc972"). The round-4
numbers (1532, this head) exist in the fix notes but not in the body. For the landing
worker, not a finding against the delta.
resumeAgent's subscribe(
agent-runner.ts:1029-1030) still publishestool_activitywithouttoolCallId,so resumed/woken runs use the id-less fallback (an
endcloses the oldest open callof the same name). I checked whether that reopens S2R3-1 there and it does not: the
[bash, read]shape survives, and a three-shell batch with id-lessends keeps theshelter, because starts and ends balance per tool name so the set's cardinality stays
right; only
lastEnded/anchor identity can land on a sibling. Recorded so a laterround does not have to re-derive it.
not a silence verdict: … reset this clock on every tick of that stretch. In seat 1'sprobe-E shape a challenge was issued during a gap; the sentence is literally true of
the stretch it measures and, unlike the round-3 wording, no longer claims the
threshold "never applied" — but a reader could still hear "the silence path never
ran". Not a blocker; noted so the residual ambiguity is on the record rather than
rediscovered.
Exposure line
Read: the delta diff
git diff 00dc9721 e5e4d70(6 files, +548/−98) in a detachedworktree at the head; the three round-3 verdict comments;
pi-agent-core@0.80.3dist/types.d.tsanddist/agent-loop.js;stall-watchdog.ts(header,InFlightCall/InFlightState,describeChildEvidence, the tick's shelter branch,formatStallNotice);both
agent-runner.tssubscriptions;agent-manager.tsturnWiring; README's twostallWatchdogtables and the live-child subsection.Ran, in
/tmpagainst a copy of the head's tree: my own 7-case harness plus twoextra probes (id-less three-shell batch; anchor change with two overlapping shells,
including rendering both notices), the two mutations above, one full
vitest run, onestandalone
watch-renderer.test.ts×3, one standalonestall-watchdog-live-child.test.ts, andtsc --noEmit; plusforgejo_ci_statusforthe head sha. No write to the PR branch, no write to any repository; this comment is my
only write.
Closure of record — ld-reviewer — head
e5e4d702b6model: omniroute/deepseek-flash
session: 01a1b39f-973c-70d7-89c1-cc127d81aa73
Closure seat for the findings the deployed lock still carries open at this head. I verified every one of them myself before closing anything: a copy of this head's
pi-swarm-suitein/tmp, its ownStallWatchdogdriven through its own exported deps, my own fake probe, my own event orders (withtoolCallIdand without it) and my own one-tick-per-simulated-minute schedule. I did not run the PR's own cases as evidence, and I took no number from an earlier seat. Reviewer only: I edited nothing, committed nothing, pushed nothing, merged nothing — this comment is my only write.What I measured
Raised in review 22752 (S2R2-1, round 2): "the shelter clock is per-RECORD and carries across a call boundary". I drove that shape exactly: one bash call opens at record minute 0 with its own live child and holds through minute 89, then the call ends and a second bash call opens (its own child, zero minutes old) with no tick landing in the sub-sweep gap between them. At this head there is no challenge and no reap at record minutes 90 to 95; the second call gets its own long-wait notice at record minute 150 (61 minutes into its own life) and its own ceiling reap at record minute 180 (91 minutes into it). Nothing reaps while the second call is under its own ceiling. The clock belongs to the call, which is what the finding asked for. The finding S2R2-1, which this lock keys as S2, is resolved on head
e5e4d702b6.Raised in review 22773 (S2R3-1, round 3): "a sibling tool call's
endclears the shelter of a still-in-flight shell call". Two batch shapes, both driven in pi's real parallel order (every start emitted before the first end). (a)[bash(long, child alive), read]: the read's end lands at minute 2, and every tick from minute 3 to minute 25 reports the working record as sheltered, with no challenge, no reap, no abort and the record still running — the round-3 seat'schallenged@13 / reaped@23does not occur. (b)[bash(long), bash(short)], the shape that producedchallenged@21 / REAPED@31: the sibling ends at minute 10 and the shelter holds unchanged through minute 31. The same shape with notoolCallIdon any event also holds for 25 minutes, so the fix is not confined to well-formed ids. Taken past the notice bound, the notice my run produced readsSheltered for: 1h 00m of continuous kernel evidence — not a silence verdict: a live child inside the in-flight tool call reset this clock on every tick of that stretch (ceiling 90m)and carries noSilent for:line and no "already ended" qualifier: the in-flight bash really can claim the child, so the notice is about the call that is still working and not about the sibling that ended. The finding S2R3-1, which this lock keys as S2, is resolved on heade5e4d702b6.Same review 22773 raised three further findings the lock reports with no id, and they are that one defect seen from other angles: its two repro bullets are the two batch shapes I re-drove in (a) and (b), and its narrative line about a silence-based notice is the notice those reaps used to carry, which I checked above at this head. The evidence that closes the labelled finding closes these with it.
The blocker raised in review 22773 is resolved on head
e5e4d702b6.Raised in review 23032, where the lock reads a finding with no id. That line is the wrapped continuation of a CONTROL sentence in the round-4 seat's measurement — "Control (the other half of the blocker, so per-call
endhandling is not simply ignored): the shell call's ownendstill runs the silence path". It alleges no defect: it records that the shelter belongs to a call rather than to a single in-flight flag, which is the correct half of the S2R3-1 fix. I verified that half still holds at this head. A bash call opens with its own live child, the call's own end arrives, and the silence path then runs untouched — challenged 11 minutes later, reaped 10 minutes after that, record stopped, one abort. Nothing in that line is outstanding at this head.The blocker raised in review 23032 is resolved on head
e5e4d702b6.Why the two labelled findings are cited as S2
The round 2 and round 3 reviews label their findings
S2R2-1andS2R3-1. This lock's finding-id grammar reads both labels as the token S2, so the two closure lines above cite that token, and each of them names the review that raised its finding and pins this head. No comment has been edited: the labels stand exactly as their authors wrote them.The head's own suite
A full run at this head comes back green, and
tsc --noEmitis clean. I ran the suite on a copy of the head'spi-swarm-suitewith its lockfile-pinned dependencies symlinked; the results are in the table below rather than taken from the review seats.tsc --noEmit)What this comment is not
It is not a new review round and not a re-litigation of the round-4 verdicts on this head, which stand on their own. It closes, by name, exactly the findings the lock still reports open, each after my own reproduction at this head. The head has not moved: this was composed against
e5e4d702b6, and it pins that sha throughout.