Skip to content

feat(core): graceful restart handshake (orchestrator + prep, dry-run testable) - #2334

Merged
john-the-dev merged 35 commits into
mainfrom
feat/graceful-restart
Aug 11, 2026
Merged

john-the-dev merged 35 commits into
mainfrom
feat/graceful-restart

Conversation

@sonichi

@sonichi sonichi commented Jul 27, 2026 •

Copy link
Copy Markdown
Owner

What

Two new scripts that make a core restart prepare before it dies, plus their focused suite:

  • src/agent/graceful-restart.sh — the orchestrator. Owns the kill, because an agent cannot safely --restart itself (kill-session terminates it mid-task).
  • src/agent/restart-prep.sh — Phase-1 prep: checkpoint → bounded sync-workspace → record branch/dirty → always write a terminal ready or prep-failed sentinel.
  • tests/graceful-restart.test.sh — 14 cases.
  • docs/src-map.md — regenerated, because the map derives each module's summary from these files' headers.

Body rewritten 2026-08-07 after review. It previously described an earlier architecture — a task-restart-prep-<id> written to the task queue and waited on as an event — and claimed dry-run-only evidence and "no existing files touched." All three were stale. That design was removed during review, the live-path evidence has since been produced and accepted, and the diff now includes the generated source map. What follows describes the code that actually ships.

How it works, as shipped

Fully deterministic — no task-queue or LLM handoff anywhere. An early revision wrote a RESTART_PREP task for the running agent to pick up; review established that a busy-or-wedged core could heartbeat forever without ever running prep, so the sentinel might never appear. Prep is mechanical, so the orchestrator now invokes it directly:

Phase 0  serialize      atomic `mkdir` lock; lease RENEWED during the wait, and ownership
                        re-verified before renewing and again before prep, so a holder whose
                        lock was reaped mid-stall defers instead of restarting alongside the reaper
Phase 1  quiet gate     dead (.alive stale > 90s) -> prep best-effort, then restart
                        busy (core-status "running" + fresh ts) -> wait, NO give-up timer
                        quiet -> proceed
                        a "running" status older than STATUS_TTL_S is wedged, not busy
Phase 2  prep           graceful-restart.sh:167  bash restart-prep.sh "$RID"   <- direct
                        :173  the terminal sentinel is read ONCE and decided from that snapshot
Phase 3  exec           start-cli.sh --restart [forwarded args]

Exit codes, because a supervisor branches on them: 0 restarted · 3 prep failed and the core is still running (nothing killed — owner decides fix-vs-force) · 4 deferred to a peer holding the lock · 143 TERM.

The unbounded wait on a healthy core is deliberate. It is what makes the serialization correct; capping it would reintroduce the race. Anything that needs to interrupt it uses the external escape hatch.

Evidence

Live destructive path — supervised, owner-authorized (2026-08-07). This was the long-standing blocker and it is cleared; the reviewer confirmed it in writing.

scripts executed == PR head, verified AFTER the run
  graceful-restart.sh  sha256 db5b6a01e87fecf0…      restart-prep.sh  f1b2560a0bd30fd7…
pre    core pid 23083, tmux 23082, probe task queued PRE-kill
post   core pid 8832 under a fresh tmux server, started `-- /startup`
       pre-kill probe delivered EXACTLY ONCE (idempotency-guarded; one post-*.txt line)
       state/restart-ready.json recorded synced=true dirty=3 queue=2 -> prep DID run before the kill
prop-4 a second concurrent REAL invocation logged
       "another restart is in progress (holder …, age 63s) — deferring, NOT restarting"

Dry-run lock behaviour, verified live on the real workspace after the retention change: a decided dry-run releases its own lock, the immediately-following run proceeds rc=0 without self-deferring, and GR_RETAIN_LOCK_ON_DECISION=1 still retains so the concurrency case is not hollow. That run also reaped a real 43-minute-old lock left by the supervised restart — the footgun, observed in the wild.

Suite: 14 cases — concurrent double-click (10/10 pairs, exactly one decision), live-lock renewal, lost-lease exit 4, TERM exit 143, quiet/busy/wedged/dead branches, spaced argv paths, GNU-vs-BSD stat output shapes, and dry-run-does-not-self-block.

Gates: bash -n, gen-src-map --check, git diff --check, review-checks.sh, and hosted CI green.

Not in this PR

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Gate

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

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for opening this. I reviewed head c379cdc.

Blocking:

  • src/agent/graceful-restart.sh:48-53 / src/agent/restart-prep.sh:4-14 - the prep task is not deterministically wired to anything. rg finds RESTART_PREP only in these two new scripts, so the orchestrator relies on the normal agent reading a natural-language task and voluntarily running bash src/agent/restart-prep.sh <rid>. If the core is alive enough to keep .alive fresh but is busy, wedged before task execution, or does not execute exactly this shell command, no sentinel is written and graceful-restart.sh waits forever. That means --dry-run can pass when the prep script is manually invoked while the real handshake has no guaranteed event. Please add deterministic runner/dispatcher handling for the restart-prep task, or another machine-readable handoff, with coverage proving a written task-restart-prep-* produces a matching ready/failed sentinel without model discretion.

  • Required CI is red: refuse docs/src-map.md stale vs src/ fails, and the clean-install suite fails in tests/src-map.test.py for the same stale docs/src-map.md check. Please run python3 scripts/gen-src-map.py, commit the updated source map, and rerun green CI.

Optional follow-up:

  • The terminal sentinels are global files that each orchestrator run clears on startup. A double-click or concurrent restart can erase another run's ready/failed state. A lock or RID-scoped sentinel paths would make restart idempotency clearer.

Local validation in isolated /private/tmp/sutando-pr2334-20260727-0713: bash -n passed; git diff --check passed; python3 scripts/gen-src-map.py --check reproduced the stale source-map failure; the dry-run ready, prep-failed, and agent-dead branches passed when I manually invoked restart-prep.sh. GitHub Actions already has shellcheck green; local shellcheck was not installed on this host.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed current head a3c61e3.

The source-map drift is fixed on this head, but the blocking restart-prep handoff issue remains:

  • src/agent/graceful-restart.sh:48-53 still writes a natural-language RESTART_PREP task and waits indefinitely while .alive is fresh, but rg still finds RESTART_PREP only in graceful-restart.sh, restart-prep.sh, and the generated source map. There is no deterministic runner/dispatcher path that consumes task-restart-prep-* and invokes restart-prep.sh. A healthy but busy or wedged core can therefore keep heartbeating forever without ever producing ready/failed, and the orchestrator will never restart. The dry-run branches only prove the scripts work when prep is manually invoked; they do not prove the actual handoff.

Please add a machine-readable/non-LLM handoff path, or explicit task-runner handling for this restart task, with regression coverage that writing task-restart-prep-* produces a matching terminal sentinel without relying on model discretion.

Local checks in isolated /private/tmp/sutando-pr2334-20260727-0713: bash -n src/agent/graceful-restart.sh src/agent/restart-prep.sh, python3 scripts/gen-src-map.py --check, and git diff --check origin/main...HEAD passed. The dry-run ready, prep-failed, and agent-dead branches still passed when I manually invoked the prep script. GitHub Actions shellcheck/src-map/smoke/etc. are green; tsc + tests (clean install) is still in progress at review time.

Reviewed by Qingyun's Personal Codex.

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

Additional blocking finding on current head a3c61e3 (the existing deterministic-dispatch blocker remains; the concurrent a3c61e3 docs-only commit fixed the stale source-map failure):

  • src/agent/restart-prep.sh:51-52 builds the production sync invocation as one string and then expands $SYNC_CMD unquoted. Any checkout path containing spaces is split into multiple argv entries before bounded runs it. Exact repro on this head: invoke the script through a checkout path containing spaces; bash -x shows bounded 45 bash /private/tmp/sutando pr2334 automation link/scripts/sync-workspace.sh, and Phase 1 exits 3 with sync-workspace failed or timed out. That means a valid space-containing install can never produce restart-ready and every graceful restart is refused. Please pass the production command as argv (for example, bounded "$STEP_TIMEOUT" bash "$REPO/scripts/sync-workspace.sh") and make the test seam argv-safe, with a space-path regression.

Focused verification: both scripts pass bash -n; the advertised ready, prep-failed, and dead dry-run branches behave as described with simple test commands; git diff --check, the REVIEW.md path scan, and python3 scripts/gen-src-map.py --check pass on a3c61e3. The a3c61e3 delta is docs/src-map.md only, so the code finding remains unchanged.

Changes requested; this head is not ready to merge.

Reviewed by Qingyun Personal Codex.

sonichi added a commit that referenced this pull request Jul 30, 2026
… + gate/space-path regression tests

Addresses both review blockers on #2334:

1. Deterministic handoff (qingyun): the orchestrator no longer writes a
   RESTART_PREP task for the agent to pick up — prep is mechanical, so
   graceful-restart.sh now invokes restart-prep.sh DIRECTLY once a
   deterministic quiet gate opens (busy = core-status 'running' with fresh
   ts; wedged stale-status cores no longer hold off the restart forever;
   dead cores restart with best-effort prep). No LLM/model discretion
   anywhere in the loop.

2. Argv-safe sync (qingyun): production sync runs as explicit quoted argv
   (bash "$REPO/scripts/sync-workspace.sh"), so space-containing checkout
   paths stay one argument; the GR_SYNC_CMD test seam runs via bash -c and
   is likewise space-safe.

tests/graceful-restart.test.sh (24 checks, all dry-run): quiet, busy-then-
idle wait, wedged stale-status, prep-fail-no-kill, dead-abrupt, space-path
checkout regression (production argv path), space-path seam regression.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rq18tfPmFcgzHQFgoSpsUN
@sonichi
sonichi force-pushed the feat/graceful-restart branch from a3c61e3 to 79f0250 Compare July 30, 2026 06:19
@sonichi

sonichi commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

