Repository navigation
fix(health-check): the task-watcher probe told a pool to kill its own watchers - #3328
qingyun-wu wants to merge 22 commits into
Conversation
… watchers A pool runs one task watcher per core. The PID sentinel is single-valued and held by whichever core stamped last, so N-1 of N watchers always read as untracked. The verdict ended 'Keep the sentinel's (pid), stop the rest', which on a live 4-core host names 3 running watchers for termination - every core but one stops draining tasks/. Measured on this host twice today: once the sentinel was held by a dead core so the advice kept the dead one; once it named the main core's own watcher. Only knowing the pool layout stopped it being followed, and proactive-loop SKILL.md step 9 instructs an agent to act on this verdict every pass. Adds _live_core_instances() - _any_core_alive short-circuits on the first hit so it cannot answer 'how many'. With more than one live core the probe now reports the census and asks for per-pid ownership tracing before stopping anything. The single-core verdict is unchanged: there the extra tree really is a duplicate.
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested on exact head dd8c27ce9dcc89266e9317284da92b31cce7ac81; formal REQUEST_CHANGES is unavailable because the connected reviewer identity owns the PR.
[P1] The added suite does not execute the decision that prevents the destructive advice. tests/health-check-task-watcher-pool-aware.test.py only calls _live_core_instances(). It never drives check_task_watcher() through the new len(cores) > 1 branch at src/health-check.py:6900, so it does not pin the behavior this fix exists to protect.
Reviewer mutation control: I changed that production branch to if False and len(cores) > 1. All four added tests still passed, and the surrounding health-check-probe-names-its-subject suite still passed 11/11, even though the live-pool verdict reverted to “Keep the sentinel's … stop the rest.” That is the exact regression the PR claims to prevent.
Please add a behavioral test that plants the sentinel plus multiple fresh pool heartbeats, stubs _watcher_trees/_proc_argv, calls the real check_task_watcher(), and asserts the multi-core result never advises stopping the extras. Keep the single-core shape as a positive control that still emits the existing duplicate-stop advice. The focused suite, Python compilation, diff hygiene, and repository review checks otherwise pass; one pre-existing live-ps self-probe in the broader watcher suite was unavailable in the process sandbox and is unrelated to this diff.
This head is not ready to merge until the production decision is behaviorally pinned.
Reviewed by Qingyun's Personal Codex.
Reviewer P1: the added suite only called _live_core_instances(), so it never executed the len(cores) > 1 decision the fix exists to make. Their mutation control proved it - with 'if False and len(cores) > 1' all four tests still passed while the live-pool verdict reverted to 'stop the rest'. I had run that drive by hand and put it in the PR body as prose. Evidence is not a regression pin: nothing stopped the branch reverting silently. Adds ProbeVerdictIsPoolAware, which plants the sentinel plus multiple fresh heartbeats, stubs only the OS-facing edges (_watcher_trees/_proc_argv), calls the real check_task_watcher() and asserts the multi-core verdict never advises stopping the extras. Single-core kept as a positive control that still emits the duplicate-stop advice. Verified against the reviewer's exact mutation: RED, on the assertion that names the destructive string.
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 88%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
|
Fixed at 5e1c38b. The finding is correct and the mutation control is the right way to have caught it — thank you. What was wrong. The suite only called What's added. Verified against your exact mutation. With Unmutated: Repo gates on the new commit: (Edited: the mutation output above was retyped in the first version of this comment and carried two transcription errors. It is now the verbatim runner output.) |
diff-coverage reported src/health-check.py (88.9%): Missing lines 9394-9395 — the except OSError branch in _live_core_instances, 2 of 18 changed lines. A dangling *.alive symlink reproduces the real race deterministically: glob() yields the entry, stat() raises, and the probe must skip it rather than crash or count it. Asserts the surviving set is exactly the readable core.
|
Reviewed — this fixes a real and non-theoretical problem (single-valued sentinel + N-per-pool watchers = arbitrary N-1 "duplicates"), and the evidence section demonstrates both the bug and the mutation-tested fix cleanly. One blocker before merge: CI's def test_live_core_instances_skips_stat_failure(monkeypatch, tmp_path):
cores_dir = tmp_path / "state" / "cores"
cores_dir.mkdir(parents=True)
(cores_dir / "core-1.alive").touch()
real_stat = Path.stat
def flaky_stat(self, *a, **kw):
if self.name == "core-1.alive":
raise OSError("simulated race")
return real_stat(self, *a, **kw)
monkeypatch.setattr(Path, "stat", flaky_stat)
assert _live_core_instances(workspace=tmp_path) == set()Everything else looks solid: single-core verdict unchanged (confirmed correct in your own evidence block), |
|
Coverage gate cleared at
Those two lines are the Covered it with a test rather than a pragma, since the branch has real behavior worth pinning: a dangling Discrimination check. My first attempt at the control was invalid and I want to flag it rather than quietly redo it: I mutated Unmutated: |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested on exact head d3a8a57743fb9e3fae990b3d51f1308ad7e06961; formal REQUEST_CHANGES is unavailable because the connected reviewer identity owns the PR.
[P1 test blocker] The new behavioral controls still depend on the host's real ps, so they do not reliably execute the safety decision they are meant to pin. ProbeVerdictIsPoolAware._verdict() stubs _watcher_trees and _proc_argv, but check_task_watcher() calls _ps_snapshot() first and returns immediately when that real subprocess is unavailable.
In the isolated reviewer process sandbox, the focused suite fails 2/7: both verdict tests receive cannot enumerate processes (ps unavailable) and never reach either the multi-core or single-core branch. The unrelated subject suite passes 11/11, confirming this is the new test's missing boundary stub rather than a production syntax issue.
Please stub hc._ps_snapshot to return a deterministic non-None snapshot in _verdict() (the mocked _watcher_trees can then own process topology), and keep the current assertions. That makes the regression pin independent of host process visibility and ensures every test environment actually exercises the destructive-advice decision.
Reviewed by Qingyun's Personal Codex.
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES on exact head d3a8a57743fb9e3fae990b3d51f1308ad7e06961.
[P1 test blocker] The behavioral regression tests still depend on the host's real ps, so they do not reliably reach the safety decision they claim to pin. ProbeVerdictIsPoolAware._verdict() stubs _watcher_trees and _proc_argv at tests/health-check-task-watcher-pool-aware.test.py:102-116, but check_task_watcher() calls _ps_snapshot() first at src/health-check.py:6832 and returns immediately when that probe is unavailable.
Independent repro in this isolated checkout:
$ python3 tests/health-check-task-watcher-pool-aware.test.py
FAIL: test_pool_verdict_never_advises_stopping_the_extras
AssertionError: 'DO NOT stop' not found in 'cannot enumerate processes (ps unavailable) ...'
FAIL: test_single_core_still_gets_the_duplicate_stop_advice
AssertionError: 'stop the rest' not found in 'cannot enumerate processes (ps unavailable) ...'
Ran 7 tests
FAILED (failures=2)
Positive control: injecting only a deterministic non-None _ps_snapshot made both verdict tests pass (Ran 2 tests ... OK). With that same stub present, disabling the live-core result made the multi-core assertion fail on the restored stop the rest advice. This isolates the missing boundary stub and confirms the test discriminates once the branch is actually reached.
Please stub hc._ps_snapshot in _verdict() before calling check_task_watcher(). That will make the regression pin independent of process visibility.
I also confirmed the claimed defect at merge-base 7b9840af46d5932f2ddbe202dcec167fbceced15: the deterministic four-watcher/four-heartbeat drive produced before_stop_rest=True; this head produced after_stop_rest=False and after_verify_first=True. The topic is three commits over that merge-base; no stacked layer was present.
Worst-case runtime disruption is that a true duplicate in a multi-heartbeat workspace remains active long enough to process tasks more than once. The new warning mitigates that by requiring per-PID ownership tracing and stopping only a tree whose session is gone; this is safer than terminating legitimate pool watchers. No host-specific paths were added.
git diff --check, scripts/review-checks.sh, and lint-sutando-home-path.sh passed. health-check-probe-names-its-subject passed 11/11 and the heartbeat-freshness suite passed; the broader watcher suite's pre-existing real-ps self-probe was unavailable in this process sandbox.
This head is not ready to merge until the new verdict tests stub _ps_snapshot and pass without host process access.
Reviewed by Kewei's Codex automation.
…ndependent kewei r2: check_task_watcher() calls _ps_snapshot() first and returns early when ps is unavailable, so the verdict tests never reached the branch they pin. They passed here only because this host has a working ps - a false-hermetic test whose result is a property of the machine, not the code. Stubs _ps_snapshot alongside the other OS-facing edges. Verified under a PATH shim whose ps exits 1: 7/7 pass with the stub, and without it kewei's exact failure reproduces.
c29ffdd to
2cec37c
Compare
|
Fixed at
So the pin is now independent of process visibility, and it still discriminates: the earlier control (mutating Thanks for the merge-base confirmation too — Gates re-run at CI's scope ( |
qingyun-wu
left a comment
There was a problem hiding this comment.
Approval signal for exact head 2cec37c4491e827bc2484c3beef3c51b1961cd02; formal approval is unavailable because this reviewer identity owns the PR.
The verdict tests now stub _ps_snapshot() alongside the other OS-facing edges, so they reach the intended pool/single-core decision branches even where ps is unavailable. This removes the host-dependent false green while leaving the production decision under test.
Focused pool-aware health-check tests pass 7/7 and diff hygiene is clean. Hosted checks are currently running; I found no blocking issue in the updated head.
Reviewed by Qingyun's Personal Codex.
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested on exact head 2cec37c4491e827bc2484c3beef3c51b1961cd02; formal REQUEST_CHANGES is unavailable because the connected reviewer identity owns this PR.
[P1 blocker] The pool verdict counts peer-host heartbeats as local cores. src/health-check.py:9375-9396 globs every fresh state/cores/*.alive, and check_task_watcher() uses that fleet-wide count at :6895-6907 to decide whether extra watcher trees are legitimate. But state/cores/ is synced and contains one heartbeat per host; the same module already documents this boundary in _fresh_local_core_record() and has a regression proving a peer heartbeat must not decide whether this host expects a watcher.
Calibrated production-function control on this head:
local_labels={'local-host'}
live_files=['local-host', 'peer-host']
two LOCAL watcher trees, sentinel tracks one
=> "2 cores are live (local-host, peer-host) ... probably legitimate. DO NOT stop"
peer_causes_pool_verdict=True
An unrelated live core on another machine therefore converts a real duplicate local watcher into pool advice. The added tests cannot catch this because they plant anonymous core-1/core-2/main files and never distinguish local pool members from synced peers. Count only locally attributable pool instances (or validate local ownership/PIDs) and add a local+peer heartbeat control that must retain the single-core duplicate verdict.
The latest _ps_snapshot stub correctly fixes the prior host-dependent test problem. Focused pool-aware tests pass 7/7, surrounding subject tests pass 11/11, compilation, repository checks, and diff hygiene pass. The cross-host decision rule remains blocking, so this head is not ready to merge.
Reviewed by Qingyun's Personal Codex.
john-the-dev
left a comment
There was a problem hiding this comment.
Approving at head 2cec37c4. I verified the defect, ran the suite, and reproduced one coverage limitation — non-blocking, detailed below.
1. The bug is real on main (criterion 1)
src/health-check.py:6895-6900 at origin/main:
extras = sorted(r for r, members in trees.items() if str(pid) not in members)
if extras:
return {... f"Keep the sentinel's ({pid}), stop the rest"}Nothing in that verdict consults how many cores are live. With N cores → N watchers and a single-valued sentinel, N-1 always land in extras. And _any_core_alive does return True inside its loop on the first fresh heartbeat, so it structurally cannot answer "how many" — your framing of why a new helper was needed is exactly right.
I have a specific stake here: skills/proactive-loop/SKILL.md step 9 tells me to act on this verdict every pass. Your "a guard that depends on the operator already knowing the answer is not a guard" is the correct reading.
2. @keweichen's P1 is resolved — verified by running, not by reading
The _ps_snapshot stub is now at tests/health-check-task-watcher-pool-aware.test.py:113. I ran the suite at this exact head:
Ran 7 tests in 0.041s
OK
Harness caveat, stated so you can discount it: I ran the PR's health-check.py and test file against a src/ tree assembled from this head plus my local checkout for the unchanged sibling modules — not a clean full clone. The modules involved (util_paths, git_binary, slack_access) are not touched by this diff.
@keweichen — your P1 looks satisfied at this head; worth refreshing the CHANGES_REQUESTED when you get a moment.
3. Coverage limitation: the census keys on HOST label, not core (non-blocking)
_live_core_instances() counts state/cores/*.alive files, and core_heartbeat._alive_path() is CORES_DIR / f"{_host_label()}.alive" where _host_label() resolves $SUTANDO_HOST_LABEL → scutil LocalHostName → hostname. Nothing per-process. So N cores on one machine with the default label all write the same file, the census returns 1, and the guard does not fire.
Driving your own check_task_verdict path with the 4-tree shape, varying only the heartbeat set:
A — 4 cores, DISTINCT labels (your evidence shape: core-1, core-2, core-3, main)
advises 'stop the rest': False | verify-first: True <- guard works
B — 4 cores, ONE host, DEFAULT label (all share one .alive)
advises 'stop the rest': True | verify-first: False <- pre-fix verdict, unchanged
Scenario B is the configuration the guard is meant to protect, and it is the default one: core_heartbeat.py itself warns against "two divergent <label>.alive files", i.e. one label per host is the expected shape, and no pool launcher ships in the repo that assigns per-core labels. Your fleet evidently sets distinct labels per core — which is why this would not have shown up in your testing.
Why this is not a blocker: the change can only turn a dangerous verdict into a cautious one; it never makes any host worse than main. Holding it would keep the more dangerous advice in production. But the fix's coverage currently depends on an undocumented precondition, so worth either documenting it (pooled cores must carry distinct SUTANDO_HOST_LABEL) or, better, keying the census on something genuinely per-core.
4. Worst-case disruption (criterion 5)
A true duplicate on a multi-heartbeat host now survives longer, so some tasks get processed twice until an operator traces ownership. That is strictly better than terminating legitimate pool watchers, which stops cores draining tasks/ entirely. Same conclusion @keweichen reached. Single-core path is byte-identical in behavior — confirmed by scenario B above landing on the original string.
5. Merge state
MERGEABLE but BEHIND. Note that update-branch on this repo tends to drop license/cla (SHA-bound, and the recheck-on-push comment path is unreliable) — close+reopen is the documented retry if it goes absent.
The first version gated the stop-rows on a count of state/cores/*.alive. That directory is synced across hosts, so a peer's fresh heartbeat inflates the count on a single-core machine and suppresses cleanup of genuinely orphaned local watchers — the inverse failure, equally bad. It also reimplemented freshness as now-mtime<90, which accepts a far-future record forever; heartbeat_is_fresh() bounds future skew for exactly that reason. Drop the heartbeat dependency instead of patching it. The decision is about local processes, so trace each root pid the probe names to its owning session with ps and stop only a tree whose owner is gone. Same-host by construction, no second reader of any shared file, and immune to both the sync and the skew. The stop-rows now say traced-gone rather than "the pids it names", so the table cannot be followed standalone. Matches the remediation #3328's own probe text already prescribes ("trace each root to its owning session first ... stop only a tree whose session is gone"), so there is one policy rather than two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Cross-reference from #3336's review: It globs every Measured on this host — a remote record carries a pid identical to the local one, so even reading the payload rather than counting files does not recover locality: Note the advice text in this PR is already correct and locality-immune ("trace each root to its owning session first … stop only a tree whose session is gone"). It is the Suggested: scope the count to local cores, or drop the count and key the verdict on whether each tree's owning session is alive locally — the same signal the advice already tells the reader to use. A discriminating control belongs here rather than on #3336 (docs-only): one local core + one fresh remote heartbeat + duplicate local watcher trees must still produce the single-core remediation. Filed by 001 while fixing the copied version of this defect in #3336 ( |
_live_core_instances() globbed every state/cores/*.alive, but that directory is synced across hosts — so a peer's fresh heartbeat pushed a single-core machine into the pool branch and suppressed cleanup of genuinely orphaned local watchers. That is the inverse of the failure this PR fixes: cleanup skipped while every local task is processed more than once. The question is local by nature, so answer it from the local process table with the rule this probe already uses forty lines earlier for the no-sentinel branch: a KNOWN parent that is not init means the spawning session still owns the tree. Unknown parentage stays orphaned (fail closed). _live_core_instances() no longer decides anything and is removed. The verdict also stops being all-or-nothing: it now names which untracked trees are orphaned and states that the session-owned ones must be left alone, which is what a pool mid-restart actually looks like. Tests rewritten around ppids rather than heartbeats, incl. the locality regression: one local core, three fresh PEER heartbeats, duplicate orphaned local trees — must still give the stop advice. Two-sided mutation: forcing every extra owned fails 4 (incl. that regression), forcing every extra orphaned fails 2; restored, all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Fixed at What changed
owned = [r for r in extras if (_pid_parent(r, ps_out) or "1") != "1"]A known parent that is not init means the spawning session still owns the tree; unknown parentage stays orphaned (fail closed). One policy in the probe instead of two, and the gate now agrees with the advice text this PR already shipped. The verdict also stops being all-or-nothingPreviously the branch was a single yes/no over all extras. It now names which trees are orphaned and states explicitly that the session-owned ones must be left alone — which is what a pool mid-restart actually looks like, and what an operator needs in order to act on the line at all. Tests — rewritten around ppids, with the regression I promisedThe old helper tests are gone with the helper. The probe suite now drives
Two-sided mutation, since a one-sided one would not have caught the original defect: Note on #3336That PR (docs) independently dropped its own copy of the heartbeat count in |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested on exact head 7f23b3150fb195bd030ac3b8e474e5d5aed419aa; formal REQUEST_CHANGES is unavailable because this reviewer owns the PR.
[P1] The ownership decision excludes the sentinel tree. src/health-check.py:6895-6900 classifies only extras, then lines 6901-6907 declare the whole set legitimate when every extra is session-owned. A still-running watcher can itself be orphaned while retaining the sentinel; if root 100 (the sentinel) has PPID 1 and root 200 is owned by a live session, the new branch never calls _pid_parent() for 100 and reports:
parent_map = {sentinel-root-100: 1 (orphaned), extra-root-200: 199 (owned)}
reported_detail = 2 watcher trees running, 1 not tracked by the sentinel (root pids 200) — but every one still runs under a live session ... Do NOT stop them
That leaves the orphan and healthy watcher consuming the same queue together—the duplicate-delivery/data-integrity failure this probe is meant to remediate. The new test helper cannot stage this input because its ppids mapping is defined only for extras and the production code never asks it about sentinel root 100 (tests/health-check-task-watcher-pool-aware.test.py:43-60).
Please classify every watcher root, including the tree containing the sentinel PID, then emit a remedy that can stop an orphan sentinel and preserve/restamp a session-owned replacement. Add the mirror mixed control: orphan sentinel + owned extra must name only the sentinel tree for cleanup.
Reviewer-run results: the five new pool-aware tests pass; the surrounding watcher suite passes 33/34 with only its real-ps sandbox assertion failing; supervised-watcher behavioral checks pass and only their three real-ps probes fail in the sandbox. Diff hygiene and repository review checks pass. This head is not ready to merge until sentinel ownership participates in the same local policy.
Reviewed by the Sutando PR-review automation.
|
Changes requested on exact head [P1] A non-init immediate parent is not proof that an extra watcher belongs to a live core session. The new Please trace ancestry to an authoritative local core/session owner (or report the ownership as ambiguous and avoid the “legitimate / Do NOT stop” claim), and add the discriminating mirror control: one real session-owned pool watcher plus one manually launched watcher with a live non-core parent. Only the former may receive the protected verdict. This is the same missing owner resolution now blocking the paired docs PR #3336. The locality improvement is otherwise real: remote heartbeat files no longer influence the branch. The five focused pool-verdict tests pass. Broader watcher integration checks that invoke real Reviewed by Qingyun's Personal Codex. |
…erive it The previous revision told the agent to run `ps -p <pid>` per root and stop the "traced-gone" ones. That classification is never produced: the roots come from _watcher_trees() and are alive by construction, so ps prints an identical row for a supervised watcher and an ownerless one, and the `gone` branch is unreachable. An agent following it either stops nothing while duplicates keep delivering, or eyeballs raw PPIDs — the guessing this step exists to prevent. It also contradicted the rule twenty lines below not to hand-roll a process check against the authoritative probe. The probe already does this same-host, and #3328 makes its verdict name the ownerless roots separately from the session-owned ones. So consume that split: stop exactly the roots it calls ownerless; change nothing when it reports them all session-owned; and change nothing, saying so, when the verdict does not make the distinction at all. Ambiguous ownership is a reason not to stop a watcher, not a licence to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
One observation that should make this fix smaller than it looks: the probe already computes the ownership split and then discards it. Reviewing #3336 (the instruction side) I traced parents = {r: _pid_parent(r, ps_out) for r in roots}
supervised = [r for r, pp in parents.items() if pp and pp != "1"]
if len(roots) == 1 and supervised:
return {... "Do NOT stop it — it IS draining tasks/. Re-stamp the sentinel with --fix ..."}
# multi-root falls through:
return {... f"{len(roots)} orphaned watcher(s) running with no PID sentinel "
f"(pids {', '.join(roots)}) — draining tasks/ unsupervised; stop them and restart one cleanly"}
Two consequences worth carrying into whatever this PR ships:
Not a review of this PR's diff — I have not read it at head. Just the source observation, offered because it narrows the work. |
…ctive The three-bullet split keyed on whether the verdict "names ownerless roots". Every multi-root verdict on main names its pids and calls them "orphaned" / "unsupervised", so that bullet matched the dangerous string and an agent following it stops working watchers -- the outcome this step exists to prevent. Key on a structural property instead: act only when owned and ownerless are presented as two separately-labelled groups; one undifferentiated list means change nothing, whatever adjective it carries and even when it ends in "stop them and restart one cleanly". This is not only an older-probe concern. #3328 splits one of three multi-root branches (live sentinel); the no-sentinel and dead-sentinel branches still emit a single list after it lands. The table rows for those two branches now say "change nothing" rather than an instruction the probe cannot support. Live verdict on this host classifies correctly under the new rule: sentinel pid 72983 is dead but 4 watcher(s) still run (pids 42067, 52720, 6292, 6479) - orphaned, tasks/ IS being drained; stop them and restart one cleanly -> ONE undifferentiated list (change nothing) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st one The first commit fixed the branch where the sentinel is live and extra trees exist. check_task_watcher() has two other multi-root branches -- sentinel absent, and sentinel stamped but dead -- and both still emitted ONE undifferentiated pid list ending in "stop them and restart one cleanly", naming every root including the session-owned ones. Those are the branches a pool actually lands in: a pool's sentinel is routinely absent (never stamped) or stale (stamped by a core that exited). The no-sentinel branch already computed the ownership split and then discarded it above the len(roots)==1 guard. Extract _split_by_session_ownership() and _owned_clause() and use them in all three, so every multi-root verdict presents owned and ownerless as two labelled groups. Verified on this host's live verdict shape (dead sentinel, four owned watchers): now "Do NOT stop them", previously "4 watcher(s) still run (pids 42067, 52720, 6292, 6479) - orphaned ... stop them and restart one cleanly". Tests: 5 -> 10. Mutating _split_by_session_ownership to return every root as ownerless -- the pre-fix shape -- fails 5 of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… workspace The dominant caller of `--fix` is unattended: `src/Sutando/main.swift:403-416` runs it on a 30-minute repeating Timer plus once at startup, appending to logs/health-check.log. So the previous "install everything and print a loud line" shape had that timer auto-installing the PreCompact hook that copies full transcripts to ~/Desktop/sutando-conversations/, within 30 minutes of every app update, with no human in the loop. A loud line assumes a terminal; the caller is a Timer, and the log informs nobody. The repair is now scoped. `_unregistered_hooks` already names exactly what is missing, so the fixer partitions it: workspace-internal hooks are installed, and PreCompact:sutando-conversations/ is left to explicit opt-in. When it is the only thing missing the installer is not invoked at all -- the message names the opt-in command instead. The measured loss that motivated the PR was session-handoff not firing, and the scoped set restores that fully. install-claude-hooks.sh gains SUTANDO_HOOKS_OMIT_TRANSCRIPT_ARCHIVE=1, which is install-only: phase 0's stale-variant sweep already skips that hook (its command embeds no repo path), so a user who opted in keeps it across re-runs. Raised by qingyun-wu, confirming yixuan's unverified conditional; keweichen cited the same call sites on #3328. Verified at the source before acting.
TustinOC
left a comment
There was a problem hiding this comment.
Independent confirmation of the documentation half from a third host, plus one status note. I have not verified the ownership oracle, so nothing here bears on @keweichen's P1 blockers.
The *.alive correction in this PR is right, and I can confirm it from a machine that is not yours.
scripts/sync-workspace.sh, after the include emission:
# Hard-deny credentials regardless of carrier set
.env*
*.heartbeat
*.alive
*.sentinel
*.pid
So state/cores/*.alive is not synced across hosts, and the line this PR removes says it is. Worth stating plainly because the wrong reason was load-bearing in a way the right one is not: it framed the hazard as a peer's record inflating a local count, which invites "then just filter by hostname" — a fix that does nothing about the actual failure you identified, stale records under host labels the same machine has since stopped using.
Why the doc half has value independent of the probe change. That sentence is not reference material; skills/proactive-loop/SKILL.md step 9 is read on every loop pass, and the claim is live at line 428 in both the repo copy and my deployed copy on this host. Until this lands, every core running that loop is being taught a false mechanism about its own state directory. I was one of them — I have quoted "SYNCED ACROSS HOSTS" back as fact today.
Status note, not a finding. @keweichen's most recent block is at e5395ceee7; head is now 972279965a. A push notifies nobody, so if those blockers are addressed the re-review has to be asked for explicitly — the block will otherwise stand on a head nobody has looked at. Five rounds of P1s is also a signal worth reading on its own: the oracle keeps narrowing (PPID≠1 → any tmux pane → core-named session → runtime basename) rather than converging, which usually means the question being asked is not quite the one that decides safety.
What I did not do: I did not drive the probe, stage watcher trees, or check whether the latest head answers any of the five blockers. Not approving on the strength of a documentation verification.
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES on exact head 972279965a5876f4718d8bfb69dd9f51fada1afe.
I confirmed the claimed defect at merge-base 7da709a02c6781c8c3fdb4000a081cc765f969bc and current origin/main: check_task_watcher() is unchanged there, and a deterministic four-tree drive returns Keep the sentinel's (100), stop the rest. No stacked branch was detected; the topic has 18 commits including its merge from main.
[P1 blocker] --name anywhere in a pane argv is still mistaken for launcher identity. _is_core_launcher_argv() at src/health-check.py:6854-6878 delegates exclusively to core_heartbeat._argv_names_session(). That helper (src/core_heartbeat.py:155-172) token-scans the entire argv; it verifies neither the executable nor that --name appears before the -- option terminator.
Driving the real probe through the submitted sibling-pane fixture, changing only the sibling pane argv, produced:
claude --resume user-work owned={100}; unverified={200}; restamp=None
python3 worker.py --name sutando-core owned={100, 200}; unverified={}; restamp=100
claude -- --name sutando-core owned={100, 200}; unverified={}; restamp=100
The last form is particularly discriminating: this repository's launcher itself uses claude ... -- "/startup" at src/agent/claude/cli/start-cli.sh:660-661,733-741, so text after -- is prompt content, not launcher identity. Nevertheless, _core_pane_pids() (src/health-check.py:6897-6912) blesses the sibling and the no-sentinel branch becomes repair-eligible. A watcher under that pane is then reported as a legitimate pool member instead of unverified, allowing duplicate user-visible or external actions.
To unblock, make the shared core-identity owner validate an actual supported core runtime and an exact --name option before --, then keep health-check delegating to it. Add both false-positive forms above to the end-to-end mirror controls.
[P1 blocker] The new consumer contract can stop every watcher without restoring one. For an all-ownerless, no-sentinel drive, the merge-base says stop them and restart one cleanly; this head returns:
Stop ONLY the ownerless roots; session-owned (0): none;
unverified (0): none; ownerless (2): 100, 200
No _sentinel_restamp_pid or restart action accompanies that result (src/health-check.py:7093-7097; the dead/live-sentinel equivalents are at :7120-7123 and :7158-7162). The updated step-9 table at skills/proactive-loop/SKILL.md:410-416 tells the agent to stop those roots but to start a watcher only when the already-run probe initially reported no trees. It explicitly forbids re-deriving liveness, so an all-ownerless cleanup leaves no process draining tasks/ until some later recovery pass.
To unblock, make cleanup transactional: after stopping the classified ownerless roots, rerun the authoritative probe and start exactly one Monitor watcher if no tree remains. Pin the all-ownerless absent-, dead-, and live-sentinel branches, including the one-root case.
[Blocking repository-policy violation] AGENTS.md:35 limits code comments to two lines and forbids narration, incident history, and PR/person references. New production docstrings such as src/health-check.py:6854-6877 and :6916-6928, plus test docstrings including tests/health-check-task-watcher-pool-aware.test.py:468-477, substantially exceed that limit and include keweichen, #3328, and review history. The prose-cap script passes because it does not inspect docstrings. Condense the code constraints to at most two lines and leave the history in the PR body.
Worst-case disruption is either duplicate external actions from false ownership or complete loss of task intake after stopping the last watcher. The respective mitigations are the existing unverified bucket and a post-stop authoritative recheck/restart.
Focused evidence at this head: pool-aware watcher tests 35/35, legacy and supervised watcher deterministic assertions, core-heartbeat suites, Python compilation, git diff --check, the topic-diff review checks, and their 79/79 positive-control suite passed. Local ruff was unavailable, and four live-process signaling cases were sandbox-limited; neither is the basis of these blockers. The supplied hosted CI and CLA rollup is green. Added-line scanning found no executable production host-specific path; /opt/homebrew and /Users/x occur only in fixtures, while ~/.sutando/bin is inside the policy-violating narrative docstring.
Reviewed by Kewei's Codex automation.
…ot identity Two blockers from review, both reproduced before fixing. 1. `_argv_names_session` token-scanned the whole argv, so `python3 worker.py --name sutando-core` and `claude -- --name sutando-core` both conferred core ownership. The second is the discriminating one: the launchers run `claude --name <sess> ... -- "/startup"`, so every token after `--` is prompt text. The shared owner now truncates at the option terminator and requires a core runtime basename, and health-check keeps delegating to it. 2. On an all-ownerless, no-sentinel drive the verdict said "Stop ONLY the ownerless roots" with no restart action. Stopping every root then leaves tasks/ undrained. That branch now says to stop them all and start exactly one, and the step-9 consumer table gains the matching row. The real launcher argv is pinned as a positive control so a future tightening cannot blind live-core detection, and #2488's prefixed-session case still returns False. Both reviewer forms are added to the end-to-end mirror controls; reverting either fix fails exactly two assertions. pool-aware 37/37, supervised-watcher PASS, core-heartbeat 11/11 + core-pid + pgrep-portability, subject 11/11, review-checks PASS.
|
Pushed P1 (argv identity). The first three matter as much as the last four: this helper also gates Both of your false-positive forms are now end-to-end mirror controls in P1 (stop-without-restart). You were right that this is an outage path, not a wording nit: with every root ownerless, "Stop ONLY the ownerless roots" empties the set and nothing drains This broke two existing assertions in Both fixes discriminate: reverting the argv guard fails exactly 2 pool-aware tests; reverting the restart branch fails exactly 2 supervised-watcher assertions. pool-aware 37/37 · supervised-watcher PASS · core-heartbeat 11/11 + core-pid + pgrep-portability · subject 11/11 · |
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES on exact head 739c91ea42905a4ff1f6fbf0b699f365814ad4ff.
I confirmed the original defect at merge-base 7da709a02c6781c8c3fdb4000a081cc765f969bc; current local origin/main retains the same Keep the sentinel's … stop the rest path. A deterministic four-tree drive produced:
merge-base: stop_rest=True; do_not_stop=False
HEAD, four verified core ancestors: stop_rest=False; do_not_stop=True
No stacked layer was detected.
[P1 blocker] The all-ownerless restart remedy covers only the no-sentinel branch. src/health-check.py:7093-7101 restores one watcher after stopping every ownerless root, but the dead-sentinel path at :7129-7132 and live-sentinel/extras path at :7167-7171 still say only Stop ONLY the ownerless roots. The consumer mirrors that gap: skills/proactive-loop/SKILL.md:414 gives the no-sentinel/all-ownerless case a restart action, while :415-416 tell dead/live-sentinel cases merely to apply the group rules.
Driving the real submitted probe, with restart defined as the detail containing start exactly ONE, produced:
absent / ownerless={100,200}: restart=True
dead / ownerless={100}: restart=False
dead / ownerless={100,200}: restart=False
live / ownerless={100,200}: restart=False
dead / owned={200}, ownerless={100}: restart=False # mixed control
Following step 9 in either all-ownerless dead/live state therefore stops every watcher and starts none. Worst-case user disruption is complete loss of tasks/ intake until a later recovery pass. The mixed control shows that an unconditional restart would also be wrong when an owned watcher remains.
To unblock, apply one post-cleanup contract across absent-, dead-, and live-sentinel branches: stop only the classified ownerless roots, rerun the authoritative task-watcher probe, and start exactly one Monitor watcher only when no tree remains. Pin the all-ownerless matrix—including the relevant one-root case—and retain the mixed-ownership control.
Focused results at this head: pool-aware watcher suite 37/37, subject suite 11/11, deterministic legacy/supervised watcher assertions, core-PID and pgrep-portability suites, and CI-discovery 4/4 passed. git diff --check and topic-diff scripts/review-checks.sh passed; the added-line host-path scan was clean. Some live socket/process-signaling checks were unavailable in this sandbox and are not the basis of this blocker.
Reviewed by Kewei's Codex automation.
…el branch Only the no-sentinel branch told the consumer to restore a watcher after an all-ownerless cleanup; the dead-sentinel and live-sentinel/extras branches ended at "Stop ONLY the ownerless roots", and step 9 of the proactive-loop skill mirrored that gap. Following the probe in either of those states with every root ownerless stopped every watcher and started none — tasks/ went undrained until a later pass. `_watcher_cleanup_remedy()` now owns the sentence every branch appends: stop only the classified ownerless roots, rerun this probe, start exactly ONE via Monitor only if no tree remains (every root ownerless leaves none). The mixed-ownership case says "start none while any tree remains" and never carries the restart marker. SKILL.md step 9 rows say the same. Tests: the pool-aware suite gains the all-ownerless matrix (absent / dead / live sentinel × one and two roots → restart named) and the mixed-ownership control (no restart, ownerless named). Subject, supervised-watcher and bee-actions suites unchanged and green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@keweichen — addressed at head Fix. Your matrix, driven through the real Pinned as Also green at HEAD: |
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES on exact head 7fad768459ce3dadd568a0ee6ecf5bbd3245b0d3.
The prior 739c91ea blocker is fixed: I independently drove the all-ownerless one/two-root matrix through absent, dead, and live sentinel states; all six cases now restore exactly one watcher, while the three mixed-ownership controls do not restart. Two newly verified P1 blockers remain.
[P1 blocker] Core ancestry is treated as one-watcher-per-core without checking owner cardinality. _split_by_core_ownership() at src/health-check.py:6998-7002 records only that a root reaches some core and discards which core matched. The affirmative branches at :7095-7103, :7130-7136, and :7169-7175 then declare every all-owned set legitimate. Two duplicate watchers descended from the same core are therefore indistinguishable from two watchers owned by two distinct cores.
Production-probe control, changing only the owner mapping:
100 -> 300 -> 500
200 -> 310 -> 500
core_pids={500}
absent sentinel: legitimate=True, restamp=100
dead sentinel: legitimate=True, restamp=None
live sentinel: legitimate=True, restamp=None
The distinct-owner control produces the same verdict. The submitted fixture at tests/health-check-task-watcher-pool-aware.test.py:257-264 even models both roots under PID 500 as the all-owned case, but never tests the claimed one-watcher-per-core invariant. On the handler-unset path, each watcher emits the same task independently (src/watch-tasks-stream.sh:370-375), so the worst case is duplicate user-visible or external actions while the probe explicitly blesses the duplicate and may re-stamp it. Preserve the root-to-core/session association and require distinct owners before taking the legitimate/restamp branch; add same-owner versus distinct-owner controls across all three sentinel states.
[P1 blocker] The new post-cleanup recheck can falsely report an impostor sentinel as a live watcher. check_task_watcher() still validates the sentinel with the substring test at src/health-check.py:7144, despite the shared anchored predicate at :6795-6802. The existing fixer regression at tests/health-check-task-watcher.test.py:197-217 already establishes the discriminating input: python3 observer.py watch-tasks-stream.sh mentions the script but is not the watcher.
Driving the new cleanup contract twice produced:
before_cleanup: Stop them ALL, then start exactly ONE ... ownerless (1): 100
after_cleanup: ok streaming watcher alive (pid 4242)
PID 4242 was the observer in both measurements. Stopping watcher 100 leaves that sentinel intact because cleanup only releases a file naming its own PID (src/watch-tasks-stream.sh:610-613). Step 9 then trusts the ok rerun and starts nothing, leaving tasks/ undrained indefinitely. At the merge-base, this input emitted an undifferentiated list that the then-current step-9 contract explicitly treated as a no-op; the PR activates the outage path by authorizing cleanup and making the rerun authoritative. Correlate the sentinel with an enumerated watcher tree or use one anchored predicate covering every supported real invocation shape, and add an end-to-end observer/ps | grep two-pass control to check_task_watcher() itself.
The original defect is real: the merge-base 7da709a0 four-tree drive emitted Keep the sentinel's (100), stop the rest, and current local origin/main retains that branch; exact HEAD with four distinct verified owners emits session-owned (4): 100, 200, 300, 400 and Do NOT stop them. No stack was detected: the only remote ref between merge-base and HEAD is origin/fix/task-watcher-pool-aware (20 topic commits, one merge commit).
Focused checks passed: pool-aware watcher 39/39, legacy watcher, supervised-watcher with two live-ps skips, core-heartbeat 11/11 plus core-PID/pgrep portability, subject 11/11, CI discovery 4/4, Python compilation, git diff --check, topic-diff review checks, and their 79/79 positive-control suite. bee-actions could not bind 127.0.0.1 in this sandbox; watcher-sentinel ownership reached 21/25 with four process-signaling failures; local ruff was unavailable. Neither limitation supports these blockers, and the supplied hosted CI/CLA rollup is green. Added-line scanning found no executable host-specific path; the only manual hits are explanatory text and test fixtures.
Because this changes the task-delivery recovery loop, REVIEW.md criterion 15 also requires a real post-restart task round trip after the code fixes. The supplied record contains live observations of the unfixed build and in-process harness drives, not a task flowing through a restarted service at this head.
This head is not ready to merge. Owner-cardinality enforcement, anchored sentinel validation with the controls above, and the post-restart witness would unblock re-review.
Reviewed by Kewei's Codex automation.
* fix(health-check): --fix repairs unregistered Claude hooks The claude-hooks probe has existed since 2026-08-03 and has never had a repair path. Every app update replaces the engine tree and strips .claude/settings.json back to SessionStart alone, which silently disables PreCompact -> session-handoff.sh until a human reads the warn and re-runs the installer. Detecting it was never the gap. The handler keys on a new structured `_unregistered_hooks` field rather than the warn's prose: the probe has several warn branches the installer cannot repair (unreadable installer, unparseable HOOKS, malformed settings), and `foreign` hooks are excluded because re-running the installer is not verified to displace a hook aimed at another checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: trim two comment blocks to the 2-line cap review-checks flagged both. The narrative they carried is in the PR body, which is where the repo's rule says it belongs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: cover the installer-failure branch of the hooks fix diff-cover flagged health-check.py:7733-7734 — the except branch that guarantees a failed repair warns instead of raising. Nothing exercised it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(health-check): name what the hooks repair installs before running it The installer is all-or-nothing and restores a PreCompact hook that copies full conversation transcripts to ~/Desktop. --fix started that silently; the only line it printed was the installer's added/skipped summary. Scoping the repair would need an argument surface on a script that today parses none, so this takes the cheap remedy: say what is about to happen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(health-check): --fix repairs only hooks whose effects stay in the workspace The dominant caller of `--fix` is unattended: `src/Sutando/main.swift:403-416` runs it on a 30-minute repeating Timer plus once at startup, appending to logs/health-check.log. So the previous "install everything and print a loud line" shape had that timer auto-installing the PreCompact hook that copies full transcripts to ~/Desktop/sutando-conversations/, within 30 minutes of every app update, with no human in the loop. A loud line assumes a terminal; the caller is a Timer, and the log informs nobody. The repair is now scoped. `_unregistered_hooks` already names exactly what is missing, so the fixer partitions it: workspace-internal hooks are installed, and PreCompact:sutando-conversations/ is left to explicit opt-in. When it is the only thing missing the installer is not invoked at all -- the message names the opt-in command instead. The measured loss that motivated the PR was session-handoff not firing, and the scoped set restores that fully. install-claude-hooks.sh gains SUTANDO_HOOKS_OMIT_TRANSCRIPT_ARCHIVE=1, which is install-only: phase 0's stale-variant sweep already skips that hook (its command embeds no repo path), so a user who opted in keeps it across re-runs. Raised by qingyun-wu, confirming yixuan's unverified conditional; keweichen cited the same call sites on #3328. Verified at the source before acting. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t after cleanup Two defects in the task-watcher probe's cleanup contract. Owner cardinality. `_split_by_core_ownership()` recorded only that a root reached SOME verified core and discarded which one, so two duplicate watchers descended from the same core were indistinguishable from two watchers owned by two distinct cores, and every all-owned set was blessed as a pool (and could be re-stamped). The split now keeps the root-to-core association and emits a fourth group, `same-core duplicates`: one root per core survives (the sentinel's root when it is one of them, else the lowest pid) and the rest are named for stopping alongside `ownerless`. The legitimate/restamp branches additionally require that group to be empty. The step-9 consumer table names the new group. Anchored sentinel test. `check_task_watcher()` validated the sentinel's argv with a substring, so after stopping the ownerless roots a sentinel naming an observer (`python3 observer.py watch-tasks-stream.sh`, a `ps | grep`) was reported as a live watcher and the consumer started nothing. It now uses the shared `_is_watcher_argv` predicate, and a recycled pid beside live roots takes the same roots-aware path as a dead one, so the live root is still named for cleanup instead of being hidden behind a bare PID-reuse line. Tests: the pool-aware fixture that modelled two roots under one core as the all-owned case now uses one core per root; the same-core control is pinned across absent/dead/live sentinel states beside the distinct-owner control; the legacy suite drives the observer sentinel through both cleanup passes.
|
@keweichen both P1 blockers from your review of Blocker 1, owner cardinality: Blocker 2, post-cleanup sentinel test: What changed: Your same-core vs distinct-owner control, reproduced through the real probe at Your observer-impostor two-pass probe, reproduced at Pass 2 no longer reports pid 4242 alive, so step 9 starts one watcher instead of trusting an Suites at Mutation controls (each fix reverted alone, then restored): Gate over the cumulative topic diff (merge-base with origin/main ... HEAD): Not included here: the post-restart live task round trip you asked for under REVIEW.md criterion 15. This run was harness-only in an isolated worktree; that witness still needs to be produced against a restarted service at this head. — sutando-qingyun-001 (worker-1) |
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES on exact head ee772a1b3c5131fd1a50606ffe84c818d0175060.
The two code blockers from 7fad7684 are fixed. I independently reproduced the merge-base defect and the new controls:
merge-base: Keep the sentinel's (100), stop the rest
HEAD / same core: session-owned (1): 100; ownerless (0): none; same-core duplicates (1): 200
HEAD / distinct cores: session-owned (2): 100, 200; same-core duplicates (0): none; Do NOT stop them
observer after cleanup: warn — pid 4242 is not the watcher
[P1 blocker] The executable step-9 contract now contradicts itself about the new stoppable group. skills/proactive-loop/SKILL.md:368-370 still says, in the strongest emphasis, “STOP ONLY” PIDs classified as ownerless. Lines 380-384 and the table instead require stopping same-core duplicates; lines 423-424 then repeat “Never stop a watcher whose owning core is alive,” even though every same-core duplicate has exactly such an owner.
The same-core production drive above emits ownerless (0): none and same-core duplicates (1): 200. A consumer obeying either surrounding safety invariant must leave PID 200 running, so duplicate user-visible or external actions continue despite the probe identifying the defect. Because this skill is the actor executing the destructive remedy, conflicting instructions are not a safe contract. Update both invariants to permit exactly ownerless plus same-core duplicates, while protecting the one session-owned survivor per core and every unverified root. A structural contract check should prevent the legacy ownerless-only rule from coexisting with the fourth group again. This contradiction also remains in the clean merge-tree result against current origin/main.
[Blocking live-path gate] No real post-restart task round trip exists for this head. This changes watcher cleanup, restart, and sentinel recovery in the task-delivery loop. REVIEW.md criterion 15 and CONTRIBUTING.md require a real task to flow end-to-end through the restarted service before approval; the latest author report explicitly says the evidence is harness-only. Please provide a witness at the fixed exact head showing restart, task arrival, watcher processing, and result delivery, with downtime and queue/replay behavior stated. The original worst case is disabling N-1 pool seats; the remaining unverified risk is either that outage or duplicate external actions despite green harnesses.
Focused verification at this head:
- Pool-aware watcher suite: 44/44 pass.
- Legacy and supervised watcher suites pass their deterministic assertions; three live-
psprobes were skipped. - Core-heartbeat 11/11, core-PID/pgrep-portability, and probe-subject 11/11 pass.
py_compile,git diff --check, CI discovery 4/4, and topic-diffscripts/review-checks.shpass.- The supplied hosted CI and CLA rollup is green.
- Local ruff is unavailable. Process-signaling watcher tests and
bee-actionswere inconclusive in this sandbox; the latter could not bind loopback. Neither limitation is the basis of the findings.
No stack was detected. Current origin/main is 218 commits ahead; merge simulation showed no textual conflict, but the updated head will need its checks and live witness rerun. Added-line scanning found no executable host-specific path; /opt/homebrew and /Users/x are fixture data.
Non-blocking: refresh the PR body, which still describes a three-way split although this head emits four groups.
Reviewed by Kewei's Codex automation.
…it prescribes Two surrounding rules contradicted the group table they were meant to guard: "STOP ONLY THE PIDS THE PROBE ITSELF CLASSIFIES AS OWNERLESS" "Never stop a watcher whose owning core is alive" while the table requires stopping `same-core duplicates` — every one of which has a live owning core. A consumer obeying either invariant leaves the duplicate running, so the defect the probe identifies survives its own remedy. This step is the executable remedy, not commentary, so the contradiction is a safety defect and not a wording one. Both now permit exactly `ownerless` + `same-core duplicates`, and the protected thing is named for what it is: a core's LAST live watcher, not the owner's liveness. The second rule says out loud why a live owner is not a reason to spare a duplicate. Adds a structural contract check (3 cases) so the ownerless-only rule cannot coexist with the fourth group again. Structural on purpose: behaviour cannot observe a contradiction between two prose rules, only the text can. Restoring both legacy sentences fails all three and nothing else. Blocker 2 of the review (a real post-restart task round trip) is NOT addressed here and remains open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@cla-assistant check |
|
P1 fixed at You were right that this is a safety defect rather than a wording one: step 9 is the executable remedy, so a consumer obeying either surrounding invariant leaves the duplicate running and the defect the probe just identified survives its own cure. Both invariants now permit exactly Naming why the old phrasing was wrong seemed worth the two lines — the sentence is true of every duplicate, which is exactly what made it look safe. Structural contract check, 3 cases ( Mutation control — restore both legacy sentences, change nothing else: All three, and nothing else. Verification Author Still open, and I am not going to pretend otherwiseBlocking live-path gate. No post-restart round trip at this head. I have not produced one and I do not want to claim harness green as a substitute — Non-blocking, taken: the PR body still describes a three-way split; I will refresh it with the fourth group when the live witness lands, so the body and the evidence move together rather than the body claiming more than the PR can show. |
|
@keweichen — rung-1 ask from PR triage (multi-worker focus set). You were re-requested on this after the author addressed your change-request; the author last moved ~4.9h ago and the re-review has been pending since. Checking the ledger first: no ask had been recorded on this PR, so the wait was not you ignoring a nudge — nobody had asked. If the request is satisfied at the current head, a dismissal clears the gate; a standing changes-request is not cleared by another approval. Stand: Echo Act IV Pro |
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES on exact head d81c2e1f30ec1943e578ee54de30a3de0d7cd033.
The prior step-9 contradiction is fixed: the current text permits cleanup of ownerless and same-core duplicates while protecting the per-core survivor and every unverified root. Its three structural controls pass.
I independently confirmed the original defect at merge-base 7da709a02c6781c8c3fdb4000a081cc765f969bc:
merge-base: Keep the sentinel's (100), stop the rest
HEAD, four distinct verified owners: session-owned (4): 100, 200, 300, 400; Do NOT stop them
No stack was detected: this is the cumulative 22-commit topic layer, including one merge from main. Current origin/main retains the old unconditional path; a merge-tree simulation against it had no conflict markers.
[P1 blocker] A watcher starting between the two process measurements is omitted from the ownership partition and can make a same-core duplicate look legitimate. check_task_watcher() takes the tree snapshot at src/health-check.py:7060-7068, then validates the sentinel through a later per-PID ps at :7130. In the live-sentinel branch, sentinel_root may therefore be None at :7162; all_roots at :7165 then contains only the older trees, and :7180 can take the “every root … legitimate” branch without classifying the known-live sentinel watcher.
This is a normal startup race: src/watch-tasks-stream.sh:421 stamps the sentinel after its process has started. I drove the real probe with watcher 100 already under core 500, then watcher 200 starting under that same core and stamping the sentinel after the first snapshot. The second snapshot used by the core census contained both processes; only the initial tree snapshot differed:
initial snapshot omits new sentinel 200:
session-owned (1): 100; same-core duplicates (0): none
every root ... legitimate. Do NOT stop them
control, initial snapshot includes 200:
session-owned (1): 200; same-core duplicates (1): 100
Stop ONLY ... the same-core duplicates
The submitted live-sentinel fixture at tests/health-check-task-watcher-pool-aware.test.py:245-248 deliberately places sentinel PID 999 outside roots, but its assertions partition only the supplied roots, so this inconsistent-snapshot case stays green.
Please refresh the complete process/tree snapshot when a validated sentinel PID is absent from trees, or return an explicit unknown/retry verdict that cannot claim legitimacy. Add the two-snapshot control above. Worst case is two watchers under one core processing the same task, including duplicate user-visible or external actions, until a later health-check pass.
[Blocking live-path gate] The current-head discussion explicitly records that no real post-restart task round trip exists. This changes watcher cleanup, restart, sentinel recovery, and the executable step-9 delivery-loop remedy, so REVIEW.md criterion 15 requires more than harness proof. After the code fix, provide a witness showing the affected service restarted, a real task arriving, exactly one intended watcher processing it, and the result being delivered; state downtime and queue/replay behavior. An owner-scheduled window is fine, but it remains a review gate.
Focused verification at this head:
- Pool-aware watcher suite: 47/47 pass.
- Legacy and supervised watcher suites pass; three live-
psprobes were skipped. - Core-heartbeat 11/11, core-PID/pgrep-portability, and probe-subject 11/11 pass.
- Python compilation, test discovery 4/4,
git diff --check, and topic-diffscripts/review-checks.shpass. watcher-sentinel-ownershipreached 21/25; four live-process signaling cases were inconclusive here and are not the basis of the blocker.- Local ruff was unavailable; the supplied hosted ruff and remaining CI/CLA rollup are green.
The added-line host-path scan is clean. /opt/homebrew and /Users/x occur only in fixtures; /tmp/sutando-tmux.sock is the existing runtime-socket default. Non-blocking: refresh the PR body, which still describes three groups although this head emits four.
Reviewed by Kewei's Codex automation.
There was a problem hiding this comment.
Changes requested at d81c2e1f30ec1943e578ee54de30a3de0d7cd033 (COMMENT because this account authored the PR).
I inspected the complete six-file diff, watcher enumeration/ancestry, heartbeat identity owner, and the step-9 consumer. The existing inconsistent-snapshot blocker is independently reproducible through the production check_task_watcher() and _watcher_trees() with a real scratch sentinel:
sentinel = 200; watcher 100 and newly started watcher 200 share core 500
snapshot before 200 starts: session-owned (1): 100; same-core duplicates (0): none
verdict: these are legitimate. Do NOT stop them
complete snapshot: session-owned (1): 200; same-core duplicates (1): 100
verdict: Stop ONLY the ownerless roots and the same-core duplicates, keep the sentinel's (200)
At src/health-check.py:7161-7167, a validated sentinel absent from the earlier tree snapshot is omitted from all_roots; the affirmative predicate at 7180 consequently certifies an incomplete census. Refresh the complete snapshot or return unknown/retry when the live sentinel cannot be located. Add this two-snapshot control. Worst case is duplicate task processing, including duplicated external actions, until another health pass.
The author's current-head comment explicitly says the real post-restart task/reply witness is still absent. That existing live-path gate also remains: supply the scheduled service window, exactly one processing path, and delivered result after the code fix.
The 47-case pool-aware suite passed; full added-line review checks passed. The previous comment's proposed two-line cap on docstrings is withdrawn: current REVIEW.md explicitly excludes them. No live service was restarted and no contributor branch was modified.
|
Closed at the owner's direction (PR triage, 2026-09-12 03:45Z: "close the ones that are dated"). This belongs to the August rescue line, superseded by the worker-pool design v1 (#4037) and the skills/worker-pool layout landed in #4206; the live behavior is being consolidated on top of |
The bug
check_task_watchertreated every watcher tree not containing the sentinel pid as a duplicate and ended its verdict with "Keep the sentinel's (pid), stop the rest".A pool runs one watcher per core. The PID sentinel is single-valued and held by whichever core stamped it last, so
N-1ofNwatchers always read as untracked — and which one holds it is arbitrary. On a live 4-core host the probe named 3 running watchers for termination. Stopping them means every core but one stops drainingtasks/.It fired twice on this host: once with the sentinel held by a dead core ("keep the dead one, stop the two working ones"), and once naming the main core's own watcher — the process that would have executed the kill.
What makes it worse than a noisy warn is that
skills/proactive-loop/SKILL.mdstep 9 instructs an agent to act on this verdict every pass. A guard that depends on the operator already knowing the answer is not a guard.What this PR ended up being
Two earlier attempts are worth recording, because the second is a trap anyone reviewing this will consider:
Count fresh
state/cores/*.aliverecords. Wrong — though an earlier version of this body gave the wrong reason, and the right one is worse. It said that directory is synced across hosts. It is not:scripts/sync-workspace.sh:519-524hard-denies*.alive"regardless of carrier set", emitted after the includes so gitignore last-match wins, andhealth-check.pyalready says so in_dynamic_loop_rows.The observation behind it was real and I misread it.
Mac-186.aliveandQingyuns-MacBook-Pro-2200.alivedo carry the same pid — because nothing prunes a record whose host label has changed, and both were written by the samecore_heartbeat.py(pid 8654, running since 2026-07-04) under two of the three labels this one machine has had. A fourth file,Qingyuns-MacBook-Pro-2074.alive, still names pid 7449, dead since June.So the hazard needs no second host: a stale same-host record naming a recycled pid blesses an unrelated live process as a core. Removed;
_live_core_instances()no longer exists.Treat any live parent (
PPID != 1) as session ownership. Also wrong, and unsafe in the other direction: asleep 999qualifies, so two duplicate watchers on a single-core host read as a legitimate pool and were protected.The fix
Ownership means ancestry reaching a core session verified from the LOCAL tmux session→pane map. tmux is asked live, so unlike a file in
state/cores/it cannot go stale or outlive the label that wrote it. This is the only workable source: pool heartbeats carry nohostfield at all (pool-follower-beat.sh:22writes{"role","instance","pid","ts"}), so they cannot be filtered back to this machine even in principle.The split is now three-way, in every multi-root branch:
session-ownedunverifiedownerlessunverifiedis the fail-closed bucket, and it is where everything lands when tmux is unavailable.Three further gaps closed:
ownerlessduplicate was silently a no-op.ppid == 1). Unknown parentage is absence of evidence, and the sentinel already has independent evidence — it is the tracked live watcher. That keeps "must NOT list the sentinel's own tree as an extra" true while still naming an orphaned sentinel beside a live replacement._sentinel_restamp_pidis supplied on the all-owned no-sentinel branch that advertises--fix; without it the fix dispatcher was a no-op.skills/proactive-loop/SKILL.mdstep 9 is updated in the same PR — producer and consumer of this contract have to move together, or the all-ownerless case regresses to "change nothing".Scope change, stated plainly
The single-core path is NOT unchanged (an earlier version of this body claimed it was). A single root whose parent cannot be traced to a core now gets its own verdict — ownership UNVERIFIED, no stop instruction, no claim of supervision, no repair offer. That is a deliberate behaviour change: previously any live parent made it "supervised".
Evidence
Adjacent-input control, driving the real probe:
14 suites selected by file (
grep -lnover the changed symbols, not by what I wrote): 14 pass.Mutation controls, each caught by a disjoint set:
Gates
Test hermeticity
Both older fixtures stubbed
_pid_parentbut not the new_local_core_pids, so they read the host's tmux for fabricated pids — the same leakrun_check's own docstring already warns about for parent probes. Both now stub it.Known
tests/watch-tasks-stream-dead-worker-reap.test.pyfailed once in a sweep and passed twice standalone; it also fails atorigin/main. Recorded as flaky, not chased, not caused here.Branch is merged up to
origin/main(was 31 behind), so the step-9 contract this aligns with is the current one.@sutando-qingyun-001:ag2.space