fix: merge-queue crashed on every pass decoding em-dash output (errors=replace + lint now enforces it) - #764
Merged
Merged
Conversation
The AesopMergeQueue scheduled task failed with exit 1 on 24+ consecutive 5-minute passes, merging nothing while ~30 PRs piled up behind it. Root cause: commit 2418634 added `encoding='utf-8'` to 62 subprocess call sites without an error handler. The shared `git()` / `gh()` transport therefore decoded output STRICTLY. Git emits raw bytes from refs, config and commit messages without transcoding them, and byte 0x97 -- the cp1252 em-dash that queued PR titles and branch names are full of -- is not valid UTF-8. subprocess decodes captured output in a reader THREAD, so the UnicodeDecodeError never reached the caller's frame: it killed the thread, `result.stdout` came back None, and the next `.strip()` died with a meaningless `AttributeError: 'NoneType' object has no attribute 'strip'`. The crash landed inside build_batch, after the integration branch had already been created -- which is also why the deployed queue worktree is stranded off main. Fixed by adding `errors='replace'` -- never 'ignore', which DELETES the byte and would turn a corrupted branch name into a different, plausible-looking string the queue then acts on. Guardrail gap: G10 mandated `encoding='utf-8'` but never an error handler, so encoding_lint.py reported clean the entire time the queue was hard down. A rule a linter does not enforce is how this shipped. encoding_lint.py now flags any subprocess call that sets `encoding=` without a safe `errors=` handler ('replace'/'backslashreplace'/'surrogateescape'; 'ignore' and 'strict' are rejected), and G10's wording in tools/CLAUDE.md and hooks/CLAUDE.md matches. The new rule found 74 more sites of the identical class across tools/ and driver/. Because the pre-push hook runs this lint over the WHOLE repo and fail-closes on any finding, all 74 are brought into compliance here rather than deferred -- otherwise the new rule would block every Python-touching push. Tests are behavioral and red-first: a real git subprocess emits a real 0x97 byte. Reverting the fix makes them fail with the exact production error (`AttributeError: 'NoneType' object has no attribute 'strip'`), and a companion test pins the pre-fix `stdout is None` behavior so the regression stays proven rather than merely asserted. Verified: full `python -m unittest discover -s tests` green, `encoding_lint.py --check` exit 0, `secret_scan.py --staged` exit 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The RED-half negative control asserted only the Windows shape of the bug (reader thread dies, result.stdout is None). On POSIX, communicate() decodes on the calling thread, so the UnicodeDecodeError propagates out of subprocess.run instead -- the test ERRORED on all four Linux ci shards while passing on windows. Both shapes are the same defect and both are unusable output, so the proof now accepts either while still failing if a plain decoded string ever comes back (which would mean the fixture stopped reproducing the 0x97 crash). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Conflict in hooks/CLAUDE.md item 8: main documented the resolve_aesop_root() worktree fix on the old baseline-ratchet wording; this branch had corrected that wording (the tool has no --baseline flag and .encoding-baseline.json is a stale artifact nothing reads) and added the errors= half of G10. Kept both: the worktree fix, the corrected fail-closed description, and the new rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
matt82198
added a commit
that referenced
this pull request
Aug 4, 2026
PR #764 added errors='replace' to ~60 subprocess calls. This merge preserves that comprehensive fix while keeping unique contributions: self-healing repark in worktree_is_safe() and regression tests in test_merge_queue_encoding.py. Conflict resolution: took main's versions for encoding_lint.py (stronger), CLAUDE.md (G10 docs), INDEX.md (regenerated). Both gh/git functions now have properly formatted errors='replace' handlers.
matt82198
added a commit
that referenced
this pull request
Aug 4, 2026
Resolves PR #722 conflict by: - Accepting main's reformatted CLAUDE.md structure for write_api.py - Retaining snapshot optimization documentation (2026-08-03) - Composing with #722's FsClaimLog durability/skew/GC increments - Preserving OCC WriteConflict guard and snapshot==full-replay invariants Code merges cleanly: - coordination.py: snapshot optimization from main merged - write_api.py: snapshot optimization from main merged - fs_claim_log.py: durability/skew/GC from #722 preserved - Subprocess encoding from PR #764 applied throughout Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
The queue was hard down
AesopMergeQueue(the 5-minute scheduled task that is the merge loop) exited 1 on24+ consecutive passes, merging nothing while ~30 PRs piled up behind it.
From
state/cron-merge-queue.log:Root cause
Commit
2418634d("add encoding=utf-8 to all subprocess calls") addedencoding=to62 call sites with no error handler. The shared
git()/gh()transport thereforedecoded output strictly.
Git emits raw bytes from refs, config and commit messages without transcoding them, and
byte
0x97— the cp1252 em-dash that queued PR titles and branch names are full of — isnot valid UTF-8.
The nasty part is where it fails.
subprocessdecodes captured output in a readerthread, so the
UnicodeDecodeErrornever reaches the caller's frame. It kills thethread,
result.stdoutcomes backNone, and the next.strip()dies with ameaningless
AttributeErrorpointing at a line that looks perfectly fine. The crashlanded inside
build_batchafter the integration branch was created, which is alsowhy the deployed queue worktree is stranded off
main.The fix
errors='replace'on every decoding subprocess call on the daemon's traced call path:the shared
git()/gh()transport, plus the two regenerators the pass shells out to(
verify_test_suite_count.py,gen_tool_index.py).merge_queue.py's ownrun_regeneratoralready had it.replace, neverignore.ignoredeletes the byte, silently turning a corruptedbranch name into a different, plausible-looking string that the queue then acts on. A bad
byte must stay visible as U+FFFD.
The guardrail gap (this is the real story)
Guardrail G10 mandated
encoding='utf-8'but never an error handler, soencoding_lint.pyreported clean the entire time the queue was hard down. A rule alinter does not enforce is how this shipped.
encoding_lint.pynow flags anysubprocess.run/check_output/Popenthat setsencoding=without a safeerrors=handler. Allowed:replace,backslashreplace,surrogateescape(all lossless or visible). Rejected:ignore(deletes data) andstrict(spelling the unsafe default out loud does not make it safe). G10's wording intools/CLAUDE.md,hooks/CLAUDE.mdanddriver/CLAUDE.mdnow matches what the linteractually enforces.
Sweep: 74 more time bombs of the identical class
The new rule found 74 further sites across
tools/(44 files) anddriver/. All arefixed here rather than deferred — the pre-push hook runs this lint over the WHOLE repo and
fail-closes on any finding, so a rule the repo does not satisfy would block every
Python-touching push. Every edit is the same mechanical insertion of
errors='replace'beside an existing
encoding=, applied by AST position.Proof
Red-first, behavioral — a real git subprocess emits a real
0x97byte (a git configvalue written as raw bytes; git echoes it back verbatim). Reverting the fix:
A companion test pins the pre-fix behaviour (
result.stdout is Noneon a successfulcall) so the regression stays proven rather than merely asserted — if the fixture ever
stops reproducing the crash, that test fails and says so.
The real daemon pass, end to end. A pass with the fixed code checked out as
mainexecuted
build_batch— the exact function that was crashing — all the way through:worktree_is_safe, fetch, integration-branch creation, 8 x (pr_view+ fetch + merge),the regenerators, push, batch-PR creation, labelling, and the try/finally tree restore.
That is PR #763, opened by the revived queue logic. Working tree left clean and on main.
Gates:
python -m unittest discover -s testsgreen (the real CI command),encoding_lint.py --checkexit 0 repo-wide,secret_scan.py --stagedexit 0,claudemd_sync_gategreen.Deployment note
The live daemon runs from the worktree
C:/Users/matt8/aesop-queue-main. Two things standbetween this PR and a working queue, and neither is fixable from inside this branch:
its own fix. This PR needs an out-of-band merge.
integrate/q-1785796683rather thanmain(the crash escaped theexcept subprocess.TimeoutExpiredcontainment, so therestore never ran).
worktree_is_safefail-closes on any non-main branch, so even withthis fix it would refuse every pass until someone puts it back on
main. Separately,the local
mainref is 34 commits behindorigin/mainand nothing in the daemonfast-forwards it — so it also needs a pull before the fixed code is what actually runs.
🤖 Generated with Claude Code