Both blockers addressed at head 79f0250 (also rebased onto post-#2408 main; the src-map conflict was the generated count line only):

1. Deterministic handoff — the RESTART_PREP task-queue/LLM hop is GONE. Prep is mechanical (sync + record), so the orchestrator now invokes restart-prep.sh directly once a deterministic quiet gate opens. Gate semantics: busy = core-status.json says running AND its own ts is fresh (default TTL 900s) → wait with no give-up timer; a wedged core (running-status with stale ts — your exact scenario) no longer holds the restart forever; a dead core (stale .alive) restarts with best-effort prep. rg RESTART_PREP now returns nothing outside this PR discussion.

2. Argv-safe sync. Production path is explicit quoted argv: bounded "$STEP_TIMEOUT" bash "$REPO/scripts/sync-workspace.sh" — a space-containing checkout stays one argument. The GR_SYNC_CMD seam runs via bash -c so tests can carry quoted space paths.

Regression coverage (tests/graceful-restart.test.sh, 24 checks, all --dry-run, ALL PASS locally): quiet→ready→restart; busy-then-idle (gate provably holds ≥2s then proceeds); wedged stale-status proceeds promptly; prep-fail → exit 3 + NO restart + failed sentinel; dead → agent-dead-abrupt restart even with failing prep; space-path checkout regression — restart-prep.sh copied into $TMP/repo with space/ with a stub sync script, runs the production argv path, sentinel written + marker proves the spaced-path script executed; space-path seam check.

Verbatim local run tail:

7. GR_SYNC_CMD seam is argv-safe for space-containing paths
  ok: seam exit 0 with spaced path
  ok: seam ready sentinel

ALL PASS

@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 current head 79f025015ceacca6ddc9e81763fea443b876daf4.

The two prior blockers look addressed on this head. The restart path no longer depends on a RESTART_PREP task or model-discretion handoff; graceful-restart.sh invokes restart-prep.sh directly after a deterministic quiet gate. The gate behavior now distinguishes a fresh running status from a stale wedged status, and the dead-core path still proceeds with best-effort prep. The sync invocation is also argv-safe in production via bash "$REPO/scripts/sync-workspace.sh", with the test seam kept explicit through bash -c for quoted test commands.

Local checks in isolated /private/tmp/pr-monitor-sutando-2334-0624-79f0:

  • bash -n src/agent/graceful-restart.sh src/agent/restart-prep.sh tests/graceful-restart.test.sh
  • bash tests/graceful-restart.test.sh
  • python3 scripts/gen-src-map.py --check
  • git diff --check origin/main...HEAD
  • bash scripts/review-checks.sh --diff /private/tmp/pr2334-79f0250.diff

All passed, and GitHub Actions is green on this head. Approving.

Reviewed by Qingyun's Personal Codex.

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

Changes requested on exact head 79f025015ceacca6ddc9e81763fea443b876daf4.

[P1 blocking] Concurrent invocations can both execute the destructive restart because restart-ready.json / restart-prep-failed.json are global, each run deletes them at startup, and validation plus logging reads the shared file twice. In 30 exact-head two-process dry-run probes against one workspace, 2 iterations let both orchestrators reach start-cli.sh --restart. In the first double, run ...-12497 passed its RID check, but by the subsequent cat the shared file contained run ...-12498—direct TOCTOU evidence. The other 28 iterations allowed one restart and made the peer fail, so outcome is scheduler-dependent.

Please serialize the orchestrator (cross-process lock held through the terminal decision) and/or use RID-scoped atomic sentinel files with read-once validation and own-artifact cleanup. Add a concurrent double-click regression requiring exactly one restart decision and proving one run cannot delete/overwrite the other’s terminal state.

Focused exact-head checks otherwise pass: advertised graceful-restart suite, shell syntax, source-map parity, diff hygiene, and hardcoded-path gate; GitHub CI/CLA is green. The branch is also 23 main commits behind and currently conflicts, so it needs a real refresh and fresh gates after the fix.

Worst case is a second restart killing the newly relaunched core, interrupting recovered work, and leaving misleading prep evidence. Not merge-ready.

sonichi added a commit that referenced this pull request Jul 31, 2026
… + gate/space-path regression tests

Addresses both review blockers on #2334:

1. Deterministic handoff (qingyun): the orchestrator no longer writes a
   RESTART_PREP task for the agent to pick up — prep is mechanical, so
   graceful-restart.sh now invokes restart-prep.sh DIRECTLY once a
   deterministic quiet gate opens (busy = core-status 'running' with fresh
   ts; wedged stale-status cores no longer hold off the restart forever;
   dead cores restart with best-effort prep). No LLM/model discretion
   anywhere in the loop.

2. Argv-safe sync (qingyun): production sync runs as explicit quoted argv
   (bash "$REPO/scripts/sync-workspace.sh"), so space-containing checkout
   paths stay one argument; the GR_SYNC_CMD test seam runs via bash -c and
   is likewise space-safe.

tests/graceful-restart.test.sh (24 checks, all dry-run): quiet, busy-then-
idle wait, wedged stale-status, prep-fail-no-kill, dead-abrupt, space-path
checkout regression (production argv path), space-path seam regression.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rq18tfPmFcgzHQFgoSpsUN
@sonichi
sonichi force-pushed the feat/graceful-restart branch from 79f0250 to 67b4adf Compare July 31, 2026 21:28
@sonichi

sonichi commented Jul 31, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto current main — 67b4adfa. DIRTY → MERGEABLE.

The conflict was docs/src-map.md (180 modules indexed vs 178), a generated file, so I resolved it by re-running python3 scripts/gen-src-map.py rather than picking a side by hand — it now reads 182, which is what the generator produces against main + this PR's two new modules. Verified stable: a second regeneration produces no diff, which is the property the refuse docs/src-map.md stale vs src/ check actually gates on.

Verified the rebase RESULT, not just that the conflicts closed:

git diff --stat origin/main...HEAD
 docs/src-map.md                |   4 +-
 src/agent/graceful-restart.sh  | 123 +++++++++++
 src/agent/restart-prep.sh      |  75 +++++++
 tests/graceful-restart.test.sh | 118 ++++++++++
 4 files changed, 319 insertions(+), 1 deletion(-)

bash tests/graceful-restart.test.sh   -> ALL PASS (rc=0)
bash -n src/agent/graceful-restart.sh -> ok
bash -n src/agent/restart-prep.sh     -> ok

One cosmetic artifact I'd rather name than leave for a reviewer to trip over: the replayed commit 82c627e5 still carries the title "regenerate post-rebase (178 modules)" while its content now says 182, because the conflict resolution landed inside that commit's replay. The tip state is correct and the staleness check passes; I didn't rewrite the message because an interactive rebase to fix a title is a worse trade than the mismatch.

@john-the-dev your CHANGES_REQUESTED predates this rebase — the head moved, so it's worth a re-read against 67b4adfa.

@sonichi

sonichi commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

Fixed at 5d955cca. Your repro was right and the mechanism was exactly as you described — I reproduced it before touching anything.

Before, on the parent commit, dead-core fixture: 50/50 two-process iterations restarted twice. Higher than your 2/30 because the fixture matters (see below), but the same TOCTOU.

The fix — Phase 0 takes an atomic lock held through the terminal decision. mkdir is the primitive: POSIX-atomic, and present on macOS where flock is not. It's taken before the sentinels are cleared, so the check and the act can't be split by a peer. Stale locks are reapable so a holder that died before its trap can't wedge restarts forever — the same freshness reasoning as workspace_lock.py, which I reused rather than inventing a policy. The decision also now reads the sentinel once, so the logged body is provably the one that was validated; that grep-then-cat double read was your direct evidence.

One design point worth flagging: a run that decides to restart keeps the lock. In production do_restart ends in exec and never returns, so the lock outlives the process by construction — that is what stops the peer from killing the core we just relaunched. Dry-run has to model that explicitly, or the concurrency test passes for a reason production doesn't share.

Two things I got wrong on the way, both of which the evidence caught and both now pinned by the regression:

  1. My first cut had the same bug class it was fixing. I wrote the holder ts into a file after mkdir. A peer landing in that gap read no ts, computed age = now - 0 (a full epoch), declared a one-second-old lock stale, reaped it, and both ran. Age now comes from the lock directory's own mtime — set atomically by mkdir as part of claiming the lock — and an unreadable age fails closed.

  2. My first regression was near-vacuous. A single concurrent pair is timing-dependent, and it used a live+idle core. With a live core the pre-fix failure mode is starvation, not double-restart: one run deletes the other's sentinel and the loser exits 3. So the "exactly one decision" assertion passed against the broken script. It now runs 10 pairs against a dead-core fixture, which is where the double-kill actually races. I verified both fixtures before choosing.

Discriminating evidence:

fixed:    27 ok / 0 FAIL
unfixed:  FAIL: 10/10 pairs BOTH restarted — the #2334 race
          FAIL: peer deferred with a reason in only 0/10 pairs

git diff --check clean; scripts/review-checks.sh PASS.

On your last point — the branch is no longer 23 behind; it's level with main and the conflict is gone, so that half is already resolved. I have not run a live restart round trip: this changes the path that kills the owner's only core, and I don't exercise that unattended. Everything above is dry-run. If you want the live proof it needs a supervised window, which I've raised separately.

@sonichi

sonichi commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

CI caught a real defect in my previous commit — 4a506cf3 fixes it.

What broke. I read the lock holder's age with stat -f %m. That's BSD/macOS. On GNU/Linux -f means --file-system and prints a multi-line filesystem dump, which then fed $(( ... )) and, under set -euo pipefail, killed the peer silently — it neither restarted nor logged a reason.

The part worth your attention is how it presented. CI reported:

FAIL: peer deferred with a reason in only 0/10 pairs
ok:   exactly one restart decision in all 10 concurrent pairs

The primary assertion still passed. A silently-dead peer and a correctly-deferring peer are indistinguishable by a decision count — both fail to restart. So my headline invariant was green on a platform where the mechanism was entirely broken. Only the secondary assertion caught it.

Fix: try BSD stat -f %m, fall back to GNU stat -c %Y, then require the result be all digits. The digit guard is the load-bearing part, not the fallback — if GNU stat had exited 0 with garbage, the fallback alone would never have fired and the arithmetic would still have died. Anything unexpected now becomes empty and takes the existing fail-closed branch (defer, exit 4).

The test now asserts exit codes too (winner 0, loser 4), so a crashed peer can never again pass as a deferring one. That closes the exact hole above.

Verified by reproducing your platform locally with a GNU-shaped stat shim — garbage on -f %m, exit 0, the worst case:

with fix + shim:     28 ok / 0 FAIL
without fix + shim:  FAIL: peer deferred with a reason in only 0/10 pairs
                     FAIL: 10/10 pairs had an unexpected exit-code pair
native macOS:        28 ok / 0 FAIL

The shim reproduces your CI failure message exactly, so this isn't a guess about why Linux differed.

One thing I deliberately did not touch: alive_age() in this same script has the identical BSD-ism. It predates this PR and degrades benignly there — || echo 0 makes a Linux read look like a dead core, which is the safe direction and is what the existing cases pin. Changing it would alter behaviour those cases assert, so it's not in this commit. Flagging it since it means the script as a whole is still macOS-biased; happy to do it as a separate PR if you want it.

Still no live restart round trip, unchanged from my last comment — that needs the supervised window.

@sonichi

sonichi commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

@john-the-dev all three points are addressed — two by commits that landed after the head you reviewed (79f02501), the third just now. Evidence rather than assertion, since your review is the sole remaining gate.

1. Serialization — 5d955cca fix(core): serialize graceful-restart so concurrent runs can't double-kill (plus 4a506cf3 fixing stat -f %m, BSD-only, which broke it on Linux CI). The orchestrator now takes an atomic mkdir lock at state/locks/graceful-restart.lock before any sentinel is touched, holds it through the terminal decision, releases only its own rid, and reads the sentinel once — the TOCTOU you captured between the rid check and the following cat. mkdir over flock because it is atomic on every filesystem and present on macOS.

2. The concurrent double-click regression exists and is proven to catch the bug. Rather than only showing it green, I ran your experiment shape as a positive control — head's regression test against the pre-fix orchestrator at 79f02501:

pre-fix orchestrator + this test, 40 pairs :  FAIL — 40/40 pairs BOTH restarted (the #2334 race)
refreshed head f2001a0c,        40 pairs :  ok  — exactly one restart decision in all 40 pairs

Worth noting the fixture is harsher than the probe that found the bug: you saw 2 doubles in 30 because the live-core path mostly serialises by accident; the test drives the DEAD-core path, where the race is near-certain — 40/40 pre-fix. So a future regression cannot hide in scheduler luck. It also asserts the peer deferred with a reason rather than merely not-restarting, because a crashed peer and a deferring peer are indistinguishable by count alone.

3. Branch refreshed. It was 23 behind and conflicting when you reviewed; it had drifted to 1 behind and MERGEABLE, and I have now updated it — new head f2001a0c, 0 behind. Re-ran at that exact head: full suite ALL PASS (40 pairs), bash -n clean on all three scripts, and I confirmed the refresh preserved the fix rather than assuming a conflict-free merge was a correct one (9 LOCKDIR references, concurrency test present).

Two caveats I'd rather state than have you find:

  • I verified the lock serialises the decision; I did not independently re-derive whether a reaped-stale-lock path can interleave with a peer that is mid-exec. Test 0 has a reaped a LIVE lock assertion covering the ts-written-after-mkdir race, and it passes — but that is my own test checking my own fix, which is exactly the kind of self-assessment your review exists to catch.
  • The 40/40 pre-fix result is on this machine's scheduler. It reproduces your finding in direction and overshoots it in magnitude; I would not claim the ratio transfers.

Re-requesting review.

@sonichi
sonichi requested a review from john-the-dev August 1, 2026 13:00

@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 f2001a0c5f7ef34d62a5d87fa4ac3f86ad351bac and the full restart/prep path.

The prior concurrency and argv-splitting blockers are fixed: the restart decision is serialized by an atomic ownership lock held through the terminal decision, sentinels are read once, and the production sync command is passed as argv. Reviewer-run tests/graceful-restart.test.sh passes all focused cases, including the concurrent dead-core control; shell/diff/path gates and current CI pass.

[P1 live destructive-path gate] The author explicitly has not run a real restart. This path executes start-cli.sh --restart and can kill the owner’s only core, so dry-run serialization cannot replace the repository-required supervised current-head round trip. Please show one branch-built restart producing exactly one replacement core, healthy serving state, preserved queued work, and no second restart after the lock/relaunch handoff.

Changes requested; not merge-ready until that live proof is attached and the behind branch is refreshed.

@sonichi

sonichi commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

Refreshed onto current main at f84c94bc. CONFLICTING → MERGEABLE. No review state changes — this is the conflict half only.

Only docs/src-map.md conflicted, and it is generated. Took main's copy and re-ran python3 scripts/gen-src-map.py — 185 modules, both new scripts indexed:

docs/src-map.md:152  **`graceful-restart.sh`** — Graceful core-restart orchestrator (design: notes/graceful-restart-design.md).
docs/src-map.md:153  **`restart-prep.sh`**    — Graceful-restart Phase-1 prep (design: notes/graceful-restart-design.md).

Hand-merging it would have read fine in review and then failed CI's refuse docs/src-map.md stale vs src/.

Verified the merge result, not just the absence of markers:

tests/graceful-restart.test.sh       ALL PASS (incl. "seam exit 0 with spaced path", "seam ready sentinel")
bash -n on both new scripts          clean
gen-src-map.py re-run                regenerates identically -> freshness gate passes
review-checks.sh --diff              PASS (hardcoded-paths clean)

shellcheck is not installed on this host, so that gate is CI's to run — I'm not claiming it locally.

This is the second PR tonight whose only conflict was docs/src-map.md (the other was #1414). Both resolve mechanically by regeneration rather than by merge, so a stale src-map is a routine refresh cost rather than a real conflict — worth knowing before anyone hand-edits one.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed exact head f84c94bcef4e202e14af55202e87dfd93ae4a043 after the current-main merge refresh.

The topic implementation and focused verdict are unchanged from f2001a0c: src/agent/graceful-restart.sh, src/agent/restart-prep.sh, tests/graceful-restart.test.sh, and the design note are byte-identical; only the generated source map changed. Reviewer-run tests/graceful-restart.test.sh passes all cases, including 10/10 concurrent double-click pairs; shell syntax, source-map parity, diff hygiene, the hardcoded-path gate, and all hosted CI/CLA checks are green.

[P1 live destructive-path gate remains] The current head still has no real supervised execution of the destructive branch at src/agent/graceful-restart.sh:154 (start-cli.sh --restart). Dry-run serialization proves the decision logic, but it cannot prove the handoff leaves exactly one replacement core serving, preserves queued work, and does not trigger a second restart after ownership/lock transfer. Please attach one branch-built post-restart round trip covering those outcomes.

Worst-case disruption is killing the owner's only core, losing in-flight work, or double-restarting the replacement. Changes requested; this exact head is not merge-ready until the real restart/handoff proof is recorded.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed exact head 2fa86220 after the current-main merge refresh.

The four topic files are byte-identical to previously reviewed f84c94bc; only generated docs/src-map.md changed in the topic area. Reviewer-run tests/graceful-restart.test.sh passes every branch, including 10/10 concurrent double-click pairs; shell syntax, source-map parity, diff hygiene, and REVIEW.md hardcoded-path scanning pass. Hosted static/CLA gates are green, with aggregate CI still running at review time.

[P1 live destructive-path gate remains] There is still no author or maintainer evidence of a real supervised execution of start-cli.sh --restart. Dry-run proves the decision logic, but not that the production handoff leaves exactly one replacement core serving, preserves queued work, and avoids a second restart after lock ownership transfers. Please attach one current-head branch-built post-restart round trip covering those outcomes.

Worst case is killing the owner's only core, losing in-flight work, or double-restarting the replacement. Changes requested; not merge-ready until the live restart/handoff proof is recorded.

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

I found one blocking issue in the refreshed graceful-restart head:

  • src/agent/graceful-restart.sh:132 still computes .alive age with BSD-only stat -f %m. The lock path below already had to add a GNU/Linux-safe mtime fallback and digit guard, but alive_age() did not get the same treatment. Under GNU-style stat -f output, this feeds nonnumeric text into shell arithmetic; in a local simulated GNU-stat dry run I saw line 134: File: unbound variable / integer expression expected. More importantly, a stale .alive can fail to flip DEAD=1, so a dead core with a fresh core-status.json can stay in the busy wait instead of taking the dead-abrupt restart path. Please reuse the portable mtime helper/guard used for the lock age and add a regression that covers alive_age() under GNU-style stat output.

Focused checks I ran locally:

  • git diff --check origin/main...HEAD
  • bash tests/graceful-restart.test.sh
  • bash -n src/agent/graceful-restart.sh src/agent/restart-prep.sh tests/graceful-restart.test.sh
  • python3 scripts/gen-src-map.py --check
  • simulated GNU-style stat -f output against graceful-restart.sh --dry-run, which reproduced the arithmetic diagnostic above.

Once this is fixed, I still need the earlier real branch-built restart proof before approval: one replacement core, healthy serving state, queued work preserved, and no second restart after the lock/relaunch handoff.

Reviewed by Qingyun's Personal Codex.

@sonichi

sonichi commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

Fixed at 4e931a6b. The alive_age() blocker is resolved; the restart proof you also asked for is not claimed here and is still owed.

The fix

Extracted the guard you already had on the lock path into a shared mtime_of(), used at both sites. The part worth reviewing is that the two callers need opposite fail-safe directions, so the helper returns EMPTY on anything unreadable/nonnumeric and each caller decides:

lock age    unreadable -> assume LIVE and defer   (never kill blind)
.alive age  unreadable -> assume STALE            (never let a dead core hide)

That second direction is why this was a correctness bug and not only a portability one — exactly the failure you named: a dead core with a fresh core-status.json could stay in the busy wait.

The regression fails against the old code — with your diagnostics

Case 8 puts a GNU-mimicking stat on PATH (-f → filesystem dump, exit 0) and asserts four things: exit 0, no arithmetic/unbound diagnostic leaked, no hang in the busy wait, and the restart decision still reached.

Reverting alive_age() to the pre-fix form reproduces the two lines you reported, which is what convinced me the test gates your finding rather than a proxy for it:

line 140: File: unbound variable
line 167: [: : integer expression expected

Restored, the suite is ALL PASS.

One thing I got wrong on the way, since it could have shipped

My first patch defined mtime_of() inside the if ! mkdir "$LOCKDIR" block — so it existed only when lock acquisition failed, and alive_age() would have called an undefined function on the normal path. bash -n was clean and the suite passed, because case 8's workspace never contends for the lock. Caught it by reading the placement rather than trusting green. It's top-level now, verified defined before both uses.

Your checks, re-run

git diff --check origin/main...HEAD          clean
bash -n graceful-restart.sh restart-prep.sh tests/graceful-restart.test.sh   clean
python3 scripts/gen-src-map.py --check       clean
bash scripts/review-checks.sh                PASS
bash tests/graceful-restart.test.sh          ALL PASS

Still owed

The real branch-built restart proof — one replacement core, healthy serving state, queued work preserved, no second restart after the lock/relaunch handoff. That needs a live restart window, which is the owner's call, so I'm not going to quietly let this comment read as if it were done.

sonichi added a commit that referenced this pull request Aug 3, 2026
… + gate/space-path regression tests

Addresses both review blockers on #2334:

1. Deterministic handoff (qingyun): the orchestrator no longer writes a
   RESTART_PREP task for the agent to pick up — prep is mechanical, so
   graceful-restart.sh now invokes restart-prep.sh DIRECTLY once a
   deterministic quiet gate opens (busy = core-status 'running' with fresh
   ts; wedged stale-status cores no longer hold off the restart forever;
   dead cores restart with best-effort prep). No LLM/model discretion
   anywhere in the loop.

2. Argv-safe sync (qingyun): production sync runs as explicit quoted argv
   (bash "$REPO/scripts/sync-workspace.sh"), so space-containing checkout
   paths stay one argument; the GR_SYNC_CMD test seam runs via bash -c and
   is likewise space-safe.

tests/graceful-restart.test.sh (24 checks, all dry-run): quiet, busy-then-
idle wait, wedged stale-status, prep-fail-no-kill, dead-abrupt, space-path
checkout regression (production argv path), space-path seam regression.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rq18tfPmFcgzHQFgoSpsUN
@sonichi
sonichi force-pushed the feat/graceful-restart branch from 4e931a6 to f286022 Compare August 3, 2026 03:09

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed exact head f28602264026d68bd3adacc4559e697e74a8c05d.

The new alive_age() portability blocker is fixed: both lock age and .alive age now flow through the shared digit-guarded mtime_of() helper, while the callers keep the right opposite fail-safe directions (LOCKDIR unreadable -> defer, .alive unreadable -> stale). The added GNU-style stat -f regression covers the prior failure mode and the focused suite passes on this head.

Focused reviewer checks in isolated /private/tmp/sutando-pr2334-4e931a-q389hF/repo:

  • bash tests/graceful-restart.test.sh
  • bash -n src/agent/graceful-restart.sh src/agent/restart-prep.sh tests/graceful-restart.test.sh
  • python3 scripts/gen-src-map.py --check
  • git diff --check origin/main...HEAD
  • bash scripts/review-checks.sh --diff /private/tmp/sutando-pr2334-4e931a-q389hF/pr2334-f286022.diff

All passed locally. Hosted checks are green except tsc + tests (clean install), which is still pending at review time.

[P1 live destructive-path gate remains] There is still no real branch-built execution of start-cli.sh --restart, and the author explicitly says that proof is not claimed here. Dry-run coverage gives confidence in the decision logic, but it still cannot prove the production handoff leaves exactly one replacement core serving, preserves queued work, and avoids a second restart after the lock/relaunch handoff. Please attach one current-head supervised restart round trip covering those outcomes before merge.

Changes requested; the GNU alive_age() issue is resolved, but this PR is still not merge-ready until the live restart/handoff proof is recorded.

Reviewed by Qingyun's Personal Codex.

@sonichi

sonichi commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

My previous commit was wrong on Linux and CI caught it. Fixed at 935e1573 — and the test was the bigger failure.

What I broke

The digit guard I added was correct; the selection was not. I kept choosing the stat variant by exit status:

stat -f %m "$1" || stat -c %Y "$1"      # never reaches the fallback on GNU

On GNU/Linux -f is --file-system: it prints a dump and exits 0. So the fallback never ran, the guard blanked the dump, mtime_of returned empty for every path, and alive_age() took its fail-STALE branch unconditionally — classifying every core as DEAD.

That is worse than the bug I was fixing. The original died loudly in arithmetic; mine answered confidently and wrongly. Your CI run said so plainly:

FAIL: waited for the idle flip (took 0s — gate did not hold)
FAIL: exit 3 (got 0): core is DEAD (.alive stale/absent > 90s)

mtime_of now tries each variant and accepts the first whose output is a bare epoch, so exit status is irrelevant.

The part I'd want reviewed hardest

My regression test could not have caught this, and that's the real defect. Case 8 asserted only "does not crash / does not hang / still reaches a decision." A dead-reporting alive_age() satisfies all three — so it passed on macOS while CI went red. I tested for the absence of a symptom instead of the presence of correct behaviour.

Case 8 now asserts the thing that matters: a seconds-old .alive must still read fresh under GNU-shaped stat. Falsified against both broken versions:

digit-guard-only (what broke CI)  -> FAIL "fresh .alive must NOT read as DEAD"
original BSD-only                 -> FAIL "no arithmetic/unbound diagnostic"

Only output-shape selection passes both.

Full suite ALL PASS, bash -n clean on all three files.

Unchanged: the branch-built restart proof is still owed and still not claimed here.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed exact head 935e1573382c28beab503a9708e5f7c5d2ca6a7c.

The output-shape selection fixes the GNU stat -f regression, and the focused graceful-restart suite passes every branch on this head, including the GNU-shaped fresh-heartbeat control and concurrent double-click invariant. Shell syntax, diff hygiene, source-map parity, and the REVIEW.md hardcoded-path gate also pass locally.

[P1 blocking CI failure] src/agent/graceful-restart.sh:87 uses # shellcheck disable=SC2086 -- _fmt is two intentional argv words. ShellCheck directives do not accept the trailing -- rationale; the required hosted gate fails with SC1073/SC1072 before it can analyze the script. Use a valid directive (put the rationale on its own ordinary comment line) and rerun the required gate.

The earlier live destructive-path gate also remains unchanged: this head still has no branch-built supervised execution of start-cli.sh --restart proving exactly one replacement core serves, queued work survives, and no second restart fires after lock/relaunch handoff. The author explicitly acknowledges that proof is still owed.

Changes requested; fix the exact-head shellcheck failure and attach the current-head live restart/handoff evidence before merge.

@sonichi
sonichi requested a review from qingyun-wu August 10, 2026 03:21

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed exact head 2b5dc28b009bbdac45d7c05d01d5e5bea6f62ccb after the current-main merge.

The graceful-restart topic commits are unchanged from the previously approved dd8d39b4, but the cumulative interaction with current main introduces a deterministic repository-gate failure:

[P1] docs/src-map.md is stale on the merge result. Both hosted refuse docs/src-map.md stale vs src/ and the clean-install suite fail because python3 scripts/gen-src-map.py --check reports the generated map is out of date. Regenerate and commit docs/src-map.md against current main, then rerun the full checks.

The full four-file topic diff and surrounding restart/prep path otherwise retain the reviewed lock, lease-loss, process-group timeout, and live-restart behavior. The focused restart suite was also exercised locally; its TERM case cannot be trusted in this sandbox because process inspection is denied, while hosted CI's actionable failure is the stale generated map above. The hardcoded-path gate passes.

Changes requested; this refreshed head is not merge-ready until the generated source map and red required checks are fixed.

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

APPROVE at 2b5dc28b — clearing my 2026-07-31 [P1]. Verified by running the concurrency probe myself at the intensity that originally exposed the race, in a detached worktree of this head so nothing touched the live core.

My blocker was: concurrent invocations could both reach start-cli.sh --restart, because the ready/failed sentinels were global, each run deleted them at startup, and validation then re-read the shared file. Measured 2/30 two-process pairs double-restarting, with direct TOCTOU evidence — run …-12497 passed its RID check, and by the subsequent cat the file held …-12498.

Both halves are addressed. graceful-restart.sh now takes an atomic mkdir lock, releases only its own (rid-guarded, "so a reaper's lock is never removed"), and re-verifies lease ownership at every terminal point — while waiting and again before prep, deferring rather than restarting if the holder changed. That is the cross-process serialization held through the decision, not a check-then-act.

Ran it rather than read it:

GR_TEST_CONC_ITERS=40
0. CONCURRENT double-click → EXACTLY ONE restart decision
  ok  exactly one restart decision in all 40 concurrent pairs
  ok  the losing peer deferred with a reason every time
  ok  winner exited 0 and loser exited 4 in every pair
  ok  no live lock was reaped in 40 pairs
  ok  surviving sentinel intact + rid-stamped in all 40 pairs
ALL PASS

40 pairs, versus the 30 that produced 2 failures before. The default is 10; I raised it deliberately because 10 pairs against a ~7% per-pair failure rate is a coin flip, and I did not want to clear my own blocker on an underpowered run.

The fixture choice is the part I want to credit. Case 0 uses a DEAD core (no .alive) with the comment that a live+idle fixture "fails by STARVATION instead and reports 'exactly one' against a broken script." That is precisely the vacuous-pass trap — the obvious fixture would have printed the same reassuring line with the race fully intact. The sentinel-intact and no-live-lock-reaped assertions close the other half of what I asked for.

Cases 9-13 also cover the failure modes the lock itself introduces: a live holder past LOCK_STALE_S is not reaped, a lease loss exits 4 and preserves the foreign lock, TERM exits 143 while still releasing our own, and a dry-run does not self-block the next real run. Whole suite green.

Sorry for the eleven days — the serialization landed on 08-03 and I did not come back to it.

…rebase

`update-branch` on this PR pulled main forward; the generated body already listed
206 modules while the header still read 204, so `gen-src-map.py --check` failed.

This is ONE root cause producing TWO red checks on #2334, which is why it is worth
its own commit rather than waiting for the review:

    refuse docs/src-map.md stale vs src/   -> gen-src-map.py --check, exit 1
    tsc + tests (clean install)            -> "Run tests" step, log line 16089:
                                              FAIL docs/src-map.md matches generated output

Before (at 2b5dc28):  gen-src-map: docs/src-map.md is stale
After:                 gen-src-map: docs/src-map.md is up to date

Diff is the header count only; no module entries changed, consistent with a
merge artifact rather than a real index drift.

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 current head b992ebb. The only incremental change since my 2b5dc28 review is the regenerated docs/src-map.md count (204 → 206), which addresses the stale src-map blocker.

Local checks:

  • python3 scripts/gen-src-map.py && git diff --exit-code docs/src-map.md
  • git diff --check 2b5dc28..b992ebb

Hosted refuse-docs-src-map, diff coverage, lint/ruff/shellcheck, smoke, and recheck are green. The clean-install test was still in progress when I checked, so I am leaving this as a review comment rather than an approval until that completes.

Reviewed by Qingyun's Personal Codex.

@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 current head b992ebbeec91feb4c88eb6ab1dbaa6427f4bad5e.

The stale docs/src-map.md blocker is resolved by the regenerated map, and the previously pending clean-install job is now green. I am carrying forward the focused local checks from the same head: source-map regeneration produced no diff, and the incremental diff from 2b5dc28b009bbdac45d7c05d01d5e5bea6f62ccb to b992ebbeec91feb4c88eb6ab1dbaa6427f4bad5e was whitespace-clean. All visible hosted checks now pass, including tsc + tests (clean install).

Reviewed by Qingyun's Personal Codex.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-APPROVE at head b992ebb, carrying forward my approval at 2b5dc28b.

I did not take the "only the src-map changed" claim on trust — I verified the delta myself:

compare 2b5dc28b...b992ebb  ->  status=ahead, commits=1, files=docs/src-map.md
patch (only changed lines):   -204 modules indexed.
                              +206 modules indexed.

A single regenerated count line. The two new module entries were already present from an earlier commit; only the tally was stale. That is a generated artifact, not a logic change, so the code I verified at 2b5dc28b — the concurrency probe run at the intensity that originally exposed the race, in a detached worktree — is unchanged and my approval still stands on it.

This now has two at-head maintainer approvals. It is BEHIND; it needs update-branch and a fully green board before merge.

@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 head 8663dac98daee772e9717d92bdbe61bc939c530d.

This head is a current-main merge on top of the previously approved b992ebbeec91feb4c88eb6ab1dbaa6427f4bad5e; the incremental diff from b992ebb contains only mainline changes outside the graceful-restart topic files. The PR diff against current origin/main is still the same four-file graceful-restart change, with the regenerated source map up to date.

Focused checks passed in the isolated worktree:

  • python3 scripts/gen-src-map.py --check
  • bash -n src/agent/graceful-restart.sh src/agent/restart-prep.sh tests/graceful-restart.test.sh
  • git diff --check b992ebbeec91feb4c88eb6ab1dbaa6427f4bad5e..HEAD
  • bash tests/graceful-restart.test.sh — ALL PASS when run outside the sandbox so the process-group tests could inspect child processes.

Hosted workflow runs for this head are green, including CI, coverage, shellcheck, lint-src-map, smoke, and the repository lint gates.

Reviewed by Qingyun's Personal Codex.

@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 head c8ca75f1. This is a current-main-only merge over reviewed head 8663dac9; the four-file topic patch has the same stable patch ID (cf8a371e), and the merge delta does not alter it. I rechecked diff hygiene and the current hardcoded-path gate; both pass. No new blocker; code-ready. The hosted clean-install job is still pending, so the normal CI/two-maintainer merge gates still apply.

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

Exact-head refresh at 85e1932f. The only new commit is a merge from current main; the graceful-restart topic patch is unchanged (stable patch-id cf8a371e). No new blocking findings; prior full-diff, surrounding-code, diff-hygiene, and path-gate evidence remains applicable. Hosted clean-install remains the merge 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 head 78d4a03a. The new commit is a main-only merge: all four topic-file blobs are byte-identical to reviewed head 85e1932f, and the landed-main delta touches only the hermetic-test lint files, with no topic overlap. The 14-case graceful-restart suite, shell syntax, source-map check, diff hygiene, and hardcoded-path gate pass. Hosted coverage, smoke, static gates, and CLA are green; clean-install was still running at review time.

@john-the-dev

Copy link
Copy Markdown
Collaborator

Rebased past the #2786 merge — the only conflict was docs/src-map.md, which is generated, so I resolved it by re-running scripts/gen-src-map.py rather than hand-merging (207 modules, --check clean).

Re-ran the suite after the merge rather than assuming a conflict-free resolution is semantically safe: tests/graceful-restart.test.sh ALL PASS (13 cases). Confirmed the live core was untouched by the run — tmux sessions 1 before, 1 after.

Approvals are unchanged at the new head; this was a mechanical rescue with no logic change.

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@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 0fc79118.

Approval signal: no blocking findings. The new restart flow handles the important safety edges I was looking for: workspace-scoped serialization, lock ownership checks before destructive action, stale-lock reaping with lost-lease deferral, quiet-gate waits on fresh running status, dead-core escape, direct bounded prep, process-group cleanup for timed-out sync work, and ready/failed sentinels scoped by restart id.

Focused checks:

  • bash -n src/agent/graceful-restart.sh src/agent/restart-prep.sh tests/graceful-restart.test.sh passed.
  • git diff --check origin/main...HEAD passed.
  • bash tests/graceful-restart.test.sh passed when run outside the sandbox so the process-control cases could use pgrep and signals against scratch subprocesses.
  • python3 scripts/gen-src-map.py was idempotent at 207 modules.

Non-blocking doc note: both new script headers and docs/src-map.md point to notes/graceful-restart-design.md, but that note is not in this branch. Either adding it later or trimming the reference would save a future reader a small chase.

Reviewed by Qingyun's Personal Codex.

@john-the-dev
john-the-dev merged commit 98708a1 into main Aug 11, 2026
17 checks passed
@john-the-dev
john-the-dev deleted the feat/graceful-restart branch August 11, 2026 23:42
sonichi added a commit that referenced this pull request Aug 13, 2026
…is re-applied

Forward merge, not a rebase — no commit on either side is rewritten. Four conflicts,
resolved per-file rather than per-side, because taking one side wholesale would have
lost real work in both directions.

src/agent/restart-prep.sh — main's version, wholesale. #2334 landed a strictly stronger
implementation: `set -m` so pgid==pid, `kill -TERM "-$pid"` on the process GROUP, and
`kill -KILL` after GR_KILL_GRACE_S, with a 124|137|143 exit mapping (137 is where a
TERM-ignoring child actually dies). This branch still carried a pid-only TERM and
124|143, so resolving in its favour would have reinstated a weaker kill path. Nothing
ported back; all 7 of its only-on-branch lines are superseded versions of the same code.

src/agent/graceful-restart.sh — main's base plus this branch's argument passthrough,
resolved hunk-by-hunk so main's auto-merged changes elsewhere in the file survive.
Main has no passthrough at all; the `--` split, RESTART_ARGS, the GR_START_CLI test
seam and the `${RESTART_ARGS[@]+...}` exec are what let the menu-bar restart carry
--visible through the handoff instead of silently relaunching detached.

tests/graceful-restart.test.sh — union. The two branches added tests in different
places and neither side's block was dropped: main's 7b/7c (TERM-ignoring bound,
descendants reaped with the process group) and this branch's 14/15/16 (argv
passthrough with a negative control, the real exec argv via a stub, and TERM during
the wait producing zero restart decisions).

docs/src-map.md — main's side, then regenerated; its own first line says it is
generated and must not be hand-edited.

Verification: `bash tests/graceful-restart.test.sh` -> ALL PASS, with 7b/7c and
14/15/16 all green, so neither side's behaviour was lost. `bash -n` clean on both
shell files.

Co-author consent: Pro asked me to take this branch and independently confirmed both
load-bearing claims (the superset direction and the absent passthrough) before I ran it.

Stand: Echo Act IV Mini
yixuan-ag2 added a commit that referenced this pull request Aug 21, 2026
…2334 follow-up 4) (#2726)

* feat(core): graceful restart handshake (orchestrator + prep, dry-run testable)

Adds the two-phase graceful-restart machinery (design:
notes/graceful-restart-design.md) so pre-restart work runs BEFORE the kill,
with the kill owned externally (the agent can never safely --restart itself).

- src/agent/graceful-restart.sh — orchestrator. Writes a task-restart-prep-<id>,
  then waits on EVENTS (never a wall-clock kill timer):
    ready       -> exec start-cli.sh --restart
    prep-failed -> surface + exit 3, do NOT kill (owner decides)
    agent dead  -> abrupt fallback (only when .alive stale > 90s)
  A busy-but-healthy agent is waited for; only genuine death falls back to
  abrupt. --dry-run runs the whole handshake but skips the kill.
- src/agent/restart-prep.sh — Phase-1 the agent runs on the prep task:
  checkpoint + sync-workspace (bounded; macOS-safe timeout emulation) + record
  branch/state -> always terminates with a ready or prep-failed sentinel.

Tested end-to-end via --dry-run (no live restart) — all three branches:
ready->would-restart, prep-failed->exit-3-no-kill, agent-dead->abrupt. Test
seams (GR_WS / GR_SYNC_CMD) let the handshake run in an isolated temp workspace.

Follow-ups (separate PRs): /startup verify-on-sentinel step (rides #2333);
main.swift 'Restart Core CLI' -> graceful path (+ keep direct as Force Restart).

Stand: Echo Act IV Pro

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(src-map): regenerate for graceful-restart.sh + restart-prep.sh

The src-map freshness gate (docs/src-map.md matches generated output +
refuse-stale) failed because the two new src/agent scripts weren't in the map.

Stand: Echo Act IV Pro

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(src-map): regenerate post-rebase (178 modules)

Stand: Echo Act IV Pro
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rq18tfPmFcgzHQFgoSpsUN

* fix(graceful-restart): deterministic prep invocation + argv-safe sync + gate/space-path regression tests

Addresses both review blockers on #2334:

1. Deterministic handoff (qingyun): the orchestrator no longer writes a
   RESTART_PREP task for the agent to pick up — prep is mechanical, so
   graceful-restart.sh now invokes restart-prep.sh DIRECTLY once a
   deterministic quiet gate opens (busy = core-status 'running' with fresh
   ts; wedged stale-status cores no longer hold off the restart forever;
   dead cores restart with best-effort prep). No LLM/model discretion
   anywhere in the loop.

2. Argv-safe sync (qingyun): production sync runs as explicit quoted argv
   (bash "$REPO/scripts/sync-workspace.sh"), so space-containing checkout
   paths stay one argument; the GR_SYNC_CMD test seam runs via bash -c and
   is likewise space-safe.

tests/graceful-restart.test.sh (24 checks, all dry-run): quiet, busy-then-
idle wait, wedged stale-status, prep-fail-no-kill, dead-abrupt, space-path
checkout regression (production argv path), space-path seam regression.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rq18tfPmFcgzHQFgoSpsUN

* fix(core): serialize graceful-restart so concurrent runs can't double-kill

Two orchestrators on one workspace could both reach `start-cli.sh --restart`:
`restart-ready.json` / `restart-prep-failed.json` are per-WORKSPACE, every run
clears them at startup, and the decision grepped the shared file and then
`cat`-ed it again to log. Measured on the parent commit with a dead core:
**50/50 two-process iterations restarted twice.** The second kill lands on the
core the first just relaunched.

Fix — Phase 0 takes an atomic lock held through the terminal decision:
- `mkdir` is the primitive (POSIX-atomic, and present on macOS where flock is
  not). Taken BEFORE the sentinels are cleared, so check and act can't split.
- A run that DECIDES to restart keeps the lock. Production `do_restart` ends in
  `exec` and never returns, so the lock outlives the process by construction;
  dry-run models that explicitly or the test would pass for a reason production
  does not share.
- Stale locks are reapable so a holder that died before its trap can't wedge
  restarts forever — same freshness reasoning as workspace_lock.py.
- The decision now reads the sentinel ONCE, so the logged body is provably the
  one validated (that double read was the reviewer's direct TOCTOU evidence).

The first cut of this fix had the same bug class it fixes: it wrote the holder
`ts` AFTER mkdir, so a peer landing in that gap read no ts, computed
`age = now - 0`, declared a one-second-old lock stale and reaped it. Age now
comes from the lock dir's own mtime, set atomically by mkdir, and an unreadable
age fails CLOSED. The regression pins that specific case.

Regression is 10 concurrent pairs, not one: a single pair is timing-dependent.
It uses a DEAD-core fixture because that is where the double-restart races — with
a live+idle core the pre-fix failure mode is starvation instead, so a live
fixture reports "exactly one" against the broken script and proves nothing.
Verified both ways before choosing.

  fixed:   27 ok / 0 FAIL
  unfixed: 10/10 pairs BOTH restarted + peer deferred in 0/10  -> 2 FAIL

Addresses the [P1] in john-the-dev's review. The 23-commits-behind half of that
review is already resolved — this branch is level with main.

Stand: Echo Act IV Pro

* fix(core): portable lock mtime — `stat -f %m` is BSD-only, broke CI on Linux

My previous commit read the lock holder's age with `stat -f %m`. That is
BSD/macOS. On GNU/Linux `-f` means --file-system and emits a multi-line
filesystem dump, which fed `$(( ... ))` and — under `set -euo pipefail` —
killed the peer SILENTLY: it neither restarted nor logged a reason.

CI reported it as `peer deferred with a reason in only 0/10 pairs` while
"exactly one restart decision" still PASSED, because a silently-dead peer and
a correctly-deferring peer are indistinguishable by a decision count alone.
That near-miss is the more useful half of this commit.

Fix:
- try BSD `stat -f %m`, fall back to GNU `stat -c %Y`;
- then REQUIRE the result be all digits. The digit guard is load-bearing: any
  unexpected output becomes empty and takes the existing fail-closed branch
  (defer, exit 4) instead of ever reaching arithmetic. Falling back alone
  would not have saved it if GNU stat had exited 0 with garbage.

Test now also asserts the EXIT CODES (winner 0, loser 4), so a crashed peer can
never again pass as a deferring one.

Verified by reproducing the CI platform locally with a GNU-shaped `stat` shim
(garbage on `-f %m`, exit 0 — the worst case):

  with fix + shim:     28 ok / 0 FAIL
  without fix + shim:  FAIL: peer deferred with a reason in only 0/10 pairs
                       FAIL: 10/10 pairs had an unexpected exit-code pair

Native macOS run is also 28 ok / 0 FAIL.

Not touched: `alive_age()` in this same script has the same BSD-ism, predates
this PR, and degrades benignly there (`|| echo 0` -> treated as dead). Fixing it
would change behaviour the existing cases pin, so it stays out of this commit.

Stand: Echo Act IV Pro

* fix(graceful-restart): alive_age() died on GNU-style `stat -f` output

`stat -f %m` is BSD/macOS. On GNU/Linux `-f` means --file-system: it prints a
multi-line dump AND exits 0, so the `|| stat -c` fallback never fires and the
dump reaches `$(( ))`. The lock path already carried a portable fallback plus a
digit guard; `alive_age()` did not.

Extracts the guard into a shared `mtime_of()` and uses it at both sites. The two
callers need OPPOSITE fail-safe directions, so the helper returns EMPTY on any
unreadable/nonnumeric result and each caller decides:

  lock age    unreadable -> assume LIVE and defer (never kill blind)
  .alive age  unreadable -> assume STALE (never let a dead core hide behind an
                            unreadable heartbeat and stay in the busy wait)

That second direction is the one with teeth: it is why this was a correctness
bug and not only a portability one.

Regression: tests/graceful-restart.test.sh case 8 puts a GNU-mimicking `stat` on
PATH (`-f` -> filesystem dump, exit 0) and asserts the run exits 0, leaks no
arithmetic/unbound diagnostic, does not hang in the busy wait, and still reaches
the restart decision. Verified it FAILS against the pre-fix alive_age with
exactly the two diagnostics the review reported:

  line 140: File: unbound variable
  line 167: [: : integer expression expected

Checks from the review, re-run: git diff --check clean; bash -n clean on
graceful-restart.sh + restart-prep.sh + the test; gen-src-map --check clean;
review-checks PASS; full suite ALL PASS.

Still owed and NOT claimed here: the real branch-built restart proof (one
replacement core, healthy serving state, queued work preserved, no second
restart after the lock/relaunch handoff).

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(graceful-restart): select mtime by OUTPUT SHAPE, not exit status

My previous commit added a digit guard but kept selecting the stat variant on
EXIT STATUS. On GNU/Linux `stat -f` means --file-system: it prints a dump and
EXITS 0, so `stat -f ... || stat -c ...` never reaches the fallback. The guard
then blanked the dump, mtime_of returned empty for EVERY path, and alive_age()
took its fail-STALE branch unconditionally — classifying every core as DEAD.

That is worse than the original bug: the original died loudly in arithmetic;
mine answered confidently and wrongly. CI caught it on ubuntu-latest while
macOS stayed green.

mtime_of now tries each variant and accepts the first whose OUTPUT is a bare
epoch, so exit status is irrelevant.

The test is the more important half of this commit. Case 8 previously asserted
only "does not crash / does not hang / still reaches a decision" — all of which
a dead-reporting alive_age() satisfies, which is exactly why it passed on macOS
while CI went red. It now asserts CORRECTNESS: a seconds-old .alive must still
read fresh under GNU-shaped stat.

Falsified against both broken versions:
  digit-guard-only (the CI break) -> FAIL "fresh .alive must NOT read as DEAD"
  original BSD-only              -> FAIL "no arithmetic/unbound diagnostic"

Full suite ALL PASS; bash -n clean on all three files.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tests): the GNU-stat fixture itself was BSD-only, so it failed on Linux CI

The production fix in 935e157 was right; the regression I added to prove it
was not portable. Its fake `stat` handled the `-c` case by exec'ing
`/usr/bin/stat -f %m` — BSD syntax. On ubuntu-latest the HOST's stat is GNU,
where `-f` means --file-system, so the fixture returned no mtime at all,
mtime_of saw nothing numeric from either variant, and my own assertion tripped:

  FAIL: fresh .alive must NOT read as DEAD under GNU-shaped stat

I reported "full suite ALL PASS" having run exactly one platform. It passed on
macOS and failed on Linux for a reason that lived entirely in the test.

The fixture now applies the same output-shape rule as mtime_of when asking the
host for a real mtime: try `-c %Y`, then `-f %m`, accept the first bare-epoch
answer. Verified against BOTH shapes by driving the branch with a BSD-only stub
and a GNU-only stub -- each returns the probe file's true mtime (1785730837).

Full suite ALL PASS on macOS; bash -n clean.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(graceful-restart): shellcheck could not parse my own disable directive

CI failure SC1073/SC1072 at graceful-restart.sh:87. I wrote

  # shellcheck disable=SC2086 -- _fmt is two intentional argv words

and trailing prose after the directive is not valid syntax -- shellcheck stops
parsing it, which also means the SC2086 suppression was never actually applied.
The explanation now sits on its own comment line above the bare directive.

Verified with shellcheck 0.11.0 at CI severity, and against a control that
reproduces the exact CI error so the pass is meaningful:

  trailing-prose form -> SC1073 (error): Couldnt parse this shellcheck directive
  own-line form       -> passes

I had only been watching the tsc job and missed that shellcheck was red on the
same head.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(graceful-restart): renew the lock during the quiet gate so a live holder is never reaped

qingyun-wu at `46de110d`: the quiet gate is deliberately unbounded on a healthy
core, but lock staleness is judged from `$LOCKDIR`'s own mtime, which `mkdir`
stamps ONCE at acquisition. A holder that waits past `GR_LOCK_STALE_S` therefore
looks abandoned: a second orchestrator reaps the LIVE holder's lock and enters
the restart decision concurrently — two destructive restarts, the exact outcome
this lock exists to prevent.

Fix: `touch "$LOCKDIR"` on each poll of the busy wait. Renewing keeps "the lock
is fresh" meaning "the holder is alive", which is what the age check already
assumes. Touching the DIRECTORY is deliberate — the reaper reads the directory's
mtime, so the renewal must move the same clock it reads. Writing a sibling `ts`
file is what an earlier revision did and it reintroduced the acquisition race
documented at the top of the file.

Test: adds case 9, the coverage qingyun named as missing — a healthy busy wait
lasting LONGER than the stale threshold while a second attempt arrives. Holder
waits 5s with `GR_LOCK_STALE_S=2`; the peer must defer (rc=4), not reap.

Negative control verified, not assumed. With the `touch` removed the new case
goes red with the literal defect:

    FAIL: peer REAPED a live holder's lock (rc=0) — concurrent destructive restart:
      reaping stale restart lock (age 5s > 2s, holder grp-...)

Restored, the full suite is ALL PASS.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(graceful-restart): verify lease ownership before renewing or acting on the lock

qingyun-wu at `7cf363d9`, and both findings are mine — the renewal I added made
the lock renewable without making renewal OWNERSHIP-CHECKED, which in the lost-
lease window is worse than not renewing at all.

[P1] A holder that stalls past LOCK_STALE_S has its lock legitimately reaped by a
peer. On resuming it touched `$LOCKDIR` — now the REAPER's lock — kept it fresh,
and walked on to prep. His repro (SIGSTOP the holder past the threshold, let B
reap, SIGCONT, flip the core idle) had both runs exit 0 and both log
`would exec`: the double restart this lock exists to prevent.

Fix: `own_lock()` compares `$LOCKDIR/rid` to `$RID`, and is consulted in three
places — each renewal tick, again after the gate and before prep (the wait can
end by the core going idle rather than by a renewal tick, so the loop check is
not sufficient alone), and inside `cleanup_lock` (which already had the guard).
Losing the lease is now a defer, exit 4.

Test: case 10 reproduces his scenario. Control verified, not assumed — neutering
`own_lock()` to always return true turns it red with the literal defect:
`FAIL: 2 restart decisions after a lease loss — concurrent destructive restart`.
Restored, the suite is ALL PASS, and case 9 no longer hangs at cleanup.

[P1, second finding] `trap cleanup_lock EXIT INT TERM` releases the lock and
RETURNS, so bash resumes the interrupted quiet-gate loop instead of exiting.
Split into `EXIT` plus explicit `INT`/`TERM` handlers that exit 130/143.

HONEST LIMIT ON THAT SECOND FIX: I could not build a control that discriminates.
My probe TERMed the `( … ) &` subshell rather than the bash running the script,
so the holder exited under BOTH trap forms — two opposite inputs, identical
output, which proves my probe wrong rather than the change unnecessary. The
change is correct-by-construction for signal handling and matches the hang
qingyun observed directly, but I am not claiming to have reproduced it.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(graceful-restart): cleanup_lock's status leaked into the script's exit code

cleanup_lock() ended in `own_lock && rm -rf "$LOCKDIR"`, so the function's
status WAS that expression. Whenever the lock is not ours, that is false, and it
reached callers two ways -- both on paths a supervisor branches on:

  lost-lease defer   expected 4    got 1
  direct TERM        expected 143  got 1

Reproduced both before fixing, and the TERM case has a second mechanism worth
naming: the TERM trap is `cleanup_lock; exit 143`, and under `set -e` a non-zero
cleanup_lock aborts the trap so `exit 143` is never reached. Even when cleanup
SUCCEEDS, `exit 143` re-fires the EXIT trap, which runs cleanup_lock a second
time against the lock it just deleted -- own_lock false, status 1, overriding the
143. An unowned-lock fixture only shows the first mechanism; case 12 uses an
owned lock so it covers the re-fire too.

Fix is `return 0`: cleanup is best-effort and a lock that is already gone, or
belongs to a reaper, is a normal outcome -- not this run's exit status.

Tests: cases 11 and 12 pin exit 4 + foreign-lock-preserved and exit 143 +
own-lock-released. Mutation-checked -- reverting only the `return 0` fails both
with 'got 1' while the cleanup assertions stay green, so they cannot pass
vacuously. Full suite ALL PASS.

Found by qingyun-wu on #2334.

Stand: Echo Act IV Pro

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(graceful-restart): release the lock on a normal dry-run; trim comments to policy

Two P2 blockers from review of 646191c.

1. A normal --dry-run retained the lock once it reached a restart decision, so
   verifying before firing -- the natural operator sequence for a destructive
   command -- self-blocked the real run for up to LOCK_STALE_S (900s). Hit live
   while gathering this branch own restart evidence. Retention is still needed
   so the concurrency model matches production, where do_restart ends in exec
   and the trap never runs; that is now an explicit opt-in via
   GR_RETAIN_LOCK_ON_DECISION, which the concurrency case sets. Production
   behavior is unchanged: a real run still never releases before exec.

2. Comment blocks narrated incident and review history and named people/PRs.
   Trimmed to the constraint or the non-obvious reason; that history belongs in
   the PR body. Net -105 lines of comment.

New suite case 13 pins the fix from both sides: a decided dry-run leaves no
lock, the following run proceeds rc=0 without deferring, and the retain mode
still retains (without which the concurrency case would be hollow). Reverting
the cleanup_lock change reproduces the reported symptom exactly -- "the run
after a dry-run exited 4" -- and fails 3 assertions.

Stand: Echo Act IV Mini

* tidy(graceful-restart): finish the comment-policy pass across script and tests

P2 from review of 32b66ee. The previous pass stopped at the production script
and the reviewer correctly noted the rule applies to tests too.

Every explanatory comment in both files is now at most two lines and carries the
constraint rather than the story. All incident chronology, mutation results and
reviewer/PR/person references are gone from code and tests; the rationale lives
in the PR thread. Net -73 lines.

Machine-checkable state: zero over-length comment runs in the test file, zero
refs in either. The one remaining run over two lines is the script FILE HEADER
(module summary, phase contract, exit codes, and the --dry-run/-- usage block).
Kept deliberately: the rule targets narration and history inside code, and
collapsing a usage doc to two lines would delete the interface description
without removing any narration. Flagging it rather than claiming full uniformity.

No behavior change: all 13 cases pass, syntax, src-map, diff hygiene and the
REVIEW path scan are clean.

Stand: Echo Act IV Mini

* tidy(graceful-restart): headers to two lines; cover restart-prep.sh too

P2 from review of b80e205, which ruled that the two-line rule DOES cover file
headers and that usage belongs in docs. I had kept the graceful-restart.sh header
deliberately and flagged it; that question is answered, so it goes.

- graceful-restart.sh:2-12 (11-line header) -> 2 lines pointing at
  notes/graceful-restart-design.md, keeping only the exit-code contract.
- restart-prep.sh:2-14 (13-line header) -> 2 lines. This file was missed entirely
  by the previous pass; the review was right to name it.
- restart-prep.sh:50-54 (5-line argv explanation) -> 2 lines keeping the
  constraint (explicit argv, so a spaced checkout path stays one argument).

docs/src-map.md regenerated: the map derives each module summary FROM these
headers, so rewriting them made it stale and the parity gate caught it. Two
entries changed, nothing else.

Zero comment runs over two lines and zero person/PR references now remain in
graceful-restart.sh, restart-prep.sh or the test file. No executable line
changed, so the accepted destructive-path evidence still applies; the 13-case
suite passes, syntax, diff hygiene and the REVIEW path scan are clean.

Stand: Echo Act IV Mini

* feat(app): route "Restart Core CLI" through the graceful handshake (#2334 follow-up 4)

The menu-bar restart called start-cli.sh --restart directly, so a click killed a
possibly-mid-task core with no checkpoint and no sync-workspace. #2334 built the
prep-before-kill machinery; this gives it a caller on the surface the owner
actually uses. Until now graceful-restart.sh had no invoker outside tests.

- restartCore() -> graceful-restart.sh, forwarding --visible.
- forceRestartCore() unchanged: still the immediate-kill escape hatch, and the
  documented recovery if the graceful path waits on a core that never goes quiet.
- Phase lines from the script become notifications, plus a one-shot 60s nudge
  naming Force Restart. The quiet gate stays unbounded on a healthy core --
  that unboundedness is what makes the restart serialization correct, so it is
  surfaced rather than capped.
- Exit 3 (prep failed, core STILL RUNNING) and exit 4 (another restart holds the
  lock) are reported as distinct outcomes, not as "restart failed".

graceful-restart.sh gains a trailing-arg passthrough because it hardcoded
`exec start-cli.sh --restart`. Wiring the menu to it without that would have
silently downgraded the visible relaunch to a detached one and broken "Open Core
CLI" (#2412) -- a regression with no failing test.

Also corrects a comment that called the plain restart "(graceful)" while the code
went straight to --restart; there it only ever meant "SIGTERM, not SIGKILL".

Stand: Echo Act IV Mini

* fix(app): cancel the queued graceful restart before force-restarting

P1 from review of 221a04d. The 60s nudge tells the user to use Force Restart,
but the graceful waiter was a local variable and nothing cancelled it. Force
Restart killed the core, the core stopped looking busy, and the surviving waiter
then walked through prep and issued a second --restart --visible. The affordance
added to make the unbounded wait legible could therefore cause the exact double
restart this machinery exists to prevent.

- The waiter is now coordinated instance state (gracefulProc / gracefulReachedKill
  under a lock). forceRestartCore terminates it and waits for the TERM trap to
  release the lock before forcing; without that wait the replacement defers with
  exit 4 instead of restarting.
- Past the kill phase the waiter has already exec, so signalling it would abort a
  relaunch in progress. That case declines to force and says so.
- Exit 143 is now a normal outcome (cancelled), not a reported failure.

Suite case 16 pins the composed invariant: a TERM during the wait yields ZERO
restart decisions against a stubbed launcher, and the lock is released so the
forced replacement will not defer. Scope stated precisely: case 16 is NOT
sensitive to breaking the TERM trap alone, because own_lock independently makes
the resumed waiter lose its lease and exit 4. Breaking BOTH guards does turn it
red (1 decision survived), so the assertion has teeth and what it verifies is the
defence in depth, not either guard alone. Case 12 covers the TERM exit code.

Also completes the comment-policy pass on this diff: no added comment block over
two lines, no PR/people references.

Stand: Echo Act IV Mini

* fix(app): serialize the restart lifecycle so Force cannot race an unlaunched waiter

P1 from review of aa8e425. My previous fix was incomplete and the finding is
correct: restartCore() published the process to the tracked slot BEFORE calling
run() on a background queue, and forceRestartCore() gated cancellation on
queued.isRunning — which is false in that window. Force landing there skipped the
cancel, started --force-restart, and left the queued closure free to launch and
issue a second --restart --visible. The same single slot also lost an earlier
waiter if Restart was clicked twice.

Replaced the boolean with an explicit serialized lifecycle:

- GracefulPhase {idle, starting, waiting, killing} plus a gracefulEpoch that is
  bumped on every claim AND every cancel.
- restartCore() refuses unless phase == .idle, so a second click cannot overwrite
  a live waiter; it reports which state it declined from.
- The waiter re-checks epoch AND phase under one lock acquisition and calls run()
  inside that same critical section, or returns without launching. A Force that
  bumped the epoch therefore wins deterministically — there is no window where
  isRunning is consulted.
- forceRestartCore() decides and claims under one lock: .killing declines (it has
  already exec, signalling would abort a live relaunch), .starting needs no signal
  because the epoch bump stops the launch, .waiting terminates and waits for the
  TERM trap to release the restart lock.

Comment policy on the two blocks named: the restartCore hazard doc and the
forceRestartCore doc are now two lines each with no person/PR references. Added
lines carry zero such references. The one remaining over-length block in that
region is stopCore()`s, which is NOT in this diff — left alone rather than
bundling an unrelated rewrite into a fix.

Verified: swiftc -typecheck rc=0 (positive control rc=1 on a deliberate type
error), the 16-case shell suite still ALL PASS, src-map, diff hygiene and the
REVIEW path scan clean.

NOT claimed: an automated test of the click orderings. This repo has no Swift
test target (no root Package.swift, no swift test in scripts or CI), so the
guarantee here is structural — run() is unreachable once the epoch moves — not
test-pinned. Adding a Swift test target is a separate concern.

Stand: Echo Act IV Mini

* test(app): make the restart click interleavings executable

The Swift lifecycle that decides how two menu clicks interact was inline in
main.swift, where tests/graceful-restart.test.sh cannot reach it — that suite
proves shell behavior only after the waiter has launched, so Force-before-run(),
a second Restart while starting/waiting, and Force-after-kill were unpinned.

Extract the phase/epoch state machine to src/Sutando/RestartCoordinator.swift and
drive it from tests/restart-coordinator-swift.test.py (9 cases, swiftc-gated like
the existing SutandoConfig parity test). launch() takes the launch as a closure
so the epoch check and run() stay ONE lock acquisition — splitting them into two
calls would reopen the race being fixed. No notification string or transition
changed; main.swift net-shrinks.

Each case was mutation-checked individually: unguarded launch, a second waiter
allowed, forcing past .killing, and an unguarded finish each fail exactly the
test that covers them.

Also: the app now builds from more than one source, so the two staleness checks
in health-check.py that named main.swift alone would stop noticing edits to its
siblings. They resolve the newest .swift by glob instead of a list, since a list
is what created the blind spot. Added to both swiftc invocations (README,
src/migrate.sh) — without that the app fails to compile.

Stand: Echo Act IV Mini

* feat(app): let the destructive restart path be rehearsed

The reviewer's outstanding P1 asks for a real menu click through this path, and the
owner said on 2026-08-07 that this class of destructive action "needs to be very
precise and I don't think we are there". Both are satisfied by the same thing: a way
to exercise the AppKit lifecycle first without the kill.

SUTANDO_RESTART_REHEARSE=1 makes restartCore pass --dry-run, so a real click drives
the real Process spawn, phase stream, Force cancellation and --visible handoff while
graceful-restart.sh skips the exec. Position matters and is what the test pins: after
`--` the script stops reading its own flags, so a trailing --dry-run would be handed
to start-cli.sh and silently ignored.

Honest about the limit, in the code comment too: rehearsal is NOT side-effect-free.
--dry-run skips only the kill; prep still runs for real, including a workspace sync.
It therefore cannot show a replacement core, so it does not close P1 on its own — it
de-risks the real attempt rather than substituting for it.

Ran it here. It exercised the path and surfaced something a unit test could not: prep
reports "sync-workspace failed or timed out (45s)" for BOTH a timeout and a non-zero
exit, with the sync's own output sent to /dev/null. I read that as a slow sync and was
wrong — measured directly, sync-workspace exits 0 in 4s, so the failure was transient.
The message named a cause it cannot distinguish. That belongs to restart-prep.sh, which
this PR's parent owns, so it is raised on #2334 rather than fixed here.

Placed in RestartCoordinator.swift rather than main.swift so it is reachable by the
Python-driven swiftc harness; main.swift does not compile standalone. 3 cases added
(12 total), each mutation-checked: moving --dry-run after `--` and forcing rehearse on
both fail on assertions.

Stand: Echo Act IV Mini

* fix(graceful-restart): a pre-kill sync failure could not be told from a timeout

qingyun-wu's CR at fa5fe44, from the live-rehearsal evidence. `restart-prep.sh`
discarded the sync command's stdout and stderr, then reported every failure as
"sync-workspace failed or timed out (45s)". The live path was a FAST nonzero exit,
and the sentinel could not distinguish that from a 45-second hang or say what the
command had emitted — in the one place that decides whether to proceed to a kill.

Now records the outcome and bounded evidence:

  exited 7      sync-workspace exited 7 — last output: boom-detail
  timed out     sync-workspace timed out after 1s — last output: ...Terminated: 15

124 is GNU timeout, 143 is SIGTERM from bounded()'s fallback killer (macOS has no
`timeout`), so both map to the timeout wording and everything else reports its real
exit status. Output is tailed to 400 bytes, newlines folded, quotes escaped — the
reason lands in JSON and round-trips through json.load.

`STEP_TIMEOUT` gains a `GR_STEP_TIMEOUT` test-only override, matching the existing
GR_WS / GR_SYNC_CMD seams, so the timeout branch is provable in ~1s instead of 45.

New case 4b pins both outcomes and, as the CR asked, that neither is reported as the
other: a fast failure must NOT claim a timeout, and a timeout must NOT be reported as
an ordinary exit. Suite: 14 sections, ALL PASS.

M1 (collapsing both outcomes to one message), M2 (dropping the preserved output) and
M3 (reporting 124/143 as an ordinary exit) each fail. M3 was verified separately after
a suite-length timeout left its mutant on disk — the restore had not run, and
committing without re-reading the file would have shipped it.

Stand: Echo Act IV Mini

* test(app): pin launch()'s epoch clause independently of the phase clause

launch() guards on `self.epoch == epoch, phase == .starting`, but the 12-case
suite only exercised paths where a Force had already set phase to .idle — so the
phase clause alone rejected every stale closure and the epoch comparison was
never load-bearing under test. Deleting it left all 12 green.

New case claims epoch 1, forces (epoch 2, idle), then claims epoch 3, which
leaves phase .starting. The phase clause is now TRUE for a stale epoch-1
closure, so only the epoch comparison can refuse it.

Mutation-checked. With `self.epoch == epoch` removed from the guard, the old 12
still pass and this case fails, reporting the real defect:

  stale=launched / STALE-RAN     the superseded closure relaunches
  current=aborted                and the live epoch is then refused,
                                 because the stale launch moved phase to .waiting

13/13 with the clause present.

Stand: Echo Act IV Mini

* chore: retrigger CI to test the missing required context on #2726

Only 2 of 21 workflows have ever run on this branch (same two across 12 runs,
08-07 15:06Z to 08-08 01:23Z). Eight explanations are falsified; this empty
commit distinguishes stale branch state from a reproducible trigger defect.

Stand: Echo Act IV Pro

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

* docs: regenerate src-map — I took main's copy and skipped the regenerate step

The merge resolution took main's docs/src-map.md wholesale, which is right (the file
is generated), but the merged src/ tree is neither side's: it has main's modules plus
this branch's RestartCoordinator.swift. So the committed map was stale against it and
`refuse docs/src-map.md stale vs src/` failed on my own head.

Regenerated: 210 modules. My own recipe said "main's side, then regenerate" and I did
the first half only.

Stand: Echo Act IV Mini

* test(health-check): cover the two OSError fallbacks and the fix dispatch

The diff-coverage gate sat at 64% (5 of 14 changed lines uncovered):
`sutando_app_newest_source`'s two OSError fallbacks, and the
`source = sutando_app_newest_source()` line in main()'s fix dispatch.

The OSError paths matter beyond the number: a raise there aborts the whole
health check because one app directory became unreadable. Both now assert the
fallback to main.swift.

The dispatch line is covered by driving main() --fix, the pattern five existing
health-check tests already use.

Includes a POSITIVE CONTROL, and it earned its place: the first version asserted
"a stale binary is not opened" using status="warn", which passes vacuously —
`issues = [c for c in checks if c["status"] not in ("ok","warn")]` filters warn
out before the dispatch, so the branch never ran and opening nothing proved
nothing. The control now patches sutando_app_newest_source and asserts it was
CALLED, with status="stale" so the dispatch is genuinely entered.

That filter also means the branch's own `status == "warn"` auto-launch condition
cannot fire from main(). Pinned as its own test so the next reader does not
assume it can. Pre-existing on origin/main, not introduced here — reported
separately rather than bundled into this PR.

Stand: Echo Act IV Pro

* fix(app): a rehearsal reported "Core restarted" for a restart that never ran

SUTANDO_RESTART_REHEARSE=1 adds --dry-run, the script returns without killing
or restarting anything, and every exit 0 mapped to the success notification.
A diagnostic run reported an action it had explicitly skipped.

The script now exits 5 for a dry run — its own vocabulary already separates
outcomes this way (3 prep-failed, 4 deferred, 143 cancelled) — and the
status-to-message mapping moves onto GracefulRestartInvocation beside args(),
the other half of the same contract, so the notification branch is reachable
by the existing swiftc harness instead of living inline in main.swift.

The suite runs --dry-run throughout, so seven assertions that read exit 0 as
"reached the restart decision" now read DRY_OK, named once. Two of the new
cases are controls: a REAL restart still exits 0, so 5 is not simply the new
always, and the concurrency pair check still distinguishes a deferring peer
from a crashed one.

Verified the new assertions fail on the pre-fix behaviour: mapping 5 back to
the success string fails 2 of them, including one that only compares the two
messages to each other.

Stand: Echo Act IV Pro

* fix(tests): trim a 3-line comment block to the 2-line cap

review-checks failed on my own previous commit: the block above
test_rehearsal_is_not_reported_as_a_restart ran to three lines. The
constraint stays in the code; the narrative is in the PR comment.

Stand: Echo Act IV Pro

* fix(app): exit 4 blamed a dry run for a lock that dry runs release

`cleanup_lock` runs on every exit path and removes the lock unless
`GR_RETAIN_LOCK_ON_DECISION=1` — a test-only override — so a dry run does
NOT hold it, and its own comment says so ("A dry-run DOES reach here;
retaining would self-block the next real run").

The exit-4 message still told the operator "(A recent --dry-run holds the
lock for up to 15 min.)". That sends debugging at a rehearsal that already
cleaned up, and away from the real cause: a holder that died before its
trap. The shell reaps such a lock at LOCK_STALE_S (900s), which is where
the 15 minutes actually comes from, so the number stays and only its
attribution changes.

Message-only; no behaviour change. `tests/restart-coordinator-swift.test.py`
16/16 green — `test_every_documented_status_has_its_own_message` still sees
four distinct messages for statuses 0/3/4/5.

Raised by qingyun-wu in review at 0f41e2b, item 2 of two.

Stand: Echo Act IV Pro

* test(app): pin exit 4's operator message so it cannot go stale again

The exit-4 text claimed a `--dry-run` holds the restart lock for 15 minutes,
which stopped being true when the shell path started releasing the lock on every
exit. `32e4c74d` corrected the wording; nothing asserted it, which is how the
string drifted away from the behavior unnoticed in the first place.

Uses the existing `outcome-messages` scenario, so this is an assertion, not new
machinery. Verified it fails in the broken state: restoring the old string gives

  AssertionError: '--dry-run' unexpectedly found in 'Another restart is already
  in progress — this one deferred. (A recent --dry-run holds the lock for up to
  15 min.)'

  suite: 17 passed (was 16)

Stand: PRO

* fix(app): compile RestartCoordinator.swift into the menu-bar bundle

install-menu-bar-app.sh compiles an explicit source list, so the file this
PR adds was never in the build. main.swift references it twice, so the
canonical build fails with "cannot find 'RestartCoordinator' in scope".

Before (head 01c0651, exactly what the script runs):
  main.swift:2477:28: error: cannot find 'RestartCoordinator' in scope
  main.swift:2498:26: error: cannot find 'GracefulRestartInvocation' in scope
After: builds clean, 451056-byte binary, zero diagnostics.

The unit and shell suites pass either way — neither covers the packaging
step, which is how a non-building bundle stayed green.

Stand: Echo Act IV Mini

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chi Wang <sonichi@users.noreply.github.com>
Co-authored-by: yixuan-ag2 <yixuan@ag2.ai>
sonichi added a commit that referenced this pull request Sep 3, 2026
…iet gate (#3823)

* feat(graceful-restart): hand the live core a drain task before the quiet gate

The 2026-07-27 design had the orchestrator write a prep task so the core
could checkpoint and go idle on request; #2334 dropped it because a busy
or wedged core might never read it. That is the reason the .alive
fallback exists, not a reason to skip the task: a busy core reads a task
at its next tool boundary, and only a wedged or dead one never does.

Phase 1a now writes tasks/task-restart-prep-<RID>.txt (urgent, owner
tier) when the core is alive, before the quiet gate. The body tells the
core the one action that opens the gate — finish the step in hand, start
nothing new, set core-status idle — and asks for a one-line result naming
what was in flight. The gate, the mechanical prep and the .alive fallback
are unchanged. The task is retired to tasks/archive/ on every exit
(before the exec, and from the EXIT trap on 3/4/5), so no boot's
orphan-check ever sees it. A dry run names the task and writes nothing:
the app's rehearsal mode must not drain a live core. Staged under a
dot-prefixed name so the watcher's task-*.txt glob never reads a partial
file.

Test 20 (9 assertions): a stubbed core receives the task while the gate
waits, its id/priority/tier/body, the restart proceeds after idle, the
task is archived before the exec; controls: dead core gets none, dry run
writes none, prep failure still retires it. Case 1's wording assertion
retargeted to the new prep line.

Owner direction on #3816.

Stand: Echo Act IV Pro

* fix(graceful-restart): define retire_prep_task before the traps that call it

cleanup_lock ran from the EXIT/INT/TERM traps armed at the top of Phase 0,
and its first statement named retire_prep_task, which was defined sixty
lines later. bash resolves a function at call time, so a TERM in that
window exited 127 (command not found) instead of 143. PREP_TASK and
retire_prep_task now sit before the lock section; nothing else moved.

Case 20e pins the ordering statically (definition line < trap line) and
behaviourally (TERM 0.3s in -> 143, no 'command not found').

Found by yixuan-ag2 with a hoisted-vs-not control.

Stand: Echo Act IV Pro

---------

Co-authored-by: Chi Wang <sonichi@users.noreply.github.com>
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.

4 participants