Skip to content

fix: merge-queue crashed on every pass decoding em-dash output (errors=replace + lint now enforces it) - #764

Merged
matt82198 merged 3 commits into
mainfrom
fix/merge-queue-decode-crash
Aug 4, 2026
Merged

matt82198 merged 3 commits into
mainfrom
fix/merge-queue-decode-crash

Conversation

@matt82198

Copy link
Copy Markdown
Owner

The queue was hard down

AesopMergeQueue (the 5-minute scheduled task that is the merge loop) exited 1 on
24+ consecutive passes, merging nothing while ~30 PRs piled up behind it.
From state/cron-merge-queue.log:

Exception in thread Thread-124 (_readerthread):
  File "C:\Python314\Lib\subprocess.py", line 1614, in _readerthread
    buffer.append(fh.read())
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x97 in position 356: invalid start byte
Traceback (most recent call last):
  ... merge_queue.py, in build_batch
  ... merge_train.py, in git
    result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', timeout=120)
AttributeError: 'NoneType' object has no attribute 'strip'
MERGE-QUEUE: FAILED - exit 1

Root cause

Commit 2418634d ("add encoding=utf-8 to all subprocess calls") added encoding= to
62 call sites with no 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.

The nasty part is where it fails. subprocess decodes captured output in a reader
thread, so the UnicodeDecodeError never reaches the caller's frame. It kills the
thread, result.stdout comes back None, and the next .strip() dies with a
meaningless AttributeError pointing at a line that looks perfectly fine. The crash
landed inside build_batch after the integration branch was created, which is also
why 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 own
run_regenerator already had it.

replace, never ignore. ignore deletes the byte, silently turning a corrupted
branch 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, 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.run/check_output/Popen that sets
encoding= without a safe errors= handler. Allowed: replace, backslashreplace,
surrogateescape (all lossless or visible). Rejected: ignore (deletes data) and
strict (spelling the unsafe default out loud does not make it safe). G10's wording in
tools/CLAUDE.md, hooks/CLAUDE.md and driver/CLAUDE.md now matches what the linter
actually enforces.

Sweep: 74 more time bombs of the identical class

The new rule found 74 further sites across tools/ (44 files) and driver/. All are
fixed 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 0x97 byte (a git config
value written as raw bytes; git echoes it back verbatim). Reverting the fix:

FAILED test_gh_survives_undecodable_byte
FAILED test_git_survives_undecodable_byte
AttributeError: 'NoneType' object has no attribute 'strip'   <- the exact production error
tools\merge_train.py:236: AttributeError

A companion test pins the pre-fix behaviour (result.stdout is None on a successful
call) 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 main
executed 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.

merge-queue pass: ok
  - opened batch integrate/q-1785801893 with #717, #689, #696, #702, #703, #712, #716, #723
PASS_EXIT=0

That is PR #763, opened by the revived queue logic. Working tree left clean and on main.

Gates: python -m unittest discover -s tests green (the real CI command),
encoding_lint.py --check exit 0 repo-wide, secret_scan.py --staged exit 0,
claudemd_sync_gate green.

Deployment note

The live daemon runs from the worktree C:/Users/matt8/aesop-queue-main. Two things stand
between this PR and a working queue, and neither is fixable from inside this branch:

  1. Bootstrap. The queue is what merges PRs, and the queue is broken — it cannot merge
    its own fix. This PR needs an out-of-band merge.
  2. The deployed worktree is stranded, sitting on integrate/q-1785796683 rather than
    main (the crash escaped the except subprocess.TimeoutExpired containment, so the
    restore never ran). worktree_is_safe fail-closes on any non-main branch, so even with
    this fix it would refuse every pass until someone puts it back on main. Separately,
    the local main ref is 34 commits behind origin/main and nothing in the daemon
    fast-forwards it — so it also needs a pull before the fixed code is what actually runs.

🤖 Generated with Claude Code

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>
@matt82198 matt82198 added merge-queue Queued for the merge-queue advancer daemon merge-priority Jump the merge queue labels Aug 4, 2026
matt82198 and others added 2 commits August 3, 2026 19:29
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
matt82198 merged commit 18d755c into main Aug 4, 2026
12 checks passed
@matt82198
matt82198 deleted the fix/merge-queue-decode-crash branch August 4, 2026 01:30
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>
@matt82198 matt82198 mentioned this pull request Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-priority Jump the merge queue merge-queue Queued for the merge-queue advancer daemon

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant