Skip to content

fix(watcher): a session watcher exits when the process that started it is gone - #5801

Merged
marklysze merged 4 commits into
sonichi:mainfrom
marklysze:fix/watcher-exits-when-parent-gone
Oct 21, 2026
Merged

marklysze merged 4 commits into
sonichi:mainfrom
marklysze:fix/watcher-exits-when-parent-gone

Conversation

@marklysze

@marklysze marklysze commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

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 session watcher its Monitor started is reparented to launchd and keeps holding the inbox. The revived core's /startup then gets WATCHER_HELD and 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 session watcher walks up from $PPID to its nearest ancestor named claude or codex (the owning core) and records that process's pid and lstart. Every 3 s a small checker confirms the owner is still there. Once it is gone, a zombie, or its pid was reused (different lstart), the watcher TERMs itself, and the existing cleanup() 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 104142b2 after review): under a real Claude Code Monitor, the watcher's direct parent is a zsh -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:

42613 42611 42611 bash        <- watch-tasks-stream.sh --role session
42611 36043 42611 /bin/zsh    <- Monitor wrapper
36043 35736 36043 claude      <- owner the walk finds

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 -9 of core pid 33456 orphaned watch-tasks-stream.sh pid 39402 (ppid→1), which held <workspace>/tasks as role=session. The new core's /startup logged WATCHER_HELD: … pid=39402 role=session … read=unknown and 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, plus WATCHER_PID / WATCHER_START_PPID captured right after arg parsing, plus one kill line in cleanup().
  • tests/watch-tasks-stream-exits-when-parent-gone.test.sh: new. It runs the real watcher (with the fswatch-poll-stub fixture) 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_verified updated.

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 is bash symlinked as claude.

=== watcher at main 0f51c5838
  PASS (a) the session watcher started under the core and stamped
  FAIL (a) after kill -9 of the core the watcher exits within 10s
  FAIL (a) ...and names the core on stderr
  FAIL (a) ...and releases its sentinel
  FAIL (a) ...and its fswatch is gone
  PASS (a2) the watcher's direct parent is the wrapper, not the core
  FAIL (a2) after kill -9 of the core the watcher exits within 10s though its wrapper lives
  FAIL (a2) ...and releases its sentinel
  PASS (b) with its core alive the watcher is still up after 8s
  PASS (c) with no claude/codex ancestor, killing its launcher leaves the watcher up
  PASS (d) the watcher's parent is pid 1 from the start
  PASS (d) ...and it is still up after 8s
  PASS (e) a standby whose core is SIGKILLed keeps running
TESTS FAILED

=== watcher at 76d6fce4 (direct-parent check)
  PASS (a) after kill -9 of the core the watcher exits within 10s
  FAIL (a) ...and names the core on stderr
  FAIL (a2) after kill -9 of the core the watcher exits within 10s though its wrapper lives
  FAIL (a2) ...and releases its sentinel
  FAIL (c) with no claude/codex ancestor, killing its launcher leaves the watcher up — …the process that started this watcher (pid 18909) is gone; exiting|
  (all other checks PASS)
TESTS FAILED

=== HEAD dc918869
  PASS (a) the session watcher started under the core and stamped
  PASS (a) after kill -9 of the core the watcher exits within 10s
  PASS (a) ...and names the core on stderr
  PASS (a) ...and releases its sentinel
  PASS (a) ...and its fswatch is gone
  PASS (a2) the watcher's direct parent is the wrapper, not the core
  PASS (a2) after kill -9 of the core the watcher exits within 10s though its wrapper lives
  PASS (a2) ...and releases its sentinel
  PASS (a3) the session watcher started with its output piped to the core
  PASS (a3) after kill -9 of the core the watcher exits within 10s though its output pipe is dead
  PASS (a3) ...and releases its sentinel
  PASS (a3) ...and the exit line is on disk in logs/, though stderr's reader is dead
  PASS (a3) ...and its fswatch is gone
  PASS (b) with its core alive the watcher is still up after 8s
  PASS (c) with no claude/codex ancestor, killing its launcher leaves the watcher up
  PASS (d) the watcher's parent is pid 1 from the start
  PASS (d) ...and it is still up after 8s
  PASS (e) a standby whose core is SIGKILLed keeps running
ALL TESTS PASS

(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" (the ppid != 1 → continue mutation). (c) pins "no runtime ancestor → never exits", which the 76d6fce4 direct-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 '' PIPE in 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)
Build ag2space-0.7.20-witness.1 (desktop main + #898 cinny pin + engine 104142b2), md5 5f6da136f7ce3bd3fe6a4d28ddb60661, fresh VM witness-5801 (sutando-base-dvorak clone), clean first-run onboarding on Claude Code 2.1.292 (OAuth). All timestamps guest UTC.

## Step 1 — before the kill (21:03:18Z; pid ppid pgid lstart command)
 5396     1  5396 21:02:24  tmux -S …/space.ag2.app/run/tmux.sock new-session -d -s sutando-core …
10889  5396 10889 21:02:42  claude --name sutando-core --remote-control Sutando --chrome --plugin-dir …/claude-observer/plugin --dangerously-skip-…   (core, session bcc0af50)
13506 10889 13506 21:02:59  /bin/zsh -c source '…/.claude-sutando/shell-snapshots/snapshot-zsh-1792616566720-wt28lv.sh' …   (Monitor shell wrapper)
13513 13506 13506 21:03:00  bash src/watch-tasks-stream.sh --role session --inbox …/workspace/tasks   (watcher)
13579 13513 13506 21:03:00  fswatch -l 0.5 --event Created --event Renamed --event Updated …/workspace/tasks …
13602 13513 13506 21:03:00  bash src/watch-tasks-stream.sh --role session --inbox …/workspace/tasks   (owner-watch checker subshell; child `sleep 3`)
sentinel: workspace/state/watch-tasks-stream.pid => [13513]  (mtime 21:03:00)
watcher stderr: pipe 0x867450bcb25daef6, sole reader claude 10889 (fd 40) → copied to /private/tmp/claude-501/…/bcc0af50-…/tasks/bovxmg06m.output:
  [stderr] watch-tasks-stream: role=session inbox=/Users/admin/Library/Application Support/space.ag2.app/workspace/tasks pid=13513
host.log: [2026-10-21T21:02:49.298Z] [stderr] sutando-supervisor: boot outcome agent-online

## Step 2 — kill -9 the core (no owner action)
--- kill -9 10889 sent at 2026-10-21T21:07:26.958Z

## Step 3 — old watcher exit (0.25 s poll)
watcher 13506 GONE at 2026-10-21T21:07:29.037Z (+2.08 s after kill); wrapper 10889 alive=no; kids alive=[]
(at +2.08 s: zsh wrapper 13506, watcher 13513, fswatch 13579, checker 13602 all gone; `pgrep -f "watch-tasks-stream.sh --role session"` empty; nothing re-parented to pid 1)
sentinel: (no watch-tasks-stream* in state/)  — released
"watch-tasks-stream: the core that owns this watcher (pid 10889) is gone; exiting": NOT captured. It is written to stderr = the Monitor pipe whose only reader was the killed core; bovxmg06m.output stayed at its 7 pre-kill lines. (Note: the script ignores only URG/WINCH, so the echo hits a reader-less pipe; the watcher tree exited regardless.)

## Step 4 — supervisor revive → new core arms its own watcher, no WATCHER_HELD
[2026-10-21T21:07:36.918Z] [stderr] sutando-supervisor: engine outcome -> core-only
[2026-10-21T21:07:36.927Z] [stderr] sutando-supervisor: availability=degraded — revive attempt #1, next probe in 15s
[2026-10-21T21:07:37.328Z] [stderr] sutando-engine: launch-sutando: app-managed Claude Code 2.1.292 (Claude Code) verified at …/cli/claude/bin/claude
[2026-10-21T21:07:40.185Z] [stderr] sutando-engine:   ⚠ sutando-core exists but core Claude is gone — healing core window (sibling windows preserved)
[2026-10-21T21:07:40.408Z] [stderr] sutando-engine: Healed core window in sutando-core (session + sibling windows preserved).
[2026-10-21T21:07:55.613Z] [stderr] sutando-supervisor: engine outcome -> agent-online
[2026-10-21T21:07:55.623Z] [stderr] sutando-supervisor: core online again after 1 unhealthy tick(s) — recovery state reset
new tree (21:08:23Z):
24559  5396  21:07:40  claude --name sutando-core …   (new core, session 94cae26a)
26893 24559  21:08:13  /bin/zsh -c source '…/snapshot-zsh-1792616869748-u1moyy.sh' …
26900 26893  21:08:13  bash src/watch-tasks-stream.sh --role session --inbox …/workspace/tasks
26964 26900  21:08:14  fswatch -l 0.5 …   |   27010 26900  21:08:14  bash src/watch-tasks-stream.sh … (checker)
sentinel: workspace/state/watch-tasks-stream.pid => [26900]  (mtime 21:08:14)
new core Monitor output /private/tmp/claude-501/…/94cae26a-…/tasks/boazqpz5k.output (complete):
  [stderr] watch-tasks-stream: role=session inbox=/Users/admin/Library/Application Support/space.ag2.app/workspace/tasks pid=26900
WATCHER_HELD: none (the string appears in the new transcript only inside the /startup instruction text).

## Step 5 — Mark's DM after the revive ("What's the weather in Sydney")
21:09:51Z   DM sent (task file `timestamp:` field; source_message_id <redacted>, receiving_instance @mark-embedded-cc-6.agent)
21:09:53     tasks/task-fba08a236d29ae5807.txt created (size 1553; host poller saw it at 21:09:53.498Z)
21:09:53     watcher 26900 announced it — boazqpz5k.output line 2: `TASK_FILE: task-fba08a236d29ae5807.txt`
21:09:53.533Z core received the Monitor event (transcript: Monitor event "Streaming task watcher" TASK_FILE: task-fba08a236d29ae5807.txt)
21:09:56.379Z core read the task file
21:10:20.257Z core wrote workspace/results/task-fba08a236d29ae5807.txt (reply)
21:10:21Z    result archived as results/archive/task-fba08a236d29ae5807-1792617021.txt (delivered via results/.outbox)
21:10:22.118Z tasks/task-fba08a236d29ae5807.txt → tasks/archive/ (host poller)
[2026-10-21T21:10:23.511Z] [stderr] ui: notification.reach show=true perm=granted room=<redacted>
21:10:25.938Z core: "Your Sydney weather answer was sent."
Queue → pick-up: ≤1 s (watcher event 21:09:53, same second as the file). Pick-up → delivered: 28 s (core work incl. two weather fetches). DM → answer in room: ~32 s.

## Summary
kill 21:07:26.96 → old watcher tree gone +2.08 s, sentinel released → core-only +9.96 s → new core +13 s → agent-online +28.7 s → new watcher +46 s (own sentinel, no WATCHER_HELD) → first post-revive DM answered in ~32 s. One supervisor, one watcher throughout.
Evidence (host, AG2/dev/VMs): witness1-step1-pre.txt, witness1-step2-kill.txt, witness1-hostlog.txt, witness1-tasks-poll.txt, witness1-bundle.md.

Summary: kill 21:07:26.96 → old watcher tree gone at +2.08 s, sentinel released → new core at +13 s → its /startup armed its own watcher with no WATCHER_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 at dc918869 (above)
  • bash tests/watch-tasks-stream-start-lock.test.sh → ALL TESTS PASS
  • bash tests/watch-tasks-stream-self-check.test.sh → ok
  • bash tests/watch-tasks-stream-role-session-kills-standby.test.sh → PASSED
  • bash tests/watch-tasks-stream-pool-writer.test.sh → PASS
  • bash tests/watch-tasks-stream-announce-survives-failure.test.sh → PASS
  • python3 tests/watch-tasks-stream-sentinel-ownership.test.py → passed
  • python3 tests/watch-tasks-stream-priority-sweep.test.py → passed
  • python3 tests/watch-tasks-stream-readiness-window-decision-instant.test.py → PASS
  • bash 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, so scripts/run-shell-suites.sh routes it to the serial tail. It has no cost-table entry, so it counts as 1s.

Checklist

  • No other open PR covers this (gh pr list --state open --search "watcher orphan parent" / "watch-tasks-stream")
  • Author email mark@sze.family
  • Single concern
  • Bug exists on origin/main (parent-commit output above)
  • Test added
  • Before/after evidence pasted
  • Live path: real Monitor-launched core SIGKILL + supervisor revive + post-revive task on a desktop VM (engine 104142b2; later commits covered by the suite)
  • Stacked PR: N/A
  • No hardcoded host paths in added lines (the tests use mktemp)
  • CI workflow: N/A
  • Workspace resolution: N/A (uses the watcher's existing WORKSPACE_DIR)

…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.
@github-actions

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ No Python changes — nothing to gate (bar: 95% on changed lines).

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 $PPID at start until the command is claude/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, then kill -9 the 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 sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 76d6fce. The direction is right, but the check misses the process tree Claude Code actually builds:

  1. 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 a zsh -c -l … eval wrapper 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.
  2. Test (watch-tasks-stream-exits-when-parent-gone.test.sh:28): it kills the direct bash -c parent. 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 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@marklysze

Copy link
Copy Markdown
Collaborator Author

Reworked at 104142b2 for the finding all four reviews raised: the Monitor's zsh -c '… && pwd -P >| …' wrapper survives a SIGKILLed core, so the watcher's ppid never changes.

  • Owner, not parent (liususan091219, john-the-dev, qingyun-wu, sonichi): at start the watcher walks up from $PPID to its nearest claude/codex ancestor and records pid + lstart. It exits through cleanup() once that pid is gone, a zombie, or reused. With no such ancestor it never self-exits.
  • Tests (liususan091219 point 3): added (a2), the wrapper layout, which fails at 76d6fce4; (c), no runtime ancestor with its launcher killed → stays up (76d6fce4 wrongly exits here); and (d), a watcher born under pid 1 → stays up. Every case is double-forked so the runner's own ancestry is never walked. Before/after output for main, 76d6fce4 and HEAD is in the body.
  • Pipe (john-the-dev minor): the checker subshell's stdout goes to /dev/null.
  • REVIEW.md Update suggestion chips with killer features #15 witness (sonichi, liususan091219): still to come. A real Monitor-launched core SIGKILL + revive is being run on a desktop VM pinned to 104142b2. The real Monitor ancestry on my host (watcher → zsh wrapper → claude) is in the body. Not ready to merge until that witness is posted.

… 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.
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@marklysze

Copy link
Copy Markdown
Collaborator Author

@liususan091219 @john-the-dev @qingyun-wu @sonichi ready for re-review at dc918869.

  • REVIEW.md Update suggestion chips with killer features #15 witness is in the body. A real Monitor-launched Claude core was SIGKILLed on a fresh desktop VM. The old watcher tree was gone at +2.08 s and its sentinel released. The supervisor revived the core, which armed its own watcher with no WATCHER_HELD. A post-revive DM was announced the same second and answered in ~32 s. That run was at 104142b2.
  • Since then: e1472f3f adds trap '' PIPE to the checker. New case (a3), output piped only to the core, fails at 104142b2 once SIGPIPE is at its default (the test's Python launcher had been hiding that). dc918869 also logs the exit line to <workspace>/logs/watch-tasks-stream.log, because stderr's only reader is the dead core, as the witness shows.

@marklysze

Copy link
Copy Markdown
Collaborator Author

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 104142b2.

Two commits came after this run: e1472f3f (the checker ignores SIGPIPE) and dc918869 (the exit line is also appended to <workspace>/logs/watch-tasks-stream.log). No re-run needed. On this VM the checker already exited cleanly (claude's children appear to inherit SIGPIPE as ignored). The PIPE trap only covers a host where they don't, which case (a3) proves at its default disposition. The file log only adds evidence, and (a3) checks it. Step 3's evidence here is the process exit + sentinel release: the stderr line had no reader left once the core was killed, which is exactly what dc918869 fixes.

Verbatim, except a chat message id and a room id are redacted and an unrelated side note is dropped:

Build ag2space-0.7.20-witness.1 (desktop main + #898 cinny pin + engine 104142b2), md5 5f6da136f7ce3bd3fe6a4d28ddb60661, fresh VM witness-5801 (sutando-base-dvorak clone), clean first-run onboarding on Claude Code 2.1.292 (OAuth). All timestamps guest UTC.

## Step 1 — before the kill (21:03:18Z; pid ppid pgid lstart command)
 5396     1  5396 21:02:24  tmux -S …/space.ag2.app/run/tmux.sock new-session -d -s sutando-core …
10889  5396 10889 21:02:42  claude --name sutando-core --remote-control Sutando --chrome --plugin-dir …/claude-observer/plugin --dangerously-skip-…   (core, session bcc0af50)
13506 10889 13506 21:02:59  /bin/zsh -c source '…/.claude-sutando/shell-snapshots/snapshot-zsh-1792616566720-wt28lv.sh' …   (Monitor shell wrapper)
13513 13506 13506 21:03:00  bash src/watch-tasks-stream.sh --role session --inbox …/workspace/tasks   (watcher)
13579 13513 13506 21:03:00  fswatch -l 0.5 --event Created --event Renamed --event Updated …/workspace/tasks …
13602 13513 13506 21:03:00  bash src/watch-tasks-stream.sh --role session --inbox …/workspace/tasks   (owner-watch checker subshell; child `sleep 3`)
sentinel: workspace/state/watch-tasks-stream.pid => [13513]  (mtime 21:03:00)
watcher stderr: pipe 0x867450bcb25daef6, sole reader claude 10889 (fd 40) → copied to /private/tmp/claude-501/…/bcc0af50-…/tasks/bovxmg06m.output:
  [stderr] watch-tasks-stream: role=session inbox=/Users/admin/Library/Application Support/space.ag2.app/workspace/tasks pid=13513
host.log: [2026-10-21T21:02:49.298Z] [stderr] sutando-supervisor: boot outcome agent-online

## Step 2 — kill -9 the core (no owner action)
--- kill -9 10889 sent at 2026-10-21T21:07:26.958Z

## Step 3 — old watcher exit (0.25 s poll)
watcher 13506 GONE at 2026-10-21T21:07:29.037Z (+2.08 s after kill); wrapper 10889 alive=no; kids alive=[]
(at +2.08 s: zsh wrapper 13506, watcher 13513, fswatch 13579, checker 13602 all gone; `pgrep -f "watch-tasks-stream.sh --role session"` empty; nothing re-parented to pid 1)
sentinel: (no watch-tasks-stream* in state/)  — released
"watch-tasks-stream: the core that owns this watcher (pid 10889) is gone; exiting": NOT captured. It is written to stderr = the Monitor pipe whose only reader was the killed core; bovxmg06m.output stayed at its 7 pre-kill lines. (Note: the script ignores only URG/WINCH, so the echo hits a reader-less pipe; the watcher tree exited regardless.)

## Step 4 — supervisor revive → new core arms its own watcher, no WATCHER_HELD
[2026-10-21T21:07:36.918Z] [stderr] sutando-supervisor: engine outcome -> core-only
[2026-10-21T21:07:36.927Z] [stderr] sutando-supervisor: availability=degraded — revive attempt #1, next probe in 15s
[2026-10-21T21:07:37.328Z] [stderr] sutando-engine: launch-sutando: app-managed Claude Code 2.1.292 (Claude Code) verified at …/cli/claude/bin/claude
[2026-10-21T21:07:40.185Z] [stderr] sutando-engine:   ⚠ sutando-core exists but core Claude is gone — healing core window (sibling windows preserved)
[2026-10-21T21:07:40.408Z] [stderr] sutando-engine: Healed core window in sutando-core (session + sibling windows preserved).
[2026-10-21T21:07:55.613Z] [stderr] sutando-supervisor: engine outcome -> agent-online
[2026-10-21T21:07:55.623Z] [stderr] sutando-supervisor: core online again after 1 unhealthy tick(s) — recovery state reset
new tree (21:08:23Z):
24559  5396  21:07:40  claude --name sutando-core …   (new core, session 94cae26a)
26893 24559  21:08:13  /bin/zsh -c source '…/snapshot-zsh-1792616869748-u1moyy.sh' …
26900 26893  21:08:13  bash src/watch-tasks-stream.sh --role session --inbox …/workspace/tasks
26964 26900  21:08:14  fswatch -l 0.5 …   |   27010 26900  21:08:14  bash src/watch-tasks-stream.sh … (checker)
sentinel: workspace/state/watch-tasks-stream.pid => [26900]  (mtime 21:08:14)
new core Monitor output /private/tmp/claude-501/…/94cae26a-…/tasks/boazqpz5k.output (complete):
  [stderr] watch-tasks-stream: role=session inbox=/Users/admin/Library/Application Support/space.ag2.app/workspace/tasks pid=26900
WATCHER_HELD: none (the string appears in the new transcript only inside the /startup instruction text).

## Step 5 — Mark's DM after the revive ("What's the weather in Sydney")
21:09:51Z   DM sent (task file `timestamp:` field; source_message_id <redacted>, receiving_instance @mark-embedded-cc-6.agent)
21:09:53     tasks/task-fba08a236d29ae5807.txt created (size 1553; host poller saw it at 21:09:53.498Z)
21:09:53     watcher 26900 announced it — boazqpz5k.output line 2: `TASK_FILE: task-fba08a236d29ae5807.txt`
21:09:53.533Z core received the Monitor event (transcript: Monitor event "Streaming task watcher" TASK_FILE: task-fba08a236d29ae5807.txt)
21:09:56.379Z core read the task file
21:10:20.257Z core wrote workspace/results/task-fba08a236d29ae5807.txt (reply)
21:10:21Z    result archived as results/archive/task-fba08a236d29ae5807-1792617021.txt (delivered via results/.outbox)
21:10:22.118Z tasks/task-fba08a236d29ae5807.txt → tasks/archive/ (host poller)
[2026-10-21T21:10:23.511Z] [stderr] ui: notification.reach show=true perm=granted room=<redacted>
21:10:25.938Z core: "Your Sydney weather answer was sent."
Queue → pick-up: ≤1 s (watcher event 21:09:53, same second as the file). Pick-up → delivered: 28 s (core work incl. two weather fetches). DM → answer in room: ~32 s.

## Summary
kill 21:07:26.96 → old watcher tree gone +2.08 s, sentinel released → core-only +9.96 s → new core +13 s → agent-online +28.7 s → new watcher +46 s (own sentinel, no WATCHER_HELD) → first post-revive DM answered in ~32 s. One supervisor, one watcher throughout.
Evidence (host, AG2/dev/VMs): witness1-step1-pre.txt, witness1-step2-kill.txt, witness1-hostlog.txt, witness1-tasks-poll.txt, witness1-bundle.md.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at dc91886. Both blockers are resolved:

  • The watcher now records its nearest claude/codex ancestor'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 sh between the core and the watcher, and it fails at 76d6fce.

The §15 witness shows:

  • the real zsh wrapper in the tree;
  • a SIGKILL of claude itself, 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 ps fails then, the watcher runs without the protection, which is safe and the same as today.

LGTM once CI is green.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 is claude, so ${__comm##*/} matches the ancestor walk. Without such an ancestor WATCHER_OWNER_PID stays empty and the watcher never exits on its own, which is the safe default. Tests (c) and (d) pin it.
  • The ps failure path. An empty stat for a pid that kill -0 still sees is treated as ps failing and the watcher keeps running. A pid kill -0 can't see is treated as gone, which is correct.
  • trap '' PIPE plus 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.sh at 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 no WATCHER_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 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 -axo loop. 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, so kill -0 still 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/bash sits 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 lstart string 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. lsof on a live checker shows both on the output fifo. Its sleep 3 can 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 >&2 leaves its text buffered, and the printf into 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_root already 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)

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at dc91886 on the owner's word. Both blockers are resolved (session-ancestor tracking with a reuse guard; a wrapper-shell test), the live witness shows a real Monitor-tree SIGKILL handled cleanly, and CI is green.

@john-the-dev john-the-dev left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 bare claude, so ${__comm##*/} matches it. The desktop app's Claude/Codex processes are capitalised, so the case-sensitive match skips them.
  • pid reuse. ps -o lstart= (trimmed) and the lstart that read -r stat start takes from ps -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, different lstart), a zombie (Z), and a pid that is gone (empty ps, failing kill -0). All three reach the exit. An empty ps answer for a pid that kill -0 still 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 cleanup is already trapped on HUP/INT/TERM/EXIT. The exit is a kill -TERM of the watcher, which runs cleanup (sentinel released only by its owner, fswatch/beat/checker stopped). The early exit 1 paths come before the checker exists. If the watcher is SIGKILLed, the checker sees kill -0 fail 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: a claude-named core holds a socketpair end as the watcher's stdout+stderr (SIGPIPE at default), launches the watcher through zsh -c '…; true', and is then SIGKILLed. At 104142b2 (and at main) the watcher is still alive 10 s later and holds its sentinel. At e1472f3f it exits with the sentinel released. At dc918869 it also writes … the core that owns this watcher (pid N) is gone; exiting (watcher pid M) to logs/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.

  1. 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.
  2. cleanup TERMs the checker subshell but not its sleep 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.

@john-the-dev
john-the-dev dismissed their stale review October 21, 2026 21:30

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.

@marklysze
marklysze merged commit b95a3f8 into sonichi:main Oct 21, 2026
50 of 52 checks passed
marklysze added a commit that referenced this pull request Oct 21, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants