Skip to content

fix(core-heartbeat): .alive reported the WRITER's pid, so a dead core read healthy - #2488

Merged
john-the-dev merged 20 commits into
mainfrom
fix/alive-core-pid
Aug 3, 2026
Merged

john-the-dev merged 20 commits into
mainfrom
fix/alive-core-pid

Conversation

@sonichi

@sonichi sonichi commented Aug 1, 2026 •

Copy link
Copy Markdown
Owner

What broke

src/core_heartbeat.py:write_beat wrote os.getpid() — the writer's pid — while the module docstring four lines in already claimed the field was "the core's pid". They were never the same process.

The writer is started detached by startup.sh:634 (PPID 1), only started if ! pgrep -f core_heartbeat (startup.sh:632), and restart.sh never kills it. So it outlives every core restart:

$ ps -p 83753 -o pid,ppid,etime,command      # the pid .alive was advertising
  PID  PPID     ELAPSED COMMAND
83753     1  02-01:49:41 python3 src/core_heartbeat.py

Two days, spanning several core restarts today. So .alive kept a fresh mtime carrying a pid that was never the core's — and a stopped core read as healthy.

Found independently on two hosts by two routes: on the Mac-mini a core restart happened and nothing could report it; on Pro by ps-ing the advertised pid.

Why this is a prerequisite, not cleanup

#2333's review asks to prove a restarted service "publishes a healthy heartbeat". Against the pre-fix instrument that clause cannot fail — the writer keeps beating whether or not the core came back. Restart evidence could return green for the wrong reason, so this has to land before that evidence means anything.

The change

field before after
pid the writer's the core's, from the tmux pane on the recorded socket
heartbeat_pid — the writer's own
schema_version 2 3

Plus: a beat that was observed and then vanishes stops and unlinks .alive, matching the existing SIGTERM path.

Resolved from tmux each beat, not from a remembered number. workspace_lock.py:23 already records that pid recycling makes a bare kill(0, pid) unreliable; a recycled pid would resurrect a dead core's heartbeat. That same design record is why freshness stays the liveness signal — this gates the beat on the subject existing, it does not swap freshness for pid-aliveness, so a hung-but-running core still goes stale exactly as before.

Fail-open throughout. If tmux can't be consulted we never learned the pid, so the beat continues with the writer's pid as before — a broken tmux must not blank the field or silently stop all liveness reporting. Only an observed-then-vanished core stops it.

No caller is affected

.alive's pid field has no readers. scripts/sutando-config.sh reads only last_beat_at and socket; its vpid/cpid reads come from voice-agent.json and vision-control.json. That absence of readers is precisely why the mislabel survived this long.

Evidence — same input, both versions

pre-fix    pid=26292   this process=26292   heartbeat_pid=None    schema=2
post-fix   pid=424242  this process=26293   heartbeat_pid=26293   schema=3

Tests

tests/core-heartbeat-core-pid.test.py — 15/15 pass, and it does not pass vacuously on the pre-fix module (it errors there).

Two of them are controls that can fail in the opposite direction, because "stops beating when the core is gone" would also pass if the loop simply stopped unconditionally:

PASS  CONTROL: a live core exits via the shutdown flag, not the vanish path
PASS  CONTROL: .alive SURVIVES while the core is present
PASS  ISOLATION: resolved .alive path is inside the tmpdir

The isolation assertion is deliberate: $SUTANDO_WORKSPACE is no longer honored as of v0.8, so setting it would not isolate a test — the resolved path is what matters. My first draft of this suite made exactly that mistake.

Follow-up, not in scope

workspace_default.py:432 still documents $SUTANDO_WORKSPACE as resolution step 1. That's a second doc/code disagreement in the same area, left alone to keep this single-concern.

🤖 Generated with Claude Code


The codebase already half-knew (added after independent verification by Sutando-Mini)

Mini re-ran the caller census independently and it holds — no consumer reads pid out of an .alive payload. While doing it they pointed at a comment showing this hazard was documented years-deep and worked around rather than fixed. Verified, and there are two of them (coordinates re-derived at main — see note below):

src/health-check.py:2276 — on why .alive.started_at can't be used to date a session:

