merge-compile-guard: only a compile that finished red refuses; a slow read or a pending compile warns and merges

Merged
TE
15d ago
+308-79

Leo-ask: ask-99c1b0ed16
Leo-ask: ask-265dff2c7c
Leo-ask: ask-bdaebcd48a
Leo-ask: ask-daa4b85426
Leo-ask: ask-4c8d8efef8

Leo, 2026-10-21: "IF THE MERGE HOOK IS WRONG THEN JUST FUCKING MEREG", "WTF ARE YOU DOING WITH FUCKING MERGE HOOKS JUST MERGE!?", "DUDE I FUCJKING HATE YOUR MERGE HOOKS".

This repo's PreToolUse merge hook (.claude/settings.json → scripts/ci/guards-from-base.sh → scripts/ci/merge_compile_guard.py) refused merges it couldn't judge. On cosmic-gray at load ~200, its GitHub reads timed out and refused #23660 and #23371 for hours. Both had a SHIP receipt at the head, green fast gates and a green compile. It also refused every merge whose compile was still queued or running.

HQ's landing gate (require-cmux-guards.py, cmuxterm-hq#2960 and #2976) already refuses only four things:

  • no SHIP receipt at the head;
  • a compile that finished red;
  • --admin;
  • --auto without a pinned head.

A pending compile or a slow read is only a warning there. This PR makes the repo hook agree.

What changes

  • Still refused: a Swift PR whose cmux-next Release compile (Xcode 26) or cmux-next app compile (build fleet) finished red with no passing attempt. A red fleet compile refuses even while a Release compile is queued beside it, and a fleet compile-check pass doesn't clear it.

  • Now a warning (a "Hold"), not a refusal:

    • a compile that is still queued or running, hasn't been created yet, or was cancelled on every attempt;
    • a GitHub read that failed or ran out of time;
    • a merge the guard can't name (a shell variable, a GraphQL mutation);
    • a checkout it can't read.

    The hook prints a systemMessage plus additionalContext, with no permissionDecision. The merge goes ahead, and both the person and the agent see why.

  • A warning never hides a refusal. Every merge in the command is judged, and any red refuses.

  • guards-from-base.sh runs both guards.

    • A refusal is the answer as soon as its guard gives it. It is read as JSON, whatever the spacing.
    • Otherwise the guards' warnings are joined into one, so a merge's warning never hides the update-branch warning in the same command.
    • A guard that crashes or exits non-zero is only a warning. Claude Code treats exit 2 as a block.
  • Guards that can't be loaded: when guards-from-base.sh or the settings hook can't load the guards from origin/feat-cmux-next, it now warns instead of refusing.

  • Launcher failure: if the launcher itself fails, the settings hook exits 1, which doesn't block.

Overlap: #23858 (lane-host) replaces this guard with the installed HQ gate and deletes these files. It conflicts with feat-cmux-next and has been idle since 2026-10-21 23:50Z. Whichever PR lands second takes the other's side; nothing here blocks it.

Review

A Codex reviewer (gpt, xhigh, on cmux16) blocked three rounds. Each finding got a failing test first, then a fix:

  1. A red fleet compile next to a queued Release compile read as a wait. A guard exiting 2 blocked the tool call. A merge warning short-circuited the update-branch warning.
  2. A refusal written with other JSON spacing was merged into a warning.
  3. A known refusal waited behind the slower advisory hook and could be lost to the 120 s timeout.

Round 4: SHIP at the head.

Tests

  • python3 tests/test_merge_compile_guard.py: 54 OK.
  • python3 tests/test_guard_hooks_from_base.py: 17 OK.

Every behaviour change has a failing test committed before its fix (d8d6e2a9754, 3b19f092067 and the two later (red) commits). The new cases:

  • a slow GitHub (gh sleeps past CMUX_MERGE_GUARD_GH_TIMEOUT=1) and an unreadable GitHub let the merge through with a warning;
  • a pending compile warns; a red Release compile or fleet compile refuses;
  • in gh pr merge "$PR" ...; gh pr merge 5 ..., the warning on the first merge doesn't hide the red on the second;
  • a crashing guard or launcher never exits 2;
  • a refusal comes back within 20 s while a 30 s advisory guard is still running;
  • a refusal written with indent=2 and odd separators still refuses.

Observed / Not run

  • Observed: both suites on cmux16. cmux16 has no timeout, which the settings hook uses for its fetch, so a stand-in that runs the command went first on PATH. scripts/cmux-next/prepush-gate.py: no new violations.
  • Not run: a live merge under load. The stop condition is an under-load merge of a gated PR going through, and I'll report that once this is on feat-cmux-next.

Changelog

none

Agent session: Air-Blue

šŸ¤– Generated with Claude Code