cmux-next: environment picker and New Worktree in the composer location row (preview)
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 tobase_red_excuse.py. But that only reads the base's own runs of the same workflow, andfeat-cmux-nextpushes 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 togh pr merge, which failed only after every gate had run and printed 38 lines of usage text. Under| tail -5the agent saw only "GitHub refused to merge". - Two more silent paths on macOS
/bin/bash3.2. First, the ERR trap fires inside$(...)even underif !, 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 underset -u. A repo with noci-status(hq) and an advisory red Vercel check therefore crashed withmust[*]: unbound variable.
Changes
base_red_excuse.pyreads 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 -5shows 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.
--shaand--match-head-commitnow check the current head instead of being forwarded. Knowngh pr mergeflags pass through as before. --dry-runruns every gate, then posts no comment and merges nothing. With--main-fixit passes--check-onlyto the validator.- When
gh pr mergerefuses, 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
mustarrays 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.pywith 3 failures and 6 errors, andtests/test_gh_merge_green.pywith 11 failures. - Green on 7c988b16a3:
test_base_red_excuse40 OK,test_gh_merge_green144 OK,test_merge_fast_gate21 OK (1 skipped),test_gh_merge_green_revert5 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 andcmux-next perf gate) with their job URLs. For each it lists the base tipfb8fe79906and the 5 commits before it, the merge-base316b5ad6b0and 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