Repository navigation
fix(notifier): a closed events pipe ends the notifier, every stop is bounded, one supervisor per inbox - #6141
qingyun-wu wants to merge 23 commits into
Conversation
…bounded, one supervisor per inbox A standby notifier spun at 60-90% CPU for ~9 h and ignored TERM: its watcher had closed the events FIFO without finishing its exit, so every timed read returned EOF at once (bash 3.2 reports it as 1, like a timeout) while kill -0 still answered. TERM's cleanup then waited on that watcher forever, and the supervisor waited on the notifier forever, outliving its tmux session. - src/notifier-guard.sh: events_stream_ended and stop_group_bounded (TERM, a wall-clock grace, then KILL), sourced by both notifiers and the supervisor. - Both notifiers stop on an ended stream and log why; a zero-time non-zero read backs off 1 s; stop_watcher is bounded. - Supervisor: bounded stop_child, restart backoff for a crash-looping notifier, one supervisor per inbox (an older one for the same inbox is replaced after an identity check). - health-check helper-cpu: names a Sutando helper averaging >50% CPU for 10+ min (src/helper_cpu_guard.py). - notifier-guard.sh joins both launchers' notifier version fingerprints. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
qingyun-wu
left a comment
There was a problem hiding this comment.
Verdict: changes needed (COMMENT because GitHub disallows a formal change request from the PR author account) at 63ec594fc8e1d1ab636988603ef18c2cc03aa1b3. The original EOF spin is fixed, but the new stop/ownership/probe contracts have reproduced gaps.
-
[P1] Verify the live supervisor's identity and inbox before signalling it —
src/agent/codex/cli/task-notifier-supervisor.sh:220–225.old_inboxcomes from the stale pidfile, not the live process; the process check only searches its command for the script basename. A reused PID belonging to another inbox's supervisor therefore passes. In an isolated probe, a supervisor sourced with inbox A was stopped when inbox B's stale record named its PID. Store and validate process birth identity plus the actual repo/inbox ownership, and validate child identities before acting on the captured PID list. A basename match is insufficient to protect other live inboxes. -
[P2] Serialize slot claiming and release —
src/agent/codex/cli/task-notifier-supervisor.sh:218–233. Atomic rename publishes a complete record but does not make read/replace/stop an atomic ownership operation. I synchronized two isolated claimants after both read an empty slot: both returned fromclaim_supervisor_slotand stayed alive, with only one recorded. The separate read-then-remove in release can also delete a replacement record. Use a per-inbox exclusion/ownership protocol covering claim and release, and test concurrent claimants plus release racing replacement. -
[P2] Escalate against surviving group members after the leader exits —
src/notifier-guard.sh:43–50. Both the wait condition and KILL decision check only the leader PID. With a synthetic group whose leader exits on TERM and whose child ignores TERM,stop_group_boundedreturned successfully while the child remained alive; the group never received KILL. This leaves precisely the helper descendants the group stop is intended to clean up. Track the owned process group independently of leader liveness and retain a bounded escalation for surviving members. The existing test covers a TERM-ignoring leader, which does not expose this case. -
[P2] Classify the executed script, not any repo-path argument —
src/helper_cpu_guard.py:61–63. A Node CLI executing a script outside the repo with--plugin-dirpointing inside the repo is classified as that plugin directory. Feeding two synthetic samples spanning 700 seconds with 560 seconds of CPU then reports it as a Sutando helper at 80%, andchecklabels it a busy loop and recommends stopping it. Parse the interpreter's execution mode/script operand; do not treat option values or data paths as the executable helper. Legitimate long CPU work also should not be diagnosed as a proven busy loop solely from utilization.
Validation: all 17 supplied tests pass on the branch. The real Codex notifier closed-pipe test, retained unchanged but run against the base implementation, fails by staying alive past its 20-second timeout; that same case passes on the branch. I did not independently repeat the claimed full eight-failure baseline matrix. Default integer restart backoff doubles to the cap and resets after a healthy lifetime; the supplied fractional-delay case deliberately stays constant. All additional process probes used synthetic sessions/inboxes and cleaned up their own processes; no live core was started, restarted or stopped.
CI, newest run per check name: still pending overall, with a concrete current failure: ShellCheck reports src/notifier-guard.sh:1:1 SC2148 because the sourced helper has neither a shebang nor a shell directive. Add # shellcheck shell=bash (or an appropriate shebang) and rerun. Passing the supplied tests is not sufficient for approval until the ownership and stop gaps above are addressed.
keweichen
left a comment
There was a problem hiding this comment.
Changes requested at 63ec594fc8e1d1ab636988603ef18c2cc03aa1b3.
The original defect is real: with the new test retained and the notifier restored to merge-base a60955fe, the Codex closed-pipe case stayed alive past its 20-second timeout; at this head all 17 supplied cases pass. The fix is still not merge-ready because the new ownership, stop, and probe contracts introduce the following blockers.
-
[P1] The slot handoff can kill a healthy supervisor for another inbox (
src/agent/codex/cli/task-notifier-supervisor.sh:218-225,282-292). The recorded inbox comes from the stale pidfile; the live identity check only greps argv for the script basename. I ran a supervisor-shaped process with inbox A, put its PID in inbox B's stale record, and productionclaim_supervisor_slotkilled it as B's owner. A replacement aimed at a dead tmux target can likewise evict healthy existing coverage before its firsttarget_alivecheck, then exit and leave no slot. Validate the claimant's target before handoff, bind canonical inbox/repo plus process-birth identity to the live holder, and revalidate every PID immediately before signalling it. -
[P2] Claim/release is not an ownership protocol (
task-notifier-supervisor.sh:218-233). Barrier-synchronizing two production claimants after both read an empty slot yieldedalive=2with only one recorded PID. The release check/remove can also delete a replacement published after its read. In addition, hashing rawTASKS_DIRgivestasksandtasks/separate slots for the same physical inbox. Canonicalize the inbox and serialize read/decide/publish/release; add concurrent claim and release-vs-replacement tests against the production writer. -
[P2] Group escalation stops when the leader exits (
src/notifier-guard.sh:43-50). With a TERM-exiting group leader and a same-group child that ignores TERM,stop_group_boundedreturned while the child remained alive. Both the loop and KILL decision key only on the leader PID. Track process-group existence independently; the current test covers only a leader that itself ignores TERM. -
[P2]
helper_cpu_guarddoes not identify the executed script reliably (src/helper_cpu_guard.py:54-63,69-70,123-134). It treats any later repo path as the script:node /outside/worker.js --plugin-dir /r/sutando/skills/pluginwas classified asskills/plugin, and two samples produced an 80% "busy loop; stop it (SIGKILL...)" warning. Conversely, macOSps -o command=loses argv boundaries, so the same repo script under a path containing spaces classified asNone—including this supportedApplication Supportcheckout shape. Sustained CPU alone also does not prove a loop, nonzeropsexits are reported as a clean result, andos.getuid()can escape the catch on Windows. Parse the interpreter's actual execution mode/script operand (or narrow to known helpers), preserve unknown/error states, and avoid prescribing termination without a second signal. -
[P2] The CPU sample record loses concurrent updates (
helper_cpu_guard.py:83-116). Atomic replace prevents torn JSON but does not serialize read/compute/write. A deterministic two-process run let a newer state containing PIDs 1+2 land, then an older writer replaced it with PID 1 only; both exited 0. This matters because app and fallback health checks can overlap. Add locking/merge semantics and a concurrency test that calls the production writer. -
Current CI failures are diff-owned. Python shard 2 flags the new test's unisolated environments at lines 59, 94, and 187; shard 7 flags unbound watcher processes at lines 102 and 249; ShellCheck reports SC2148 at
src/notifier-guard.sh:1. I reproduced both Python structural failures locally. Thetsc + testsfailure is only the aggregate gate reflecting those Python failures, not a TypeScript failure. -
A real restarted-path witness is still required. This changes startup and notifier delivery, while all supplied evidence is synthetic. After the code issues are fixed, provide the repository-required post-restart end-to-end task round trip before approval.
Worst case today: a stale/racing record removes healthy notifier coverage for another inbox, duplicate unrecorded supervisors process one inbox, or teardown leaves descendants alive; the CPU probe can simultaneously miss the affected helpers or tell an operator to kill unrelated legitimate work.
Validation: topic diff was cut from merge-base; git diff --check and scripts/review-checks.sh passed, with no hardcoded-host-path finding. python3 tests/notifier-spin-guard.test.py -v ran all 17 named tests and passed at head. Verdict: changes requested; not ready to merge.
sonichi
left a comment
There was a problem hiding this comment.
Agent-assisted review by Sutando, an AI agent working for this account, at 63ec594. Requesting changes. The original busy loop is fixed, and 11 of the 13 fix hunks fail their own test when reverted. Two gaps are listed at the end. But CI is red because of this PR, the CPU probe can't see a desktop install, and the one-supervisor-per-inbox guarantee has holes. I agree with all four findings in your own review at this head, and reproduced the third.
Blocking
- The CPU probe is blind on desktop installs (
src/helper_cpu_guard.py:52-63,helper_script). It runscommand.split(), then looks for an argument starting with the repo path, so a repo under.../Application Support/...never matches. Against this host's real supervisor command line it returnsNone. A relative launch (bash src/watch-tasks-stream.sh, which is how this host's session watcher runs) is missed too. Sohelper-cpureports ok on the very layout where the incident happened. The tests only use paths without spaces. - CI failures this PR causes:
- ShellCheck SC2148 on
src/notifier-guard.sh:1: no shebang and no# shellcheck shell=bash. tests/hermetic-env-structural.test.pyfails:tests/notifier-spin-guard.test.py:59,:94and:187copy the outer env; usetests/_helpers/hermetic_env.py.tests/watcher-fixtures-are-bound.test.pyfails: unbound process launches at:102and:249.- "tsc + tests" fails as a knock-on of the Python shards.
- ShellCheck SC2148 on
- Old-supervisor identity (
task-notifier-supervisor.sh:218-225). The inbox comes from a possibly stale pidfile, and liveness is apsgrep for the script name, so a reused pid serving another inbox would be stopped. This is your #1. - One supervisor per inbox isn't race-free.
- Claim and release are plain read-check-rename/remove with no lock. Two concurrent starts can both win, and a release can delete a replacement's record. This is your #2.
src/holder_lock.pyalready provides exactly this (a flock plus an owner record); reuse it rather than rebuilding it. - New:
supervisor_slot_file(~:208-212) hashes the rawTASKS_DIRstring, not the canonical path.ws/tasksandlink/tasks(through a symlink) get different slot files, so two supervisors can serve one inbox. Use the repo'scanonical_tasks_dir.
- Claim and release are plain read-check-rename/remove with no lock. Two concurrent starts can both win, and a release can delete a replacement's record. This is your #2.
- A bounded stop only checks the group leader (
src/notifier-guard.sh:38-52,stop_group_bounded). If the leader exits on TERM, KILL is never sent. Reproduced: a child that ignores TERM survived. Watchers run in their own process groups, so a notifier killed after the grace period can leave its watcher behind. This is your #3.
Should fix
6. The probe's verdict overreaches.
- An option value inside the repo (
--plugin-dir <repo>/...) is taken for the running script. This is your #4. - The warning calls anything over 50% for 10 minutes a busy loop and advises SIGKILL, even for a legitimately long job.
- The sample key rounds start time to 10 s, so
etimejitter near a boundary can drop a sample.
- Comment cap.
- The
notifier-guard.sh:1-9header is 9 lines and tells the incident story. events_stream_endedandstop_group_boundedhave 5-6-line comments.- The supervisor's slot, backoff and
stop_childcomments are 4 lines each. - The
helper_cpu_guard.pydocstring tells the incident story too. - review-checks only scans
.pyfor prose.
- The
- No
CHANGELOG-PENDING.mdentry, although this is a user-visible fix with a new health probe. - Revert-check gaps:
- removing the top-level
claim_supervisor_slotcall fails no test, because the test calls the function directly; - removing zombie detection in
notifier_proc_livefails no test.
- removing the top-level
Checked
- EOF detection is correct with the default timeouts.
- The leader stop is bounded (TERM, about 3 s, then KILL).
- Both launchers fingerprint the new script.
- The
--role sessionarm and standby stand-down path are unchanged, and the launcher tests pass.
Tests: tests/notifier-spin-guard.test.py 17/17; tests/codex-core-launcher.test.py 97 OK, 6 skipped. Revert checks: 11 of the 13 hunks fail their test when reverted; the two that don't are listed in 9.
…ead, CPU probe parses the script - One supervisor per inbox is a holder_lock on the inbox's canonical directory (src/notifier_slot.py), held for the supervisor's life; a newcomer waits for a predecessor still exiting, then stands down. No process is ever signalled, so a stale or reused pid can't evict another inbox's supervisor, two spellings of one inbox share a slot, and claim and release are serialized by the flock. - stop_group_bounded tracks the process group, so members that outlive their leader are killed too. - A TERM/INT/HUP during the notifier's timed events read is only recorded: an exit from the trap left bash 3.2's read timeout armed and the shell spun in cleanup (state R, child unreaped), ignoring the TERM. - helper_cpu_guard reads the interpreter's script operand (never an option value), finds paths with spaces and relative launches, keys samples by exact start time, locks the sample file and keeps unseen samples for an hour, reports an unreadable process table as such, and no longer prescribes a kill. - notifier-guard.sh: shellcheck directive; comments within the cap. - Tests: hermetic child envs, bound fixtures, real-supervisor slot cases, group/zombie cases, probe parsing and concurrency. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rprints Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
liususan091219
left a comment
There was a problem hiding this comment.
Request changes at 63ec594f. I agree with the standing change requests from keweichen and sonichi, and with your own review at this head. Two findings I checked myself, one of them new:
-
New: a source-grep test (
tests/notifier-spin-guard.test.py:332-335,test_health_check_runs_the_probe). It doesread_text()onsrc/health-check.pyandassertIns two code substrings. A refactor that keeps the probe turns it red, and a broken wiring that keeps the strings stays green. Replace it with behavior: runhealth-check's check list, orcheck_helper_cpu(), against a fakepsand assert that thehelper-cpuentry shows up. -
Reproduced:
helper_scriptis blind on a desktop install (src/helper_cpu_guard.py:52-64). I called the real function at this head:bash /Users/x/Library/Application Support/…/engine/sutando/src/agent/codex/cli/task-notifier-supervisor.sh --inbox /twith that repo →Nonebash src/watch-tasks-stream.sh --role session→Nonebash /r/sutando/src/watch-tasks-stream.shwith repo/r/sutando→'src/watch-tasks-stream.sh'
command.split()breaks the repo path at its space, and relative launches never match. So the probe reports ok on exactly the layout where the 9-hour spin happened. -
Still open from the other reviews:
stop_group_boundedkeys only on the leader pid (src/notifier-guard.sh:43-50).- The supervisor slot's claim and release are unserialized, and it hashes raw
TASKS_DIR(task-notifier-supervisor.sh:208-233;src/holder_lock.pyalready does this). - A reused pid serving another inbox can be stopped (
:218-225).
-
CI red from this PR: ShellCheck SC2148 on
src/notifier-guard.sh:1, plushermetic-env-structuralandwatcher-fixtures-are-boundon the new test file.
The original EOF spin fix itself looks right. license/cla is success, and there are 0 unresolved threads.
— Sutando (susans-MacBook-Pro)
|
Pushed 62eae4d (normal pushes; main merged in plainly). Point by point, against all three reviews at 63ec594. The slot: one supervisor per inbox (qingyun-wu 1–2, keweichen 1–2, sonichi 3–4)The pidfile slot and its eviction are gone. No supervisor ever signals another one.
Group stop (qingyun-wu 3, keweichen 3, sonichi 5)
New test: a leader that exits on TERM, with a same-group child ignoring TERM. The child is killed. That test fails on 63ec594. A finding of my own: TERM during the notifier's timed readWhile writing the TERM tests I found a bash 3.2 failure that matches the original "ignored TERM at high CPU":
The fix (
CPU probe (qingyun-wu 4, keweichen 4–5, sonichi 1 and 6)
CI (keweichen 6, sonichi 2)
The rest (sonichi 7–9)
Tests at this head
Still owed (keweichen 7)The post-restart end-to-end round trip on a live install. It needs the core's notifier sessions restarted on this branch, which core runs. I'll post the witness once that's done. |
sonichi
left a comment
There was a problem hiding this comment.
Approving at 62eae4d. All five blockers from my review at 63ec594 are fixed:
- The CPU probe finds helpers under "Application Support" and relative launches, checked against a real process table.
- The ShellCheck directive is in. hermetic-env-structural, watcher-fixtures-are-bound and notifier-spin-guard pass locally.
- and 4. The slot is a holder_lock on the inbox, so two spellings of one inbox share it and no process is signalled.
- Reproduced again: a child ignoring TERM now gets KILL; at 63ec594 it survived.
Items 6, 8 and 9 are fixed, and 7 is fixed apart from one nit. codex-core-launcher: 97 OK, 6 skipped.
Before merge: the PR conflicts with main in CHANGELOG-PENDING.md, so CI hasn't run at this head. Please resolve that and let CI go green.
Non-blocking follow-ups:
- Revert tests: 10 of 15 new hunks fail their test. These five don't: the signal-safe read, the valued-option skip, the slot helper's parent watch, target_alive before the claim, and the codex launcher's notifier_slot.py fingerprint. The read one matters most: on /bin/bash 3.2 I reproduced the spin it fixes (a signal during the read, cleanup outlasting the read timeout), and no test pins it.
- The read-timeout comment in codex task-notifier.sh is three lines.
(agent-assisted, Blue for Chi's seat)
…nts within the cap A 3 s events poll makes the read's timeout fire inside the watcher's grace, so NotifierHonoursTerm now fails (both notifiers) with the trap's exit restored, and passes with the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fier-spin # Conflicts: # CHANGELOG-PENDING.md
|
Pushed 81291a0 (normal pushes):
@sonichi, the push dismisses your approval at 62eae4d; please re-approve at 81291a0. The other three unpinned hunks you listed (the valued-option skip, the slot helper's parent watch, and the launcher fingerprint entry) stay as noted follow-ups. |
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 92%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
qingyun-wu
left a comment
There was a problem hiding this comment.
Verdict: changes needed at 81291a0ad21556073d484ea41b075d4f4c3068a3 (COMMENT review, not an approval). Two reproduced lifetime gaps remain.
-
[P1] Deferred TERM exceeds the supervisor deadline and strands the watcher —
src/notifier-guard.sh:49,src/agent/codex/cli/task-notifier.sh:577,src/agent/claude/cli/task-notifier.sh:992. On macOS Bash 3.2, setting the stop flag does not finish the ongoing timed read. Both adapters default to 30 seconds, butstop_childgives only three seconds before KILL. The notifier dies before its EXIT cleanup can stop its watcher, which has a separate session/process group (codex/task-notifier.sh:446,claude/task-notifier.sh:865). At this head, the actual Codex notifier with a TERM-responsive fixture watcher and the default timeout producednotifier_rc=-9 stop_elapsed=3.72 watcher_alive=Trueunder productionstop_group_bounded. On the prior head with identical runtime logic, both adapters reproduced; reverting only the copied Codex notifier's trap to direct exit changed the responsive-watcher case tonotifier_rc=0 stop_elapsed=0.23 watcher_alive=False. Keep the Bash-safe trap, but ensure deferred-read completion plus watcher cleanup fits the stop deadline, or give the stop owner a verified way to clean up that separate watcher. Add a default-timeout test that invokes the actual supervisor stop path and asserts both processes are gone. Direct TERM with a 30-second allowance and a shortened fixture read does not cover this interaction. -
[P2] The slot fences the helper, not the supervisor's full lifetime —
src/notifier_slot.py:77,src/agent/codex/cli/task-notifier-supervisor.sh:211. Afterheld, the supervisor never checks that its slot helper remains alive. The published record identifies that helper; if it crashes/is killed, the flock releases while the supervisor keeps running. Reusing the productionSupervisorSlotfixture at this head: start A, wait forheld, SIGKILL only its recorded lock-helper PID, then start B on the same inbox. Result:first_alive=true, second_alive=true, with B holding the lock. No supervisor signalled another, but exclusion was lost. Tie lock ownership to the consumer's lifetime and fence operation after lock loss; a successor must not run alongside the still-live original supervisor. Add this helper-death schedule to the existing concurrency/handoff tests.
The specific prior fixes otherwise check out: inode-keyed holder_lock removes pidfile-based cross-supervisor eviction; process-group escalation now checks surviving members independently of the leader; paths containing spaces and relative script launches are recognized; failed ps is reported as warn / not checked, not a clean CPU result.
Validation and limits:
- Fetched/rechecked the current SHA above after the PR advanced from
62eae4d4. Both added fault-injection findings reproduced on the current SHA. Fixture groups were explicitly cleaned up; no live core/notifier service was restarted or stopped. - Current head:
python3 tests/notifier-spin-guard.test.py— 24 tests, OK;git diff --checkand repository review-checks pass. - Broader suites ran at
62eae4d4: 14 of 19 Python suites passed, including holder-lock (233 tests, 1 skip), inflight (21), long-prompt (19), archived-head, worker-holds, abandoned-head/inflight, resolved-announcement, FIFO/standby tagging, both notifier health checks, and both structural guards. The current relevant runtime diff only changes notifier comments; the new fixture read timeout changes from 5 to 3 seconds, alongside merged base changes. - That broader run was not green: the new suite initially had one closed-pipe timeout (passed on isolated rerun and in the current full suite); legacy Claude notifier/submit/gate suites had timeout/timing failures; codex-core-launcher exceeded the 240-second harness limit. The shell supervisor standby-gate suite failed its worker-inbox arming scenario. A selected legacy timeout case passed on both base and reviewed-head reruns; I do not attribute the remaining timing failures to this PR without a discriminating comparison.
- Current CI includes a failing
shell tests (serial tail 2/3)job. Attempting to read its failure logs returned that the run is still in progress and logs are not yet available; no diagnosis claimed. Other checks remain pending. The required live post-restart task round trip is also still outstanding in the author's evidence.
Worst-case disruption: a normal stop or role handoff leaves an orphan standby watcher, or lock-helper loss permits competing supervisors on one inbox. The two lifetime regressions need fixing and the relevant suites/CI need a clean rerun before this is ready to merge.
sonichi
left a comment
There was a problem hiding this comment.
Requesting changes at 81291a0. I'm replacing my approval at 62eae4d, because CI at this head now fails in exactly the area this PR changes. main at 3057a1f is green.
-
Standby handoff, shell tests (serial tail 2/3):
- tests/watch-tasks-stream-role-session-kills-standby.test.sh fails (a), (d), (e), (e2) and (f). The session watcher never stands the standby down ("standby gone at 'never'"). After the session watcher dies, no standby is re-armed: "no standby re-armed after the session watcher died", and in (f) "no event came back from the inbox within 2s; no sentinel written and no standby touched".
- tests/watcher-writes-its-beat.test.sh fails too: "the hosting-mode handoff keeps the supervisor alive and stands the standby down only on readiness".
These are the supervisor and standby paths that the new slot (holder_lock on the inbox) and the supervisor rewrite touch. If the standby can't be re-armed, a dead session watcher leaves the inbox unwatched, which is the outage this PR set out to prevent. "tsc + tests (clean install)" is red as a knock-on.
-
Diff coverage: 65% against the 95% bar. src/notifier_slot.py shows 0%, because it runs as a subprocess, and helper_cpu_guard.py misses 124, 165-166 and 181-182. Either bring notifier_slot under the coverage runner (call its main from a test in-process) or test it directly.
The earlier fixes still hold, including the signal-safe read, which is now pinned: reverting notifier_signal fails 2 tests. I'll re-approve once the standby suites and coverage are green.
(agent-assisted, Blue for Chi's seat)
…fences on a lost slot A TERM deferred to the end of the notifier's 30 s read outlived the supervisor's 3 s stop, so the notifier was killed before its cleanup and its watcher (a session of its own) kept running. Each notifier now records its watcher's pid and start time; the supervisor stops that exact watcher after the notifier. Every supervisor wait loop checks its slot helper: a dead helper is reclaimed at once, or, if another supervisor took the slot, this one exits. notifier_slot runs in-process in the tests so diff coverage sees it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Pushed 63c645c (normal pushes; one push for everything below). Point by point, against the author-account review at 81291a0 and sonichi's CHANGES_REQUESTED. 1. [P1] A deferred TERM outran the stop and stranded the watcher. Confirmed. A TERM during the 30 s read is now honoured only once the read returns, so
2. The CI failures (
3. [P2] The slot fenced the helper, not the supervisor. Confirmed: a dead helper releases the flock while the supervisor runs on.
4. Diff coverage (65%,
Tests at this head:
|
qingyun-wu
left a comment
There was a problem hiding this comment.
Re-check at 63c645c3f234256595ce12f9ba71f22b2e54a87b: the two original reproduced lifetime cases are addressed, but changes are still needed for an identity-check failure path introduced by the watcher-record cleanup. COMMENT verdict only.
- Deferred TERM/default 30-second read: the supervisor now calls
stop_recorded_watcherafter stopping the notifier, and after a natural notifier exit. Both newSupervisorStopsTheWatcherToocases pass through actualstart_child/stop_child, with both notifier and watcher gone. - Lock-helper loss:
fence_slotdetects the dead helper and either reacquires or stops its notifier and exits when a successor owns the slot. Both helper-death schedules pass. This fixes the persistent duplicate-supervisor reproduction; it is polling-based recovery, not a guarantee of zero transient overlap.
[P2] Reject missing start-time identity before signalling — src/notifier-guard.sh:60,69. The writer records a PID even when its start-time query returns no output. The reader only tests string equality, so another failed query produces "" == "" and authorizes stop_group_bounded. notifier_proc_live also treats an unreadable ps status as non-zombie. I reproduced this using the production functions, a synthetic child process, and a ps stub exiting 1: notifier_record_watcher wrote a PID with no start identity, then stop_recorded_watcher killed it (killed_without_start_identity). This does not establish that a real reused PID was killed; it proves the claimed PID-plus-start fence fails open precisely when process identity is unavailable. A stale record/reused PID could therefore target a different process.
Require a nonempty recorded start and a nonempty successfully read current start before comparing; skip signalling on unknown identity. Have the writer omit/refuse an unverifiable record as well. Add regressions for empty/failed start reads, mismatched starts, and the matching-start happy path. Keep the existing default-read cleanup tests, since the successful-identity behavior fixes the original P1.
Validation: python3 tests/notifier-spin-guard.test.py ran 37 tests in 76.457s, OK (with subprocess/file ResourceWarnings); git diff --check and repository review-checks pass. No live service was restarted or stopped. At the final SHA check, GitHub reports merge conflicts and exposes only successful recheck and CLA contexts; this is not evidence of a green full CI suite. The prior live post-restart witness remains outside this re-check.
Worst-case remaining disruption is signalling an unrelated process when start-time verification is unavailable. Fix the fail-open comparison, then rerun the focused tests and full CI after resolving the merge conflict.
|
Read 63c645c. The fix is right, and it explains the standby failures. At 81291a0 the 3 s bounded stop KILLed the notifier while its TERM was still deferred in The one thing in the way: the branch conflicts with main, so CI never ran on this head, only the CLA checks. Please merge main, and I'll approve once the shell suites and the 95% diff-coverage gate are green. My change request stays until then. Non-blocking:
(agent-assisted, Blue for Chi's seat) |
…not a source grep Runs run_all_checks() against a fake process table with a helper hot for a full window and asserts the helper-cpu row warns and names it. Dropping the registration fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@liususan091219 Thanks. Your review is at 63ec594, and two rounds have landed since (62eae4d, then 63c645c). Pushed 053ba8f, a normal push, for the one point still partly open. Point by point:
Tests at 053ba8f: |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 79a01ff against previously reviewed 10db14a. The delta is test-only: armed() now writes the executable wrapper and counted notifier once per fixture, before launching the first supervisor. Subsequent calls reuse those files, avoiding the reported concurrent rewrite. No production files changed.
The affected SupervisorSlot suite passes all 7 tests locally, including both repeated-armed() cases (undecided slot and delayed predecessor fence). review-checks passes. This is macOS validation; I did not reproduce Linux ETXTBSY locally. Exact-head CI snapshot has 16 successful, 21 running, 7 queued and one skipped check, with no failures observed, so Linux verification is still pending.
Scoped LGTM for this fixture delta; overall verdict remains changes needed for the unresolved production behavior reported at #6141 (review). notifier_signal at src/notifier-guard.sh:51-53 still only sends TERM while read is active. A TERM-ignoring writer keeps the pipe open, so it does not guarantee prompt exit. The earlier bash 3.2 fixture reproduction on 10db14a left both notifiers and writer alive after nine seconds with a 30-second read; that production code is byte-for-byte unchanged here. This update does not clear that finding. No live sessions were changed.
sonichi
left a comment
There was a problem hiding this comment.
Re-approving at 79a01ff (since 10db14a only a test fix: the armed-supervisor fixtures are written once, since rewriting a script a running supervisor execs is ETXTBSY on Linux). Before that, since 01a11f5 (whose serial shell tail failed), 10db14a makes a TERM that lands during the notifier's events read end that read at once: the deferred-TERM handler now also TERMs the watcher's group (NOTIFIER_WAKE_PID, set by both notifiers), so the read hits EOF instead of waiting out its 30 s timeout; a test per notifier asserts TERM is honoured in under 8 s at a 30 s poll and leaves no watcher. Before that, 01a11f5 which makes overlap impossible rather than merely unlikely. notifier_slot.py serve takes the inbox's serving flock without blocking (LOCK_EX|LOCK_NB, opened O_NOFOLLOW under the lock dir), marks the fd inheritable, then setsid() and execs the notifier. The lock now lives exactly as long as the notifier's process tree, its watcher included, rather than the slot helper's lifetime. A successor gets exit 3 and starts nothing while any of that tree survives, and an unreadable lock gets exit 4, also starting nothing, so it fails closed. The supervisor uses it whenever there's an inbox, and the no-inbox path is unchanged. The tests cover held, unreadable and released.
Non-blocking: any descendant that outlives the notifier (a daemonized child of the watcher) keeps the fd, and so the lock, until it exits. While it lives, every successor gets exit 3 and the inbox has no notifier. It's worth a log line naming the holder pid, or a lsof-style hint in the exit-3 message, so a stuck lock is diagnosable.
(agent-assisted, Blue for Chi's seat)
… open The deferred-signal wake sent only TERM to the events writer, so a writer that ignored it kept the FIFO open and the read waited out its 30 s timeout. The wake is now the bounded stop the supervisor uses: TERM the group, wait up to 3 s, KILL what is left. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Pushed d573d93 (normal push) for the author-account review at 10db14a (P2, repeated at 79a01ff). A writer that ignores TERM held the events read open: confirmed and fixed. Cause. Fix.
Test.
On bash 3.2.
Since 79a01ff, the only changes are the one wake line in |
qingyun-wu
left a comment
There was a problem hiding this comment.
Re-reviewed exact head d573d93. Scoped LGTM: the TERM-deaf writer finding from my earlier reviews is resolved. This replaces that finding's changes-needed assessment; it is not a full merge-readiness attestation.
Independent before/after reproduction used the real notifier scripts in isolated FakeRepo fixtures, explicitly launched with /bin/bash 3.2.57, a DEAF_WATCHER holding stdout open, and a 30-second read timeout. TERM was sent 1.5 seconds after writer startup. With the parent guard implementation, both Claude and Codex notifiers remained alive after nine seconds. At this head, Claude exited with 143 in 3.05 seconds and Codex with 0 in 2.56 seconds; both writer processes were gone. Fixture processes and temporary repositories were cleaned up; no live notifier or worker was touched.
The read-active signal branch records NOTIFIER_STOP and calls stop_group_bounded. That helper sends TERM, waits for its bounded grace, escalates to KILL, and returns; there is no exit in that branch or helper. Both callers reset NOTIFIER_IN_READ after read -t returns and then exit on NOTIFIER_STOP. This preserves the Bash 3.2 no-exit-inside-read constraint while forcing EOF even when the writer ignores TERM.
Validation: all seven focused NotifierHonoursTerm / NotifierEndsAClosedStream tests pass (including the new test's Claude and Codex subcases); shell syntax checks and review-checks pass; worktree clean. Exact-head CI snapshot: 16 successful, 28 running and one skipped check, no failures observed. Remaining CI and the broader live-path witness are outside this scoped check; COMMENT rather than formal approval.
sonichi
left a comment
There was a problem hiding this comment.
Re-approving at d573d93. Since 79a01ff the deferred-TERM wake is the bounded stop_group_bounded (TERM, 3 s, KILL) of the events writer's group, so a writer that ignores TERM can no longer hold the read open for its 30 s timeout; a DEAF_WATCHER test per notifier pins it. Earlier, 79a01ff wrote the armed-supervisor fixtures once (ETXTBSY on Linux). Before that, since 01a11f5 (whose serial shell tail failed), 10db14a makes a TERM that lands during the notifier's events read end that read at once: the deferred-TERM handler now also TERMs the watcher's group (NOTIFIER_WAKE_PID, set by both notifiers), so the read hits EOF instead of waiting out its 30 s timeout; a test per notifier asserts TERM is honoured in under 8 s at a 30 s poll and leaves no watcher. Before that, 01a11f5 which makes overlap impossible rather than merely unlikely. notifier_slot.py serve takes the inbox's serving flock without blocking (LOCK_EX|LOCK_NB, opened O_NOFOLLOW under the lock dir), marks the fd inheritable, then setsid() and execs the notifier. The lock now lives exactly as long as the notifier's process tree, its watcher included, rather than the slot helper's lifetime. A successor gets exit 3 and starts nothing while any of that tree survives, and an unreadable lock gets exit 4, also starting nothing, so it fails closed. The supervisor uses it whenever there's an inbox, and the no-inbox path is unchanged. The tests cover held, unreadable and released.
Non-blocking: any descendant that outlives the notifier (a daemonized child of the watcher) keeps the fd, and so the lock, until it exits. While it lives, every successor gets exit 3 and the inbox has no notifier. It's worth a log line naming the holder pid, or a lsof-style hint in the exit-3 message, so a stuck lock is diagnosable.
(agent-assisted, Blue for Chi's seat)
…fier-spin # Conflicts: # src/health-check.py
|
Pushed 448d320: a merge of main only (normal push). It brings in #6170, #6172 and #6174, and resolves the conflict that made the PR DIRTY.
Re-run after the merge, under
Re-review at 448d320 please. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Re-reviewed exact merge head 448d320. Scoped LGTM carries forward from d573d93; the previously resolved TERM-deaf-writer finding stays resolved.
Confirmed the first parent is d573d93 and the second is main a62cb15. The notifier scripts, supervisor, shared guard, slot helper, CPU helper, launcher changes and notifier tests are unchanged from the reviewed head. The health-check conflict resolution retains both helper_cpu_guard and wedge_probes imports. Imported the merged health-check module successfully and verified both bindings without executing health checks or core operations.
Reran all seven NotifierHonoursTerm / NotifierEndsAClosedStream tests on this exact head with notifier processes explicitly launched under /bin/bash 3.2. All pass, including both adapters with TERM-deaf writers and the 30-second read. The production read-active handler still records the stop and performs the bounded writer-group stop; the caller exits after read returns. review-checks and git diff --check pass; worktree clean.
Exact-head CI snapshot: 6 successful, 11 running, 27 queued and one skipped check, no failures observed. This is a merge-resolution and shutdown-invariant recheck, not a fresh audit of every incoming main change or a full readiness attestation. Remaining CI and the live-path witness still apply. No live services, workers or core state were changed.
liususan091219
left a comment
There was a problem hiding this comment.
Approve at 448d320b. This is a re-review of my change request at 63ec594f. Every point in it is addressed, and I checked each one at this head.
- Source-grep test: fixed.
test_health_check_runs_the_probe(tests/notifier-spin-guard.test.py:854-873) now callscheck_helper_cpu()and runs the realrun_all_checks()against a fake process table that has a hot helper in it. It asserts thehelper-cpurow appears once. Nothing in it reads source. - Probe blind on desktop installs: fixed. I called the real
helper_scriptat this head against a repo under.../Application Support/engine/sutando:bash <repo>/src/agent/codex/cli/task-notifier-supervisor.sh --inbox /t→src/agent/codex/cli/task-notifier-supervisor.sh(it wasNoneat63ec594f)bash src/watch-tasks-stream.sh --role session→src/watch-tasks-stream.sh(it wasNone)node /outside/worker.js --plugin-dir <repo>/skills/plugin→None, so an option value is no longer taken for the script.
- Points still open from the other reviews: fixed.
stop_group_boundednow tracks the process group on its own (src/notifier-guard.sh:27-44).- The slot is now a
holder_lockflock on the canonical inbox (src/notifier_slot.py), and that module signals no other process. That closes the claim/release race, thetasksvstasks/spelling, and the reused-pid kill. - The CPU sample record is read and written under
locked_file(src/helper_cpu_guard.py:132).
- CI: green. 46 checks pass and 1 is skipped (Windows).
license/clais success (1 context). There are 0 unresolved threads.
No change-request condition fires. I found no hardcoded-path match on added lines, and the busy-loop fix covers both notifiers and the supervisor.
Bar: expert approval from sonichi (OWNER) at this exact head. I re-ran tests/notifier-spin-guard.test.py at this head: 47 tests, OK. The PR body says 8 of the cases fail on main.
Still owed (named, not blocking): a live post-restart task round trip on a real install. keweichen's change request at 63ec594f is still standing. His code points look addressed to me, but a re-review from him would clear it.
— Sutando (susans-MacBook-Pro)
keweichen
left a comment
There was a problem hiding this comment.
Changes requested at exact head 448d320bf5b539f64122c8426844fa2bc0575b27.
-
[P2] Sample persistence failures make the helper CPU probe report a false clean result —
src/helper_cpu_guard.py:161-166,tests/notifier-spin-guard.test.py:840-852.evaluate()catches everytmp.write_text/os.replaceOSErrorand discards it. That makes the publiccheck()handler at lines 178-182, which is supposed to warn that samples are unwritable, unreachable for these failures.I drove the exact-head module twice with the same real helper identity. The first observation was 1 second of CPU at wall time 1000; the second was 601 seconds at wall time 2000, or 60% over 16m40s. With normal replacement, the first call returned
okand the second returnedwarn, naming the helper at 60%. Withos.replaceforced to raise on both calls, both returnedok: no Sutando helper above 50% CPU for 10+ min, no sample state existed, and a temporary sample file remained. Every later run starts without a baseline and can repeat that false-clean result indefinitely.The current test deliberately pins the gap: it patches
os.replaceto fail and asserts thatevaluate(...)is quiet, while the warning assertion covers only a parent-path failure beforeevaluateruns. Let the write failure reach the public verdict (or return an explicit unknown), clean the temporary file, and add acheck()regression with the working-state positive control. -
The mandatory live-path witness is still absent. This PR changes notifier startup and delivery ownership. The author previously said the real post-restart round trip was still owed; no exact-head input-received → reply-delivered witness appears in the record.
CONTRIBUTING.mdand REVIEW lesson 15 require that real restarted-path evidence before approval. Green harness tests cannot establish it.
The prior code findings from my review at 63ec594f are otherwise resolved: canonical lock-backed ownership, serving-lifetime exclusion, group escalation after leader exit, true script-operand parsing including an Application Support path, locked sample updates, and the earlier CI failures all have discriminating evidence. Exact-head CI and CLA are green and there are no review threads, but the structured decision/evidence tools still return unknown.
Validation: the focused suite ran 47/47 under both Bash 5.2 and macOS Bash 3.2; parser, sample-lock, health-registration, and group-liveness mutations failed their targeted tests; git diff --check and review checks passed. No live service or watcher was changed.
Worst case today: the health check claims no hot helper while its sample state is not being persisted, allowing the original high-CPU notifier failure to remain invisible; separately, the restarted production path could still lose task delivery without any real witness having exercised it. Fix the false-clean state contract and provide the exact-head live round trip for re-review.
Agent-assisted review by Sutando for this account.
|
@keweichen — pushed
The complete 49-test notifier suite under macOS Bash 3.2 finished with 47 passing: the Claude closed-pipe test timed out at 20 seconds (also reproduced unchanged at old head Additional local passes: GitHub verified head Agent-assisted implementation by Sutando for this account. |
|
CI investigation at unchanged head
No code changes or push were needed. The live restart/delivery witness remains with the core/operator. PR left open and unmerged. Agent-assisted CI investigation by Sutando for this account. |
The persistence and ownership review findings were already repaired, but the helper CPU and slot module narratives still exceeded the repository comment limit. Keep only the non-obvious lifetime and CPU interpretation constraints. Runtime behavior is unchanged; the live restarted delivery witness remains outstanding.
|
@sonichi — follow-up on your November 2 change requests, checked from b9aada9 and the docstring-only follow-up pushed below. Agent-assisted by Sutando for this account.
For your later 81291a0 review: standby cleanup now records and stops the detached watcher using verified birth identity ( Local focused validation: 31 tests passed (HelperCpu, HelperScript, NotifierSlotInProcess, Guard), including write/replace failure warnings and the 60% positive control. No live core or watcher was restarted, no task files were created, and this is not the outstanding live-path witness. Please re-review; do not clear that witness requirement based on harness tests. |
|
@keweichen — reply to both points in your November 4 review at 448d320. Agent-assisted by Sutando for this account.
The earlier code findings you marked resolved at 448d320 remain in place. Focused local validation: 31 tests passed across HelperCpu/HelperScript, slot in-process ownership and guard process-group/zombie tests. Before the docstring update, GitHub had 46 SUCCESS and one SKIPPED at b9aada9; CI must be rechecked on the new head. No merge performed, and the outstanding witness is not being dismissed. |
|
Follow-up: CI correctly flagged the generated source map after the docstring summary edits. Regenerated docs/src-map.md and pushed normally; gen-src-map --check and all 27 src-map tests pass. No runtime change. The live restarted delivery witness remains outstanding. |
sonichi
left a comment
There was a problem hiding this comment.
Re-approving at 394c7c2. My last approval was at 448d320; since then the PR has three changes of its own plus a merge of main, and I read the three and the merge's conflict resolution.
The fix for keweichen's P2 (b9aada9, src/helper_cpu_guard.py). evaluate() used to swallow every OSError from the sample write or os.replace, so check()'s "samples unwritable" warning was unreachable and a failing disk read as a clean result. Now the temp file is created before the try, the write and replace are not caught, and a finally removes the temp file. The error reaches check(), whose except OSError returns warn with "not checked: samples unwritable". I ran tests/notifier-spin-guard.test.py at 7ccbc54 in a scratch copy: 49 tests pass. It includes the cases keweichen asked for: write and replace failing, no temp file left, the previous sample preserved when replacement fails, and the working-state positive control (ok, then warn at 60%).
The docstring trims (7ccbc54, helper_cpu_guard.py, notifier_slot.py) remove comment text only, to keep within the two-line comment rule. 394c7c2 regenerates two lines of docs/src-map.md and nothing else.
The merge of main (e5ee7f8) has two combined hunks, in task-notifier-supervisor.sh and health-check.py. Both keep the PR's restart-streak variables and its check_helper_cpu entry alongside main's macos-notify.sh call and check_sandbox_delegation.
Not covered: keweichen's second point, the mandatory live-path witness, which I did not read to its end, and whether this head changes anything about that. I did not rerun the tests at 394c7c2, only at 7ccbc54; the delta between them is documentation. The review is the owner's repo, so merging and reconciling keweichen's open change request is yours.
Preserve adaptive pickup retries and fail-closed read handling alongside bounded notifier shutdown. Source the shared guard in the real-loop fixture. Sutando-Task: task-5bcbdbeff0c4eb50b2
Retain readiness polling for an exited standby, but use one probe when a still-running watcher has failed. Both adapter EOF tests pass. Sutando-Task: task-5bcbdbeff0c4eb50b2
Supply the authoritative argv vector alongside the displayed command in the unread-watcher fixture, so a real process with the fixture PID cannot change its verdict. Sutando-Task: task-5bcbdbeff0c4eb50b2
|
@cla-assistant check |
What happened
On one host, a Claude core's standby
task-notifier.shran at 60–90% CPU for about 9 hours. TERM didn't stop it; it needed SIGKILL. Its parent, the sharedtask-notifier-supervisor.sh, was 3+ days old and no longer belonged to any live tmux session.Why
The busy loop. The notifier reads its standby watcher's announcements from a FIFO with
read -t.kill -0 "$watcher_pid".kill -0: it may be mid-cleanup, or not yet reaped.readreturned EOF at once and the loop went round with no sleep, forkinglsand friends on each pass.TERM looking ignored.
exit 143runs the EXIT trap, whosestop_watchersent TERM and then waited, with no bound, on that same stuck watcher. So the notifier never finished exiting.Supervisors outliving their sessions. When a launcher replaces a
<session>-watchertmux session, the supervisor's HUP/TERM trap runsstop_child, the same unbounded TERM-then-wait. A notifier that doesn't exit therefore kept its supervisor alive past its session. That's the 3+-day orphan. On the host, every other supervisor maps one-to-one to a live watcher session: one for the core and one per pool worker, by design."A codex supervisor running a claude notifier" is by design.
src/agent/codex/cli/task-notifier-supervisor.shis the shared supervisor for every runtime, andSUTANDO_NOTIFIER_SCRIPTselects the notifier it runs. Only its path is codex.The fix
src/notifier-guard.sh(new), sourced by both notifiers and the supervisor:events_stream_ended: the stream has ended when its writer is gone or a zombie, or when a timed read returned well before its timeout. That return is EOF, and no writer ever reopens this FIFO.stop_group_bounded: TERM the group, wait up to 3 s of wall time (polling with thekill -0builtin, not a fork per poll), then KILL.stop_watcheris bounded.stop_childis bounded.RESTART_DELAYis used as given.state/notifier-supervisors/. A new supervisor replaces an older live one for the same inbox, and that one's notifier children with it. It acts on a recorded pid only when that process still runs this script for this inbox.health-check: a newhelper-cpuprobe (src/helper_cpu_guard.py) names any Sutando helper averaging over 50% CPU for 10+ minutes, by pid and script.notifier-guard.shjoins both launchers' notifier version fingerprints, so a change to it replaces the running sessions as other notifier changes do.Tests
tests/notifier-spin-guard.test.py(new, 17 cases) runs the real scripts against a fake repo whosewatch-tasks-stream.shis a stub:stop_child, the backoff sequence (including a fractional delay), replacement of an older supervisor for the same inbox, and a recorded pid that isn't a supervisor being left alone.helper-cpuprobe: parsing, helper detection, the window, a reused pid, a gone process, and its wiring into health-check.Against main, 8 of the 17 fail: both notifier cases of each kind, the bounded
stop_child(it hangs), the single-slot replacement, the backoff sequence and the probe's wiring. With the fix, all pass.tests/codex-core-launcher.test.py: its fixture now copiessrc/notifier-guard.sh, and its copy of the version formula includes it.Existing notifier, supervisor, launcher and health-check tests:
start-cli-claude-config-dirandstart-cli-model-pin, fail identically on main on the same host.tests/codex-core-launcher.test.py: its failures on this host are environmental, and the set is identical on main and on this branch (none only on the branch).Reproduction
The spin came from a watcher stuck mid-exit. To reproduce, start a notifier against a stub watcher that runs
trap '' TERM; exec 1>&- 9>&-; while :; do sleep 1; done.🤖 Generated with Claude Code