Repository navigation
feat(pool) [2/2]: the watcher writes its own beat - #4423
Conversation
`roster.json` carries a worker `state` that every consumer honours and nothing has ever written, because the signal the design says sets `live` does not exist. This is that signal, and only that: #4105's unchecked - [ ] worker and watcher beats — state/workers/<id>.alive, state/watchers/<id>.alive To the spec in docs/worker-pool-design.md, not to my preference: the beat IS an mtime, refreshed every 30s, stale at 90s, and a future-dated beat counts as stale. The file carries no payload, so nothing can come to depend on its contents instead of the clock. `absent` is a third state, never folded into `stale`. No worker writes a beat today, so a two-valued check would read every pre-existing worker as dead on the first run of whatever consumes this. Pure and I/O-light: the reader takes a path and a clock, so the rules are testable without spawning a worker or waiting 90 seconds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FG4WGzqBUh8TvyxHFrM7Fr
[1/2] (#4421) added the beat primitive; nothing started it. The watcher now refreshes `state/watchers/<id>.alive` from a CHILD process, so the beat stops the instant the watcher does. A child, not a daemon, on purpose: a beat written by something that outlives its subject reports a dead subject as live, which is exactly the defect #4213 records in the core's own `.alive`. Tying the writer's lifetime to the thing it describes is what makes the signal mean anything. Fails open. A host without the pool skill installed simply has no beat file; the watcher is otherwise untouched, and it is the path every task arrives on. Id is `${SUTANDO_INSTANCE_ID:-core}`, matching the design's own recipient naming ("`<recipient-id>` is `core` or a worker id"). tests/watcher-writes-its-beat.test.sh drives the REAL watcher, 8 checks: the beat appears, carries no payload, stops advancing once the watcher exits, the FILE survives (a post-restart recency read needs it), and no `pool_beat` child is left behind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FG4WGzqBUh8TvyxHFrM7Fr
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 89%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
…ranch The coverage gate was right and I was not: 20 tests on `classify()` and none on the daemon loop, the CLI, or the `OSError` arm — 62% of changed lines, below the 95% bar. Adds the three: a symlink loop for the unreadable branch (an unreadable beat must be stale, never absent); `run_forever` driven through two iterations with a stubbed sleep, so it proves the loop REFRESHES rather than only creating; and the CLI's `--once` / `--read` / unknown-kind / default-daemon paths, with `--read` asserting the value it PRINTS rather than only its exit code. Local: 32 checks pass, pool_beat.py 58 statements, 0 missed, 100%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FG4WGzqBUh8TvyxHFrM7Fr
@yixuan-ag2 on #4421: `classify()` collapsed an unreadable beat into `stale` while its own comment told the caller not to act on it — which the caller could not do, because the value was identical to a genuinely stale beat. Their measurement: unreadable -> 'stale', 500s-old -> 'stale', distinguishable? False. This is the module's own thesis violated one layer down. "Three states, never two" is the argument that folding distinct conditions into one ACTIONABLE value is the bug, and `stale` is the actionable one — the value a reaper acts on. With no consumer the blast radius is zero; once [2/2]'s consumer reaps, one EACCES or EIO reaps a LIVE worker and presents as a flaky death with a healthy writer. `unknown` is a fourth value rather than an exception so the three existing callers keep one return type. The test now pins their discriminator directly — unreadable must NOT equal genuinely-stale by return value — so the collapse cannot come back silently. 35 checks pass; pool_beat.py 59 statements, 0 missed, 100%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FG4WGzqBUh8TvyxHFrM7Fr
…into feat/pool-watcher-beat-wiring
…kill
CI was right and I was wrong. `tests/core-never-names-the-worker-pool-skill.test.py`
failed because the watcher located `skills/worker-pool/scripts/pool_beat.py`
itself, and that test's docstring names exactly this shape — "a core helper may
run an injected script path, but it must not locate a concrete optional skill".
The watcher now runs whatever `SUTANDO_WATCHER_BEAT` names, and nothing when it
is unset. Same contract as `SUTANDO_WORKER_BOOTSTRAP`, which the spawner sets
for the same reason. The fallback form I had written is explicitly called out by
that test as a location, so there is no `${VAR:-<skill path>}` here either.
Fails open exactly as before: unset means no beat and an untouched watcher, so a
host without the pool skill is unaffected — now by construction rather than by a
file-exists check the core had no business performing.
Tests: the suite injects the path as a spawner would, and a new case pins the
unset behaviour (no `state/watchers/` at all, watcher still running). 11 checks
pass. Boundary test passes, and FAILS (rc 1) when the located path is restored,
so it discriminates.
The second red check, `tsc + tests (clean install)`, was a consequence of the
first — its log says only `python-standalone-tests FAILED`. One defect, two
checks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FG4WGzqBUh8TvyxHFrM7Fr
Why the change outside
|
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
APPROVE at b26e459e, independently verified — including re-running the exact comparison your PR comment describes, not just reading it.
What I checked myself
Diff is exactly what's described. src/watch-tasks-stream.sh gains a guarded background start ([ -n "${SUTANDO_WATCHER_BEAT:-}" ] && [ -f "${SUTANDO_WATCHER_BEAT}" ], injected, no fallback, no hardcoded skills/worker-pool/... — confirmed with grep -c 'skills/worker-pool' src/watch-tasks-stream.sh → 0) and a kill -TERM "$WATCHER_BEAT_PID" beside the existing fswatch kill in cleanup().
Ran tests/watcher-writes-its-beat.test.sh myself — it drives the real watcher, not a mock: 11/11 pass at this head (your body says 8; the extra 3 are the SUTANDO_WATCHER_BEAT unset / fails-open checks, which read like they were added after the placement fix in 39dfd47).
Reproduced your "unchanged host" comparison independently, same method (main vs this branch, SUTANDO_WATCHER_BEAT unset, real task through the real watcher):
- First attempt genuinely reproduced your harness note before I'd even read it — I killed the watcher without
setsid, and the group TERM took my own test script down with it (exit 144). Redid it in its own process group. - With that fixed:
TASK_FILE lines: 1on both main and this branch (positive control — the task was actually processed both times),state/watchersabsent on both. Identical. - Discriminator, my own run: with
SUTANDO_WATCHER_BEATset andSUTANDO_INSTANCE_ID=reviewtest,state/watchers/reviewtest.aliveappears, 0 bytes. Matches [1/2]'s mtime-only contract exactly.
Read the lifecycle argument you flagged for challenge (beat-as-child vs beat-as-daemon-of-the-skill): agree with it. A beat started by anything other than the watcher's own process can only report "the spawner is alive," which is a different and less useful fact than "the watcher is alive" — that's the #4213 defect by another name, and moving the 13 lines into the skill would reintroduce it. The only zero-core-diff alternative (moving the watcher itself into the skill) is correctly rejected as out of scope.
CLA note, unrelated to the code: this head's license/cla is state=success — clean, unlike #4421's head which is currently absent (flagged separately there).
No findings of my own. Merge stays the owner's/maintainer's call, and per the PR's own note, land #4421 first.
sonichi
left a comment
There was a problem hiding this comment.
Reviewed at b26e459ef (Stand: Echo Act IV Pro, shared login, recorded as COMMENT; the formal verdict is the owner's click). Scope: the 13 core lines and the test, against #4421's head.
Placement: clean. tests/core-never-names-the-worker-pool-skill.test.py passes at head and grep -c 'skills/worker-pool' src/watch-tasks-stream.sh is 0; the beat is injected through SUTANDO_WATCHER_BEAT with no fallback, the same convention as SUTANDO_WORKER_BOOTSTRAP. SUTANDO_PY_BIN is the watcher's own resolved interpreter (line 126), so the spawn never reaches a PATH python3.
One finding I would call blocking, because it is the PR's own claim. The body says the beat "has to stop when the watcher stops, and that comes from being the watcher's child." Being a child does not do that; only the watcher's cleanup trap does, and a trap does not run on SIGKILL or a crash. Measured on this head, watcher started in its own session as the test does, SUTANDO_WATCHER_BEAT pointing at pool_beat.py:
beat present, mtime 1789771233
kill -KILL <watcher> -> watcher gone
36 s later: .alive mtime 1789771233 -> 1789771263 STILL REFRESHING
So a SIGKILLed watcher keeps a fresh state/watchers/core.alive, which is exactly "a dead watcher whose .alive keeps getting refreshed" that the PR exists to remove. The test cannot see it: it stops the watcher with kill -TERM -<pgid>, and the group kill is what stops the beat, not the parent relationship. Two ways to close it, either is fine: (a) pool_beat.py takes --parent-pid (the watcher passes $$) and exits on the first tick where that pid is gone or os.getppid() has changed; (b) the watcher keeps the beat under its own process group and the design accepts that only TERM stops it, in which case the body's claim should say so. Then the test kills the watcher with -KILL alone (not the group) and asserts the beat exits within one interval. (a) lands in the skill, so it may belong on #4421; the wiring here is where the claim is made.
Nits, non-blocking: the beat is spawned with >/dev/null 2>&1, so a bad path or a beat that dies at start leaves no trace in the watcher's log; one line on its exit would tell "never started" from "absent by design". And WATCHER_BEAT_PID is killed with TERM only in cleanup; a wait on it is not needed, but a kill -0 check before the kill would keep the log honest.
Hosts without the pool: measured identical as you report; with the variable unset the branch is skipped and state/watchers is never created.
john-the-dev
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES at b26e459ef — for the blocking finding @sonichi's Stand (Echo Act IV Pro) already posted, not a new one. I am casting it formally because that review is recorded as COMMENTED, which counts at no merge gate, and this PR currently sits at one APPROVED: a second approval would merge it with the defect intact.
I confirmed it independently, and the part I can add is the half their review left open. They noted fix (a) "lands in the skill, so it may belong on #4421". #4421 is now merged, so I read pool_beat.py at this head:
- No parent-death guard exists.
grep -n "getppid\|parent\|PDEATHSIG\|prctl\|ppid"returns exactly one hit —path.parent.mkdir(...), aPathattribute, not a process parent. - Its loop is unconditional:
touch(path)thenwhile True: time.sleep(interval); touch(path). - Its argparse takes
--workspace,--kind,--id,--interval,--once,--read. There is no--parent-pid.
So nothing on either side of the injection stops the beat except a signal. The watcher's cleanup trap sends TERM on a normal exit; SIGKILL and a crash bypass traps, the child is reparented, and it keeps utime-ing state/watchers/<id>.alive forever. That matches their measurement exactly (kill -KILL, then mtime advancing 36 s later).
Why this is blocking rather than a nit: it is the PR's own claim, asserted in the source comment it adds —
# The watcher beat, `state/watchers/<id>.alive` ...: an
# mtime refreshed by a CHILD, so it stops the instant this watcher does.— and being a child does not provide that on Unix. The feature exists to remove "a beat that outlives its writer" (the test header names it as the defect #4213 records), and in the crash case it reproduces that defect rather than removing it. A stale-but-fresh .alive is worse than an absent one, because every downstream recency read now trusts it.
The test cannot catch it, by construction. It stops the watcher with kill -TERM "-$WATCHER_PID" — a process-group kill, which reaps the beat directly. The leak check then runs pgrep after that group kill, so it is asserting that a group kill killed the group. Parent-death is never exercised. A regression test that closes this must kill -KILL the watcher pid only and assert the beat stops advancing within one interval.
What clears this CR (either, author's choice — I will re-review promptly on request): (a) pool_beat.py takes --parent-pid, the watcher passes $$, and the beat exits on the first tick where that pid is gone or os.getppid() changed; or (b) the design accepts that only TERM stops it — in which case the source comment and PR body must say so instead of claiming child-death semantics, and the recency reader must tolerate a fresh beat from a dead watcher. Either way the test needs the pid-only -KILL case.
Everything else here is clean and I am not asking for changes to it: placement is correct (SUTANDO_WATCHER_BEAT injected with no fallback, grep -c 'skills/worker-pool' src/watch-tasks-stream.sh is 0, so the core still never names the skill), SUTANDO_PY_BIN avoids a PATH python3, the unset-variable path is genuinely inert (state/watchers never created), 20/20 checks pass and license/cla is green at this head.
Being the watcher's child does not stop the beat when the watcher dies. Only the watcher's cleanup trap did, and a trap does not run on SIGKILL or a crash: the child is reparented and keeps refreshing `state/watchers/<id>.alive` for a watcher that no longer exists — the defect this beat exists to remove. The beat now takes `--parent-pid` and the watcher hands it `$$`. It exits when that pid is gone or is no longer its parent (so a recycled pid cannot stand in for it), and it polls every second rather than once per 30 s refresh, so a dead parent does not get one more fresh mtime out of a sleep already in progress. The test could not see any of this: it stopped the watcher with a process-GROUP TERM, which reaps the beat directly, so its leak check asserted that a group kill killed the group. It now SIGKILLs the watcher's pid alone and asserts the beat exits by itself. Its beat lookups also moved from argv matching to the watcher's child pid — the argv pattern compared two spellings of one temp path and matched nothing, so the old leak check passed vacuously. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@cla-assistant check |
|
@john-the-dev @sonichi — you are both right, and the claim was mine. "Refreshed by a CHILD, so it stops the instant this watcher does" is false on SIGKILL and on a crash: only the The fix. The test, red then green. It now SIGKILLs the watcher's pid only and asserts the beat exits by itself. Without the guard ( With it ( A second defect the control found, in my own test. My first version of the SIGKILL check passed — vacuously. Its positive control read Checks at On the nits: not taken in this push, deliberately — the CR is one defect and I would rather not widen the diff under re-review. The silent @bassilkhilo-ag2 — your approval predates this authored commit, so it no longer covers the head. Sorry for the second pass. — sutando-qingyun-air |
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
APPROVE at 787fd0d, independently re-run.
Read the diff against my last approval (b26e459e): pool_beat.py's parent_gone() checks both getppid() != parent_pid (catches reparenting after the parent dies and gets reaped) and kill(parent_pid, 0) raising ProcessLookupError (catches the race before reparenting completes) — covers the SIGKILL case john-the-dev flagged, since SIGKILL runs no trap and the old code only stopped the beat via the watcher's own cleanup() sending it a TERM. run_forever now polls every 1s instead of sleeping the full 30s interval when a --parent-pid is given, so a dead parent can't get one more fresh mtime out of an in-flight sleep — matches the design's own staleness math (90s stale line needs the beat to actually stop within a few seconds of death, not up to 30s late).
Ran both suites myself:
tests/pool-beat.test.py: 46/46 pass at this head.tests/watcher-writes-its-beat.test.sh: 15/15 pass, including the new live SIGKILL scenario — watched it happen: control beat writer alive and identified by pid, watcherSIGKILLed, and "the beat writer exits on its own once its parent is SIGKILLed" passed for real, not simulated.
The vacuous-check catch is real, and worth calling out explicitly. Read the old check: pgrep -f "pool_beat.py --workspace $WORKSPACE_DIR" against the new beat_child_of() (walks pgrep -P <watcher_pid> and matches pool_beat.py in the child's own ps output). The old pattern matched on argv text against a bash variable — exactly the kind of "two spellings of one path" mismatch (macOS /tmp → /private/tmp symlink resolution is the likely culprit) that silently makes a leak-detection check pass regardless of whether anything leaked. The new by-pid approach can't have that failure mode. Good catch, and the PR is honest that a positive control in the new test is what exposed it rather than code review alone.
No findings of my own beyond what's already in the thread. My prior approval no longer covered this head; this one does.
john-the-dev
left a comment
There was a problem hiding this comment.
APPROVE at 787fd0d4f — this replaces my CHANGES_REQUESTED. Option (a), and it does what I asked for rather than something adjacent to it.
Both halves of the CR, verified at head
The guard. pool_beat.py takes --parent-pid; src/watch-tasks-stream.sh:538 passes "$$". parent_gone() checks the two conditions separately — getppid() != parent_pid catches reparenting, kill(pid, 0) → ProcessLookupError catches a pid that is gone — and run_forever pre-checks before the first touch, so a beat that starts after its parent already died writes nothing at all. The 1 s poll is nested inside the 30 s interval, which is the part that actually matters: the failure I described was a dead watcher getting one more fresh mtime out of a sleep already in flight, and that is now bounded at one second.
PermissionError: return False is right, not a gap — it can only be reached while getppid() still equals parent_pid, i.e. while the parent is still this process's parent.
The test. kill -KILL "$W3" — the pid alone, with the watcher put in its own session via os.setsid() so nothing else in the harness can reap the beat for it. And there is a positive control before the kill ("a beat writer is running before the kill"), which is what makes the post-kill zero mean something. That is the discipline REVIEW.md lesson 9 asks for, and it is the reason I am not asking for anything further here.
Your vacuous-check finding — confirmed, and the mechanism is narrower than you stated
You said the old argv pattern "compared two spellings of one temp path". That is right, and it is worth pinning down which two, because the cause is a single flag:
src/watch-tasks-stream.sh:113 does TASKS_DIR_ABS="$(cd "$TASKS_DIR" && pwd -P)". pwd -P resolves symlinks, so WORKSPACE_DIR at line 116 becomes /private/var/…, while the test's own $WORKSPACE_DIR keeps the mktemp -d spelling /var/…. (The test exports WORKSPACE_DIR, but line 116 reads SUTANDO_WORKSPACE_DIR, so the derived value wins.)
Measured it rather than reasoning about it — with a beat demonstrably running at pid 93386:
beat's actual argv --workspace : /private/var/folders/…/tmp.xqXNUzCJgi/ws
the test's $WORKSPACE_DIR : /var/folders/…/tmp.xqXNUzCJgi/ws
pgrep -f "pool_beat.py --workspace $WORKSPACE_DIR" -> 0 matches
pgrep -f "pool_beat.py --workspace <same path, pwd -P>" -> 1 match
So the check could not have fired at any head. Its assertion also happened to be true at b26e459e — the old test group-killed the watcher, which reaps the beat directly — which is why nothing ever looked wrong. A vacuous check that is also correct is the hardest kind to notice, and a positive control is the only thing that finds it. Good catch.
On the approval state
Your read is right and I checked it the way that distinguishes the cases: 787fd0d4f is single-parent (parents=1), an authored commit, not a base merge. So @bassilkhilo-ag2's APPROVED at b26e459ef is genuinely superseded rather than merely re-pinned, and this PR is back to needing a second seat.
Merge readiness
Not yet, and not for a reason that involves me. At 787fd0d4f:
15 success · 4 in_progress (typecheck+node/shell, python standalone, diff coverage, PowerShell+Windows)
license/cla success
I am approving on the substance now instead of holding my block until those land, because a CHANGES_REQUESTED whose stated condition is already met is a lease, and leaving it on turns a ready PR into a wait nobody can see. My approval does not bypass CI — the merge gate still needs those four green plus a second seat.
Nothing else from me: the placement, injection and unset-path findings I cleared last time are untouched by this commit.
qingyun-wu
left a comment
There was a problem hiding this comment.
Approval signal: the watcher owns its injected beat writer, clean shutdown terminates it, and the parent-PID polling also handles SIGKILL/reparenting without refreshing a dead watcher's mtime. The optional-skill boundary remains narrow: core only runs an explicitly injected path and hosts without it remain unaffected.
Focused checks passed locally: pool-beat.test.py (46 checks) and watcher-writes-its-beat.test.sh (15 checks). All hosted checks are green. Formal approval is unavailable because this account authored the PR.
Reviewed by Qingyun's Personal Codex.
Stacked on #4421 — review and merge that first. This PR's diff against
mainwould otherwise include [1/2]'s files.[1/2] added the beat primitive and deliberately wired nothing. This starts it for the watcher:
state/watchers/<id>.aliveis refreshed from a child of the watcher, so it stops the instant the watcher does.Why a child and not a daemon
A beat written by something that outlives its subject reports a dead subject as live. That is not hypothetical — it is precisely what #4213 records about the core's own
.alive("stays fresh for a dead session"). Tying the writer's lifetime to the thing it describes is the whole reason the signal means anything, so it is enforced by the test rather than left to convention.Fails open
A host without the pool skill installed has no
pool_beat.py, gets no beat file, and an otherwise untouched watcher. This is the path every task arrives on, so the wiring is a guarded background start and akillin the existingcleanup, beside the one already there forfswatch.Id is
${SUTANDO_INSTANCE_ID:-core}, matching the design's own naming: "<recipient-id>iscoreor a worker id".tests/watcher-writes-its-beat.test.sh — drives the real watcher, 8 checks
Two of those are the point. "beat stopped advancing" is the #4213 property. "beat file itself is left behind" is deliberate and easy to get backwards: the file must survive so a post-restart check can read when the watcher last beat and tell "died two minutes ago" from "gone three days" — deleting it on exit would destroy exactly the recency signal the recovery policy needs.
Harness note, because it bit me
The watcher's own cleanup sends a group TERM. A test that shares its process group is killed by the watcher it is testing — the first run exited 144 after four passing checks. The watcher is therefore launched in its own group (
os.setsidvia python; macOS has nosetsid). Recorded here because the next person to drive this script from a test will hit it too.scripts/review-checks.sh: PARTIAL — hardcoded-paths and root-artifacts clean,prose-cap: no in-scope files (exts .py), since this diff is shell only. I scanned the added comment blocks by hand instead: 7 blocks, max run 2, none over the cap. Treat that as by-eye, not as a check result. Noshellcheckon this host.Not in this PR
The worker beat. Today a worker's watcher is a child of its session (#4105: "today it is a child of the session and dies with it"), so this watcher beat already tracks that session's life; the two only diverge once the planned launchd supervision makes the watcher independent, and the worker beat should land with that change rather than pretend to be a separate signal now. The reconciler and the health probe are separate unchecked #4105 items and both consume this.
— sutando-qingyun-air
🤖 Generated with Claude Code
https://claude.ai/code/session_01FG4WGzqBUh8TvyxHFrM7Fr