guard(queue): restore the kill switch + atomic lock + worktree/timeout failure containment (deep-scan A1-A3,A7) - #753
Merged
Conversation
…t failure containment Four verified P1s in the merge-queue advancer, which merges to main unattended every 5 minutes. Deep-scan 2026-08-03, findings A1-A3 and A7. A1 -- the kill switch was dead. tools/halt.py writes .HALT to the state dir it resolves (AESOP_STATE_ROOT > config state_root > ./state); run-merge-queue.sh and run-watchdog.sh both read a hard-coded $AESOP_ROOT/state/.HALT. Any AESOP_STATE_ROOT other than $AESOP_ROOT/state made the two diverge, so `halt.py --status` reported HALTED while the queue kept merging. Both scripts now read EVERY location the writer may have used, first match wins, deduped so one halt logs once. A2 -- double-advancer lock race. acquire_lock reclaimed a stale lock with shutil.rmtree followed by os.mkdir. Those are two steps: pass B could claim in the window, and pass A's rmtree then deleted B's FRESH lock and A claimed it too. Both believed they held it -- the observed #727/#728 double-batch shape. os.mkdir is now the lock primitive and a stale lock is reclaimed only by a single atomic os.rename onto a private name, VERIFIED against the fingerprint of the lock judged stale; a live lock taken by mistake is handed straight back. A3 -- stranded worktree. A `gh pr create` failure returned from build_batch without restoring main, leaving the SHARED tree on integrate/q-*, so every later pass died `unsafe_worktree` while record_exception's dedupe wrote the row once and then went silent. Batch construction is now try/finally: main is restored on every exit path including a raise, and the branch delete moved into the finally (after checkout, since `git branch -D` refuses the current branch). A7 -- no timeout handler. The shared transport runs subprocess with timeout=60 (gh) / 120 (git) and nothing in merge_queue caught TimeoutExpired, so a hang escaped as a traceback from a scheduled task: no exception row, possibly a stranded tree. Timeouts are now contained in preconditions, in the pass body, and as a main() backstop -- a `subprocess_timeout` ledger row, the tree back on main, and a clean non-zero exit. Cleanup paths use a git wrapper that cannot raise a second time out of a finally. Green-check logic (classify_check, required_checks_green, merge_and_verify) is deliberately untouched -- the audit proved that part sound. Tests: tests/test_merge_queue.py 128 -> 140 (12 new, all RED first), plus tests/test-daemon-halt-sentinel.sh proving both daemons find the sentinel tools/halt.py actually writes. Full shell suite 15/15. Zero-sleep AST proof still green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… changed here The doc-sync gate requires tools/ code changes to land with the domain doc. Surgical append to the existing merge_queue.py bullet only -- no restructuring, to minimise conflict with the tools/CLAUDE.md restructure in PR #751. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ixes # Conflicts: # tools/CLAUDE.md
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four VERIFIED P1s in the autonomous merge-queue advancer -- the thing that merges to main unattended every 5 minutes. From the Opus deep-scan of 2026-08-03 (findings A1-A3, A7). Every fix is TDD: the test was written first and observed RED against the current code.
A1 -- the kill switch was dead (highest)
tools/halt.pywrites.HALTto the state dir it resolves (AESOP_STATE_ROOTenv > configstate_root>./state).daemons/run-merge-queue.shanddaemons/run-watchdog.shboth read a hard-coded$AESOP_ROOT/state/.HALT-- while run-merge-queue.sh's own header documentsAESOP_STATE_ROOTas a separate variable. Set them to different paths andhalt.py setwrites a sentinel no daemon ever reads:halt.py --statusprints HALTED, the queue keeps merging. The human abort silently does nothing.RED evidence (
tests/test-daemon-halt-sentinel.sh, before the fix):Fix: both scripts now check EVERY location the writer may have used --
$AESOP_STATE_ROOT/.HALTfirst (halt.py's own precedence),$AESOP_ROOT/state/.HALTsecond -- deduped so a single halt logs exactly once. The watchdog diverged the same way and got the same one-line-class fix.A2 -- double-advancer lock race
acquire_lockreclaimed a stale lock withshutil.rmtree(ignore_errors=True)followed byos.mkdir. Two steps: pass B could claim in the window, and pass A'srmtreethen deleted B's fresh lock and A claimed it too. Two advancers running the same queue concurrently is the observed #727/#728 double-batch shape.RED evidence:
AssertionError: 2 != 1 : exactly one pass may hold the advancer lock, got [True, True]Fix:
os.mkdiris the lock primitive (atomic;FileExistsErrormeans you did not get it). A stale lock is reclaimed only by a single atomicos.renameonto a private name -- whoever wins that rename is the only process that can go on to claim -- and the result is then VERIFIED against the(pid, timestamp)fingerprint of the lock judged stale. A live lock taken by mistake is renamed straight back. The interleave test forces the race deterministically rather than racing threads.A3 -- stranded worktree
A
gh pr createfailure returned frombuild_batchwithout restoring main, leaving the SHARED tree onintegrate/q-*. Every later pass then diedunsafe_worktree-- andrecord_exception's dedupe wrote that row once and went quiet. The queue stops merging and says nothing.Fix: batch construction is
try/finallyfrom the moment the integration branch is created. Main is restored on every exit path including a raise, and the branch delete moved into thefinallyafter that restore, becausegit branch -Drefuses the branch you are standing on. Covered for pr-create failure, push failure, success, and an exception thrown mid-construction.A7 -- no timeout handler
The shared transport runs subprocess with
timeout=60(gh) /120(git) and nothing inmerge_queuecaughtTimeoutExpired. A hang escaped as a traceback out of a scheduled task: no exception row, possibly a stranded tree, and the only record is a stack trace nobody reads.Fix: contained in the precondition phase, in the pass body, and as a
main()backstop. Each writes asubprocess_timeoutledger row, restores the tree to main, and exits non-zero cleanly. Cleanup paths use agit_safewrapper so afinallycan never raise a second time over the real failure.Not touched (deliberately)
classify_check,required_checks_green,merge_and_verify-- the audit proved the green-check logic sound and nothing here weakens it. No--admin,--auto, force-push,--no-verify, or stash anywhere.Verification
tests/test_merge_queue.py: 128 -> 140 (12 new, all RED first), full suite greentests/test-daemon-halt-sentinel.sh: new, 5 tests, proves both daemons find the sentinelhalt.pyactually writes (and still honour the legacy location)pre-push-policy.sh --test18/18tools/verify_test_suite_count.py --check: OK (shell count regenerated 13 -> 14)tools/secret_scan.py --staged: exit 0tools/CLAUDE.mdis touched only because the doc-sync gate blocks the push otherwise -- a surgical append to the existingmerge_queue.pybullet, no restructuring, to minimise conflict with PR #751.🤖 Generated with Claude Code