Repository navigation
feat(core): graceful restart handshake (orchestrator + prep, dry-run testable) - #2334
Conversation
Coverage Gate✅ No Python changes — nothing to gate (bar: 95% on changed lines). |
qingyun-wu
left a comment
There was a problem hiding this comment.
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.rgfindsRESTART_PREPonly in these two new scripts, so the orchestrator relies on the normal agent reading a natural-language task and voluntarily runningbash src/agent/restart-prep.sh <rid>. If the core is alive enough to keep.alivefresh but is busy, wedged before task execution, or does not execute exactly this shell command, no sentinel is written andgraceful-restart.shwaits forever. That means--dry-runcan 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 writtentask-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 intests/src-map.test.pyfor the same staledocs/src-map.mdcheck. Please runpython3 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
left a comment
There was a problem hiding this comment.
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-53still writes a natural-languageRESTART_PREPtask and waits indefinitely while.aliveis fresh, butrgstill findsRESTART_PREPonly ingraceful-restart.sh,restart-prep.sh, and the generated source map. There is no deterministic runner/dispatcher path that consumestask-restart-prep-*and invokesrestart-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.
There was a problem hiding this comment.
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-52builds the production sync invocation as one string and then expands$SYNC_CMDunquoted. Any checkout path containing spaces is split into multiple argv entries beforeboundedruns it. Exact repro on this head: invoke the script through a checkout path containing spaces;bash -xshowsbounded 45 bash /private/tmp/sutando pr2334 automation link/scripts/sync-workspace.sh, and Phase 1 exits 3 withsync-workspace failed or timed out. That means a valid space-containing install can never producerestart-readyand 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.
… + 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
a3c61e3 to
79f0250
Compare
|
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 2. Argv-safe sync. Production path is explicit quoted argv: Regression coverage ( Verbatim local run tail: |
qingyun-wu
left a comment
There was a problem hiding this comment.
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.shbash tests/graceful-restart.test.shpython3 scripts/gen-src-map.py --checkgit diff --check origin/main...HEADbash 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
left a comment
There was a problem hiding this comment.
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.
… + 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
79f0250 to
67b4adf
Compare
|
Rebased onto current main — The conflict was Verified the rebase RESULT, not just that the conflicts closed: One cosmetic artifact I'd rather name than leave for a reviewer to trip over: the replayed commit @john-the-dev your CHANGES_REQUESTED predates this rebase — the head moved, so it's worth a re-read against |
|
Fixed at 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. One design point worth flagging: a run that decides to restart keeps the lock. In production Two things I got wrong on the way, both of which the evidence caught and both now pinned by the regression:
Discriminating evidence:
On your last point — the branch is no longer 23 behind; it's level with |
|
CI caught a real defect in my previous commit — What broke. I read the lock holder's age with The part worth your attention is how it presented. CI reported: 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 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 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: Still no live restart round trip, unchanged from my last comment — that needs the supervised window. |
|
@john-the-dev all three points are addressed — two by commits that landed after the head you reviewed ( 1. Serialization — 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 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 Two caveats I'd rather state than have you find:
Re-requesting review. |
qingyun-wu
left a comment
There was a problem hiding this comment.
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.
|
Refreshed onto current Only Hand-merging it would have read fine in review and then failed CI's Verified the merge result, not just the absence of markers:
This is the second PR tonight whose only conflict was |
qingyun-wu
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
I found one blocking issue in the refreshed graceful-restart head:
src/agent/graceful-restart.sh:132still computes.aliveage with BSD-onlystat -f %m. The lock path below already had to add a GNU/Linux-safe mtime fallback and digit guard, butalive_age()did not get the same treatment. Under GNU-stylestat -foutput, this feeds nonnumeric text into shell arithmetic; in a local simulated GNU-stat dry run I sawline 134: File: unbound variable/integer expression expected. More importantly, a stale.alivecan fail to flipDEAD=1, so a dead core with a freshcore-status.jsoncan 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 coversalive_age()under GNU-style stat output.
Focused checks I ran locally:
git diff --check origin/main...HEADbash tests/graceful-restart.test.shbash -n src/agent/graceful-restart.sh src/agent/restart-prep.sh tests/graceful-restart.test.shpython3 scripts/gen-src-map.py --check- simulated GNU-style
stat -foutput againstgraceful-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.
|
Fixed at The fixExtracted the guard you already had on the lock path into a shared 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 The regression fails against the old code — with your diagnosticsCase 8 puts a GNU-mimicking Reverting Restored, the suite is One thing I got wrong on the way, since it could have shippedMy first patch defined Your checks, re-runStill owedThe 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. |
… + 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
4e931a6 to
f286022
Compare
qingyun-wu
left a comment
There was a problem hiding this comment.
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.shbash -n src/agent/graceful-restart.sh src/agent/restart-prep.sh tests/graceful-restart.test.shpython3 scripts/gen-src-map.py --checkgit diff --check origin/main...HEADbash 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.
|
My previous commit was wrong on Linux and CI caught it. Fixed at What I brokeThe digit guard I added was correct; the selection was not. I kept choosing the stat -f %m "$1" || stat -c %Y "$1" # never reaches the fallback on GNUOn GNU/Linux 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:
The part I'd want reviewed hardestMy 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 Case 8 now asserts the thing that matters: a seconds-old Only output-shape selection passes both. Full suite Unchanged: the branch-built restart proof is still owed and still not claimed here. |
qingyun-wu
left a comment
There was a problem hiding this comment.
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.
qingyun-wu
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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 --checkbash -n src/agent/graceful-restart.sh src/agent/restart-prep.sh tests/graceful-restart.test.shgit diff --check b992ebbeec91feb4c88eb6ab1dbaa6427f4bad5e..HEADbash 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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
# Conflicts: # docs/src-map.md
|
Rebased past the #2786 merge — the only conflict was Re-ran the suite after the merge rather than assuming a conflict-free resolution is semantically safe: Approvals are unchanged at the new head; this was a mechanical rescue with no logic change. |
|
@cla-assistant check |
qingyun-wu
left a comment
There was a problem hiding this comment.
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.shpassed.git diff --check origin/main...HEADpassed.bash tests/graceful-restart.test.shpassed when run outside the sandbox so the process-control cases could usepgrepand signals against scratch subprocesses.python3 scripts/gen-src-map.pywas 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.
…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
…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>
…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>
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--restartitself (kill-session terminates it mid-task).src/agent/restart-prep.sh— Phase-1 prep: checkpoint → boundedsync-workspace→ record branch/dirty → always write a terminalreadyorprep-failedsentinel.tests/graceful-restart.test.sh— 14 cases.docs/src-map.md— regenerated, because the map derives each module's summary from these files' headers.How it works, as shipped
Fully deterministic — no task-queue or LLM handoff anywhere. An early revision wrote a
RESTART_PREPtask 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:Exit codes, because a supervisor branches on them:
0restarted ·3prep failed and the core is still running (nothing killed — owner decides fix-vs-force) ·4deferred to a peer holding the lock ·143TERM.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.
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=0without self-deferring, andGR_RETAIN_LOCK_ON_DECISION=1still 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, TERMexit 143, quiet/busy/wedged/dead branches, spaced argv paths, GNU-vs-BSDstatoutput 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