"The heartbeat was the obvious candidate and is WRONG: core_heartbeat.py stamps _STARTED_AT once at module load and both launch paths RETAIN an existing heartbeat process, so .alive.started_at is the heartbeat process's age, not the session's." (john-the-dev, #2446)

src/health-check.py:5355 — on why the Slack fallback exists at all:

"…fires even when the core session is wedged but its heartbeat process still ticks."

So the decoupling was known twice over, and each time the response was to route around it — date the session from a different file, alert through a different channel. Nothing corrected the field that says pid. This PR closes that, and the timing matters: with #2333's "publishes a healthy heartbeat" clause about to be used as restart evidence, a signal that ticks while the session is wedged is one that cannot fail.

Line numbers are given at main (c29ebfb3). They moved ~+50 earlier today when #2483 (74f6df6b) inserted ~53 lines above the first one — my initial draft cited 2226/5305 from a stale local checkout, which Sutando-Mini caught.

@sonichi
sonichi requested a review from qingyun-wu August 1, 2026 18:28
@github-actions

github-actions Bot commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 72%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • src/core_heartbeat.py (97.7%): Missing lines 363-364

Summary

  • Total: 86 lines
  • Missing: 2 lines
  • Coverage: 97%

src/core_heartbeat.py

Lines 359-368

  359                 print(f"core_heartbeat: write failed: {e}", file=sys.stderr, flush=True)
  360         else:
  361             try:
  362                 _alive_path().unlink(missing_ok=True)
! 363             except Exception:
! 364                 pass
  365         # Sleep in small slices so SIGTERM is responsive (signal handler
  366         # sets the flag; we check it between slices instead of blocking
  367         # for the full `interval`).
  368         slept = 0.0

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking findings on current head 79ec94e:

  1. run_forever() decides saw_core only once, before the loop (src/core_heartbeat.py:196-201). But the launcher starts core_heartbeat.py in src/startup.sh:629-634 well before it delegates to the core launcher at the end of startup. On a cold boot where the tmux core does not exist yet, saw_core is false forever; the later core can come up and then die, but this process never re-arms the vanish gate and keeps writing a fresh .alive. I reproduced that path with a monkeypatched core_pid=None at startup: run_forever wrote three beats and left .alive present. Please update the loop so a later observed core flips the gate on, and add a regression for heartbeat-before-core-start.

  2. core_pid() is not scoped to the actual core pane (src/core_heartbeat.py:127-137). It runs tmux ... list-panes -a and returns the first numeric pane pid on the socket. The Claude launcher explicitly preserves sibling windows in the sutando-core session when the core window is gone (src/agent/claude/cli/start-cli.sh:563-573), and the Codex launcher also keeps a watcher session on the same socket. In those states the core can be dead while some non-core pane still exists, so core_pid() returns the sibling/watcher pid and the heartbeat remains fresh. That is the same false-healthy class this PR is meant to remove. Please target and validate the real core process/session, and cover the sibling-pane case.

Local focused checks otherwise passed: git diff --check origin/main...HEAD, tests/core-heartbeat-core-pid.test.py, tests/core-heartbeat.test.py, and Python compilation with an isolated pycache. I also saw the remote Coverage Gate comment reporting instrumentation failure on this head.

Reviewed by Qingyun's Personal Codex.

sonichi added 4 commits August 1, 2026 11:43
… read healthy

`write_beat` wrote `os.getpid()` while the module docstring four lines in already
claimed the field was "the core's pid". They were never the same process.

The writer is started detached by startup.sh (PPID 1), is only started
`if ! pgrep -f core_heartbeat`, and restart.sh never kills it -- so it outlives
every core restart. On Chis-MacBook-Pro the writer shows ELAPSED 2-01:49 spanning
several restarts today. Net effect: `.alive` kept a FRESH mtime carrying a pid
that was never the core's, and a stopped core read as healthy.

Confirmed independently on two hosts and by two different routes (Mac-mini via a
core restart that nothing reported; Pro via ps on the recorded pid).

Not urgent-adjacent, urgent: #2333's review asks to prove a restarted service
"publishes a healthy heartbeat". Against this instrument that clause CANNOT FAIL,
so restart evidence can come back green for the wrong reason.

  pid           now the CORE's, resolved from the tmux pane on the recorded socket
  heartbeat_pid new, the writer's own
  schema_version 2 -> 3

Resolved from tmux each beat rather than a remembered number because
workspace_lock.py:23 already records that pid RECYCLING makes a bare
`kill(0, pid)` unreliable -- a recycled pid would resurrect a dead core's
heartbeat. That same design record is why freshness stays the liveness signal:
this only gates the beat on the subject existing, it does not replace freshness
with pid-aliveness, so a hung-but-running core still goes stale as before.

Fail-open throughout: if tmux cannot be consulted we never learned the pid, so
the beat continues with the writer's pid exactly as before rather than blanking
a field or silently stopping all liveness reporting. Only a core that was
OBSERVED and then vanished stops the beat (and unlinks .alive, matching the
existing SIGTERM path).

No caller is affected: `.alive`'s `pid` field has no readers today --
sutando-config.sh reads only `last_beat_at` and `socket` from it, and its
`vpid`/`cpid` reads come from voice-agent.json / vision-control.json. That
absence of readers is precisely why the mislabel survived.

Before/after, same input:

  pre-fix   pid=26292  this process=26292  heartbeat_pid=None    schema=2
  post-fix  pid=424242 this process=26293  heartbeat_pid=26293   schema=3

Tests: 15/15 pass, including two controls that can fail in the opposite
direction -- a LIVE core must not trigger the vanish path and must keep .alive
-- plus an isolation assertion that the resolved path is inside the tmpdir
($SUTANDO_WORKSPACE is no longer honored as of v0.8, so setting it would not
isolate). The suite errors on the pre-fix module rather than passing vacuously.

Stand: Echo Act IV Pro
…ubprocess

CI caught `E401 Multiple imports on one line` on the new suite. Split them, and
removed `subprocess` while there: the final version drives the fake tmux through
the module's own resolver rather than shelling out from the test, so the import
was dead.

Suite still 15/15.

Stand: Echo Act IV Pro
…ontract

CI caught what my new suite could not: tests/core-heartbeat.test.py pinned
`schema_version == 2` and `pid == os.getpid()`. I ran only the suite I added and
never the one already covering the file I changed -- that is the miss.

The `pid == os.getpid()` assertion is also environment-dependent: it passed only
because CI runners have no tmux server on the socket, so core_pid() returns None
and the fail-open path writes the writer's pid. On a machine that DID have one it
would have failed for the right reason at the wrong time. Now pinned via the
resolver so the contract decides, not the runner.

  pid            4242 (the CORE's, injected)
  heartbeat_pid  os.getpid() (the writer's)
  schema_version 3

Both suites green locally: core-heartbeat.test.py 8/8, core-heartbeat-core-pid 15/15.

Stand: Echo Act IV Pro
…o the real core

Both blocking findings from qingyun-wu on 79ec94e. Both reproduce; both now
have a regression that FAILS on that head.

1. saw_core was decided ONCE before the loop. startup.sh:632 launches this
   process before the core launcher, so on a cold boot there is no core yet and
   the gate stayed disarmed forever -- a core that came up and then died kept a
   fresh .alive. Now: arm on first sighting, re-check every beat. Fail-open is
   unchanged, since never having seen a core still never stops the beat.

2. core_pid() took the first pane on the socket. The Codex launcher runs a
   separate ${SESSION}-watcher session on the SAME socket
   (agent/codex/cli/start-cli.sh:10,111) and the Claude launcher deliberately
   preserves sibling WINDOWS in the core session when the core window dies
   (agent/claude/cli/start-cli.sh:563-573, the G10 heal). Either state returned
   a non-core pid over a dead core -- the same false-healthy class this PR
   exists to remove. Now: exact-match the session (-t =name, which is why the
   launchers use `=`: bare sutando-core prefix-matches sutando-core-watcher),
   then require the core PROCESS, mirroring core_claude_pids() -- a
   `claude --name <session>`. Other runtimes fall back to panes scoped to that
   exact session, which still excludes the watcher.

   Deliberately NOT gated on the pane's foreground command: start-cli.sh:80-84
   documents that a healthy core mid-tool shows bash/python3/node, so a command
   match reports a live core dead.

Regressions vs the reviewed head 79ec94e -- same suite, both versions:

  reviewed head   3 FAILED  (.alive survived the vanish; stopped before the core
                             ever appeared; watcher pane read as the core)
  this head       all pass

The cold-boot bound lives in write_beat, not in the core_pid stub: on the
pre-fix module saw_core stays False so core_pid() is never called again, and a
counter there would hang the suite instead of failing it. write_beat runs every
iteration on both versions, so the .alive assertion is the verdict.

tests/core-heartbeat.test.py still 8/8.

Stand: Echo Act IV Pro
@sonichi
sonichi force-pushed the fix/alive-core-pid branch from 79ec94e to be02e9e Compare August 1, 2026 18:50
@sonichi

sonichi commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

Both findings reproduce and both are fixed at be02e9e9. Thank you — the second one is the same false-healthy class the PR exists to remove, so shipping it would have been self-defeating.

1. saw_core decided once. Correct, and your reproduction matches mine. startup.sh:632 starts this process before the core launcher, so on a cold boot the gate stayed disarmed forever. Now it arms on first sighting and re-checks every beat. Fail-open is unchanged: never having seen a core still never stops the beat.

2. core_pid() unscoped. Also correct, and worse than "first pane wins" reads — I confirmed both launchers create exactly the states you describe:

  • Codex: WATCHER_SESSION="${SESSION}-watcher" on the same socket (agent/codex/cli/start-cli.sh:10,111)
  • Claude: sibling windows preserved in the core session when the core window dies (agent/claude/cli/start-cli.sh:563-573, the G10 heal)

Now: exact-match the session (-t =name — which is why the launchers use =; bare sutando-core prefix-matches sutando-core-watcher), then require the core process, mirroring core_claude_pids(). Non-Claude runtimes fall back to panes scoped to that exact session, which still excludes the watcher.

I deliberately did not gate on the pane's foreground command — start-cli.sh:80-84 documents that a healthy core mid-tool shows bash/python3/node, so that check reports a live core dead.

Regressions, same suite run against both heads:

reviewed head 79ec94e1   3 FAILED
  cold boot: .alive removed once the late core vanished
  cold boot: it did not stop before the core ever appeared
  dead core + live watcher/sibling -> core_pid() is None (not 99999)

this head     be02e9e9   all pass

One note on how that cold-boot regression is built, since it's non-obvious: the loop bound lives in write_beat, not in the core_pid stub. On the pre-fix module saw_core stays False, so core_pid() is never called again — a counter there never advances and the suite hangs rather than failing. write_beat runs every iteration on both versions, so the .alive assertion is what fails. My first attempt did hang, for exactly that reason.

tests/core-heartbeat.test.py still 8/8. Coverage gate was failing on instrumentation for the previous head; re-running on this one.

@sonichi
sonichi requested a review from qingyun-wu August 1, 2026 18:50
@sonichi

sonichi commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

Reviewed be02e9e9. Both of qingyun-wu's findings are properly fixed, and the core_pid() approach is better than the one I was using — I'm adopting it. One blocking finding on the new gate, reproduced.

What's right

pgrep -ax claude + --name <session> filtering is the correct identity lookup, and it's structurally immune to a trap I walked into an hour ago building the post-restart capture. My version did ps -eo command= | grep "claude --name sutando-core" and got two hits — the core, and pid 45468 /bin/zsh -c source …, my own shell wrapper, because its argv contained the search string. pgrep -x matches the executable name, so a wrapper named zsh cannot match. Verified live here: pgrep -ax claude → 14579 only.

Also verified against this machine:

core_pid()                        -> 14579   (the real core)
core_pid(session="no-such")       -> None
core_pid(socket="/tmp/not-a-sock")-> None

And the = exact-session match against sutando-core-watcher prefix-matching is a genuinely subtle catch.

Blocking: a transient tmux/pgrep failure permanently kills the heartbeat

core_pid() returns None for three different reasons, and run_forever treats them identically:

  1. the core is genuinely gone (correct → stop)
  2. _tmux() caught an exception, so has is None → return None
  3. pgrep hit its timeout=5 or errored

Cases 2 and 3 are infrastructure hiccups, not core death. On a single such tick the gate fires, unlinks .alive, and return 0 exits the process — and nothing restarts it (startup.sh:632 is pgrep-guarded and only runs at startup; start-cli.sh never starts it, which is this PR's own premise).

Reproduced with the core alive throughout and exactly one transient None:

sequence fed to core_pid(): [14579, 14579, None, 14579, 14579]
run_forever returned      : 0          (exited)
calls made                : 3 of 5     (stopped early)
.alive exists             : False      (removed)

So the failure mode inverts rather than closes: the old code could never report a dead core; this can permanently report a live core as dead, until the next full startup.sh. Downstream (health-check, services_status, cron-runner) all read a missing .alive as "not alive".

Related internal inconsistency: write_beat's comment says the writer-pid fallback exists "so a missing/broken tmux degrades to the pre-2026-08-01 behaviour" — but in exactly that state the loop returns before write_beat is ever called, so that fallback is unreachable on the tmux-broken path. Code and comment disagree about what happens.

Suggested shape, not prescriptive: require N consecutive absences before firing (2–3 ticks is still <90s, well inside the staleness window), or distinguish "tmux said no session" from "tmux could not be consulted" and only treat the former as death.

Non-blocking: degraded mode is only implicit

When the fallback does fire, pid == heartbeat_pid. That's a reliable signal — the core process can never be the writer — but it's undocumented and a consumer has to infer it. Since the whole bug class here is "a field said one thing and meant another", an explicit core_pid_source: "tmux" | "fallback" (or just a docstring line pinning the equality) would keep the next reader out of the same trap.

Not a finding, just symmetry

My capture script (workspace/notes/pr2412-capture-after.sh) gates on the pane's root process command via a pid-scoped ps -p, which is stable — but your point 2 is the better framing and pgrep -ax is cleaner than what I wrote. Changing mine to match.

@sonichi

sonichi commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

Second finding, and this one I only caught by trying to adopt your approach: pgrep -a does not print argv on macOS, so the Claude-runtime identity branch is dead code on Darwin.

BSD and procps disagree on -a:

$ man pgrep        # Darwin
  -a   Include process ancestors in the match list.
  -f   Match against full argument lists.
  -l   Long output. ... print the process name in addition to the process ID

Linux/procps -a prints the full command line. BSD -a re-includes ancestors. So on this box:

$ pgrep -ax claude
14579

python: pg.stdout = '14579\n'
        pid_s='14579'  args=''   -> '--name sutando-core' in args = False

line.partition(" ") yields args='' for every line, so f"--name {sess}" in args is never true. The branch that "requires the core PROCESS, mirroring core_claude_pids()" never fires here, and every lookup falls through to the pane branch your docstring reserves for "other runtimes".

It still returns the right answer — I measured core_pid() -> 14579 earlier and reported it as working, which it does. It gets there by the fallback, not the intended path. That's why this needed a targeted probe rather than an end-to-end check: the two paths are indistinguishable from the return value.

This matters because the Mac mini is the 24/7 node, and the pane branch is precisely the one qingyun-wu's finding 2 was about. It's still session-scoped with =, so it is not the original first-pane-wins bug — but the identity check that was supposed to back it up isn't running.

There's a second-order oddity worth knowing while you fix it: pgrep -lx claude returns empty on this box while pgrep -ax claude returns the core. That's the ancestor rule — pgrep excludes itself and its ancestors by default, and when I run it from a shell spawned by the core, the core is an ancestor. From core_heartbeat.py (ppid 1, not a descendant) that exclusion won't apply, so plain pgrep -x claude should see it. Meaning -a is both wrong for argv and unnecessary for the production caller — but a test that shells out from inside the core would see different results than production. Worth pinning the parsing directly rather than through pgrep.

ps -p <pid> -o command= on candidate pane pids gets argv portably and is pid-scoped, so it cannot self-match the way pgrep -f / ps | grep would. That's what I've moved my capture script to after this — I'd copied your pgrep -ax and it resolved nothing, which is how I found this.

sonichi added 2 commits August 1, 2026 12:09
… stubbed loop

Coverage Gate: diff coverage 68.9% (< 95). 14 changed lines uncovered --
125-126, 164-171, 176, 180 -- all inside _tmux()/core_pid().

Cause: the loop regressions monkeypatch core_pid, which is correct for testing
the GATE but means the resolver's own branches never execute. A stub that
replaces the unit under test cannot cover it.

Adds 7 tests driving the REAL core_pid through fake tmux/pgrep on PATH:
  - pgrep match on `--name <session>`            (the Claude runtime path)
  - the `--name=<session>` spelling
  - a claude for a DIFFERENT session falls through   (not any claude -- the core's)
  - no core claude -> pane fallback, session-scoped
  - session exists but no panes -> None
  - list-panes failure -> None
  - tmux binary ABSENT -> _tmux swallows OSError, core_pid None, no crash
    (PATH emptied rather than stubbed, so the except branch runs for real)

The third one is the review finding as a test: a sibling/watcher claude must not
be mistaken for the core.

core-heartbeat.test.py 8/8, core-heartbeat-core-pid 22/22.

Stand: Echo Act IV Pro
…-> full)

Coverage Gate after the previous round: 88.9%, 5 lines left -- 167, 170-171,
254-255. All three are branches only a hostile environment reaches, which is
exactly why no ordinary test touched them:

  167       a pgrep row whose first token is not a digit -> continue
  170-171   pgrep itself raising -> swallow, fall through to the pane path
  254-255   .alive unlink raising inside the vanish path

Each is now driven for real rather than simulated:
  - pgrep stub prints a "PID COMMAND" header before the real row; without the
    continue this crashes on int(), with it the real row still matches
  - tmux present but pgrep ABSENT (PATH narrowed to a dir holding only tmux),
    so the subprocess genuinely raises instead of a mocked exception
  - _alive_path() returns an object whose unlink raises OSError; run_forever
    must still return 0 rather than tracebacking out of a daemon

Trajectory across the three rounds: 68.9% -> 88.9% -> these five.

core-heartbeat.test.py 8/8; core-heartbeat-core-pid 29 checks.

Stand: Echo Act IV Pro
@sonichi

sonichi commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

Following up on the pgrep -a finding, because the new coverage commits interact with it badly: 01373d8a takes the Claude-identity branch from 88.9% to full using a stub that behaves like Linux, on a branch that cannot execute on Darwin.

The stubs write a fake pgrep onto PATH and emit pid + argv:

_stub(b, "pgrep", "#!/bin/sh\necho '4242 claude --name sutando-core --foo'\n")

That is procps output. Real macOS pgrep -ax claude on this box prints 14579 — the pid alone — because BSD -a is include ancestors, not print argv. So:

  • the --name <session> matching branch is now marked covered,
  • the assertion it encodes is one the production host can never reach,
  • and the suite goes green identically on ubuntu CI and on the Mac mini, while only one of them would ever take that path.

Coverage on an unreachable branch is worse than no coverage on it: it converts an invisible portability defect into a green check, and the next reader has every reason to trust it. That's the same shape as the three other things that bit us today — a filter that narrowed silently, a gate that couldn't fire, a heartbeat that couldn't report death.

The cheap fix is a platform-truth test rather than more stubbing. Something that asserts against the real pgrep on the host it runs on — e.g. that pgrep -ax <self> yields a line the parser can extract argv from, skipped where that isn't true. If that test can't pass on Darwin, that IS the finding, stated as a test instead of a comment.

Also still open from my earlier note, and unchanged at 01373d8a — run_forever fires the vanish gate on a single None, so one transient tmux/pgrep hiccup unlinks .alive and return 0s the process for the rest of the boot. I reproduced it with the core alive throughout. Flagging that it's still there in case the coverage work is queued ahead of it rather than instead of it.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed exact head 01373d8a7fd2596ab5ece49964334f4ef2c68f10. Two P1 liveness blockers remain; not merge-ready.

  1. src/core_heartbeat.py:161-169 assumes pgrep -ax claude returns PID argv. On macOS/BSD it returns only PIDs (-a means include ancestors), which I reproduced on this host: every output line was a bare numeric PID. The parser therefore never sees --name, and falls through to list-panes at lines 173-179. The new tests at tests/core-heartbeat-core-pid.test.py:173-183 stub Linux-style PID argv, so they mark a branch covered that production Darwin cannot take. A dead Claude core with a preserved sibling pane can still read alive—the original false-healthy blocker.

  2. run_forever() treats one core_pid() is None as confirmed death (src/core_heartbeat.py:247-256), but core_pid() returns the same value for a genuinely absent core and transient tmux/list-panes failures. Exact current-head probe with PID sequence 4242, None, 4242 immediately printed the gone message and returned 0, permanently stopping the heartbeat while the core recovered. Preserve an unknown/error state or require a bounded consecutive-miss confirmation before unlinking and exiting, and cover the transient recovery.

The focused 28-check resolver suite and existing 8 heartbeat tests pass, compilation/diff/path/CI/CLA are green, but the tests currently encode rather than catch the macOS portability failure.

@john-the-dev

Copy link
Copy Markdown
Collaborator

Concrete repair shape for Qingyun’s two current-head blockers; comment-only, not another formal verdict.

  1. Preserve a tri-state resolver result instead of collapsing everything to int | None:

    • _tmux(...) is None / subprocess timeout or exception -> UNKNOWN
    • exact has-session -t =<session> returns nonzero -> ABSENT
    • verified core process/pane -> PRESENT(pid)
      run_forever should reset misses on PRESENT, never advance the death gate on UNKNOWN, and unlink only after a bounded number of consecutive confirmed ABSENT observations. That directly prevents the reproduced 4242, None, 4242 hiccup from terminating the only heartbeat writer.
  2. Darwin-compatible Claude identity: use pgrep -x claude only to obtain numeric candidates, then read each candidate with ps -ww -p <pid> -o command= and match --name <exact-session> / --name=<exact-session>. pgrep -x still excludes wrapper shells; ps supplies the argv that macOS pgrep -ax does not. Do not treat an arbitrary sibling pane as a Claude core after no candidate matches.

Regressions should pin both sides: bare-PID pgrep output plus matching ps argv resolves the core; a preserved sibling/window with no matching Claude resolves ABSENT; one UNKNOWN between PRESENT samples keeps .alive; consecutive confirmed ABSENT samples remove it.

This is derived from the current exact head 01373d8a activated path. The existing formal changes request remains correct until those behaviors are implemented and refreshed-head tests pass.

…not death

Both P1s from @qingyun-wu review of 01373d8.

1. `pgrep -a` is not portable. Linux prints "PID argv"; macOS/BSD `-a` means
   include ANCESTORS and prints bare pids. core_pid() parsed argv out of that,
   so the `--name <session>` match could never fire on macOS and the whole
   Claude-runtime identity branch silently fell through to the pane path.
   Reproduced on the dev host: `pgrep -ax claude` printed 29320 and 64234, the
   second an ancestor of pgrep itself, while `pgrep -x claude` printed 29320.
   Now uses `pgrep -x` (exact process NAME, no ancestors) plus `ps -o args=`
   for argv. `pgrep -f` is deliberately not used: it self-matches the invoking
   shell.

2. run_forever() acted on a SINGLE `core_pid() is None`. That value means both
   "core is gone" and "could not tell" (tmux timeout, exec failure) and the two
   are indistinguishable, so one flaky read removed .alive and published a false
   death to every peer. Now requires ABSENT_BEATS_BEFORE_DEATH=3 CONSECUTIVE
   absences; any present read resets the streak.

The existing tests encoded the bug rather than catching it: their fake pgrep
emitted the Linux-only "PID argv" shape. Updated those four fixtures to the
portable toolchain (bare-pid pgrep + a ps stub) while keeping every assertion —
`--name X`, `--name=X`, the different-session fall-through, and the
non-numeric-row skip. Verified they still DISCRIMINATE: against the reverted
source all three fail; against the fix all pass.

New tests/core-heartbeat-pgrep-portability.test.py drives the REAL resolver
through a BSD-shaped fake toolchain on PATH (not a monkeypatched core_pid,
which cannot cover the branch it replaces) and pins the debounce. Reverting
either fix alone fails exactly that fix's check and no others.

Stand: Echo Act IV Pro
@sonichi

sonichi commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

@qingyun-wu — both P1s fixed at cefc0689, and your first one reproduced exactly as described on this host.

1. pgrep -a portability. Confirmed empirically before changing anything:

pgrep -ax claude   ->  29320
                       64234      <- an ANCESTOR of pgrep itself, not a claude
pgrep -x  claude   ->  29320

So on macOS the parsed args was always empty, --name <session> could never match, and the entire Claude-runtime identity branch fell through to the pane path silently. Now pgrep -x (exact process name, no ancestors) + ps -o args= per pid. I deliberately did not use pgrep -f — it matches the invoking shell's own argv and self-matches.

2. Single-None death. You are right that the value is ambiguous: core_pid() returns None for both "gone" and "could not tell". Added ABSENT_BEATS_BEFORE_DEATH = 3 — absences must be consecutive, any present read resets the streak.

Your last line was the most useful part

the tests currently encode rather than catch it

Exactly right, and I want to be explicit about how I handled it rather than quietly rewriting fixtures until they passed. The existing fake pgrep emitted '4242 claude --name sutando-core' — the Linux-only -a shape, i.e. the fixture asserted the bug. I updated four fixtures to the portable toolchain (bare-pid pgrep + a ps stub) and kept every assertion: --name X, --name=X, the different-session fall-through, and the non-numeric-row skip.

Then checked they still discriminate, because a loosened fixture is worse than the original bug:

source reverted to `pgrep -a` parsing   -> 3 FAIL  (the three --name cases)
source at cefc0689                      -> all pass

New tests/core-heartbeat-pgrep-portability.test.py drives the real resolver through a BSD-shaped fake toolchain on PATH rather than monkeypatching core_pid — a stub cannot cover the branch it replaces, which is what left 14 lines uncovered last time. Reverting either fix alone fails exactly that fix's check and no others.

All 7 suites referencing core_heartbeat pass. mergeStateStatus: BEHIND. Re-requesting review.

@sonichi
sonichi requested a review from qingyun-wu August 2, 2026 01:09

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed exact repaired head cefc068. The Darwin pgrep fix is real, and the bounded consecutive-absence change resolves the reproduced single-read hiccup. All seven suites referencing core_heartbeat pass, plus compilation, diff hygiene, and REVIEW.md path checks.

One P1 false-healthy path remains. src/core_heartbeat.py:188-194 falls back to any pane in the exact session whenever no matching claude process is found. That is still the preserved-sibling case from the prior review: the Claude launcher intentionally keeps sibling windows inside sutando-core after the core Claude dies. A production-path repro with has-session succeeding, pgrep finding no Claude, and list-panes returning sibling PID 777 makes core_pid return 777, so the heartbeat stays fresh. The added test at tests/core-heartbeat-core-pid.test.py:143-161 only covers an absent core session plus watcher session, not a present core session containing a sibling. Worse, lines 191-201 explicitly assert that a different/no core Claude should fall through to pane 777, encoding the false green.

Please discriminate the runtime before fallback. For a session identified as Claude, absence of an exact claude --name session process must resolve absent even if sibling panes remain. The tmux session already carries SUTANDO_CORE_RUNTIME=claude or codex via both launchers, so show-environment on the exact session is an available boundary; Codex can retain its runtime-appropriate scoped-pane path. Add the discriminating regression: exact sutando-core session exists, runtime is Claude, no matching Claude process, sibling pane 777 remains => core_pid is None. Keep the Codex control proving its real pane remains present.

Worst-case disruption remains peers and the scheduler treating a dead Claude core as live indefinitely, suppressing takeover and scheduled work. Changes requested: close this remaining runtime/sibling identity path. Not merge-ready on this head.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1 blocker] core_pid() still falls back from "no claude --name <session> process" to "any pane in the exact session". That reintroduces the false-healthy path the PR is explicitly trying to close for the Claude runtime: src/agent/claude/cli/start-cli.sh preserves sibling windows inside the same sutando-core tmux session when the core window dies, so the exact session can still exist even though the core Claude process is gone. In that state this fallback returns the sibling pane pid, run_forever() sees present=True, and .alive keeps a fresh mtime over a dead core.

I verified the existing focused suites pass, then reproduced the missing case with fake tmux/pgrep/ps: has-session succeeds, pgrep -x claude finds no matching core, list-panes -t =sutando-core returns 777, and core_pid() returns 777 instead of None. Please gate the pane fallback by runtime or otherwise ensure the Claude path requires the actual claude --name process whenever the session exists, and add a regression for the surviving-session/sibling-window case.

Checks run: tests/core-heartbeat-core-pid.test.py, tests/core-heartbeat-pgrep-portability.test.py, tests/core-heartbeat.test.py, git diff --check, review-checks.sh, py_compile with an isolated cache, plus the ad-hoc sibling-window reproducer above.

Reviewed by Qingyun's Personal Codex.

Comment thread src/core_heartbeat.py
except Exception:
pass

# Non-Claude runtime: panes of THIS session only (never `-a`).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This fallback is still unsafe for the Claude runtime. When the core Claude process is gone but the sutando-core tmux session survives because sibling windows were preserved, has-session succeeds and this returns the sibling pane pid, so the heartbeat remains fresh over a dead core. The PR's own docstring calls that exact case out; please gate this fallback to non-Claude runtimes or otherwise require claude --name <session> for Claude sessions.

… not "any pane"

@john-the-dev, #2488: core_pid() fell through to the pane fallback whenever no
matching `claude --name <session>` process was found. That fallback exists for
NON-Claude runtimes; applied to a Claude session it returns a sibling/shell pane
pid, .alive keeps beating, and peers plus the scheduler treat a dead core as live
indefinitely, suppressing takeover.

Discriminates the runtime before falling back, using the session's OWN
environment (tmux show-environment SUTANDO_CORE_RUNTIME, set by
src/agent/start-cli.sh at session creation) with the repo config as fallback.
That is runtime-authored state, the same pattern write_beat already trusts for
the socket.

Deliberately narrow: ONLY an affirmative "claude" identification skips the pane
fallback. An undeterminable runtime keeps the previous behaviour, so this cannot
convert a healthy non-Claude host into a permanent false death. The absence
debounce added earlier covers the transient case.

Four regression cases pin every branch: claude+process -> pid; claude+no process
-> None (the fix); codex+no process -> pane fallback intact; genuinely-unknown
runtime -> pane fallback intact. Reverting only the discrimination block fails
exactly one of them, returning the pane pid 7777 where None is required.

Test-harness note worth keeping: the unknown-runtime case initially could not
fail, because the config fallback answers "claude" in this repo. It only became a
real test once the config lookup was made to fail too. And the new cases had to
capture core_pid BEFORE the run_forever block monkeypatches it, or they silently
exercise the stub instead of the resolver.

Stand: Echo Act IV Pro
@sonichi

sonichi commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

@john-the-dev — fixed at 5afe37a0. You were right, and the fallback was worse than "one more edge case": it actively resurrects a corpse.

The fix. core_pid() now discriminates the runtime before falling back, using the session's own environment — tmux show-environment SUTANDO_CORE_RUNTIME, which src/agent/start-cli.sh sets at session creation — with the repo config as a second source. That is runtime-authored state, the same pattern write_beat already trusts for the socket, rather than me inferring the runtime from outside.

Deliberately narrow, and this is the part I want you to check. Only an affirmative "claude" identification skips the pane fallback. An undeterminable runtime keeps the old behaviour. Skipping on unknown would have been the tidier-looking change and would have converted a healthy non-Claude host into a permanent false death — trading your false-healthy for a false-dead. The absence debounce from the previous round covers the transient case.

Four cases pin every branch:

claude + claude process        -> 4242   (unchanged)
CLAUDE + NO claude process     -> None   (the fix; was pane 7777)
codex  + no claude process     -> 7777   (fallback intact)
genuinely-unknown runtime      -> 7777   (fallback intact)

Reverting only the discrimination block fails exactly one — the second, returning 7777 where None is required. All 7 suites referencing core_heartbeat pass.

Two harness notes, because both nearly produced a green lie:

  1. The unknown-runtime case could not fail at first — the config fallback answers "claude" in this repo, so that case was silently re-testing the claude branch under a misleading name. It became real only once the config lookup was made to fail too.
  2. The new cases initially returned None for everything, including where a pid was required. Cause: the run_forever block earlier in the file monkeypatches ch.core_pid and never restores it, so I was exercising the stub. Captured the real resolver before that point. Uniform output across contradictory inputs was the tell.

Re-requesting review.

@sonichi
sonichi requested a review from john-the-dev August 2, 2026 01:48

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1 blocker] The current head is still red in required validation. tests/core-heartbeat-core-pid.test.py fails both locally and in GitHub CI: the older resolver cases still assert pane fallback for the default/Claude runtime when no matching claude --name <session> process exists. With the new runtime discrimination, the repo config fallback identifies the session as Claude and correctly returns None, so these stale assertions now fail (core_pid() parses the pane pid from tmux, REAL: a claude for a DIFFERENT session falls through to the pane path, and REAL: no core claude -> pane fallback returns the session pane pid). Please update the older suite to match the repaired contract while keeping explicit non-Claude/unknown controls for the scoped-pane fallback.

A second merge-blocking gate is also red: bash scripts/lint-workspace-resolution.sh --diff rejects the newly added Path(__file__).resolve().parent.parent / "scripts" / "sutando-config.sh" lookup in src/core_heartbeat.py. I reproduced that locally, and the remote refuse new direct workspace-path resolution job reports the same line. The Coverage Gate failure is downstream of the same failing Python test suite, so coverage is not meaningful until the suite is green.

The previous surviving Claude-session/sibling-window blocker is fixed in the runtime-discrimination logic and the new portability suite passes; this head just needs the stale tests and required lint gate cleared before it is merge-ready.

Reviewed by Qingyun's Personal Codex.

@sonichi

sonichi commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

Live confirmation of the defect on a second host (MacBook Pro), plus one subtlety that I think deserves a test case.

The bug this PR fixes is currently live here. state/cores/<host>.alive records the heartbeat's pid, not the core's:

.alive recorded pid: 83753  -> comm: Python        (core_heartbeat.py itself)
real core           : 64234  -> claude --name sutando-core --model opus ...
src/core_heartbeat.py:110    "pid": os.getpid(),

So a reader that trusts .alive.pid is pointed at the wrong process, which is exactly the "dead core reads healthy forever" failure mode.

The subtlety: BSD pgrep excludes its own ancestors, so this resolver is ancestry-sensitive.

I nearly filed this PR's own approach as broken, because from a shell that is a descendant of the core the resolver finds nothing:

from a core-descendant shell:    pgrep -x claude -> [29320]           <- core 64234 MISSING
from a detached probe (ppid=1):  pgrep -x claude -> [29320, 64234]    <- core found

pgrep -ax claude returns both from either tree — -a adds precisely the excluded ancestor, and the non-ancestor pid appears in both arms as a control.

Production is the second case, so the code here is correct as written — core_heartbeat.py runs orphaned (ppid=1, verified) and does see the core. No change needed to the resolver.

But nothing currently pins that, and there is a path that violates it: /schedule-crons step 5.5 starts the heartbeat with nohup python3 src/core_heartbeat.py & from the core's own shell. Between spawn and reparenting, pgrep -x returns no core. ABSENT_BEATS_BEFORE_DEATH = 3 plus the pane fallback already absorb it, which is good design, but it is currently accidental rather than asserted.

Proposed addition to tests/core-heartbeat-pgrep-portability.test.py (same fake-toolchain-on-PATH harness already in this PR): a fake pgrep that returns a claude pid without the core (simulating ancestor exclusion) must still resolve the core via the pane fallback, and must not report it dead. That case fails against a resolver that trusts pgrep alone, which is the property worth locking in.

Happy to push that test to this branch if you'd like it here rather than as a follow-up.

@qingyun-wu, #2488 at 5afe37a: both merge-blocking gates were red.

1. `bash scripts/lint-workspace-resolution.sh --diff` rejected the newly added
repo-root walk used to locate `scripts/sutando-config.sh` for the `core-runtime`
shell-out. Replaced the shell-out entirely with a direct
`from sutando_config import resolve_core_runtime`. This module already lives in
`src/`, and line ~62 puts `src/` on sys.path for `workspace_default`, so no
walking is needed at all — and it drops a subprocess from the config-fallback
path. Reaching for `os.path.realpath` instead would only have dodged the regex
while keeping the walk; the import removes the reason for the walk.

2. `tests/core-heartbeat-core-pid.test.py` had three assertions predating the
runtime discrimination, all asserting the pane fallback for a Claude/default
session with no matching `claude --name <session>` process. Under the repaired
contract that state resolves ABSENT, so they failed. Updated to the repaired
contract and, per the review, each Claude arm is now PAIRED with the identical
state on a non-Claude session — so the assertion proves the discrimination
rather than merely that something returned None.

Two further cases in that suite ("NO panes -> None", "list-panes failure ->
None") and one in the portability suite ("pgrep missing entirely") were passing
for the WRONG reason: on a Claude session the resolver short-circuits above
list-panes, so they asserted None without ever reaching the branch they name.
Pinned to a non-Claude runtime so they can fail again.

The UNKNOWN-runtime case needed the same treatment for a subtler reason. It
blinded the config fallback by stubbing `bash` on PATH — which stops working
the moment the lookup becomes an in-process import, silently turning the case
into a second CLAUDE case that still passes while claiming to prove the
fallback is preserved. `_blind_config()` now stubs the import as well; the
`bash` stub is kept in case the subprocess path ever returns.

Also stubbed `pgrep` in the "parses the pane pid" case. Without it that case
calls the REAL pgrep, and on a host actually running `claude --name
sutando-core` it returns the live pid instead of the fixture's — green in CI,
host-dependent anywhere else.

Verified at this head: lint exit 0, lint --diff exit 0, review-checks exit 0,
and all 8 suites referencing core_heartbeat pass (found via
`grep -rl core_heartbeat tests/`, not from memory).

Stand: Echo Act IV Pro

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed exact head 8de9021e2c6f9ac3577ae037eddb44ff17acef87. Changes requested — not merge-ready.

The runtime-discrimination tests and direct resolve_core_runtime() fallback repair the prior stale-test and workspace-resolution failures. Reviewer-run verification passed all eight suites that reference core_heartbeat, Python compilation, diff hygiene, the REVIEW.md path gate, and the workspace-resolution lint.

One false-liveness path remains in the core identity match (inline). Worst case is another prefixed Claude session keeping this host's .alive fresh after the intended core has died, so peers and the scheduler suppress takeover—the central disruption this PR is meant to eliminate.

Comment thread src/core_heartbeat.py Outdated
if ps.returncode != 0:
continue
args = ps.stdout.strip()
if f"--name {sess}" in args or f"--name={sess}" in args:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1] Match the --name argument exactly, not by substring. For target session sutando-core, both checks here accept argv such as claude --name sutando-core-watcher or claude --name=sutando-core-secondary. I reproduced the current head returning PID 4242 when pgrep -x claude yielded that PID and ps returned claude --name sutando-core-watcher --resume, even though no Claude process named exactly sutando-core existed. On a host with multiple/prefixed Claude sessions this reintroduces the same false-healthy heartbeat and can suppress scheduler takeover. Please parse argv/token boundaries (exact next token after --name, or exact value after --name=) and add negative controls for both prefixed spellings alongside the positive cases.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

APPROVE on exact head 849ead7a. This is a current-main merge refresh; the reviewed heartbeat topic is unchanged at stable patch ID 77997e8b4f.

Reviewer-run current-head evidence: the core-PID/failure-mode suite passes all checks, including exact session-name discrimination, absent-core fail-closed behavior, late-core arming, and transient-write controls; BSD-pgrep portability passes 10/10; heartbeat CLI/schema tests pass 8/8; isolated compilation, diff hygiene, and the REVIEW.md path scan pass. All required hosted checks and CLA are green.

Worst-case disruption remains a watcher or sibling process being reported as the core, leaving a dead core falsely healthy; exact-token, no-core, and liveness controls mitigate it. Code-ready and merge-ready from this review lane, subject to the repository's independent-approval gate.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved exact refreshed head 5e3c18b. This is a topic-preserving merge of current main at bf05035; stable topic patch ID 77997e8b4fc338aae6b547c54d0399a076586bdd matches the previously reviewed patch. The full current topic diff has no file overlap with the newly merged quota-health change, so no conflict-resolution logic was introduced. Exact-head git diff hygiene and the REVIEW.md hardcoded-path scan pass, and every hosted gate including clean install, diff coverage, and CLA is green. The previously exercised failure mode remains the heartbeat writer PID making a dead core look healthy; this refresh does not widen its blast radius or add state migration, config requirements, or auto-action behavior. Code-ready and merge-ready at this exact head, subject to the repository requirement for two qualifying formal maintainer approvals.

@john-the-dev
john-the-dev enabled auto-merge (squash) August 3, 2026 01:13
@john-the-dev
john-the-dev merged commit 9711cf5 into main Aug 3, 2026
18 checks passed
@john-the-dev
john-the-dev deleted the fix/alive-core-pid branch August 3, 2026 01:22
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

john-the-dev added a commit to john-the-dev/sutando that referenced this pull request Aug 3, 2026
…; non-dict verdict can't crash confirm-count (CR)

Two P1s from bassilkhilo-ag2's CR on sonichi#2527:

1. _gateway_running() had no gateway-configured concept: on a host with no
   remote-gateway provisioned, the CORRECTLY-absent bridge read as False — a
   down-vote — so severity_gate could reach 'act' and restart a perfectly live
   core just for lacking an optional component. Added _gateway_configured()
   (env token or channels/ag2space/.env, mirroring health-check.check_gateway_bridge
   + the sonichi#2554 comm-sweep single-owner-lane fix); not-configured/can't-tell -> None.

2. _confirm_count() called .get() on the decoded core-verdict.json without an
   isinstance check; valid-JSON-but-not-an-object ('[]') raised AttributeError,
   which the OSError/ValueError guard doesn't catch and propagated out of main().
   Non-dict -> reset to 1.

Regression tests: gateway not-configured/unknown short-circuit (no probe), the
_gateway_configured matrix, and non-dict verdict ([] and str). Existing probe-path
tests now force configured=True so they still exercise the probe logic. All three
suites green (core-health-verdict, runtime-health, core-heartbeat).

Also merged current main: main sonichi#2488 landed a superseding orphan-heartbeat guard
(core_pid() + '=name' exact-match + unlink-before-first-sighting), which already
resolves qingyun's cold-start-orphan CR and john-the-dev's tmux prefix-match
finding — so this branch defers to main's core_heartbeat.py rather than carrying
the now-redundant _core_session_alive/_should_stop_beating.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XaocEvWwhBokFUmK5ECsfs
sonichi added a commit that referenced this pull request Aug 3, 2026
…st the selected one

`list-panes -t "=<sess>"` reports only the CURRENT WINDOW's panes. A core sitting
in a non-selected sibling window is therefore invisible to the session-scoped
lookup, and the `pgrep -x claude` fallback cannot see a version-named executable
either — so `core_pid()` returns None for a live core, which is the exact bug
this stack exists to remove, reachable through a different door.

Sibling windows are not hypothetical: the Claude launcher deliberately preserves
them inside the core session (`start-cli.sh:563-573`, the G10 heal). Which window
happened to be selected decided whether the core could be found.

Review-caught by qingyun-wu on #2581, with an exact reproduction after selecting
a sibling window:

    list-panes    -t "=sutando-core" -> sibling
    list-panes -s -t "=sutando-core" -> core, sibling

Confirmed independently on a throwaway tmux server before changing anything:

    windows: 0 win1 active=0 | 1 win2 active=1
    list-panes    -t "=testsess" -> pane_pid=56598 win=win2
    list-panes -s -t "=testsess" -> pane_pid=56596 win=win1
                                    pane_pid=56598 win=win2

`-s` widens across windows WITHIN the session; `-t "={sess}"` still pins the
exact session, so #2488's "never any pane on the socket" guard is untouched —
`<sess>-watcher` remains excluded.

Applied to BOTH `list-panes` call sites. The non-Claude fallback at the end has
the identical blind spot for the identical reason; leaving one half fixed would
mean knowingly keeping a live-core-reads-dead path for Codex hosts. Flagging that
I could not exercise that branch on a real Codex host — it is the same one-token
correction under the same guard, verified through the fixture only.

The committed tests modelled several TOKENS in one `list-panes` result, never
several WINDOWS, which is why they could not see this. The fixture now
distinguishes `-s` from bare `list-panes` and places the core in a window that is
not the selected one. It FAILS on the parent commit:

    FAIL core in a NON-SELECTED window of the same session is still found (-s)
         — got None, want 4242

10/10 pass with the fix. core-heartbeat-core-pid, core-heartbeat and
codex-core-launcher suites all still pass, and the live core on this host
resolves to its real pid.

Stand: Echo Act IV Mini

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sonichi added a commit that referenced this pull request Aug 3, 2026
…st the selected one

`list-panes -t "={sess}"` resolves to that session's CURRENT WINDOW, not the
whole session. This repo deliberately keeps sibling windows (gateway, monitor) in
the core's session, healing window-scoped so they survive
(`src/agent/claude/cli/start-cli.sh`). Select a sibling and the new pane-scoped
branch inspects only that sibling, misses the live core in another window, and
returns None — the exact failure this PR exists to fix, reached through a
different door.

Review-caught, qingyun-wu, with an exact-head canary: gateway window current,
real `claude --name sutando-core` pane in a sibling window ->

    core_pid with gateway current, core sibling -> None
    list-panes args -> [('list-panes', '-t', '=sutando-core', '-F', '#{pane_pid}')]

**The repo had already solved this and I did not reuse it.** `src/health-check.py`
carries the same lesson in its own docstring — "the first version of this helper
targeted the session (`list-panes -t =sutando-core`), which tmux resolves to that
session's **current window**" — and lands on `list-panes -s -t "={session}"` plus
the argv identity check. Same resolver problem, so it now gets the same shape
rather than a second answer. That file also records why window NAME is no
discriminator: on a versioned install the core's window is auto-named after the
version, which is the same `ucomm` fact this PR is about.

Confirmed the semantics independently on a throwaway tmux server before changing
anything:

    windows: 0 win1 active=0 | 1 win2 active=1
    list-panes    -t "=testsess" -> pane_pid=56598 win=win2
    list-panes -s -t "=testsess" -> pane_pid=56596 win=win1
                                    pane_pid=56598 win=win2

`-t "={sess}"` still pins the exact session, so #2488's "never any pane on the
socket" guard is untouched — `-s` widens across windows WITHIN this session,
never across sessions, and `<sess>-watcher` stays excluded.

Applied to BOTH `list-panes` call sites. The non-Claude fallback has the
identical blind spot for the identical reason; leaving half fixed would knowingly
keep a live-core-reads-dead path for Codex hosts. I cannot exercise that branch
on a real Codex host — it is the same one-token correction under the same guard,
verified through the fixture only.

The committed tests modelled several TOKENS in one `list-panes` result, never
several WINDOWS, which is why they could not see this. The fixture now
distinguishes `-s` from bare `list-panes` and places the core in a window that is
not selected. It FAILS on the parent commit:

    FAIL core in a NON-SELECTED window of the same session is still found (-s)
         — got None, want 4242

6/6 pass with the fix. core-heartbeat, core-heartbeat-core-pid and
codex-core-launcher all still pass, and the live core on this host resolves to
its real pid.

Stand: Echo Act IV Mini

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sonichi added a commit that referenced this pull request Aug 3, 2026
…e pgrep hit

Rebased onto the updated parent (#2580), which now carries the `-s` window-
enumeration fix. What remains here is the PRECEDENCE change only.

`core_pid()` asks `pgrep -x claude` FIRST and only then looks at the session's
panes. `pgrep` sweeps the whole machine — it knows nothing of the tmux socket or
the session — so the argv test is the only thing standing between the resolver
and an unrelated pid.

Sutando-Pro verified this on a second host: `pgrep -x claude` returned a
16-day-old `claude --resume ...` running under Terminal, parented
zsh -> login -> Terminal -> launchd, not on the tmux socket at all.
`_argv_names_session` rejected it, so the outcome was correct — but only because
that process happened not to name this session.

And a process that DOES name it exists by construction. Reproduced on this host:

    pid 30960  ucomm=tmux       argv_len=582  argv_names_session=True   <- the tmux PARENT
    pid 30961  ucomm=2.1.220    argv_len=463  argv_names_session=True   <- the actual core
    list-panes -s -t "=sutando-core" -> ['30961']

The launcher passes the core's full command line to tmux, so the WRAPPER's argv
contains `--name sutando-core`. Two legitimate argv matches exist on every
tmux-launched core; enumeration scope is what separates them, and argv is the
second filter rather than the first.

To be precise about what this does NOT claim: the old path was not returning the
wrapper. `pgrep -x` never offers it (its `ucomm` is `tmux`). The wrapper proves
the argv test is INSUFFICIENT ALONE, not that the old ordering picked it.

So the session-scoped lookup goes first and the machine-wide sweep becomes the
fallback, for installs whose executable really is named `claude` and for cores
that are not their pane's root process. Both #2488 guards hold in either branch:
candidates are never "any pane on the socket" (`-t "={sess}"` is exact, so
`<sess>-watcher` is excluded), and identity always comes from argv, never the
pane's foreground command.

New case, and it FAILS on the previous ordering — pgrep offers 9999 carrying the
core's exact argv while the session's own pane is 4242, so only precedence
decides:

    old order: FAIL ... got 9999, want 4242
    new order: ok   machine-wide pgrep hit with matching argv loses to the
                    session's own pane

Two cases in core-heartbeat-core-pid.test.py needed their `ps` stub made
PID-AWARE: a blanket `ps` answering with the core's argv for EVERY pid cannot
express "this pane is the core" vs "this pane is a leftover shell", so it
silently decided which branch won. Only 4242 / 4321 are the core there now, pane
777 / 555 are shells. **No assertion changed** — both still expect the pgrep pid
and still exercise the pgrep branch, including the `isdigit()` guard the second
one exists for.

All six core-heartbeat suites pass, and the live core on this host still resolves
to its real pid.

Stacked on #2580. Merge #2580 first.

Stand: Echo Act IV Mini

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
john-the-dev pushed a commit that referenced this pull request Aug 3, 2026
…o a LIVE core reads dead (#2580)

* fix(core-heartbeat): a versioned Claude binary makes pgrep -x miss, so a LIVE core reads dead

#2488 correctly stopped a dead core from reading healthy by gating the beat on
`core_pid()` resolving the core. On this host that gate never passes for a
healthy core, so nothing is ever written and the inverse bug is now live: a core
up for ten minutes, draining tasks normally, reads DEAD to every reader.

`core_pid()` enumerates candidates with `pgrep -x claude`. `pgrep -x` matches the
kernel accounting name, and Claude Code runs from a version-named binary:

    exe   : ~/.local/share/claude/versions/<ver>
    ucomm : <ver>      <- what pgrep -x matches
    comm  : claude     <- argv[0], what ps -o comm= shows

So `pgrep -x claude` matches nothing. The `runtime == "claude"` bail then returns
None *without* the pane fallback — deliberately, because for a Claude session
"no matching process" is meant to mean dead. Here it means "the binary is not
named what we grepped for". `_argv_names_session()` is fine: given the core's
argv it returns True. Only the pid ENUMERATION is broken.

Measured on the live host, with a positive control so the negative means
something (the same function, given a synthetic .alive in an isolated tmp
workspace, returns the record):

    NEGATIVE  live workspace, core pid alive 9m59s
              _fresh_local_core_record() -> None      => alive = False
    POSITIVE  isolated tmp workspace, synthetic .alive
              _fresh_local_core_record() -> RECORD    => alive = True

Fix the enumeration, not the identity test: ask tmux for the panes of THIS exact
session and apply the same `--name <sess>` argv check to them. Both #2488 guards
survive — candidates stay scoped to the exact session (never "any pane on the
socket"), and identity still comes from argv, never from the pane's foreground
command. It is strictly stronger than the non-Claude pane fallback below it, so
it runs for every runtime, and it removes the dependency on the executable's
accounting name entirely.

Why this direction is the dangerous one: a consumer that relaunches a core it
believes dead would relaunch a LIVE one, kill the session and its in-flight task,
then find no heartbeat again on the next pass. That is #2246's restart-storm,
reached through the signal offered as the reason it could not happen. Flagged on
#2160, which consumes exactly this predicate.

The test fixture is why this was invisible. `ps` echoed the core's argv for every
pid, so the fixture could not distinguish "this pane IS the core" from "this pane
is a leftover shell", and every existing row set `pgrep` to answer — no row could
express a host whose core is healthy but not NAMED claude. `ps` is now pid-aware
and the versioned-binary axis is covered. Verified the new case FAILS against
unfixed src (got None, want 4242) while all four pre-existing cases still pass,
including the corpse guard that must keep returning None.

Stand: Echo Act IV Mini

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(core-heartbeat): cover the non-pid list-panes token — the one uncovered diff line

The coverage gate failed at 92.3% with a single missing line:

    src/core_heartbeat.py (92.3%): Missing lines 231

231 is the `continue` in `if not pid_s.isdigit(): continue` — the guard that
skips a non-numeric token from `list-panes`. No fixture ever reached it, because
every stub emitted one clean pid, so the guard was dead weight to the suite even
though it is load-bearing at runtime: real tmux can put a blank line or a warning
on stdout, and a non-numeric token must be skipped rather than abort the scan.

Adds the row that exercises it — a noisy `list-panes` emitting a non-pid token
BEFORE the real pane pid. It asserts the useful half too: the junk is skipped and
the core is still found on the next token, rather than the scan bailing out.

`_farm` now takes `pane_pid` as int OR a multi-token string and emits one line
per token via printf, which is also how `list-panes -F '#{pane_pid}'` reports a
session with more than one pane — so the fixture can now express multi-pane
sessions at all, not just this case.

Verified locally the way the gate does it (`.coveragerc` sets parallel=True, so
a bare `coverage report` reads empty and means nothing until `coverage combine`
— that trap cost me a false "covered" first): after combine, 231 no longer
appears in the missing set. 13 changed statements, 1 was missing, so this takes
the diff to 100%.

All 6 cases in the file pass, including the corpse guard and the versioned-binary
case from the parent commit.

Stand: Echo Act IV Mini

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(core-heartbeat): enumerate the session across ALL windows, not just the selected one

`list-panes -t "={sess}"` resolves to that session's CURRENT WINDOW, not the
whole session. This repo deliberately keeps sibling windows (gateway, monitor) in
the core's session, healing window-scoped so they survive
(`src/agent/claude/cli/start-cli.sh`). Select a sibling and the new pane-scoped
branch inspects only that sibling, misses the live core in another window, and
returns None — the exact failure this PR exists to fix, reached through a
different door.

Review-caught, qingyun-wu, with an exact-head canary: gateway window current,
real `claude --name sutando-core` pane in a sibling window ->

    core_pid with gateway current, core sibling -> None
    list-panes args -> [('list-panes', '-t', '=sutando-core', '-F', '#{pane_pid}')]

**The repo had already solved this and I did not reuse it.** `src/health-check.py`
carries the same lesson in its own docstring — "the first version of this helper
targeted the session (`list-panes -t =sutando-core`), which tmux resolves to that
session's **current window**" — and lands on `list-panes -s -t "={session}"` plus
the argv identity check. Same resolver problem, so it now gets the same shape
rather than a second answer. That file also records why window NAME is no
discriminator: on a versioned install the core's window is auto-named after the
version, which is the same `ucomm` fact this PR is about.

Confirmed the semantics independently on a throwaway tmux server before changing
anything:

    windows: 0 win1 active=0 | 1 win2 active=1
    list-panes    -t "=testsess" -> pane_pid=56598 win=win2
    list-panes -s -t "=testsess" -> pane_pid=56596 win=win1
                                    pane_pid=56598 win=win2

`-t "={sess}"` still pins the exact session, so #2488's "never any pane on the
socket" guard is untouched — `-s` widens across windows WITHIN this session,
never across sessions, and `<sess>-watcher` stays excluded.

Applied to BOTH `list-panes` call sites. The non-Claude fallback has the
identical blind spot for the identical reason; leaving half fixed would knowingly
keep a live-core-reads-dead path for Codex hosts. I cannot exercise that branch
on a real Codex host — it is the same one-token correction under the same guard,
verified through the fixture only.

The committed tests modelled several TOKENS in one `list-panes` result, never
several WINDOWS, which is why they could not see this. The fixture now
distinguishes `-s` from bare `list-panes` and places the core in a window that is
not selected. It FAILS on the parent commit:

    FAIL core in a NON-SELECTED window of the same session is still found (-s)
         — got None, want 4242

6/6 pass with the fix. core-heartbeat, core-heartbeat-core-pid and
codex-core-launcher all still pass, and the live core on this host resolves to
its real pid.

Stand: Echo Act IV Mini

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
john-the-dev pushed a commit that referenced this pull request Aug 3, 2026
…e pgrep hit

Rebased onto the updated parent (#2580), which now carries the `-s` window-
enumeration fix. What remains here is the PRECEDENCE change only.

`core_pid()` asks `pgrep -x claude` FIRST and only then looks at the session's
panes. `pgrep` sweeps the whole machine — it knows nothing of the tmux socket or
the session — so the argv test is the only thing standing between the resolver
and an unrelated pid.

Sutando-Pro verified this on a second host: `pgrep -x claude` returned a
16-day-old `claude --resume ...` running under Terminal, parented
zsh -> login -> Terminal -> launchd, not on the tmux socket at all.
`_argv_names_session` rejected it, so the outcome was correct — but only because
that process happened not to name this session.

And a process that DOES name it exists by construction. Reproduced on this host:

    pid 30960  ucomm=tmux       argv_len=582  argv_names_session=True   <- the tmux PARENT
    pid 30961  ucomm=2.1.220    argv_len=463  argv_names_session=True   <- the actual core
    list-panes -s -t "=sutando-core" -> ['30961']

The launcher passes the core's full command line to tmux, so the WRAPPER's argv
contains `--name sutando-core`. Two legitimate argv matches exist on every
tmux-launched core; enumeration scope is what separates them, and argv is the
second filter rather than the first.

To be precise about what this does NOT claim: the old path was not returning the
wrapper. `pgrep -x` never offers it (its `ucomm` is `tmux`). The wrapper proves
the argv test is INSUFFICIENT ALONE, not that the old ordering picked it.

So the session-scoped lookup goes first and the machine-wide sweep becomes the
fallback, for installs whose executable really is named `claude` and for cores
that are not their pane's root process. Both #2488 guards hold in either branch:
candidates are never "any pane on the socket" (`-t "={sess}"` is exact, so
`<sess>-watcher` is excluded), and identity always comes from argv, never the
pane's foreground command.

New case, and it FAILS on the previous ordering — pgrep offers 9999 carrying the
core's exact argv while the session's own pane is 4242, so only precedence
decides:

    old order: FAIL ... got 9999, want 4242
    new order: ok   machine-wide pgrep hit with matching argv loses to the
                    session's own pane

Two cases in core-heartbeat-core-pid.test.py needed their `ps` stub made
PID-AWARE: a blanket `ps` answering with the core's argv for EVERY pid cannot
express "this pane is the core" vs "this pane is a leftover shell", so it
silently decided which branch won. Only 4242 / 4321 are the core there now, pane
777 / 555 are shells. **No assertion changed** — both still expect the pgrep pid
and still exercise the pgrep branch, including the `isdigit()` guard the second
one exists for.

All six core-heartbeat suites pass, and the live core on this host still resolves
to its real pid.

Stacked on #2580. Merge #2580 first.

Stand: Echo Act IV Mini

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
john-the-dev added a commit that referenced this pull request Aug 4, 2026
…e pgrep hit (#2581)

Rebased onto the updated parent (#2580), which now carries the `-s` window-
enumeration fix. What remains here is the PRECEDENCE change only.

`core_pid()` asks `pgrep -x claude` FIRST and only then looks at the session's
panes. `pgrep` sweeps the whole machine — it knows nothing of the tmux socket or
the session — so the argv test is the only thing standing between the resolver
and an unrelated pid.

Sutando-Pro verified this on a second host: `pgrep -x claude` returned a
16-day-old `claude --resume ...` running under Terminal, parented
zsh -> login -> Terminal -> launchd, not on the tmux socket at all.
`_argv_names_session` rejected it, so the outcome was correct — but only because
that process happened not to name this session.

And a process that DOES name it exists by construction. Reproduced on this host:

    pid 30960  ucomm=tmux       argv_len=582  argv_names_session=True   <- the tmux PARENT
    pid 30961  ucomm=2.1.220    argv_len=463  argv_names_session=True   <- the actual core
    list-panes -s -t "=sutando-core" -> ['30961']

The launcher passes the core's full command line to tmux, so the WRAPPER's argv
contains `--name sutando-core`. Two legitimate argv matches exist on every
tmux-launched core; enumeration scope is what separates them, and argv is the
second filter rather than the first.

To be precise about what this does NOT claim: the old path was not returning the
wrapper. `pgrep -x` never offers it (its `ucomm` is `tmux`). The wrapper proves
the argv test is INSUFFICIENT ALONE, not that the old ordering picked it.

So the session-scoped lookup goes first and the machine-wide sweep becomes the
fallback, for installs whose executable really is named `claude` and for cores
that are not their pane's root process. Both #2488 guards hold in either branch:
candidates are never "any pane on the socket" (`-t "={sess}"` is exact, so
`<sess>-watcher` is excluded), and identity always comes from argv, never the
pane's foreground command.

New case, and it FAILS on the previous ordering — pgrep offers 9999 carrying the
core's exact argv while the session's own pane is 4242, so only precedence
decides:

    old order: FAIL ... got 9999, want 4242
    new order: ok   machine-wide pgrep hit with matching argv loses to the
                    session's own pane

Two cases in core-heartbeat-core-pid.test.py needed their `ps` stub made
PID-AWARE: a blanket `ps` answering with the core's argv for EVERY pid cannot
express "this pane is the core" vs "this pane is a leftover shell", so it
silently decided which branch won. Only 4242 / 4321 are the core there now, pane
777 / 555 are shells. **No assertion changed** — both still expect the pgrep pid
and still exercise the pgrep branch, including the `isdigit()` guard the second
one exists for.

All six core-heartbeat suites pass, and the live core on this host still resolves
to its real pid.

Stacked on #2580. Merge #2580 first.

Stand: Echo Act IV Mini

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Rui Wang <52230987+john-the-dev@users.noreply.github.com>
john-the-dev added a commit to john-the-dev/sutando that referenced this pull request Aug 5, 2026
…; non-dict verdict can't crash confirm-count (CR)

Two P1s from bassilkhilo-ag2's CR on sonichi#2527:

1. _gateway_running() had no gateway-configured concept: on a host with no
   remote-gateway provisioned, the CORRECTLY-absent bridge read as False — a
   down-vote — so severity_gate could reach 'act' and restart a perfectly live
   core just for lacking an optional component. Added _gateway_configured()
   (env token or channels/ag2space/.env, mirroring health-check.check_gateway_bridge
   + the sonichi#2554 comm-sweep single-owner-lane fix); not-configured/can't-tell -> None.

2. _confirm_count() called .get() on the decoded core-verdict.json without an
   isinstance check; valid-JSON-but-not-an-object ('[]') raised AttributeError,
   which the OSError/ValueError guard doesn't catch and propagated out of main().
   Non-dict -> reset to 1.

Regression tests: gateway not-configured/unknown short-circuit (no probe), the
_gateway_configured matrix, and non-dict verdict ([] and str). Existing probe-path
tests now force configured=True so they still exercise the probe logic. All three
suites green (core-health-verdict, runtime-health, core-heartbeat).

Also merged current main: main sonichi#2488 landed a superseding orphan-heartbeat guard
(core_pid() + '=name' exact-match + unlink-before-first-sighting), which already
resolves qingyun's cold-start-orphan CR and john-the-dev's tmux prefix-match
finding — so this branch defers to main's core_heartbeat.py rather than carrying
the now-redundant _core_session_alive/_should_stop_beating.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XaocEvWwhBokFUmK5ECsfs
john-the-dev added a commit that referenced this pull request Aug 5, 2026
…e (slice 1) (#2527)

* docs(core-health): design for one severity-tagged verdict + severity gate

Owner-approved direction (2026-08-02): items #1 (single authoritative verdict)
and #2 (severity gates action) from the watchdog systematic-fix analysis.
Consolidates point-detector sprawl (~50 merged watchdog PRs; dominant failure =
false-positive detection + consumers disagreeing on liveness). Design only, no
behavior change; first additive implementation slice (core-verdict.json +
severity_gate + false-positive fixtures) follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XaocEvWwhBokFUmK5ECsfs

* feat(runtime-health): severity-tagged core verdict + severity gate (slice 1)

First implementation slice of docs/design-core-health-verdict.md (watchdog
systematic-fix #1+#2, owner-approved 2026-08-02). Additive only — no consumer
behavior changes yet.

- derive() now tags each verdict with a severity (ok|warn|escalate|critical)
  and a signals block (process/gateway/status_fresh/pane_login) so a wrong call
  is auditable instead of shipping as another 'consumer disagreed' fix.
- severity_gate(verdict): the ONE place that turns a verdict into an action.
  ACT (restart) requires critical AND confirm>=N AND >=2 independent down-votes
  AND not freshly-booted; everything softer is report/escalate/none. needs_login
  never auto-restarts. Structural form of the owner's rule: report issues,
  restart only when critical AND confirmed.
- main() emits state/core-verdict.json (the authoritative verdict + a persisted
  confirm-count); consumers migrate to it one PR at a time.
- Fixture suite pins the false-positive-restart class (idle / stale-but-alive /
  bad-PATH mis-probe) to never reach 'act' — the class behind ~15 historical
  one-off fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XaocEvWwhBokFUmK5ECsfs

* docs: register core-health-verdict design in the docs hub

Satisfies the docs-audit gate (index in docs/README.md + catalog.json metadata,
status=draft). No content change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XaocEvWwhBokFUmK5ECsfs

* fix(runtime-health): heartbeat corroborator so offline can reach 'act' (CR #2527)

qingyun CR: a genuinely offline core with a lingering remote-gateway-bridge
produced only ONE down-vote (process=False; gateway still True, status_fresh
None), so severity_gate() returned 'report' forever — a dead core stayed
unrecoverable, and that signature was indistinguishable from a bad-PATH
mis-probe.

Add heartbeat_fresh: the per-host state/cores/<host>.alive signal, rewritten
every ~30s by a SEPARATE process (core_heartbeat.py). It is the
process-independent corroborator:
- real offline: process=False AND heartbeat stale -> 2 down-votes -> act
- bad-PATH mis-probe on a LIVE core: process reads False but it still beats
  (heartbeat fresh) -> 1 down-vote -> report (protection preserved)

Verified qingyun's exact repro (_core_running=False, _gateway_running=True,
no .alive) now returns 'act'. Both suites green; new fixture pins the offline+
lingering-gateway case and keeps the bad-PATH/stale-but-alive fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XaocEvWwhBokFUmK5ECsfs

* test(runtime-health): cover _host_label_safe + _heartbeat_fresh branches

diff-cover flagged the new helpers' defensive branches uncovered (lines 80,
84-85, 104). Add direct unit tests: label success path, both-resolution-paths-
fail -> None, unresolved-host -> None, and fresh/stale/missing .alive -> True/
False/False. No production change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XaocEvWwhBokFUmK5ECsfs

* fix(runtime-health): probe-unavailable is unknown, not a down-vote; bind heartbeat to core (qingyun CR #2527)

Addresses the two P1s in the 08-02T15:48 review:

P1-1 — probe-unavailable collapsed to a positive 'down' observation.
_run() returned rc 127 on exec failure, indistinguishable from a command
that ran and returned non-zero, so a correlated probe outage (tmux/pgrep
unavailable) read process=False AND gateway=False and, with a fresh
heartbeat, reached >=2 down-votes -> act -> a restart of a healthy core.
Now _run() returns rc None when the command cannot execute; _core_running()
and _gateway_running() pass that through as None (unknown), and derive()
routes core=None to a new 'unknown' branch that sets no False signal, so the
gate sees zero down-votes and can only report. A probe that RAN and genuinely
missed still reads False. Activated regression drives the real probe boundary
(subprocess.run patched to raise) through _run/_core_running/_gateway_running
and derive(), asserting the correlated-outage verdict gates to report.

P1-2 — orphan heartbeat could make a dead core unrecoverable. core_heartbeat.py
is a standalone sidecar; an ungraceful core death left it beating, so
process=False + lingering gateway + fresh(orphan) heartbeat scored one
down-vote -> report forever. Resolved at the source: the sidecar now binds to
the core's tmux session, and once the session it saw disappears it unlinks
.alive and exits (startup grace before first sighting; fail-open on an
unprobeable tmux; 2-miss debounce so a transient false-negative can't kill a
live core's heartbeat). A fresh .alive therefore genuinely means the core
lived within the window.

diff-cover: 100% on both changed modules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XaocEvWwhBokFUmK5ECsfs

* fix(runtime-health): gateway-not-configured is unknown (no down-vote); non-dict verdict can't crash confirm-count (CR)

Two P1s from bassilkhilo-ag2's CR on #2527:

1. _gateway_running() had no gateway-configured concept: on a host with no
   remote-gateway provisioned, the CORRECTLY-absent bridge read as False — a
   down-vote — so severity_gate could reach 'act' and restart a perfectly live
   core just for lacking an optional component. Added _gateway_configured()
   (env token or channels/ag2space/.env, mirroring health-check.check_gateway_bridge
   + the #2554 comm-sweep single-owner-lane fix); not-configured/can't-tell -> None.

2. _confirm_count() called .get() on the decoded core-verdict.json without an
   isinstance check; valid-JSON-but-not-an-object ('[]') raised AttributeError,
   which the OSError/ValueError guard doesn't catch and propagated out of main().
   Non-dict -> reset to 1.

Regression tests: gateway not-configured/unknown short-circuit (no probe), the
_gateway_configured matrix, and non-dict verdict ([] and str). Existing probe-path
tests now force configured=True so they still exercise the probe logic. All three
suites green (core-health-verdict, runtime-health, core-heartbeat).

Also merged current main: main #2488 landed a superseding orphan-heartbeat guard
(core_pid() + '=name' exact-match + unlink-before-first-sighting), which already
resolves qingyun's cold-start-orphan CR and john-the-dev's tmux prefix-match
finding — so this branch defers to main's core_heartbeat.py rather than carrying
the now-redundant _core_session_alive/_should_stop_beating.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XaocEvWwhBokFUmK5ECsfs

* test(runtime-health): make gateway probe-path tests host-independent (qingyun CR #2527)

The two probe-path controls asserted a bool from _gateway_running() and
exercised the window-scan fallback, but neither established the 'gateway
configured' precondition. Since src/runtime-health.py:229 short-circuits to
None when _gateway_configured() is not True, both checks fail on a clean-install
CI host (no gateway token) while passing where a gateway is provisioned — the
required suite was red on CI, green locally.

Force _gateway_configured=True around the real-probe and window-scan cases so
they reach the intended branch host-independently, and add an explicit
unconfigured-host -> None control.

Verified: full suite green on a configured host AND under a simulated clean
host (env -u REMOTE_TASK_TOKEN -u AG2_REMOTE_TOKEN, empty CLAUDE_CONFIG_DIR).
core-health-verdict 0-fail, py_compile, gen-src-map --check, git diff --check all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XaocEvWwhBokFUmK5ECsfs

* test(core-health-verdict): pin severity_gate as unwired (Mini cold-review #2527)

severity_gate is defined, exported, and thoroughly tested but has zero
production call sites — slice 1 is additive by design. Nothing pinned the
*unwired* state, so if slice 2 slips the gate could quietly rot into dead
code, or someone could wire it into a live restart path without a test
noticing. Add a scan asserting no module under src/ calls severity_gate(
(skips the definition and comment mentions). The day it's wired, this fails
and forces a deliberate update.

Suggested by the peer cold review on this PR (point 3, "cheap"). No blockers
were raised; this converts an unstated assumption into a recorded one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XaocEvWwhBokFUmK5ECsfs

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sonichi added a commit that referenced this pull request Aug 14, 2026
8505223 emitted `session` from core_session(), which reads $SUTANDO_TMUX_SESSION.
Sutando-Pro caught that this records the wrong VALUE on the Claude path, and the
repo agrees: claude/cli/start-cli.sh:52 hardcodes SESSION="sutando-core", :729/734
run `new-session -A -s "$SESSION"`, and CORE_ENV_ARGS (:64) never forwards
SUTANDO_TMUX_SESSION. So a user export names a session tmux never created, and the
relay would tell the owner to attach to a target that does not exist.

There are three start-cli.sh. The dispatcher (src/agent/start-cli.sh:57) honours the
env; the Claude launcher does not. core_session()'s docstring claimed it "mirrors
both launchers' default" — the launchers disagree, which is why the value looked
authoritative and was not.

_observed_session() asks tmux what this process is actually IN, and is trusted only
when $TMUX proves we are inside tmux — a bare display-message from outside resolves
some arbitrary session on a socket shared with ${SESSION}-watcher. Otherwise it
falls back to the contract. core_session() is kept and documented as NOT
authoritative; core_pid() still uses it, and its exposure is called out below.

Reproduced live on this host, where tmux holds one session named sutando-core:
  env unset:        core_session()='sutando-core'                _observed='sutando-core'
  env set to bogus: core_session()='custom-core-does-not-exist'   _observed='sutando-core'
                    has-session on the env value -> rc=1 (does not exist)

Tests stub _tmux so they do not need a live server. Control, reverting only the
writer: 3 new tests fail. Four heartbeat/relay suites pass with it.

NOT fixed here, pre-existing and worth its own issue: core_pid() resolves via
core_session() and returns None when has-session fails, so the same bad export
makes the heartbeat record the WRITER's pid as the core's — the false-healthy class
this module exists to prevent. That is a behaviour change to a resolver with its own
review history (#2488, #2580) and does not belong in this PR.

Stand: Echo Act IV Mini
sonichi added a commit that referenced this pull request Aug 16, 2026
… terminal, not by a reply (#2757)

* fix(core-supervisor-relay): a blocked-human prompt is answered at the terminal, not by a reply

`compose_message` appended "reply here or open the app to resolve" to every
non-login blocker. Neither route can answer a `blocked-human` prompt: it waits on
the core's stdin, no consumer turns a chat reply into a prompt answer (this module's
own docstring says it never resolves the signal), and the app's only core levers are
a restart intent and a fixed `watcher` keystroke -- restarting discards the in-flight
work. The owner confirmed the actual remedy after a 1h48m stall: he unblocked it at
the CLI.

Measured while fixing it: the login branch already exceeded the 260-char bound its
own test asserts (357 chars with a long prompt). The test only passed because its
fixture used a short one, so the hole predates this change. The truncation test now
exercises BOTH remedy branches and pins the prompt echo rather than total length --
the echo is the compressible half, the remedy is the actionable one.

Stand: Echo Act IV Mini

* fix(core-supervisor-relay): don't name tmux — the core may be embedded

Owner correction on the previous commit: naming `tmux sutando-core` assumes a
transport this module cannot know. The core may be embedded (AG2 Space is already a
first-class deliverable surface here), and the tmux socket is env-overridable via
SUTANDO_TMUX_SOCKET, so the name is wrong on some hosts and unhelpful on the rest.

Now: "answer it where the core is running on <host>. A chat reply can't answer it."
Still says the only two things this module can actually assert -- a reply won't reach
it, and the answer belongs at the core -- without inventing a console.

The test moves with the message: it asserted `sutando-core`, so leaving it would have
pinned the claim just removed. It now asserts the transport-neutral phrasing and that
neither `tmux` nor a session name appears.

Side effect worth noting: the shorter remedy puts the worst-case message at 241 chars,
back inside the 260 bound the previous commit had to relax.

Stand: Echo Act IV Mini

* fix(core-supervisor-relay): trim the comment narration per review

Third review pass on the same blocker. The 7-line block narrated the old wrong
routes, the app levers, restart behaviour, AG2 Space and env-overridable sockets;
only one of those is a constraint the code cannot state itself, so that is all
that stays:

    # Names no console on purpose: the core may be embedded and the socket is
    # env-overridable, so no fixed target is correct from here.

Test docstring 4 lines -> 2, keeping the bound-holds-for-BOTH-branches rule and
dropping the account of how the login branch used to be tested.

No behavioural change. The three suites the review named all pass.

Stand: Echo Act IV Mini

* fix(core-supervisor-relay): derive the terminal instead of describing it vaguely

The body promised "answer it at the core's CLI terminal on <host> (tmux
`sutando-core`)" while the code emitted "answer it where the core is running on
<host>" and the tests asserted that console is ABSENT. A reviewer approving from the
body approved the opposite behaviour. Pro caught the contradiction.

The old comment justified it: "the core may be embedded and the socket is
env-overridable, so no fixed target is correct from here." The first half is right and
the conclusion does not follow — no fixed LITERAL is correct, but the running core
records its own socket in state/cores/<host>.alive precisely so a reader need not
guess. Verified here: `backend: {type: tmux, socket: /tmp/sutando-tmux.sock}`.

So: derive it when knowable, keep the generic phrasing when not. That delivers what the
body promised without hardcoding, and matches the PR's own evidence — the owner said he
unblocked "in the CLI terminal", and naming the host alone does not tell someone who
does not already know.

Two defects found while building it, both mine:

  * the helper's `from workspace_default import ...` resolved only when sys.path[0]
    happened to be src/ — true running as a script, false under importlib, so the
    feature was silently inert under test. Now resolves src/ from __file__, per the
    convention in core_heartbeat.py.
  * fail-open lived INSIDE the helper while the call site was unguarded, so a raise
    escaped and crashed the escalation — the only channel the owner has there. The
    guard is now at the boundary. The test asserting fail-open is what found this.

Control: reverting only the source fails exactly
test_blocked_human_names_the_DERIVED_terminal; the fallback, fail-open and
length tests pass in BOTH states, so they are not vacuous and not coupled to the new
symbol. 41 tests OK. Long-prompt message 275 chars against the 400 bound.

Found by Sutando-Pro in cold review.

Stand: Echo Act IV Mini

* fix(ci): restore workspace/.gitkeep — its deletion bricks agent-api at import

This PR deleted `workspace/.gitkeep`, which is tracked on main and unrelated to
its concern. Without it `workspace/` is absent on a clean CI checkout, and
`src/agent-api.py:146` runs `TASK_DIR.mkdir(exist_ok=True)` at module scope —
`exist_ok` tolerates an existing dir but does not create a missing parent — so
import raises FileNotFoundError. Every test importing agent-api then dies and the
coverage gate exits 1, surfacing as a red `diff coverage >= 95% (python)` that is
not a coverage problem.

Root cause credit: Sutando-Pro.

Stand: Echo Act IV Mini

* fix(core-supervisor-relay): the backend target used the wrong host label, no freshness gate, and named a path instead of a command

Three standing findings from the exact-head review.

1. `_derive_backend()` read `state/cores/<host>.alive` under `platform.node()`,
   but `core_heartbeat._hostname()` WRITES that file under
   `util_paths._host_label()`. Those disagree exactly when DHCP drifts the
   hostname (#1745) — the reader then looks for a filename that does not exist,
   swallows the miss, and the owner gets generic phrasing on a host that is in
   fact addressable. Added `_core_host_label()` mirroring the writer, same
   fallback.

2. No freshness gate: a core that stopped beating leaves a record naming a
   socket that may be gone, and the relay would send the owner to it. Now
   bounded by the documented ~90s `.alive` staleness threshold.

3. The owner-visible target was a bare socket path, which is not actionable.
   The heartbeat records no session, so rather than invent a field the message
   now carries a runnable `tmux -S <socket> attach`.

Negative control — the same 6 new cases against this branch's own pre-fix
source (restored with `git checkout` in a copy, fix markers absent):

    FAILED (failures=3, errors=1)
      test_reads_the_label_the_heartbeat_wrote_under
      test_a_stale_record_is_not_a_target
      test_the_message_gives_a_command_not_a_path
      test_a_fresh_record_still_resolves (TypeError: None is not subscriptable)

Post-fix 47/47.

The staleness case initially PASSED pre-fix for the wrong reason — the label bug
made the lookup miss, so it returned None and the assertion held. It now writes
the record under both labels so only staleness can produce None.

Also trims the recorder docstring and the message comment to the two-line cap.

Stand: Echo Act IV Pro

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style(test): split a combined import so ruff E401 stops failing CI

tests/core-supervisor-relay.test.py:524 had `import sys, types`. Ruff only
started reporting it once the branch became mergeable and CI could compute a
merge commit — the defect predates the merge, it was just unreachable.

Stand: Echo Act IV Pro

* fix(test): variadic resolver stub — a zero-arg lambda asserted fail-open by accident

The `_workspace()` helper stubbed `resolve_workspace` with a zero-arg lambda.
`core-supervisor-relay` itself calls it zero-arg, so nothing broke here — but the
stub replaces the whole `workspace_default` module for every importer, and 25
production call sites pass `migrate=`/`repo=`. Any of those reaching the stub
raises TypeError straight into the helper's broad `except Exception: return None`,
so the test would assert the fail-open branch while still reporting green.

One line, three red checks: the hermetic-bridge lint failed on it directly, the
tsc+tests job failed via the two lint meta-tests that scan the tree, and the
diff-coverage job never measured coverage at all — it stops at "suite must be
green before coverage is meaningful", which reads as a shortfall but is a refusal.

Control: with the fix stashed the lint exits 1 naming line 527; restored it exits 0.
All three previously-failing test files pass locally.

Stand: Echo Act IV Mini

* test(core-supervisor-relay): drive the host-label fallback, with a control

CI's diff-coverage gate reported src/core-supervisor-relay.py at 93.8%, missing
lines 97-98 — the `except Exception: return platform.node()...` fallback in
_core_host_label(). Lines 94-96 were already covered, so util_paths imports fine
under test and only the fallback was unexercised.

Covered by DRIVING it, not asserting it exists: util_paths is replaced in
sys.modules with a module that has no _host_label, so the `from util_paths import
_host_label` inside the try actually raises and the fallback runs.

Second test is a control. If the working path also returned platform.node(), the
fallback test would pass for the wrong reason — so it pins that the two branches
return DIFFERENT values (SUTANDO_HOST_LABEL vs the node name).

Measured with the repo's own gate invocation (--rcfile=.coveragerc + combine;
a bare `coverage report` reads an empty default file and says "No data to report",
which looks identical to uncovered):
  without these tests: 157 stmts, 2 miss, 99%, missing 97-98   <- reproduces CI
  with them:           157 stmts, 0 miss, 100%

Stand: Echo Act IV Mini

* fix(core-supervisor-relay): the attach remedy must name the session

qingyun-wu, P1 at 23af722: the generated remedy was `tmux -S <socket> attach`
with no `-t`. That socket is shared — the core session, and `${SESSION}-watcher`
under Codex — so a bare attach can drop the owner into the watcher instead of the
core prompt that is actually waiting for input. The launchers all use
`tmux -S "$TMUX_SOCKET" attach -t "$SESSION"` for exactly this reason.

_derive_backend() now carries the session alongside the socket, resolved the way
the launchers resolve it: the runtime-authored heartbeat's `session` if present,
else $SUTANDO_TMUX_SESSION, else the shipped default. compose_message() emits
`tmux -S <socket> attach -t <session>`.

Three regressions, each of which FAILS with the production change reverted:
  - bare-attach guard: asserts `attach -t sutando-core` and that no `attach` is
    left without a `-t` (the old assertion passed on a substring, so it could not
    have caught this)
  - sibling session: a heartbeat recording `sutando-core-watcher` must win over
    the default — the exact case where a default sends you to the wrong prompt
  - env contract: $SUTANDO_TMUX_SESSION honoured when the record carries none

  with fix:    52 tests, OK
  without fix: rc=1, all three new tests FAIL

Stand: Echo Act IV Mini

* fix(core-heartbeat): record the tmux session, so the relay has something to read

Follow-up to 2501917. That change taught the relay to emit `attach -t <session>`,
but it read `session` from a heartbeat payload that never contained the key — the
live record carries host/pid/socket/status/locality/schema_version and nothing
else. So the recorded-session branch was unreachable in production and the test
covering it invented a payload shape that does not exist. Credit to Sutando-Pro
for catching that the gap was in the writer, not the reader.

`core_session()` already existed at core_heartbeat.py:116 and was computed only
inside core_pid(), never emitted. Emitting it next to `socket` closes the gap by
the file's own stated argument (lines 349-355): the session lives in the core's
own environment, so a reader that cannot reconstruct the launch env — which is
exactly the relay's position — must be told rather than left to guess. Guessing
from the relay's own $SUTANDO_TMUX_SESSION is precisely the reconstruction that
comment warns against.

Additive: no consumer gates on this record's schema_version, and readers that do
not know the field are unaffected, same as `locality`. Left at 3 for that reason.

Control — reverting ONLY the writer change:
  KeyError: 'session'   tests/core-heartbeat.test.py rc=1
  with the emit:        core-heartbeat rc=0, core-supervisor-relay rc=0

Stand: Echo Act IV Mini

* fix(core-heartbeat): resolve the recorded session from tmux, not the env

8505223 emitted `session` from core_session(), which reads $SUTANDO_TMUX_SESSION.
Sutando-Pro caught that this records the wrong VALUE on the Claude path, and the
repo agrees: claude/cli/start-cli.sh:52 hardcodes SESSION="sutando-core", :729/734
run `new-session -A -s "$SESSION"`, and CORE_ENV_ARGS (:64) never forwards
SUTANDO_TMUX_SESSION. So a user export names a session tmux never created, and the
relay would tell the owner to attach to a target that does not exist.

There are three start-cli.sh. The dispatcher (src/agent/start-cli.sh:57) honours the
env; the Claude launcher does not. core_session()'s docstring claimed it "mirrors
both launchers' default" — the launchers disagree, which is why the value looked
authoritative and was not.

_observed_session() asks tmux what this process is actually IN, and is trusted only
when $TMUX proves we are inside tmux — a bare display-message from outside resolves
some arbitrary session on a socket shared with ${SESSION}-watcher. Otherwise it
falls back to the contract. core_session() is kept and documented as NOT
authoritative; core_pid() still uses it, and its exposure is called out below.

Reproduced live on this host, where tmux holds one session named sutando-core:
  env unset:        core_session()='sutando-core'                _observed='sutando-core'
  env set to bogus: core_session()='custom-core-does-not-exist'   _observed='sutando-core'
                    has-session on the env value -> rc=1 (does not exist)

Tests stub _tmux so they do not need a live server. Control, reverting only the
writer: 3 new tests fail. Four heartbeat/relay suites pass with it.

NOT fixed here, pre-existing and worth its own issue: core_pid() resolves via
core_session() and returns None when has-session fails, so the same bad export
makes the heartbeat record the WRITER's pid as the core's — the false-healthy class
this module exists to prevent. That is a behaviour change to a resolver with its own
review history (#2488, #2580) and does not belong in this PR.

Stand: Echo Act IV Mini

* fix(core-heartbeat): the detached writer recorded an unverified session

$TMUX is absent on both launch paths — startup.sh:690 and start-cli.sh:188
start core_heartbeat.py detached — so _observed_session() always fell through
to core_session(), the untrusted SUTANDO_TMUX_SESSION. A lying export wrote a
nonexistent session into state/cores/<host>.alive and the relay then handed the
owner an attach command for a dead session.

Resolve it from the evidence the module already trusts rather than adding a
second rule: core_pid() exact-matches the session and verifies the core process
by argv. Take the contract value only when it resolves; otherwise find the
session that actually hosts the core. A bare display-message from outside tmux
is still never used — that remains the failure the old guard was written for.

The existing outside-tmux test asserted _tmux was never called at all, which
also forbade the scoped lookups; it now forbids the specific unsafe call.
test_write_beat_payload_schema stubbed core_pid(socket_path=None), narrower
than the real core_pid(socket_path=None, session=None); widened to match.

Regression added per review: write_beat() with $TMUX unset and a lying
SUTANDO_TMUX_SESSION must not record the lie. Verified it fails with the
production change reverted and the tests kept.

Stand: Echo Act IV Mini

* fix(core-heartbeat): correct the premise in the comment, not the fix

The previous commit's comment said both launch paths leave $TMUX absent. That
is wrong: `&` backgrounds the process without stripping the environment, and
startup.sh:673 runs inside the pane, so the live writer here inherits
TMUX=/tmp/sutando-tmux.sock,... and takes the tmux branch.

The hole is conditional, not universal — it opens when startup.sh runs from
outside a pane. The verification is still required and unchanged; only the
justification was overstated, and a wrong comment is worse than none.

The regression's own setup was already the correct scenario ($TMUX unset); its
docstring claimed universality. Reworded to name the outside-a-pane launch.

Stand: Echo Act IV Mini

* fix(tests): widen the remaining narrow core_pid stubs

core_pid's real signature is (socket_path=None, session=None). Two stubs in
core-heartbeat-core-pid.test.py modelled only the first parameter, so the
session-verified branch added in e592615 raised TypeError under them. CI caught
it as a coverage-gate failure — "test failed under instrumentation" — because the
suite must be green before coverage is computed.

I ran the two suites the review named and pushed on that. Ten test files touch
core_heartbeat; this was the third. The population to run is every suite that
imports the module, not the ones a reviewer happened to list.

codex-core-launcher.test.py also fails here, on a missing tmp context.json, and
fails identically with origin/main's core_heartbeat.py — pre-existing, not this.

Stand: Echo Act IV Mini

* docs(core-heartbeat): describe the real trust boundary, not the old contract

_observed_session's docstring still said it returns "the contract" outside tmux.
Since b642347 it validates that candidate against a live pid and scans the
socket's real sessions first, returning the unverified name only as a last
resort — so the docstring described a trust boundary the code no longer has.

Also reduces the review-narrative docstring on
test_write_beat_records_the_live_session_not_a_lying_env to the invariant it
asserts, per the repository comment contract.

No functional change. Every suite importing core_heartbeat passes (9/10);
tests/codex-core-launcher.test.py fails identically with these edits stashed at
b3dab36, so it is pre-existing and unrelated.

Stand: Echo Act IV Mini

* docs(core-heartbeat): fit the _observed_session docstring in two lines

The repository contract caps comments at two physical lines. The previous
docstring ran to three while saying the same thing; this keeps the only part
that is not evident from the code -- authoritative inside tmux, a claim to be
validated outside it.

No functional change. Every suite importing core_heartbeat passes (9/10);
tests/codex-core-launcher.test.py fails identically with this edit stashed at
parent 82a83de, so it is pre-existing and unrelated.

Stand: Echo Act IV Mini

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Rui Wang <52230987+john-the-dev@users.noreply.github.com>
qingyun-wu pushed a commit that referenced this pull request Aug 29, 2026
keweichen's P1 on this PR: `_is_core_runtime_argv` accepted any pane-root
`claude`/`codex` inside a core-named session, so an ordinary `claude --resume
user-work` sibling — which the G10 heal deliberately preserves in the canonical
session — conferred core ownership on any watcher it parented. Sutando.app runs
health-check --fix at startup and every 30 min, so that blessed duplicate also
got the sentinel re-stamped onto its pid.

Require identity a LAUNCHER authored, delegating to the single owner of the
exact-match policy, `core_heartbeat._argv_names_session` (already hardened so
`--name sutando-core` does not match the prefixed sibling session
`sutando-core-watcher`, #2488).

The replaced docstring justified the loose predicate by claiming that gating on
`--name` "would hand back the 'stop the rest' verdict this probe exists to
remove". That is false on this branch's own code: both verdict sites gate the
affirmative branch on `not orphaned and not unverified`, and the fallback names
ONLY the ownerless roots. Pool and Codex panes therefore fall to `unverified` —
neither stopped nor re-stamped — which is the mitigation the review itself
named, and is still materially different from pre-fix main, which named every
extra root as "stop the rest".

Pool panes cannot do better on this base: no launcher that starts a `core-<N>`
pane exists here (`git grep -l proactive-loop-pool` -> 0;
`scripts/pool-core-wrapper.sh` absent from origin/main and from this head — it
ships with the unmerged pool series and is staged to ~/.sutando/bin at install).

Tests: the two cases that encoded the refuted contract are inverted rather than
deleted, and the requested mirror control is added — a real core pane beside a
RUNTIME sibling in the same canonical session, plus the prefixed-session case.
Both new tests fail on the parent commit and pass here, so they measure the
reported blocker rather than passing by construction. Suite 35/35.
qingyun-wu pushed a commit that referenced this pull request Sep 2, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants