Repository navigation
fix(watcher): a session watcher exits when the process that started it is gone - #5801
Conversation
…t is gone A core killed with SIGKILL runs no Stop hook, so its Monitor-started watch-tasks-stream.sh was reparented to launchd and kept holding the inbox as role=session. A revived core's /startup then saw WATCHER_HELD and asked the owner before it could take the inbox; tasks waited until the orphan's next stdout write failed (~7 min in a desktop VM run, engine 4af900a). A --role session watcher now polls every 3s whether its parent pid has changed since start and, if so, TERMs itself through the existing cleanup (claims settled, sentinel released, fswatch stopped). Standby watchers are not checked: their notifier is their supervisor, and nothing else needs it.
Coverage Gate✅ No Python changes — nothing to gate (bar: 95% on changed lines). |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed at 76d6fce. The mechanism is sound where it applies: the ppid change is checked against the start value, an empty ps reading never kills, the loop exits once the watcher is gone, and cleanup() stops the loop. Test (c) keeps standbys out of scope. But the parent it watches isn't always the core.
On a live Claude core, the session watcher's parent is Claude Code's shell wrapper, not Claude. My core runs under tmux, and its Monitor-started watcher's ancestry is:
tmux server (10800, ppid 1)
└─ claude 17723 pgid 17723
└─ /bin/zsh -c source …/shell-snapshots/snapshot-zsh-….sh … && eval '…' 17743 pgid 17743
└─ bash …/watch-tasks-stream.sh --role session --inbox …/tasks 17750 pgid 17743
WATCHER_START_PPID is therefore 17743, the wrapper. When claude is SIGKILLed, the wrapper is orphaned but stays alive, waiting on its child. It sits in its own process group, so the pane's SIGHUP to claude's group (17723) doesn't reach it. The watcher's ppid never changes and the new loop never fires. The new test and the round trip both start the watcher directly under the parent that gets killed, so they don't cover this layout.
The VM run saw ppid→1 on the watcher itself, so on that host the wrapper either wasn't there or died with the core. Which of the two it was decides whether this fixes the incident in general.
Suggestions:
- Watch the nearest ancestor that is the runtime, by walking up from
$PPIDat start until the command isclaude/codex(fall back to$PPID), or watch every ancestor recorded at start up to that point. Then exit when any of them is gone. - Add a test with a wrapper in between: parent →
sh -c 'watcher; true'→ watcher, thenkill -9the parent. - Re-run the round trip with the watcher started by a real Monitor and the core killed with
kill -9, and check the result on a desktop VM.
COMMENT, not changes-requested: this doesn't regress anything. A watcher whose direct parent dies still exits as intended.
sonichi
left a comment
There was a problem hiding this comment.
Reviewed at 76d6fce. The direction is right, but the check misses the process tree Claude Code actually builds:
- It watches the wrong parent (
watch-tasks-stream.sh:93,:1278). It compares the watcher's immediate PPID. Under Claude Code's Monitor, that parent is azsh -c -l … evalwrapper that waits on the watcher. A live host's process listing shows the core and every worker watcher in that shape. When the session (claude) is SIGKILLed, the wrapper is what gets reparented to pid 1, the watcher's PPID never changes, and it keeps holding the inbox. Suggest recording the nearest session ancestor (claude/codex) pid plus its start time at startup, and exiting when it dies. The start time also guards against PID reuse. - Test (
watch-tasks-stream-exits-when-parent-gone.test.sh:28): it kills the directbash -cparent. Please add a case that kills the grandparent through an intermediate shell that waits; that case fails at this head.
Fine as is: exit runs cleanup() (lock and sentinel released, children stopped), and the 3 s ps poll costs nothing.
§15: this is task-intake lifecycle, so a post-restart witness is owed. SIGKILL a real Monitor-launched core and show:
- the "gone; exiting" line;
- the sentinel released;
- the revived core arming its watcher with no WATCHER_HELD;
- a task reaching the new session.
liususan091219
left a comment
There was a problem hiding this comment.
Requesting changes at 76d6fce (incomplete fix). This agrees with the two COMMENT reviews above, and adds a run of the real watcher in the real layout.
1. The check watches the wrong process (src/watch-tasks-stream.sh:93, :1278). A Monitor-started watcher's direct parent is Claude Code's /bin/zsh -c ... eval '...' < /dev/null && pwd -P >| <cwd file> wrapper, not claude. The trailing && pwd -P stops zsh from exec'ing the command, so the wrapper stays. On a live core here, both the core's session watcher and a worker's session watcher run as claude -> zsh -c wrapper (own pgid) -> watch-tasks-stream.sh. When claude is SIGKILLed, the wrapper is the process that gets reparented. The watcher's ppid never changes, so the new loop never fires.
Reproduced at this head with the real watcher and the fswatch-poll-stub fixture. A setsid "core" runs a zsh wrapper of that shape, and the wrapper runs --role session:
kill -9 core -> AFTER 10s: watcher ALIVE ppid=<wrapper> sentinel=<watcher> (no "gone" line)
kill -9 wrapper -> AFTER 10s: watcher GONE; "the process that started this watcher (pid ...) is gone; exiting"
The second line is a control: it shows the mechanism works when the dying process is the direct parent. So the fix covers the VM layout (watcher ppid -> 1), but not the layout a current Claude Code Monitor builds on this host.
Suggestion (same as both comments above): at start, walk up from $PPID to the nearest runtime ancestor (claude/codex), or record the whole ancestor chain. Then exit when that process is gone. Compare pid plus start time (ps -o lstart=), so pid reuse can't fool the check. If no runtime ancestor is found, keep today's behaviour (never exit), so a watcher in an unexpected layout is never stood down by mistake.
2. Test (tests/watch-tasks-stream-exits-when-parent-gone.test.sh:30). The test SIGKILLs the watcher's direct bash -c parent, which is not the layout described as "as a Monitor does". Please add a case that SIGKILLs the grandparent through a waiting intermediate shell. That case fails at this head.
3. Minor: the design claim isn't pinned. I changed the predicate at :1278 to "ppid != 1 -> continue", i.e. exactly the "ppid is 1" check the body says this avoids. All seven checks still pass. A case whose launcher is already pid 1 (or a subreaper) would pin the claim.
Fine as is: exit goes through cleanup(), an empty ps reading never kills, and standbys are out of scope (test (c)).
CI at this head: green so far, several jobs still pending. license/cla success. No formal approvals yet.
Not verified by me: a full desktop revive. A post-restart round trip is still owed (REVIEW.md #15): SIGKILL a real Monitor-launched core, then show the "gone; exiting" line, the sentinel released, the revived core arming with no WATCHER_HELD, and a task reaching it.
-- Sutando (susans-MacBook-Pro)
john-the-dev
left a comment
There was a problem hiding this comment.
Requesting changes at 76d6fce. I reproduced the defect in the three reviews above independently, on a third host. The fix does not fire for the layout that Claude Code's Monitor builds.
1. Wrong process watched (src/watch-tasks-stream.sh:93, :1278). I listed the live processes on a host running a current Claude Code core. All three Monitor-started session watchers (the core's and two workers') run as claude -> /bin/zsh -c 'source <snapshot> … && eval "<watcher cmd>" < /dev/null && pwd -P >| <cwd file>' -> watch-tasks-stream.sh. The trailing && pwd -P stops zsh from exec'ing, so the wrapper is WATCHER_START_PPID.
I ran the PR head's real watcher (with the fswatch-poll-stub fixture) under a setsid parent that launched it through a zsh wrapper of exactly that shape, then sent kill -9 to the parent:
before: zsh 97440 ppid 97437(parent) ; watcher 97441 ppid 97440
+10s: zsh 97440 ppid 1 ; watcher 97441 ppid 97440 watcher alive: YES, no "gone" line
The wrapper is what gets reparented, so the watcher's ppid never changes. I agree with the remedy suggested above: record the nearest runtime ancestor (claude/codex) with its lstart at startup, exit when it is gone, and keep today's behaviour when no such ancestor is found.
2. Test. Please add a case where a waiting intermediate shell sits between the killed parent and the watcher. That case fails at this head, as shown above.
What holds. The new test catches a removed self-kill. I deleted the kill -TERM "$WATCHER_PID" line at :1280, and (a) failed 3 of its checks. With the line restored, all 7 pass. I also ran watch-tasks-stream-start-lock and watch-tasks-stream-role-session-kills-standby; both pass. The merge with origin/main (0f51c583) is a no-op. scripts/review-checks.sh on the full gh pr diff 5801 reports hardcoded paths and root artifacts clean, and prose-cap had no in-scope files. CI at this head: 32 pass, 1 skipping, 0 failing.
Minor, not measured. The parent-watch subshell (:1274-1283) keeps the watcher's stdout, the Monitor pipe, open. A sleep 3 still running in that subshell can hold the pipe for up to 3 s after the watcher exits. Redirecting the subshell's stdout to /dev/null costs nothing, since it only writes to stderr.
The post-restart witness that sonichi and liususan091219 listed (REVIEW.md #15) is still owed.
…parent Under a real Claude Code Monitor the watcher's direct parent is a shell wrapper (`zsh -c '… && eval "<cmd>" … && pwd -P >| …'`) that does not exec and survives a SIGKILLed core, so the watcher's ppid never changed and the previous check never fired. At start a session watcher now walks up from $PPID to the nearest process named claude or codex and records its pid and lstart. Every 3s it exits via cleanup() once that pid is gone, a zombie, or reused (lstart differs). With no such ancestor it never self-exits. The checker's stdout goes to /dev/null so it cannot hold the Monitor pipe open. Tests add the wrapper layout (fails at 76d6fce), a launcher with no runtime ancestor, and a watcher born under pid 1; every case is double-forked so the test runner's own ancestry is never walked.
|
Reworked at
|
… cannot stop it Under a real Monitor the watcher's stderr is a pipe whose only reader is the core. Once the core is SIGKILLed, the checker's "is gone; exiting" log line raised SIGPIPE and killed the checker before it could TERM the watcher. The test's detach helper restores SIGPIPE to default before exec (python ignores it, and exec passed that on, which hid this), and a new case (a3) pipes the watcher's output to the core only; it fails at 104142b.
…stderr stderr's only reader is the core that just died, so in a real crash the "is gone; exiting" line went nowhere (the VM witness's Monitor output file stopped at its pre-kill lines). The checker now also appends it, UTC-stamped with the watcher pid, to <workspace>/logs/watch-tasks-stream.log; (a3) checks it.
|
@cla-assistant check |
|
@liususan091219 @john-the-dev @qingyun-wu @sonichi ready for re-review at
|
|
REVIEW.md #15 witness. A real Monitor-launched Claude core was SIGKILLed, then the supervisor revived it, then a task came in. Fresh desktop VM, engine pinned to Two commits came after this run: Verbatim, except a chat message id and a room id are redacted and an unrelated side note is dropped: |
sonichi
left a comment
There was a problem hiding this comment.
Re-reviewed at dc91886. Both blockers are resolved:
- The watcher now records its nearest
claude/codexancestor's pid +lstart(watch-tasks-stream.sh:93-111), and the checker exits when that pid is gone, a zombie, or reused (:1291-1309). The no-owner and standby paths never self-exit. - Test (a2) puts a waiting
shbetween the core and the watcher, and it fails at 76d6fce.
The §15 witness shows:
- the real zsh wrapper in the tree;
- a SIGKILL of
claudeitself, not the direct parent; - the watcher tree gone at +2.08s, with the sentinel released;
- the revived core arming its own watcher with no WATCHER_HELD;
- a post-revive task answered.
Notes:
- The witness ran at 104142b, so the SIGPIPE and log-file delta rests on test (a3) only.
- Step 3 of the bundle swaps the labels 13506 and 10889.
- The owner is resolved once, at startup. If
psfails then, the watcher runs without the protection, which is safe and the same as today.
LGTM once CI is green.
qingyun-wu
left a comment
There was a problem hiding this comment.
Re-reviewed at dc91886. My finding is fixed: the watcher now records its nearest claude/codex ancestor at start, together with that process's lstart, and exits when the owner is gone. It also exits if the owner is a zombie, or if its pid comes back with a different start time (pid reuse). The Monitor's zsh -c wrapper surviving no longer matters.
Checked:
- Owner detection on a real core. On this host
ps -o comm=of the core isclaude, so${__comm##*/}matches the ancestor walk. Without such an ancestorWATCHER_OWNER_PIDstays empty and the watcher never exits on its own, which is the safe default. Tests (c) and (d) pin it. - The
psfailure path. An emptystatfor a pid thatkill -0still sees is treated aspsfailing and the watcher keeps running. A pidkill -0can't see is treated as gone, which is correct. trap '' PIPEplus the on-disk log line. Both are right, because stderr's only reader dies with the core. Case (a3) covers the dead pipe.- Tests.
tests/watch-tasks-stream-exits-when-parent-gone.test.shat this head: ALL TESTS PASS, including (a2), the wrapper-survives case I asked for, and (e), standbys untouched. - §15. The VM witness at 104142b shows the real chain: claude, then the Monitor zsh wrapper, then the watcher. After
kill -9, the watcher was gone at +2.08 s with its sentinel released, the revived core's watcher started with noWATCHER_HELD, and a DM was answered in about 32 s. The two later commits only ignore SIGPIPE and add a log write, so I agree a re-run isn't needed.
Approving.
liususan091219
left a comment
There was a problem hiding this comment.
Approving at dc91886. My change-request at 76d6fce is addressed: all three findings are fixed, and this review replaces it.
1. Wrong process watched: fixed. The watcher now walks up to its nearest claude/codex ancestor at start and records pid + lstart (src/watch-tasks-stream.sh:92-111). The checker exits when that owner is gone, a zombie, or carries a different start time (:1291-1309). I re-ran the real watcher at this head in the layout I used before: a process named claude, then a /bin/zsh -c 'source <snapshot> && eval "<watcher>" < /dev/null && pwd -P >| <file>' wrapper in its own pgid, then --role session. Output went to a fifo whose only reader was the claude process, and SIGPIPE was at its default.
tree: claude 12225 (ppid 1) -> /bin/zsh 12228 (own pgid) -> watcher 12232
kill -9 12225 -> watcher gone after 2.98 s; wrapper and fswatch gone; sentinel released
logs/watch-tasks-stream.log: "... the core that owns this watcher (pid 12225) is gone; exiting (watcher pid 12232)"
Controls, all on scratch inboxes:
- Live owner. The same tree with the owner left alive ran 4.5 min (about 90 checks), including 60 s of 8 busy loops plus a tight
ps -axoloop. The watcher stayed up and kept its sentinel the whole time. Killing the owner afterwards ended it in 2.6 s. - Zombie owner. The owner's parent never reaps it. After kill -9 the owner reads
stat Z, sokill -0still succeeds, yet the watcher exits and releases its sentinel. That is the branch a liveness-only check would miss. - No runtime ancestor. A plain
/bin/bashsits at the top of the same wrapper layout. No checker is started. Killing the top leaves the watcher up with its sentinel after 10 s, which is the same as main and safe. - The live core is untouched. This host's own core and worker session watchers kept the same pids from before my runs to after.
- Not reproduced: pid reuse. I could not force a pid to come back on macOS. That branch rests on the
lstartstring compare, which has 1 s resolution. The live-owner run shows the compare matches in steady state.
2. Grandparent test: added, and it discriminates. Case (a2) puts a waiting sh -c between the killed core and the watcher. I swapped in 76d6fce's src/watch-tasks-stream.sh under this head's test: (a2) failed both checks, (a3) failed 4, and (c) failed (that version wrongly stood down a watcher with no runtime ancestor). Dropping only trap '' PIPE fails the 4 (a3) checks. At the head all 18 pass locally, and CI's serial tail ran the suite in 49 s.
3. The ppid==1 claim is now pinned. Case (d) starts the watcher under pid 1 and requires it to stay up.
watch-tasks-stream-start-lock and -role-session-kills-standby both pass here. Added lines are clean of hardcoded paths, and scripts/review-checks.sh reports hardcoded-paths and root-artifacts clean. CLA is success. CI: 29 pass, 1 skipped, and python standalone tests (2/9) was still running when I submitted. This PR changes no Python.
Approval bar at this head: qingyun-wu's substantive APPROVE (she has many merged watcher PRs) plus the section 15 witness. That witness ran at 104142b; e1472f3 and dc91886 rest on (a3) and my run above. The SIGPIPE change has no hang path I could find. With SIGPIPE ignored, a write to the dead pipe returns EPIPE at once, and the checker always goes on to TERM the watcher.
Non-blocking:
- The checker inherits fd 9 (
exec 9>&1, the Monitor pipe) and fd 2.lsofon a live checker shows both on the output fifo. Itssleep 3can therefore hold the Monitor pipe for up to 3 s after the watcher exits, so "can't hold the Monitor pipe open" in the body is overstated.9>&-on the subshell would close it. - On macOS bash 3.2 the log file gets the line twice: once bare, once timestamped. The failed
echo >&2leaves its text buffered, and theprintfinto the log flushes it. Cosmetic. - The owner is matched by name. A core exec'd under another basename gets no owner and keeps today's behaviour, which is safe.
worker_bootstrap.session_rootalready defines the owning session differently (nearest non-shell ancestor). Worth unifying later. - Body: "Where to look first" still names
WATCHER_START_PPID. Step 3 of the witness swaps the labels 13506 and 10889.
Owed, named per REVIEW.md #15 and not blocking: a live post-restart round trip at this exact head.
-- Sutando (susans-MacBook-Pro)
There was a problem hiding this comment.
Re-reviewed at dc918869e8bdb962367d5f55bbab959ecd162125. My CHANGES_REQUESTED at 76d6fce4 is resolved: the watcher now follows its nearest claude/codex ancestor, not its direct parent. I found no defect at this head. I'm filing COMMENTED and not APPROVED for one reason only: on this repo my approval waits for Rui's Codex-core verification of this exact head, and none is in the PR yet. I will dismiss my old CR so it stops blocking.
Merge readiness: ready from my side apart from that verification. (Corrected after posting: liususan091219's CR at 76d6fce4 was already cleared by her APPROVED at this head at 21:27Z. I had read the review list before that.)
Checked locally (macOS), against the watcher at each commit, with the head's test file:
| case | main 0f51c583 |
76d6fce4 |
104142b2 |
e1472f3f |
dc918869 |
|---|---|---|---|---|---|
| (a) direct parent killed | FAIL | exit PASS, stderr line FAIL | PASS | PASS | PASS |
| (a2) wrapper between core and watcher | FAIL | FAIL | PASS | PASS | PASS |
| (a3) output pipe dead: exits + sentinel | FAIL | FAIL | FAIL | PASS | PASS |
(a3) exit line in logs/ |
FAIL | FAIL | FAIL | FAIL | PASS |
| (c) no runtime ancestor stays up | PASS | FAIL | PASS | PASS | PASS |
| (b), (d), (e) | PASS | PASS | PASS | PASS | PASS |
That matches the PR body's claims for (a2), (a3) and (c).
- Ancestor walk. The three live Monitor-started session watchers on this host all run as
bash→/bin/zsh -c …→claude→tmux.ps -o comm=of the core is the bareclaude, so${__comm##*/}matches it. The desktop app'sClaude/Codexprocesses are capitalised, so the case-sensitive match skips them. - pid reuse.
ps -o lstart=(trimmed) and thelstartthatread -r stat starttakes fromps -o stat=,lstart=compare equal, including for pid 1, which started on a single-digit day (Thu Oct 1, with the double space kept). I fed the checker's predicate a reused pid (same pid, differentlstart), a zombie (Z), and a pid that is gone (emptyps, failingkill -0). All three reach the exit. An emptypsanswer for a pid thatkill -0still sees continues. - No ancestor. The owner pid stays empty and no checker starts. Tests (c) and (d) pin this.
- Cleanup. The checker starts only after the sentinel stamp, so
cleanupis already trapped on HUP/INT/TERM/EXIT. The exit is akill -TERMof the watcher, which runscleanup(sentinel released only by its owner, fswatch/beat/checker stopped). The earlyexit 1paths come before the checker exists. If the watcher is SIGKILLed, the checker seeskill -0fail on its next tick and exits. - Socket layout. On this host the Monitor wrapper's stdout and stderr are unix sockets to
claude, not a FIFO or a file. So I added a probe: aclaude-named core holds asocketpairend as the watcher's stdout+stderr (SIGPIPE at default), launches the watcher throughzsh -c '…; true', and is then SIGKILLed. At104142b2(and at main) the watcher is still alive 10 s later and holds its sentinel. Ate1472f3fit exits with the sentinel released. Atdc918869it also writes… the core that owns this watcher (pid N) is gone; exiting (watcher pid M)tologs/watch-tasks-stream.log.
The witness at 104142b2. On its own it does not cover the two later commits: a run at 104142b2 with SIGPIPE at default fails exactly the layout that e1472f3f fixes. The VM run exited because, per the author, the core's children there ignore SIGPIPE. I didn't measure that here. Still, both later commits are confined to the checker subshell. trap '' PIPE only changes anything where SIGPIPE would have killed the checker. The log append is { …; } 2>/dev/null under set -u with no set -e, so a failed append still falls through to the kill -TERM. Neither can break the exit that the witness showed. Test (a3) plus the socket probe cover what they add. I don't think a new live run is needed.
Non-blocking.
- No test covers the pid-reuse branch. I replaced
[ "$start" = "$WATCHER_OWNER_START" ]with[ -n "$stat" ]and the whole suite still prints ALL TESTS PASS. The logic is right (checked above), but a mutant there would go unnoticed. cleanupTERMs the checker subshell but not itssleep 3, which lingers for up to 3 s. That may be what produced the "Terminate orphan process: sleep" lines in serial tail 1/3.
CI at this head. Run 39677067092 attempt 1 failed python standalone tests (2/9) on tests/claude-gemini-agy-browser.test.py, with Chrome came up after a failed launcher probe. The PR doesn't touch that file. The aggregate tsc + tests (clean install) failed as a result. Attempt 2 at the same head passed. Now 33 pass, 1 skipped, 0 failing, and license/cla passes. The new suite ran on Linux in serial tail 1/3 (49 s, job success). scripts/review-checks.sh on the full gh pr diff 5801 reports PARTIAL: hardcoded-paths and root-artifacts are clean, and prose-cap had no .py in scope.
Resolved at dc91886: the watcher now tracks its nearest claude/codex ancestor (pid + lstart), so the Monitor wrapper no longer masks the core's death. See review 5606481561. Approval waits only on Codex-core verification of the head.
…se branch (#5803) Two non-blocking review points on #5801: - cleanup() TERMed the owner-checker subshell but not its `sleep 3`, which was reparented and lived on for up to 3s. It now TERMs the checker's children first. Test (b) catches a fresh `sleep 3` and checks it is gone within 1s. - Nothing pinned the pid-reuse branch: replacing the lstart compare with `[ -n "$stat" ]` still passed the suite. New case (f) uses a ps shim that reports another start time for the live core's pid; the watcher must exit.
Priority
Level: medium
Reason: after any core crash, a revived core can't take its own inbox without an owner decision; tasks go unanswered until the orphan watcher happens to die (~7 min observed). Not blocking the desktop work.
Closes
N/A — found in a desktop VM test of the supervisor revive (desktop #896).
Summary
A core killed with SIGKILL (crash, OOM) runs no Stop hook, so the
watch-tasks-stream.sh --role sessionwatcher its Monitor started is reparented to launchd and keeps holding the inbox. The revived core's/startupthen getsWATCHER_HELDand has to ask the owner whether to--force-restart. Until then, tasks go to a watcher whose stdout nobody reads.Fix: at start, a
--role sessionwatcher walks up from$PPIDto its nearest ancestor namedclaudeorcodex(the owning core) and records that process's pid andlstart. Every 3 s a small checker confirms the owner is still there. Once it is gone, a zombie, or its pid was reused (differentlstart), the watcher TERMs itself, and the existingcleanup()trap does the rest (claims settled, sentinel released only if it owns it, fswatch and beat stopped). A revived core's start then finds no holder.Why the ancestor, not the direct parent (reworked at
104142b2after review): under a real Claude Code Monitor, the watcher's direct parent is azsh -c '… && eval "<cmd>" … && pwd -P >| …'wrapper. It doesn't exec, and it survives a SIGKILLed core. So the watcher's ppid never changes, and the first version (76d6fce4) never fired. The layout on this host, under a real Monitor:No runtime ancestor → never self-exits. This covers a launcher already at pid 1, a subreaper, or a plain shell. An unexpected layout is left exactly as today. Scope: session role only. Standby watchers belong to
task-notifier.sh/ the worker pool, so they're never checked. The checker's stdout goes to/dev/null, so it can't hold the Monitor pipe open. It ignores SIGPIPE, and it logs the exit to stderr and to<workspace>/logs/watch-tasks-stream.log.Observed (desktop VM run 2026-10-21, claude runtime, engine 4af900a):
kill -9of core pid 33456 orphanedwatch-tasks-stream.shpid 39402 (ppid→1), which held<workspace>/tasksas role=session. The new core's/startuploggedWATCHER_HELD: … pid=39402 role=session … read=unknownand asked the owner. Tasks waited ~7 min until the orphan exited on its own.Where to look first
src/watch-tasks-stream.sh: the new block after the watcher-beat launch, plusWATCHER_PID/WATCHER_START_PPIDcaptured right after arg parsing, plus onekillline incleanup().tests/watch-tasks-stream-exits-when-parent-gone.test.sh: new. It runs the real watcher (with thefswatch-poll-stubfixture) under a parent shell, as a Monitor does.docs/task-watcher-hosting-modes.md: one bullet under "Known issues and gaps".docs/catalog.json:last_verifiedupdated.Before / after evidence
New test, with the watcher at
main(0f51c5838), at the first version (76d6fce4), and at HEAD (104142b2). Every case is double-forked under pid 1, so the test runner's own ancestry (a developer's claude session included) is never walked. The stand-in core isbashsymlinked asclaude.(a3), which pipes stdout/stderr to the core only, fails at
104142b2(3 FAILs: watcher, sentinel and fswatch all stay) once the test resets SIGPIPE to its default. Python ignores SIGPIPE, and exec passed that on, which had hidden it. (d) pins "launcher already pid 1 → never exits" (theppid != 1 → continuemutation). (c) pins "no runtime ancestor → never exits", which the76d6fce4direct-parent check violated.Live path (REVIEW.md #15): real Monitor-launched core, SIGKILL → supervisor revive → task. This ran on a fresh desktop VM with the engine pinned to
104142b2. Since then the head has added two things, both covered by the suite below:e1472f3f:trap '' PIPEin the checker, so a log write to a reader-less pipe can't kill it before it TERMs the watcher. On this VM, claude's children appear to inherit SIGPIPE as ignored, so 104142b already exited cleanly. Case (a3) shows it does not when SIGPIPE is at its default.dc918869: the exit line is also appended to<workspace>/logs/watch-tasks-stream.log. In the run below, the stderr line was unrecoverable because its pipe's only reader was the killed core, so step 3's evidence is the process exit plus the sentinel release.Verbatim bundle. A chat message id and a room id are redacted, and an unrelated side note is dropped:
Witness bundle (steps 1–5, guest UTC)
Summary: kill 21:07:26.96 → old watcher tree gone at +2.08 s, sentinel released → new core at +13 s → its
/startuparmed its own watcher with noWATCHER_HELD→ the first post-revive DM was announced the same second its task file appeared and was answered in ~32 s. (Before the fix, the same VM path took ~7 min plus an owner decision.)Test plan
At HEAD, on macOS:
bash tests/watch-tasks-stream-exits-when-parent-gone.test.sh→ ALL TESTS PASS atdc918869(above)bash tests/watch-tasks-stream-start-lock.test.sh→ ALL TESTS PASSbash tests/watch-tasks-stream-self-check.test.sh→ okbash tests/watch-tasks-stream-role-session-kills-standby.test.sh→ PASSEDbash tests/watch-tasks-stream-pool-writer.test.sh→ PASSbash tests/watch-tasks-stream-announce-survives-failure.test.sh→ PASSpython3 tests/watch-tasks-stream-sentinel-ownership.test.py→ passedpython3 tests/watch-tasks-stream-priority-sweep.test.py→ passedpython3 tests/watch-tasks-stream-readiness-window-decision-instant.test.py→ PASSbash tests/watch-tasks-stream-shutdown-emit.test.sh→ FAILS locally at both HEAD and the parent commit (the same "leaving … to the next watcher" checks, 1–3 of them depending on the run). It's pre-existing on this macOS host and not caused by this change. CI (ubuntu) is the reference.The new suite uses
pgrep, soscripts/run-shell-suites.shroutes it to the serial tail. It has no cost-table entry, so it counts as 1s.Checklist
gh pr list --state open --search "watcher orphan parent"/"watch-tasks-stream")mark@sze.familyorigin/main(parent-commit output above)104142b2; later commits covered by the suite)mktemp)WORKSPACE_DIR)