cmux-next: environment picker and New Worktree in the composer location row (preview)

Merged
TE
18d ago
+1749-54

Summary

On 2026-10-19 gh-merge-green refused three merges that should have gone through, and agents fell back to bare gh pr merge --match-head-commit. This PR fixes all three, and every refusal now says which check failed, at which SHA, and what it was compared with.

Builds on #22128. This branch includes #22128's two commits, cherry-picked onto current main (after #20566's fast gate). Those commits compare a red lint check with the base finding by finding. This PR adds where the base verdict comes from and how refusals read, and drops nothing from #22128. If #22128 lands first, its commits fall out of this branch on rebase.

Root causes:

  • #21807, lint red that the base also had. Lint (cmux-next checks (god files, …)) was refused before any base comparison. #22128 sends it to base_red_excuse.py. But that only reads the base's own runs of the same workflow, and feat-cmux-next pushes never run the checks job. So there was still nothing to compare against.
  • #21729, "no base verdict". The same gap. The refusal also never said which commits or runs it had looked at.
  • cmuxterm-hq#2554, no reason given. The agent passed --sha <head>. It was forwarded to gh pr merge, which failed only after every gate had run and printed 38 lines of usage text. Under | tail -5 the agent saw only "GitHub refused to merge".
  • Two more silent paths on macOS /bin/bash 3.2. First, the ERR trap fires inside $(...) even under if !, so a failed base comparison printed the generic "inspect the failed GitHub query" line before the real refusal. Second, ${must[*]} on an empty array is unbound under set -u. A repo with no ci-status (hq) and an advisory red Vercel check therefore crashed with must[*]: unbound variable.

Changes

  • base_red_excuse.py reads the check runs on the base tip and on the merge-base. When one of them has no verdict for the job, it looks back up to 5 commits before it. Any workflow and any event counts, so a job that runs in a different workflow on push is still found. A job counts as base red when either side's nearest verdict fails the same way. After that it falls back to the base workflow runs, as before. Names match exactly first, then without a matrix suffix ((1), (Xcode 26)), then without a caller prefix (web / ), and the audit says which rule matched.
  • A refusal lists every job with its head SHA and job URL, each base commit by label and SHA (base tip, base tip~2, merge-base), how many base runs were read, and the mismatch itself (failed steps, tests, errors or #22128's findings). When no base commit runs the job, it says so and suggests dispatching the workflow on the base. The detail lines come first and the one-line reason last, so | tail -5 shows the reason.
  • Continue-on-error steps that log ##[error] count as failed steps. This is how the checks job reports which lints are red.
  • Unknown options are refused before any GitHub query. --sha and --match-head-commit now check the current head instead of being forwarded. Known gh pr merge flags pass through as before.
  • --dry-run runs every gate, then posts no comment and merges nothing. With --main-fix it passes --check-only to the validator.
  • When gh pr merge refuses, its first error line goes into the final refusal line.
  • bash 3.2: the ERR trap stays quiet in subshells, and in the main shell it names the failing command and line. Empty must arrays no longer crash.

Not changed: the fast gate (#20566) still refuses a red lint outright, which is that PR's policy. Lint gets the base comparison only under --full, as in #22128.

Testing

  • Red first: e9392a159c (tests only, on top of #22128) fails tests/test_base_red_excuse.py with 3 failures and 6 errors, and tests/test_gh_merge_green.py with 11 failures.
  • Green on 7c988b16a3: test_base_red_excuse 40 OK, test_gh_merge_green 144 OK, test_merge_fast_gate 21 OK (1 skipped), test_gh_merge_green_revert 5 OK.

Observed / Not run

Observed: I ran the fixed tool against the real PRs with --dry-run, behind a gh shim that blocks every write (pr merge, pr comment, non-GraphQL POST/PUT). No merges or comments were made.

  • cmuxterm-hq#2554 with --sha 754edf65…: dry-run: every gate passes.
  • #21729 --full: refused, naming both red jobs (the checks job and cmux-next perf gate) with their job URLs. For each it lists the base tip fb8fe79906 and the 5 commits before it, the merge-base 316b5ad6b0 and the 5 commits before it, and the 20 base runs of workflow 370457270 that it read. None of them run either job.
  • #21807 --full: refused because its cmux-next run attempt 3 is still queued, with the run ID in the message.
  • #21807 on the fast gate: refused because the lint check is red, which the fast gate never waives (#20566).

Not run: a live merge through the new base-tip path. On today's feat-cmux-next no base commit runs the checks job, so #21729 and #21807 still refuse under --full, now with the list of what was compared. They pass once the checks job runs on base pushes (the PR paired with #22128).

Changelog

none

šŸ¤– Generated with Claude Code