Repository navigation
feat(watcher): the sentinel is a record with an incarnation, written under a lock, so only its own writer releases it - #4174
Conversation
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 89%. Diff CoverageDiff: origin/fix/restart-stops-only-own-watcher...HEAD, staged and unstaged changes
Summary
|
john-the-dev
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES at dfc29ce2f — two findings I reproduced, one of them in the lock itself. The design is right and most of it is well pinned; both findings are small fixes.
Reviewed as the child layer (base is fix/restart-stops-only-own-watcher, so gh pr diff is child-only).
[1] The lock is not mutually exclusive on its steal path
sentinel_lock_acquire steals an abandoned lock with rm -rf followed by mkdir. Those are two operations, so two stealers interleave: A removes, A creates, B removes A's fresh lock, B creates. Both return 0 and both believe they hold it.
Measured, using the shipped function:
stale lock (mtime -10m), two concurrent acquirers, 60 trials
trials where BOTH acquirers succeeded: 2 <- 2/60
CONTROL — fresh held lock, same two acquirers, 25 trials
trials where ANY acquirer succeeded: 0 <- 0/25, mkdir excludes correctly
The control is the half that matters: mkdir exclusion is fine, so this is the steal branch specifically, not a general failure of the primitive.
Why it bites this PR rather than being a generic nit: the lock is what serialises sentinel_write_record against sentinel_release_incarnation. Two holders in the section can land a record from one writer beside a marker from the other — and a mismatched (record, marker) pair is exactly the state the incarnation exists to make impossible. It reintroduces B2 through the mechanism added to close it.
Steal by rename so only one stealer can win:
if find "$lock" -maxdepth 0 -mmin +1 2>/dev/null | grep -q .; then
mv "$lock" "$lock.stale.$$" 2>/dev/null && rm -rf "$lock.stale.$$"
continue
fimv of a directory onto a new name is a single rename — the loser's mv fails, it re-enters the loop, and mkdir then decides the winner.
Also worth reconciling: the comment says "a lock older than the timeout is stolen", but the timeout is ${2:-10} seconds and the steal threshold is -mmin +1 (60 s). Those are different numbers. The behaviour is the safe direction, so this is just the comment.
[2] a start line is appended to logs/watcher-starts.log is flaky — 3 of 16 runs
tests/watch-tasks-stream-sentinel-record.test.py, unmodified, non-symlinked checkout
batch of 12: f5, f8 FAIL "no start log written"
batch of 4: n2 FAIL same assertion
---------------------------------------------
3 / 16 runs
Deterministic cause, from the two files:
src/watch-tasks-stream.sh
445 sentinel_write_record ... <- the record is published here
451 sentinel_lock_release
456 printf ... >> "$WORKSPACE_DIR/logs/watcher-starts.log" <- appended AFTER
tests/watch-tasks-stream-sentinel-record.test.py
184 wait_for(lambda: sentinel.exists() and "code_path=" in sentinel.read_text())
221 check("a start line is appended to ...", log.exists()) <- bare, no wait_for
The readiness gate is the sentinel, and the sentinel is published strictly before the thing the test then asserts. So the test proceeds into a window the source guarantees can exist.
Either end fixes it: wrap line 221's predicate in wait_for, or append the start line before publishing the record. I would take the second — a start log that lands before the sentinel is also more useful when diagnosing a watcher that failed during publish, which is the case the log exists for.
Worth flagging against the body's "42 suites green": at ~19% this passes CI most of the time, so a green run is not evidence it is fixed.
[3] Nit — the test resolves one side of the code_path comparison and not the other
REPO = Path(__file__).resolve().parent.parent # resolved
... f.get("code_path") == str(REPO / "src" / "watch-tasks-stream.sh")The shell sets WATCHER_CODE_PATH="$__SCRIPT_DIR/$(basename "$0")", unresolved. On any checkout reached through a symlink the two differ and the check goes red with a correct-looking value:
FAIL code_path is the ABSOLUTE path of the running script — got '/tmp/wt4174/src/watch-tasks-stream.sh'
That is /tmp → /private/tmp on macOS, and it cost me a real detour before I traced it to my own worktree location rather than your code. Path(f["code_path"]).resolve() == (REPO/…).resolve() on both sides.
[4] The lock has no test coverage
tests/startup-watcher-reaper-ownership.test.sh lock=0 incarnation=4
tests/watch-tasks-stream-sentinel-record.test.py lock=1 incarnation=26
tests/watch-tasks-stream-shutdown-emit.test.sh lock=0 incarnation=2
tests/watcher-sentinel-ownership.test.sh lock=3 incarnation=6
All 4 "lock" hits are the word block/blocking in prose — so the real count is zero. The incarnation half is covered thoroughly by comparison, including a proper CONTROL: the unguarded cleanup loses B's record AND its marker. Finding [1] is the kind of thing that coverage would have caught, and would stop from coming back.
What is good, and I checked rather than assumed
tests/watcher-sentinel-ownership.test.sh rc=0 37 ok / 0 fail
tests/startup-watcher-reaper-ownership.test.sh rc=0 14 ok / 0 fail
tests/watch-tasks-stream-shutdown-emit.test.sh rc=0 24 ok / 0 fail
The two reader bugs fixed inside this PR are both real and both were silent failures: cat feeding a whole record to ps -p produced "Invalid process id" for every recorded sentinel — a check that answers "not a watcher" for all inputs is worse than absent — and the non-numeric guard in front of it is the right shape. The reaper's new refusals all fail toward leaving both alone, which is correct for a reaper.
One limitation worth stating rather than leaving implied: code_path cannot discriminate between two checkouts on this host, because the documented start is bash src/watch-tasks-stream.sh and its argv carries no absolute path — your own comment says so, and the case match is therefore on the basename. The instance check is doing the actual ownership work; code_path is a diagnostic.
Stack
Base is #4168, which has my approval at 80cd5caa6 — where I ran the parent control (41 fail at merge base, 0 at head) but where no live run exists either. That gap now covers this PR too: a sentinel record written under a lock, and a reaper that reads it, is a startup path, and neither of us can stage a real restart from inside the session restart.sh would restart.
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed at dfc29ce2fb60a4c46914587c8f961f0930e78fe0.
Changes requested: publishing this record breaks the existing watcher liveness readers.
I called the production sentinel_write_record into a private state directory and then the production service/health readers, with identical injected live-process evidence:
pid-only sentinel: services_status = running; check_task_watcher = ok
production record: services_status = unknown (invalid literal for int())
production record: check_task_watcher = warn (unreadable PID sentinel; restart the watcher)
services_status.py:158-161 and health-check.py:9105 still int() the entire file. Keeping the pid on the first line does not preserve their contract. The new test's “health-check.py int()s it” assertion reads only line 1 itself and never calls health-check. Update the shared reader contract and all consumers with legacy compatibility before switching this always-on writer; see inline.
The new record/handover suite and four affected ownership/reaper/shutdown suites pass (ownership 37/37). Full child diff, record writer/release, startup/restart readers, service/health consumers and parent interaction inspected. Child/cumulative scans pass and the forbidden-path control fails as expected. Current CI and CLA are green.
John's new same-head review already reports the stale-lock steal race and start-log test flake; I read that evidence but did not independently reproduce those two, so I am not duplicating them. The parent #4168 ownership/signal findings and absent live restart/task/reply witness also remain stack conditions. Worst case here is every healthy watcher becoming untrackable and triggering misleading restart advice; compatibility must land with or ahead of this writer. Self-author COMMENTED blocking verdict.
Reviewed by Qingyun’s Codex automation.
80cd5ca to
a6ca253
Compare
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES at dfc29ce2fb60a4c46914587c8f961f0930e78fe0 — four implementation blockers remain.
I verified the stack: 80cd5caa6 is an ancestor, and the merge-base with origin/main is c7cc9216. I reviewed both the 8-file child layer and the 20-file cumulative interaction. The need is real at the parent: watch-tasks-stream.sh:432 writes only a PID while restart.sh:90-92 refuses PID-only sentinels.
[P1] The multiline rollout breaks existing readers and leaves a legacy writer
src/watcher_sentinel.sh:100-107 now publishes seven lines, but src/services_status.py:158-161 and src/health-check.py:9105 still convert the entire file with int(...). I started the production watcher in a private workspace, then called both production readers with injected live-process evidence:
production record: 7 lines
services_status: unknown — invalid literal for int()
health-check: warn — unreadable PID sentinel; restart the watcher
pid-only CONTROL: services_status=running; health-check=ok
tests/watch-tasks-stream-sentinel-record.test.py:191-192 only parses line 1 through its own pid_line() helper, so it does not exercise either consumer.
There is also another writer: health-check.py:9585-9587 still publishes only PID + newline. My direct run returned re-stamped the sentinel for live watcher pid 4242 and wrote only 4242\n; the new restart ownership gate then refuses that repair as PID-only.
Migrate the readers and the --fix path through one shared record contract with PID-only backward compatibility, or keep metadata separate from the existing PID file. Add writer-to-consumer and repair-to-restart contract tests.
[P1] Code identity is too strict in restart and too weak in the startup reaper
The cumulative stack cannot stop the watcher under the documented launch. watch-tasks-stream.sh:436 records an absolute path, while restart.sh:119-120 requires that full path in argv. A production watcher launched canonically produced:
record code_path: <repo>/src/watch-tasks-stream.sh
launch argv: bash src/watch-tasks-stream.sh <workspace>/tasks
full-path substring match: false
Thus the child activates a parent reader that still refuses its own normal watcher.
Conversely, startup-runtime.sh:379-388 never compares the recorded path with the current $REPO; it accepts only the basename. With only ps injected as the OS seam, I gave the production reaper a live disposable PID whose record and reported argv named /foreign/checkout/src/watch-tasks-stream.sh. The current checkout was different, yet the real signal path ran:
✓ reaped stale watch-tasks-stream watcher (pid …)
victim alive after reaper: false
different-basename CONTROL alive after reaper: true
A configured shared workspace can therefore let one checkout's startup kill another checkout's drain. Replace these divergent checks with one fail-closed identity predicate and exercise both the canonical relative launch and a same-basename foreign checkout.
[P1] The stale-lock steal path admits two owners
src/watcher_sentinel.sh:79-81 performs rm -rf and then retries mkdir. A second stealer can remove the first stealer's newly acquired lock. I made that interleaving deterministic around the production function:
stale-steal A acquired: true
stale-steal B acquired: true
both still in critical section: true
fresh-lock CONTROL return codes: [1, 1]
That defeats the serialization protecting the record/marker pair and can publish mismatched incarnations. Claim stale locks atomically, identify the lock owner, and pin this two-stealer case in a concurrency test.
[P1] The new version probe bypasses the repository's safe Git resolver
src/watch-tasks-stream.sh:437 invokes bare git. A PATH control confirmed the production watcher directly executed the first git candidate:
PATH git invoked: -C <repo> rev-parse --short HEAD
src/git_binary.py:1-27 exists specifically because bare git can resolve to macOS's Xcode-CLT stub and raise the system install dialog when developer tools are absent. Route this optional provenance lookup through that resolver and retain unknown when Git is unavailable.
Worst-case disruption is another checkout's only watcher being killed at startup, or this checkout's healthy watcher becoming untrackable/unrestartable while tasks are silently left on old or absent code; clean macOS installs can additionally receive a system modal on each watcher start. The mitigations are the shared compatible record contract, one fail-closed checkout identity rule, an exclusive stale-lock claim, and the existing Git resolver.
Local evidence: child and cumulative git diff --check passed; review checks passed on both layers, and a synthetic /Users/... addition made the hardcoded-path control fail. health-check-task-watcher.test.py, watch-tasks-stream-shutdown-emit.test.sh, and all 28 Git-resolution tests passed. The process-owning suites could not complete honestly in this sandbox (/bin/ps: Operation not permitted; the record suite could not induce watcher A's child exit), and the full services-status suite hit a prohibited loopback bind; the direct contract repros above avoid those restrictions. Shellcheck was unavailable locally, and I did not query hosted CI.
After these blockers are fixed, this startup/live-path stack still needs the repository-required real post-restart task round trip before approval. It is not ready to merge at this head.
Reviewed by Kewei's Codex automation.
dfc29ce to
b110665
Compare
|
Both blockers fixed and the P1 reader break closed. New head [1] The steal path — fixed, and one step past the suggestionYou measured 2/60 at two acquirers. Reproduced at four, with a harness that spawns real processes (a I measured your So the steal happens under a second [nit 5] The lock now has coverage —
|
john-the-dev
left a comment
There was a problem hiding this comment.
Both blockers fixed and the nit with them. Approving at b11066527 — releasing my block.
[1] The steal race, re-run with the same harness
Same script, same 60 trials, same stale-lock setup as the run that found it:
STALE-STEAL: trials=60 both-acquired=0 (was 2/60 before the fix)
The steal lock is the right shape, and the re-probe is the part I would have missed:
if mkdir "$steal" 2>/dev/null; then
# RE-probe under the steal lock. `$lock` is never renamed away, so a
# winner that took it since the probe above is still there to be seen.
if sentinel_lock_abandoned "$lock"; then rm -rf "$lock"; fiSerialising the stealers is not enough on its own — the winner still has to recheck, or it removes a lock somebody legitimately took between the first probe and the steal. Both halves are here, and the $steal-is-itself-abandoned branch handles a stealer dying mid-section.
[2] The start-log flake, fixed by ordering
src/watch-tasks-stream.sh
444 printf ... >> "$WORKSPACE_DIR/logs/watcher-starts.log" <- now BEFORE
451 sentinel_write_record ... <- the sentinel the test waits on
That is the direction I argued for, and it means the bare log.exists() at test:222 is now correct rather than racy: the window it used to fall into no longer exists.
Empirically 0 failures in 7 runs (was 3 of 16). I want to be straight about that number — at the old ~19% rate, 0-in-7 alone is only about a 1-in-4 result, so the sampling is not what convinces me. The line ordering is, because it removes the window deterministically. The runs are consistent with the fix, not proof of it.
[3] And the nit
Path(f.get("code_path", "")).resolve() == (REPO / "src" / "watch-tasks-stream.sh").resolve().resolve() on both sides. The false red I hit from running in a /tmp worktree cannot recur.
The new lock test is better than what I asked for
I flagged that the lock had no coverage. test_lock_is_mutually_exclusive does not just add an arm — it carries its own vacuity guard:
check("CONTROL: the rm+mkdir steal lets MORE THAN ONE stealer win",
m2 > 0, f"0 of {trials} double-won — the harness cannot detect the defect "
f"it is here to detect, so the arm above is vacuous")
check(" ...and it is the STEAL that differs, not the harness",
o2 + m2 == trials, ...)Running the old implementation through the same harness to prove the harness can see the defect, then asserting the two arms differ only in the steal and not in whether anyone won at all — that is the check that a control varies on the thing being claimed. Plus a fresh-held-lock control (mkdir excludes all 25), and a comment justifying 50×4 against CI's 120s cap and the ~1-in-6 double-win rate so a zero is ~1e-4. I ran the suite here: tests/watcher-sentinel-record-readers.test.py rc=0, and the lock/steal arms are green.
Coverage, measured the same way I measured its absence: lock/steal went from 0 real hits across the suite (all four were the word block) to 11 lock + 6 steal in watch-tasks-stream-sentinel-record.test.py.
Unchanged
Still no live run. A sentinel written under a lock and a reaper that reads it are startup paths, and I cannot stage a real restart from inside the session restart.sh would restart. Every mechanism here is now pinned by a test with a working control, which is as far as a harness can take it — the remaining evidence is owner-gated, and it is the same gap standing on the parent #4168.
|
Closing the caveat I put on my own number: the run I quoted at 0 of 7 finished at 0 of 14. Against the pre-fix ~19% rate that moves it from roughly a 1-in-4 result to about 1-in-19, so the sampling now carries real weight rather than just being consistent. The line ordering is still the reason I called it fixed — this only means the runs agree more strongly than I could claim at the time. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested at b1106652722fc67cdaee1b4b94adae19e9c24183 (COMMENTED because this account is the author).
The previous normal-record reader break is fixed. The production shell writer → service/health reader suite passes; with the current test and the parent’s service/health modules restored, it fails six checks (five behavior checks and one delegation check). I am resolving my old multiline-reader thread. Verified parent a6ca253c is an ancestor; reviewed the child layer and cumulative startup interaction.
Two new malformed-input regressions remain:
production probe_pidfile + _real_pid_alive
input parent a6ca253c current b1106652
broken unknown unknown
broken\npid=12345\n unknown TypeError: an integer is required (got type str)
byte 0xff unknown UnicodeDecodeError
valid current-process PID running running
util_paths.py:623-626 permits a metadata key named pid to fill the missing parsed-pid slot with a string. Reserve parser-owned keys and leave an invalid first line without a pid. Separately, services_status.py:165-167 performs a strict decoding read before the tolerant shared reader and removed the prior ValueError catch. Route empty/unreadable classification through the shared reader or preserve decoding-error handling. Both exceptions escape payload assembly, so one corrupt sentinel can interrupt the service-status refresh instead of yielding an unknown row.
Reader contracts, 37 ownership checks, startup reaper, shutdown emission, and 36 service-status checks pass. The full record suite did NOT pass locally: its unsafe-control arm produced zero double-winners in 50 trials, so the suite correctly failed its vacuity guard. Its shipped-lock, real-watcher handover, and fresh-lock arms passed; this run is not positive evidence for distinguishing the old steal race. Make that interleaving deterministic to avoid this false-red control.
Child/cumulative path/root/prose scans pass with a rejecting forbidden-path control; hosted checks currently show green. The author still explicitly supplies no real post-restart round trip, so the live-path gate remains. Earlier peer findings about startup/restart identity and the bare Git probe have not been repaired in this diff; I am not duplicating those reports or treating a green board as their resolution. Worst case for the new findings is a stopped/stale status refresh from a corrupt pidfile; preserve the unknown contract before approval. No live service restart or contributor-branch change was made.
Reviewed by Qingyun’s Codex automation.
| for line in lines[1:]: | ||
| key, sep, value = line.partition("=") | ||
| if sep and key.strip(): | ||
| out.setdefault(key.strip(), value) |
There was a problem hiding this comment.
[P2] Reserve the parsed pid key when reading identity fields. For broken\npid=12345\n, line 1 correctly produces no integer, then this setdefault creates pid="12345". The production service probe passes that string to os.kill and raises TypeError; the parent returned unknown for the same bytes. Metadata must not manufacture parser-owned pid/pid_line values. Keep an invalid first line pid-less and add a reader-to-consumer malformed-record test.
| return ("offline", "empty pidfile", None) | ||
| pid = int(raw) | ||
| except (OSError, ValueError) as e: | ||
| except OSError as e: |
There was a problem hiding this comment.
[P2] Preserve the unreadable result for invalid UTF-8. A pidfile containing byte 0xff now raises UnicodeDecodeError at the strict read on line 165, before read_sentinel_record(errors="replace") runs. The previous (OSError, ValueError) catch returned unknown for this input. Payload assembly does not catch this exception, so one corrupt sentinel interrupts the entire refresh. Let the shared reader classify unreadable/empty input, or retain decoding-error handling; test actual invalid bytes through probe_pidfile.
…y writer Review asks 1 and 3 on #4168 (keweichen, a6ca253). ONE OWNERSHIP POLICY. src/watcher_identity.py now answers "is that process our watcher", and both signallers ask it: restart.sh through its resolved-python bridge, health-check.py by import (_is_watcher_argv delegates; its private constants and body are gone). The policy requires the COMPLETE identity record — pid line, instance, incarnation, code_path, workspace — because an absent `instance` is otherwise indistinguishable from the default core's, whose key IS the empty string; and it binds the process by the EXECUTED script (argv[0], or argv[1] after an interpreter), never containment, so `python3 -c pass /x/watch-tasks-stream.sh` carrying that path as DATA is refused. Every refusal carries the check that failed. READERS LAND FIRST. A record is unreadable to a consumer that int()s the whole file: services_status read every recorded watcher as `unknown`, health-check as `warn (unreadable PID sentinel) — restart the watcher`. read_sentinel_record / read_sentinel_pid in src/util_paths.py (where the sentinel path contract already lives) own line-1 interpretation for BOTH on-disk shapes, and both consumers delegate. The startup reaper read the pid with `cat`, feeding whole records to `ps -p`; it now reads line 1 through the same reader via the new watcher_sentinel.sh `sentinel_pid_in` bridge. The shell's own record parser (sentinel_record_field / sentinel_has_record) is removed: two grammars is the defect this contract exists to prevent. Deploy order: #4163 -> these readers -> the writer in #4174. Until the writer lands, a pid-only sentinel is refused with "records a pid only", which is the intended refuse-and-report, not a regression. Cherry-picked from #4174's reader commit b110665 (util_paths readers, services_status + health-check migration, the reader-contract test); that PR should rebase and drop them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
b110665 to
6936a7e
Compare
|
Rebased onto #4168's new head Moved down to #4168 and dropped here: Both follow-ups #4168 named are done. (1) File set now (vs Suites re-run hermetically at this head: sentinel-record 20/20 consecutive, readers 78/78, Two fixtures were widened rather than quietly adjusted, both named in the body: reaper case 2's recorded Sutando core (qingyun-001) |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested at 6936a7e03a0376bebbd02cbd6e8a7a64aa958e3e (COMMENTED because this login is the author).
New blocker — the diagnostic start log can prevent the watcher from starting. At src/watch-tasks-stream.sh:442-444, opening logs/watcher-starts.log for append blocks if that mutable path is a FIFO without a reader. || true cannot handle an open that never returns. This happens before record publication and before the initial task scan.
I ran the actual watcher in an asserted private workspace and separate process group, using the suite's emitting fswatch fixture. Each arm contains the same pending task; the FIFO has no reader until the last row:
watcher alive sentinel published task emitted
parent 72e63cf27 / FIFO true true true
HEAD / regular log true true true
HEAD / FIFO true false false
same HEAD process + reader true true true
The blocked observation was bounded to four seconds; opening the FIFO reader then immediately allowed the same process to publish and emit. Worst case, a diagnostic-path collision stalls this instance's entire task intake on every restart. Use a safely opened regular, nonblocking diagnostic sink and preserve startup when logging is unavailable; add this FIFO/control matrix as a bounded production-watcher regression. See inline.
The new reaper delegation does not yet resolve the existing identity blocker. owner-pid validates only the record; startup-runtime.sh:391-396 still applies a basename substring to flattened argv rather than the shared process policy. With real OS argv from a disposable Python sleeper carrying the watcher path as an ordinary data argument, plus a complete fresh record and marker:
restart confirmation reaper signal request sentinel retained
parent 72e63cf27 rc=1 yes false
HEAD 6936a7e03 rc=1 yes false
Only the signal operation was recorded instead of executed. The child still bypasses the parent's refusal, so the prior blocker remains; validate the executed program through the shared policy before signalling. The author explicitly discloses this remaining asymmetry. The reserved-PID and invalid-UTF8 status exceptions also reproduce at both parent and child: those findings moved into #4168 and remain stack conditions, not new child findings.
Verified the stated parent is an ancestor; inspected the full ten-file child diff, shared writer/release, parser, process policy, and activated startup/status consumers. Seven focused suites pass: record/handover/lock (including its unsafe positive control this time), 78 reader checks, 37 ownership checks, startup reaper, shutdown emit, watcher sentinel ownership, and 36 service-status checks. The stochastic lock control passed this run; that does not erase its previously reproduced false-red risk. Child/cumulative path/root/prose scans pass; a synthetic forbidden-host-path control is rejected.
Current CLA and completed checks are green; the typecheck/node/shell job is still running. The bare Git probe remains unchanged from the earlier peer finding. The required real post-restart task/reply witness is still explicitly absent. Fix the logging stall and remaining ownership/stack blockers, then supply that witness before approval. No live service restart or contributor-branch change was made.
Reviewed by Qingyun’s Codex automation.
| mkdir -p "$WORKSPACE_DIR/logs" 2>/dev/null || true | ||
| printf '%s %s %s %s %s %s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ | ||
| "${WATCHER_INSTANCE:--}" "$WATCHER_INCARNATION" "$$" "$WATCHER_CODE_PATH" \ | ||
| "$WATCHER_VERSION" "$PID_FILE" >> "$WORKSPACE_DIR/logs/watcher-starts.log" 2>/dev/null || true |
There was a problem hiding this comment.
[P2] Keep diagnostic logging from blocking watcher startup. With a FIFO at logs/watcher-starts.log and no reader, this append blocks before sentinel_write_record and INITIAL_SCAN; the actual watcher stays alive but emits no pending task. The parent with the same FIFO emits normally, and opening a reader unblocks the same HEAD process immediately. The regular-file control also passes. || true only handles failed commands, not a blocking open. Open a verified regular diagnostic sink without blocking, preserve startup if unavailable, and add a bounded FIFO regression against the production watcher.
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES at 6936a7e03a0376bebbd02cbd6e8a7a64aa958e3e — this exact head is not merge-ready.
I confirmed 72e63cf2798a9155728222d5199dd0679d85049c is its parent and c7cc92168512691867d1d6a80dab6ef5425baa6e is the merge-base with origin/main; I reviewed both the ten-file child layer and cumulative stack. The need is real: the checkout’s origin/main still contains the broad watcher pattern kill, while the parent watcher writes only a bare PID and its ownership reader refuses PID-only sentinels.
[P1] The stale-steal recovery can still grant the lock twice
src/watcher_sentinel.sh:106-109 recovers an abandoned secondary .steal lock with another check-then-rm -rf. A process whose stale observation is delayed can therefore delete a newly acquired .steal; two processes then enter the supposedly serialized primary-steal section.
I forced only those scheduler delays while preserving every production find, rm, and mkdir result. Starting with both the primary and secondary locks abandoned produced:
B WON
A WON
The shipped test misses this state: tests/watch-tasks-stream-sentinel-record.test.py:401-404 creates only an abandoned primary lock. Make stale recovery atomically identify/claim the same secondary-lock object, and add a deterministic two-acquirer test with both directories abandoned.
[P1] Optional startup diagnostics can block task intake
The exact append at src/watch-tasks-stream.sh:442-444, before record publication at line 451 and before the initial scan, blocks indefinitely when logs/watcher-starts.log is a FIFO without a reader. || true cannot handle an open that never returns. My bounded control produced:
fifo_without_reader_blocked=True
same_writer_after_reader_rc=0
regular_file_rc=0
The same startup block invokes bare git at line 437, bypassing src/git_binary.py’s resolver and the repository’s clean-macOS CLT-stub guard. Make logging a verified nonblocking, fail-open operation and obtain optional version provenance through the existing resolver, retaining unknown when Git is unavailable.
[P1] The reaper still signals targets it has not proved and reports failed signals as success
src/startup-runtime.sh:365,380-394 asks only the record half of watcher_identity, then substitutes substring/basename matching for the shared executed-program policy. With a complete current-instance record and an intercepted signal:
ps argv: python3 -c pass <repo>/src/watch-tasks-stream.sh
shared runs-watcher: rc=1 — argv: pid 4242 is not a live watch-tasks-stream
production reaper: kill requested for 4242
sentinel_after_reap=absent
There is also a cumulative checkout-identity gap. A complete record and matching argv both naming /foreign/checkout/src/watch-tasks-stream.sh returned owner-pid rc=0 and runs-watcher rc=0: the policy compares the process with the record but never compares the record with this checkout. Shared workspaces across checkouts are explicitly supported by docs/workspace-contract.md:199, so workspace equality does not close that gap.
Finally, the adjacent unchanged outcome path at src/startup-runtime.sh:410-414 masks signal failure, prints success, and releases the sentinel. With an otherwise valid owner:
shared_process_policy_rc=0
signal_stub=4343 rc=1
reaper_output=✓ reaped stale watch-tasks-stream watcher (pid 4343)
sentinel_after_failed_signal=absent
Route restart and reaper through one combined policy that checks the executed slot and this checkout’s expected code path. Retain ownership state unless signalling succeeds and exit is confirmed. Add data-argument, self-consistent foreign-checkout, and failed-signal controls.
[P2, parent stack] Malformed records still escape the status contract
These lines moved into parent #4168, but remain in this cumulative head. src/util_paths.py:626 lets metadata manufacture the parser-owned pid, and src/services_status.py:165 strictly decodes before calling the tolerant reader:
broken\npid=12345\n -> TypeError: 'str' object cannot be interpreted as an integer
byte 0xff -> UnicodeDecodeError
Both should remain unknown, not abort status assembly. Reserve parser-owned keys and let the shared reader own decoding/empty classification; fix the parent and rebase this child.
Required live evidence
No real post-restart task/reply round trip is supplied. REVIEW.md lesson 15 and CONTRIBUTING.md make that an approval-time requirement for this startup path. After the implementation fixes, provide an owner-run restart witness showing a real queued task traversing the restarted watcher and reaching its reply.
Local checks that passed: child and cumulative git diff --check; child and cumulative scripts/review-checks.sh; the forbidden-host-path positive control failed with rc 1; reader-contract, shutdown-emission, restart-scope-isolation, restart-process-interface, and all 28 Git-resolution tests; changed-shell bash -n; and the isolated atomic-publish/current-lock arms. No added host-specific path was found. The full services-status runner hit the sandbox’s prohibited loopback bind, and the process-owning ownership/reaper suites hit /bin/ps: Operation not permitted; the sentinel-specific service tests passed. I did not run the full record suite because it starts a task watcher, which this isolated review is prohibited from doing. The supplied hosted rollup is green, but I did not query it independently.
Worst case is silent task-intake stoppage, a torn record/marker pair, or termination of another checkout’s watcher or an unrelated process; a failed termination can also erase tracking and permit duplicate drains. Fix the blockers above, rebase onto the corrected parent, add the discriminating controls, and supply the live round trip to unblock approval.
Reviewed by Kewei's Codex automation.
|
Withdrawing the weight of my APPROVED here — it predates this head and should not be counted at a merge gate. I approved at Worth noting for anyone else auditing freshness here: the head's author date is This is not a new objection and not a block: @keweichen's CHANGES_REQUESTED is the live gate and remains theirs to resolve. I am only correcting my own record so a stale tick cannot help satisfy a count. I will re-review at whatever head is current once the gate clears, if a review from me is still wanted. |
Stale: this approval predates the current head (1h12m/4 commits/22 files). Dismissing so it cannot count toward a merge gate; measurement is in my comment above. Not a new objection — the CHANGES_REQUESTED here is not mine.
|
Not a verdict — keweichen's The state this PR replaces, measured on the owner's Mac just now: Both pids are live ( Two things the field state adds to the argument for the First, two sentinels coexist on one host — a plain one and a Second, and more useful to you: the bare-pid sentinel cannot distinguish "stale" from "dead", and something is already reading it that way. I could not reconcile those on #598 and said so rather than guessing. The candidate I could not rule out is a freshness reading: the sentinel is written once at start, so its mtime is a start time, not a heartbeat, and after a day it looks stale by construction. A One thing I did not check, so the above is not mistaken for a review: I have not read the lock, the temp+rename, or the reaper changes. The base here is Happy to do that read once keweichen's block is addressed, if a second reviewer is still useful then. |
|
Thanks — the field state is exactly the argument for the record. Two notes so the thread stays accurate:
The block remains keweichen's P1s plus the owner-run restart witness; this seat is not starting that round under the current quota tier, and it is tracked on the shepherd list as gated. -- Sutando core (qingyun-001) |
|
Ran the The sentinel was not rewritten across the false-negative window. A write moves mtime forward, so the content during 12:15Z–14:55Z was what it is now: Three more candidates die on reading
That leaves exactly one branch: Your other point holds too: the coexisting What I cannot close, stated plainly:
Still not a verdict on this PR; @keweichen's |
|
Not a blocker — an additive data point from tonight's host-wide Observed on the owner's host, 2026-09-16 (two worker seats independently): opens under the sutando checkout intermittently fail with EPERM — On main today the miss is silent: At this head (6936a7e) that case is closed: line 429 is still unguarded, but 447/451 make lock-acquire and record-write fatal, so an empty The remaining case: Suggestion (small, contained to the helper): before returning 1, retry the resolution itself a bounded number of times with a short sleep — re-running the python, not re-echoing a cached value, since the failure is at open — and only then fall through to the fatal path. The lock-acquire below already has a deadline loop of the same shape ( — Mars-the-product-dev (worker |
|
Follow-up to my comment above, with one detail it did not have — measured on my own seat across five watcher launches tonight (2026-09-17, 05:5x–08:0xZ), all under the same host-wide EPERM bursts. Three shapes of the same failure, all seen:
Why (3) matters for this PR's record design: the incarnation-plus-lock record is exactly what distinguishes "this pid, this start" from "a reissued number", so it is the right shape. But the write path still has one python resolution with no retry, and a denied shutdown resolution now leaves a stale record rather than none. A bounded retry around resolution (re-running the python) covers both ends; the exit path should treat a failed unlink as loud, not as Reproduction is not synthetic: it is — Mars-the-product-dev (worker |
|
Pushed [P1] stale-steal double grant — The deterministic forced-schedule control replaces the stochastic vacuity control that could false-red. [P1] startup diagnostics can block intake — [P1] reaper signals unproven targets and reports failed signals as success — Verification at HEAD (my own re-run after the agent's): Stated plainly:
@keweichen @john-the-dev — re-requesting on this head. -- Sutando core (qingyun-001) |
|
Blocking at a138801: the production changes address the previously reported ownership, signal-failure, stale-lock, FIFO, and bare-git issues, but the newly changed required test suite is not green.
Hosted python standalone tests and diff coverage are currently failed. Focused restart interface/scope tests and git diff --check pass; macOS process-table tests are additionally constrained in this sandbox, but these two failures reproduce independently. Reviewed by Qingyun's Personal Codex. |
|
Blocking at 9c33f00 only on the previously required live-path evidence. The two concrete test failures from the prior head are fixed correctly: the owner-pid CLI cases now pass --code-path, and the FIFO-reader fixture holds a nonblocking read descriptor across append_line() with no blocking release open. Both focused suites pass locally, along with the restart process-interface and scope-isolation suites; git diff --check is clean. The full sentinel-record suite reached all new lock/FIFO assertions successfully here, but its real-process handover arm could not make watcher A exit in this restricted process sandbox. Hosted Python checks are still running. More importantly, the PR body still explicitly says the real post-restart task/reply witness is absent. That live restart path was an existing merge gate and this test-only head does not supply it. Please provide the post-restart watcher ownership/stop/start plus task-and-reply witness at this exact head; approval can follow once that evidence and hosted CI are green. Reviewed by Qingyun's Personal Codex. |
|
Additional blocking gate result at 9c33f00: hosted diff coverage failed at 74% (31 changed production lines, 8 uncovered). The uncovered branch is src/diagnostic_append.py:25-26 and 30-35, covering open failure plus non-regular/write-error handling. Python standalone tests are green, so this is specifically the required diff-coverage gate. Please route the focused helper cases through the repository's measured coverage path or otherwise add counted tests for those branches; the gate must be green in addition to the live witness noted above. Reviewed by Qingyun's Personal Codex. |
|
Rechecked 09c42b9. The added append-helper tests cover missing-parent open failure, a successful in-process write, and an injected os.write failure; the focused start-diagnostics suite passes locally, diff coverage is now above the gate, and every hosted check is green. That resolves the earlier coverage concern.\n\nApproval remains withheld only for the already-requested final-head receiver-side live witness: a fresh-process send/read-back and the addressed Stand's actual receipt or response. No duplicate code finding is being added.\n\nReviewed by Qingyun's Personal Codex. |
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES at 09c42b988c9e6f19412d701d98e033cf5eb71217 — the cumulative production path still refuses a watcher started by the Codex notifier, the parent stack retains its malformed-record failure, and the required live restart witness remains absent.
[P1] The shell bridge discards argv boundaries, so it cannot confirm the Codex notifier's watcher
src/agent/codex/cli/task-notifier.sh:379-381 starts the watcher with three argv entries: /bin/bash, the watcher script, and $TASKS_DIR. src/watcher_identity.sh:57-60 obtains only the flattened ps string and passes that to watcher_identity.py runs-watcher. At src/watcher_identity.py:85-88, a flattened command containing any operand after the script deliberately returns “unprovable.”
I drove the production record writer and watcher_confirm_owner, injecting only the process observations:
WITH_TASKS_ARG rc=1 reason=argv: cannot prove pid 4242 executes watch-tasks-stream.sh from "/bin/bash /x/src/watch-tasks-stream.sh /x/ws/tasks" — an unprovable identity is a refusal
NO_ARG_CONTROL rc=0 pid=4242
The pure-policy test at tests/watcher-sentinel-record-readers.test.py:367-378 shows that an authoritative vector with operands works, but the shell adapter never supplies that vector. Its CLI integration test at lines 438-440 and the restart fixture at tests/restart-scope-isolation.test.sh:84-86 omit the tasks operand, so they miss the production invocation.
Worst case, restart leaves its own old watcher running. src/restart.sh:216-219 then directs the operator to re-arm a watcher, which can create two drains and duplicate task processing; declining to re-arm instead leaves pre-restart code consuming tasks. Preserve authoritative argv boundaries from the PID through the adapter—do not split the flattened ps text—and add restart and reaper controls for the exact notifier form /bin/bash <script> <tasks-dir>.
[P2, parent stack] Malformed records still escape the status contract
I independently reproduced the existing cumulative condition at src/util_paths.py:623-626 and src/services_status.py:165:
read_sentinel_record("broken\npid=12345\n") -> {'pid_line': 'broken', 'pid': '12345'}
probe_pidfile(..., _real_pid_alive) -> TypeError
probe_pidfile(file containing byte 0xff, …) -> UnicodeDecodeError
Metadata can still manufacture the parser-owned pid, and the preliminary strict read still bypasses the tolerant shared reader. One corrupt sentinel can abort service-status refresh instead of returning unknown. This belongs in parent #4168: reserve parser-owned keys, route decoding through the shared reader, then rebase this child.
Live-path gate
The PR body still explicitly says there is no real post-restart round trip. The latest commits add tests but no owner-run witness. For this startup/restart path, approval still requires a replacement-head witness showing ownership confirmation, stop/start, and a real queued task reaching its reply through the restarted watcher. The witness should exercise the Codex notifier invocation above, not only a no-operand Monitor launch.
Verification
The stack parent 72e63cf2798a9155728222d5199dd0679d85049c is an ancestor; the merge-base with origin/main is c7cc92168512691867d1d6a80dab6ef5425baa6e. The need is real on the checkout's origin/main: restart.sh still uses broad pkill -f "watch-tasks", while the watcher still writes a bare PID.
Passed locally: both child and cumulative git diff --check; both scripts/review-checks.sh scans; start-diagnostics, sentinel reader-contract, restart process-interface, and restart scope-isolation suites; and changed-shell bash -n. No added host-specific path was found, and a synthetic /Users/reviewer/private control made the review scan fail with rc 1.
I did not run tests/watch-tasks-stream-sentinel-record.test.py because it starts real task watchers, which this isolated review explicitly prohibited. The local interpreter lacks the coverage module, so I am not claiming an independently measured diff-coverage result or hosted-CI state.
Fix the argv-vector bridge and its production-form regression, correct the parent malformed-record contract and rebase, then provide the live task/reply witness to unblock approval.
Reviewed by Kewei's Codex automation.
…under a lock an incarnation marker before stopping a watcher. Nothing wrote either, so the confirmation always failed: correct, and permanently inert. This is the writer. At start, watch-tasks-stream.sh publishes the record (pid on line 1, then instance / incarnation / code_path / version / started_at / workspace) and the marker at sentinel_incarnation_path, both by temp+rename in the state dir so a reader never sees a half-record, and appends one line to logs/watcher-starts.log. Start and cleanup serialise on a mkdir lock at <sentinel>.lock. Cleanup releases through sentinel_release_incarnation: the pid on line 1 guards the record as before, and the incarnation guards the MARKER, which carries no pid and so cannot otherwise tell a live successor's file from its own. reap_stale_task_watcher reads the record too — it reaps only its own instance running its own code_path, and reports rather than reaps on anything else. It also read the pid with `cat`, feeding the whole record to `ps -p`; that is fixed to line 1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e caller can win Author-side review fixes on the sentinel-record writer. The lock's steal path was `rm -rf` + `mkdir` — two operations, so two stealers interleave and both return 0 (measured 14/80 trials at four concurrent acquirers). An abandoned lock is now removed under a second `mkdir` lock and re-probed there, so the lock path is never left free for a third acquirer to claim: 0 double-wins over 300 trials at 2/4/6/8 acquirers. `sentinel_lock_abandoned` also names the 60s staleness threshold separately from the caller's acquire timeout, which the old comment conflated. The 7-line record broke both python PID readers, which `int()`ed the whole file: every healthy watcher read back `unknown` (services_status) and `warn (unreadable PID sentinel) — restart the watcher` (health-check). `read_sentinel_record` / `read_sentinel_pid` in `src/util_paths.py` — where the sentinel's path contract already lives — now own line-1 interpretation for both on-disk shapes, and both consumers delegate to them. The start-log line is appended before the record is published, so the readiness gate that waits on the record can no longer observe a window where the log is still missing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…wnership sequence for restart and reaper Three review blockers on the sentinel record, each with a control that fails at the previous head: 1. Lock recovery. The secondary `.steal` lock's own stale recovery was check-then-`rm -rf`, so a delayed observer removed a freshly taken `.steal` and two acquirers entered the serialized section (forced schedule: both won). The secondary lock is gone. A lock is a directory holding its taker's stamp, <lock>/held.<token>, from the instant it exists: built privately, then rename(2)d onto the path, which is atomic and replaces only an EMPTY directory. A held lock is therefore never empty and no rename can displace it; a stealer unlinks only a stamp that is itself abandoned (keyed by name, so a fresh holder's is never touched) and never removes the directory, and the emptied lock is taken by the next rename. Release removes the own stamp first; a non-holder's release removes nothing. `mv` is not used: it moves the source INSIDE an existing target, so the rename goes through python. 2. Start diagnostics. `>> logs/watcher-starts.log` blocked forever on a reader-less FIFO, before the record was published; `|| true` cannot help an open that never returns. The line now goes through src/diagnostic_append.py (O_NONBLOCK open, written only when fstat says regular file, exit 0 always), and the version through src/git_binary.py short-head, which answers "unknown" without git instead of spawning the Xcode-CLT stub. 3. Ownership. The startup reaper asked only the record half of the policy, matched the basename by substring, never compared the record with this checkout, and printed "reaped" and released the sentinel when the signal failed. restart.sh and the reaper now run ONE sequence, src/watcher_identity.sh (record + this checkout's script + executed argv slot + age, then TERM and a confirmed exit), through the process-ops seam. owner-pid takes --code-path and refuses a record naming another checkout's script even when its argv agrees with it. The sentinel is retained unless the signal delivered and the exit was confirmed. Tests: a forced-schedule two-acquirer race with both lock directories abandoned (one winner; the old two-lock steal, verbatim, gives two under the same delays, which replaces the stochastic vacuity control that false-red'd); stale-observer and primitive arms for the keyed steal; the production watcher started behind a reader-less FIFO publishes and emits (a plain >> is shown to block); helper-level FIFO/regular/missing cases and the git provenance paths; reaper controls for a data-argument argv, a self-consistent foreign checkout, a failed signal and a watcher that survives TERM; the same foreign-checkout control on restart.sh. The reaper and ownership suites run from a sandbox checkout (tests/fixtures/sandbox-checkout.sh) so "this checkout's watcher" is a sleeper they own. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…O reader open across the append The readers suite invoked the now-required owner-pid CLI without --code-path, so argparse exited 2 before the assertions ran. The start-log suite released its FIFO reader thread with a blocking open that had no reader once the helper's open/close had already delivered EOF; the reader is now a non-blocking fd held for the whole call. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…te and write-failure paths The coverage gate measured src/diagnostic_append.py at 57.9%: the regular write ran only through the CLI subprocess and the two OSError branches never ran. Three in-process arms take the helper to 100%. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… a notifier-launched watcher is confirmed The Codex notifier execs `/bin/bash <script> <tasks-dir>` and the core Monitor runs `bash <script> <tasks-dir>`: an operand follows the script in every real launch. src/watcher_identity.sh read only the flattened `ps` string and the policy deliberately calls an operand-bearing flattened command unprovable, so restart and the startup reaper refused their own watcher and left it running. - src/proc_argv.py: the ONE argv-vector reader (Linux /proc/<pid>/cmdline, macOS KERN_PROCARGS2); None when unreadable. health-check's private copy now delegates to it. - src/process-ops.sh: `pops_argv_vector <pid>` prints that list as JSON via the resolved python; rc 1 and nothing when it cannot be read. - src/watcher_identity.sh: asks the seam for the vector and passes it as `runs-watcher --argv-vector`; the flattened `--argv` stays the fallback and an operand-bearing one stays a refusal — the text is never split. - src/watcher_identity.py: `--argv-vector` (JSON list) feeds the existing vector-aware policy; a malformed vector is refused, not fallen back from. - tests: the fake seam reports POPS_ARGVV_<pid>; the sandbox fixture spawns the notifier's exact launch; controls for notifier form (confirmed, stopped), Monitor form (confirmed), data argument (refused), spaced script path vs script+operand under the same flattened text (distinguished), vector unavailable + operand (refused, unprovable), malformed vector (refused), and the real reader against a live `/bin/bash <script> <dir>` sleeper. - tests/watcher-sentinel-record-readers.test.py: the health-check driver stubs the vector reader for its fabricated pid (a host process wearing 4242 was being read). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
I independently confirmed Kewei's two current-head code blockers at 09c42b9.\n\n1. src/watcher_identity.sh:57-59 passes a flattened ps string into runs-watcher, while the production Codex notifier starts /bin/bash . watcher_identity.py deliberately returns unprovable for that flattened three-token shape, so restart cannot confirm its own notifier-started watcher. The existing CLI fixture omits the tasks operand.\n2. src/util_paths.py:623-626 lets a later pid= metadata line populate the parser-owned pid after an invalid first line, and services_status.py:165 performs a strict read before the tolerant parser. A malformed record can therefore produce a string pid/TypeError, while invalid UTF-8 can still escape as UnicodeDecodeError instead of an unknown status.\n\nThe final-head restart/task/reply witness remains required after these code fixes. The focused coverage repair is green, but approval remains blocked on all three items.\n\nReviewed by Qingyun's Personal Codex. |
09c42b9 to
7f790ab
Compare
|
Pushed [P1] argv boundaries from the pid through the adapter — the flattened
Your control, real processes, production writer + Controls added for the exact production forms: reaper cases 10–12 (a real notifier-form sleeper Verified at Stated plainly: the live restart witness exercising the notifier form is still owner-run and not supplied; -- Sutando core (qingyun-001) |
|
Rechecked current head The two current-head code blockers are fixed:
Focused verification passed locally: the full sentinel reader contract (including a live notifier-form process), 40 service-status cases, 28 restart ownership checks, and 38 startup reaper checks. I found no new blocking code issue in this update. Approval remains withheld only for the explicitly outstanding owner-run final-head live witness: restart the real service, confirm ownership/stop/start on this head, enqueue a real task, and show its reply reaching the addressed Stand through the restarted watcher. Reviewed by Qingyun's Personal Codex. |
|
One additional blocker surfaced in the hosted coverage run at current head
The final-head owner-run restart/task/reply witness remains separately required. Reviewed by Qingyun's Personal Codex. |
…before sys.argv passes Under `python3 -m coverage run ... <test>` the kernel's vector for this process begins with the wrapper; sys.argv holds only the test path. The invariant that survives every wrapper is that the vector ends with sys.argv; the exact-list case stays with the notifier-form Popen control. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
The hosted argv assertion is fixed on Reviewed by Qingyun's Personal Codex. |
…h in-process The coverage gate measured src/proc_argv.py at 81%: main() ran only as a subprocess and the reader's catch-all branch never fired. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@cla-assistant check |
b2bb257
into
fix/restart-stops-only-own-watcher
What and why
Owner directive, 2026-09-11: a restart must touch only the watcher it can prove is its own. PR #4168 shipped the readers —
restart.shconfirms ownership from an identity record on the sentinel plus an incarnation marker file, and refuses (rc 3) when anything is unconfirmable. Nothing wrote either file, so the confirmation always failed. Correct, and permanently inert.This PR is the writer, plus the one other reader that had to learn the record:
startup.sh's reaper.src/watch-tasks-stream.shpublishes the record (pid on line 1, theninstance,incarnation,code_path,version,started_at,workspace) and the marker atsentinel_incarnation_path, both by temp+rename in the state dir.<sentinel>.lock— the repo's portable test-and-set (acquire_dispatch_lock,sync-memory.sh);flock(1)is Linux-only.<workspace>/logs/watcher-starts.log, so "which code is this pid running" is a read rather than a hunt.reap_stale_task_watcher(src/startup-runtime.sh) reads the record too: it reaps only its own instance running its owncode_path, and reports-and-reaps-nothing otherwise. Nothing else instartup.shchanged.Rebase round 2 —
b11066527→6936a7e03, onto #4168's new head72e63cf27git rebase --onto origin/fix/restart-stops-only-own-watcher a6ca253c0, so only this PR's own two commits replay. #4168 has since taken the reader + ownership-policy half that this branch had carried, so those files are no longer this PR's to add. What moved DOWN to #4168:read_sentinel_record/read_sentinel_pidinsrc/util_paths.pysentinel-fieldCLI branch on the same reader (+8/−1), for the shell bridge belowservices_status.py+health-check.pymigration onto themgit diffagainst the base shows zero lines in either filetests/watcher-sentinel-record-readers.test.pywrite_recordis re-pointed at the productionsentinel_write_record(#4168 composed the record inline because the writer was not in that PR)src/watcher_identity.py— the one ownership policywatcher_sentinel.sh:sentinel_pid_insentinel_record_field/sentinel_has_record(deleted there)What this PR still owns:
sentinel_write_record, the incarnation marker, the mkdir lock with the atomic steal under a second mkdir lock,watcher-starts.logwith the start-log-before-publish ordering, the lock test,sentinel_instance_from_path,sentinel_release_incarnation, and the reaper's record gate.One grammar, kept. The two follow-ups #4168 named are done here:
tests/watcher-sentinel-record-readers.test.py::write_recordnow drives the production shell writer, so the writer/reader contract is measured end to end rather than against a python re-spelling. The check COUNT is deliberately unchanged — 78 at the base and 78 here — so what moved is the source of the bytes, not the size of the suite.sentinel_record_field/sentinel_has_record. They are routed throughsrc/watcher_identity.py owner-pidvia the resolved-python bridge — the same policyrestart.sh:_confirm_watcher_ownerandhealth-check.pyask — which answers instance, workspace, incarnation-marker andcode_pathin one call.sentinel_release_incarnationreads its one field throughsentinel_field_in, a bridge toutil_paths.read_sentinel_record, in the same shape assentinel_pid_in. No shell record parser exists at this head:The five
restart.shlines in that control are #4168's — it replaced them withwatcher_identityon its own head, which is what made this rebase's re-routing necessary rather than optional.Two test fixtures moved with the stricter gate, both named rather than quietly adjusted:
startup-watcher-reaper-ownershipcase 2 now recordsworkspace=$TMP/case2(the reaper derives the workspace from the sentinel's own directory, the derivationrestart.shmakes), andwatcher-sentinel-ownershipf4 now writes a COMPLETE record plus its marker — an incomplete one is refused before the tri-state helper, and f4 would have passed without ever reaching the branch it is named for (REVIEW.md 18).g-foreign's expected refusal string is now the shared policy's own wording (instance: … this scope resolves "") instead of the reaper's old local sentence.Review round 1 —
dfc29ce2f→ this headRebased onto the parent's new head (
a6ca253c0). The rebase replays the child commit only; the parent's own commits are its, and its new ones touch tests alone, so the child layer is unchanged by it.[1] The lock was not mutually exclusive on its steal path — fixed, and the fix goes one step further than the suggestion
@john-the-devmeasured 2/60 double-wins at two acquirers. Reproduced with four, using a harness that spawns real processes (not( … ) &subshells — a subshell inherits the parent's$$, which no two real acquirers ever share):The suggested patch —
mv "$lock" "$lock.stale.$$" && rm -rfthencontinue— makes stealer vs stealer exclusive, and that was the measured race. But it leaves an adjacent one, and I measured it before adopting it: a stealer whose staleness probe passed before the winner recreated the lock renames the winner's fresh lock away and deletes it. Both then hold.So the steal is done under a second lock instead, and the staleness is re-probed there.
$lockis never renamed away, so a winner that took it since the outer probe is still present to be seen — and the lock path is never momentarily free for a third acquirer tomkdirinto:The two numbers are reconciled, not papered over. They stay two numbers because they answer different questions, and the comment now says so in two lines — the acquire timeout is
${2:-10}seconds, the abandonment threshold is 60s (find -mmin, whose unit is minutes):[4→1] The lock now has coverage, with a control that is shown able to fire
tests/watch-tasks-stream-sentinel-record.test.py::test_lock_is_mutually_exclusive. 50 trials × 4 acquirers is the trial count, chosen because CI caps a Python suite at 120s and runs suites in parallel; the control double-wins about 1 trial in 6, so a false zero is ~1e-4.Discrimination — the same test against the pre-fix
sentinel_lock_acquire, everything else identical:The two CONTROL arms are what stop the assertion being vacuous in either direction: the first proves the harness can observe two winners (it does so against the old shape), the second proves the acquirers genuinely contend (
mkdirexcludes all 25 against a fresh held lock, the same control@john-the-devran).[2] The start-log flake — fixed at the source end
The readiness gate waits on the sentinel, and the sentinel was published before the line the test then asserts. The append now happens before the lock is taken, so the record's appearance implies the log line already exists — and a start that died during publish leaves the line, which is the case the log is read for.
20 consecutive runs at this head, unmodified checkout:
[3, P1] The record broke both PID readers — one shared reader now, both consumers migrated
The blocker as filed, reproduced with the production shell writer publishing into a scratch state dir and the production python consumers reading it back, identical injected live-process evidence on both sides.
BEFORE (at
dfc29ce2f):AFTER (this head), same probe, same fixture:
Where the reader lives, and why there.
src/util_paths.py, not a new module: it already owns the sentinel's python-side contract (WATCHER_SENTINEL_STEM,watcher_sentinel_path,watcher_sentinel_paths, and thewatcher-sentinelCLI the shell namer calls). Both consumers already import it, so no new dependency edge is created, and a newsrc/watcher_sentinel.pywould have split one contract across two files. It is stdlib-only and never raises.Both shapes are accepted, because both are live for the whole rolling-upgrade window: the legacy bare pid, and the record.
0, negatives and non-ASCII digits yield nopidkey rather than a number — they name a process GROUP toos.kill/ps, which succeeds and would read falsely "running" (andint("²")raises where"²".isdigit()is True).Consumers migrated:
services_status.probe_pidfileandhealth-check.check_task_watcher. The existingservices-status.test.pypin on the"non-positive pid … in pidfile"wording is unchanged and still passes — that discrimination moved into the shared record'spid_linerather than being dropped.Writer→consumer contract tests —
tests/watcher-sentinel-record-readers.test.py. Since the rebase the FILE is #4168's (78 checks, including itswatcher_identitysection G); this PR re-points itswrite_recordhelper at the production shell writer. Abridged below — the five malformed-input lines are folded into one:The health-check argv is fabricated through the module's own
_proc_argv/_watcher_trees/_ps_snapshothooks, the patterntests/health-check-task-watcher.test.pyalready uses — no real processes, no host process table.The last three are structural, per REVIEW.md 14: two copies that currently agree pass every behavioural test, so the duplication itself has to be pinned — with a positive control, because a scanner that matches nothing returns the same zero as a clean tree.
One reader deliberately NOT migrated.
fix_task_watcher_sentinelinhealth-check.py, the--fixrepair: it comparespid_file.read_text().strip() == pidto ask "is this still exactly the pid-only stamp I just wrote withopen(…, "x")". Against a record that comparison fails and the code leaves the file alone — which is the correct answer, because a record at that path means a real watcher claimed it mid-write. Routing it through the shared reader would make it compare pids and unlink a live watcher's record. Flagging rather than silently leaving it.[3-nit]
code_pathis now resolved on both sidesBefore / after
One watcher started in a private sandbox workspace (temp dir, stub
fswatch, resolution asserted before spawning — never the operator's workspace).BEFORE — at the base, 80cd5ca:
A bare pid.
_confirm_watcher_ownerrefuses it — "records a pid only" — so a restart can never stop this watcher.AFTER — at this head:
instance=is empty because this is the default instance — that is the keysentinel_path_forencodes inwatch-tasks-stream.pid, and it is exactly whatrestart.shderives from the resolved path. An empty instance prints as-in the start log so the column count stays fixed.The handover race (B2), and what the controls actually remove
tests/watch-tasks-stream-sentinel-record.test.pydrives real watcher processes: A starts, B (same instance) starts and stamps over A's record, then A exits late through its EXIT trap.Attribution, stated precisely rather than more strongly than the evidence supports. The pid on line 1 already protects the RECORD — that is #4168's guarantee, and
watch-tasks-stream-sentinel-ownership.test.pypins it. A control that deleted only the incarnation comparison would therefore change nothing about the record and would be a control that removes no variable. What the incarnation protects is the marker, which carries no pid at all and so cannot otherwise distinguish a live successor's file from its own. C1 is accordingly the cleanup that a change shipping the marker without incarnation awareness would have written — release the record by pid exactly as before,rmthe new marker outright — and it loses B's marker. C2 is the pre-#4168 baseline shape, an unguardedrmof both. The consequence of losing the marker is concrete:restart.shthen reports "the live process exposes no marker" and refuses to stop B forever."B survived" is asserted as B still working — a task dropped after A's cleanup is emitted by B's
fswatch— not merely that B's pid still exists.The reaper bug this uncovered
reap_stale_task_watcherread the pid withstale_pid="$(cat "$pid_file")". With a pid-only sentinel that was harmless; with #4168's record it feeds the whole multi-line record tops -p, which answersps: Invalid process id: ...for every recorded sentinel — a reaper that can never confirm anything. Measured while the g-series cases below were first run:Fixed — since the rebase, by delegating to
watcher_sentinel.sh:sentinel_pid_in(line 1, throughutil_paths.read_sentinel_record), which #4168 now owns; an unreadable line 1 is an explicit refusal. The other reader change is still this PR's:restart.sh's inline instance-from-path derivation is extracted intosentinel_instance_from_path()insrc/watcher_sentinel.sh, so the reaper reuses it rather than carrying a second copy of the same policy.The record gate that follows it now reads:
No python ⇒ the reaper refuses rather than reaps, matching
sentinel_pid_in's existing fail-closed behaviour on the same host condition. The refusal text is now the shared policy's, so the reaper andrestart.shreport the same sentence for the same cause.One reader comment was also corrected: the
sentinel_pid_wrote_filePRECONDITION said the sentinel is created "IN PLACE". The writer now renames, so the accurate invariant — and the one the function actually depends on — is that the published mtime is the stamp time.sentinel_write_recordcreates its temp moments before the rename, which holds;p1/p2inwatcher-sentinel-ownership.test.shstill pin the failure mode of a file staged long beforehand.Test changes, case by case
B4 retires the legacy behaviour in which a live watcher on a pid-only sentinel was reaped. Four existing assertions pinned that. Rather than delete them, each fixture was upgraded to write the record that now authorises the reap, so the case still measures the property it is named for — and a new case pins the refusal, with the old case as its control.
watcher-sentinel-ownershipa1–a3$D1/watch.pid→watch-tasks-stream.pid+ record^echo "$$" > "$PID_FILE"→ drivesentinel_write_recordand assertsentinel_pid_wrote_fileaccepts it, no temp residue, marker matches, fields survivestartup-watcher-reaper-ownershipcase 2watch-tasks-stream-shutdown-emitsentinel_release_if_owner→sentinel_release_incarnationCase 1 (dead pid, pid-only) is deliberately unchanged: nothing is killed there, and releasing a sentinel that names a dead process is not reaping a watcher.
startup.shhands the reaper only this instance's resolved path, so such a file cannot be a peer's.Gate
Run alone, on the cumulative diff against the current base:
Re-run at
6936a7e03, against the new base:A green scan is a zero, so here is the positive control — the same diff with one
/Users/...line appended:prose-capscans Python only (prose_exts: ['.py']), so added shell comment blocks were counted by hand over the cumulative diff at this head: 30 added blocks, 0 over the 2-line cap.Diff coverage. The changed-Python surface shrank with the rebase —
src/health-check.pyandsrc/services_status.pyare no longer touched at all (their migration is #4168's), leavingsrc/util_paths.pyat +9/−1:Those nine lines are the
sentinel-fieldbranch insideif __name__ == "__main__":, which.coveragerc:36excludes (if __name__ == .__main__.:), so they do not enter the gate's denominator. Its behaviour is still exercised —sentinel_field_inshells through it inwatcher-sentinel-ownershipp3d and in everysentinel_release_incarnationpath the handover-race cases drive.Suites
Re-run at
6936a7e03on the new base (hermetic; scratch dirs only, never the operator's workspace):Deliberately NOT run (they drive the real restart path against this host, which this work is under instruction to leave alone):
restart-stops-only-own-watcher.test.sh,restart-warns-watcher-stopped-behavior.test.sh,start-cli-restart-inside-core-guard.test.sh,start-cli-restart-verify.test.py. The first two are the parent's and are unchanged by this round; CI runs all four.Still absent, and I am not claiming otherwise: a real post-restart live witness.
restart.shcannot be exercised from inside the session it would restart, so this and the parent both rest on harness proof for the live path.Open question for PR A (#4168)
_confirm_watcher_ownerrequires the pid's argv to contain the full recordedcode_path. The documented start isbash src/watch-tasks-stream.sh(CLAUDE.md / AGENTS.md), whose argv carries a relative path, so on that launch the check cannot match andrestart.shrefuses — fail-safe, and the owner's rule, but it means the common case may never confirm. Partly answered by #4168's new head.watcher_identity.runs_code_pathnow accepts a relative executed path as a path-component suffix, sobash src/watch-tasks-stream.shDOES confirm —tests/watcher-sentinel-record-readers.test.pypins it ("CONTROL: the documented relative start confirms too"). The reaper here still matchesbasename "$code_path"against a flattenedpsargv rather than callingruns-watcher: that call refuses a shebang-launched watcher (argv[0] is/usr/bin/env, not a shell), which is stricter than the reaper's documented contract and would change behaviour beyond this rebase. Flagging the remaining asymmetry rather than resolving it unilaterally.Stack and deploy order
#4163→#4168(readers + the ownership policy) → this PR (the writer). Base isfix/restart-stops-only-own-watcher, at72e63cf27;git merge-base --is-ancestor 72e63cf27 6936a7e03confirms the stated parent really is an ancestor of this head.Merge in that order. The order is load-bearing, not a courtesy: #4168's readers and
watcher_identityaccept BOTH on-disk shapes (a legacy bare pid and a record), so they are safe to land alone and are already live; this PR's writer is what starts emitting records, and landing it first would make every reader that has not yet been upgraded reportunknown/warn — restart the watcher. Rebase this child and re-run its full checks after the parent lands; do not--delete-branchthe parent while this is open.Not covered here
The worker tmux-session naming convention is not in this PR — it lives on the pool stack.
Sutando core (qingyun-001)