Skip to content

The back half closes its own loop - #103

Merged
abedegno merged 24 commits into
mainfrom
plan/closed-loop
Sep 20, 2026
Merged

abedegno merged 24 commits into
mainfrom
plan/closed-loop

Conversation

@abedegno

@abedegno abedegno commented Sep 20, 2026 •

Copy link
Copy Markdown
Owner

What and why

Muesli #764 was implemented by a run whose model session stopped twelve minutes before CI finished red. The back half derived a terminal outcome from the red CI, recorded the run ended, and from that moment the pipeline was blind to the pull request. Everything after it — a repair, a cross-review that read one commit of thirty-eight and passed, four review rounds each finding one real defect, the merge — was a person launching --recover-pr by hand. Six times.

Three properties made that possible: a run could end while its pull request was open, red CI was an outcome rather than a repair trigger, and a reviewer FAIL past the revision bound was an ending.

This branch closes the loop, implementing sections 1 to 3 of docs/superpowers/specs/2026-09-19-back-half-closed-loop-design.md:

  • One new command. request_repair is the only door back to planned, carrying why (ci_red, review_fail, plan_conformance), the head it was requested against, and its evidence. A back-half verdict no longer transitions anything: accept, reject and request_revision all leave the run at reviewing, because a verdict is evidence, not a move.
  • A run with an open pull request cannot end. record_run_outcome is refused from an active state while the run holds an implementation output and its newest pull-request observation is absent or open. Silence counts as open.
  • One pure decision. next_step(journal, ground) -> wait | review | repair | merge | park | done is the whole policy; the runner only performs what it names and records the resulting fact.
  • Waves are the loop. Every wave resumes every open back-half run from the journal, so a dead session, a crash or a provider outage costs one wave, not the run.
  • Unbounded rounds, judged by evidence. BIRCHER_MAX_REVISIONS is gone. Progress is measured by failing-job sets and finding fingerprints; three identical rounds in a row park the run no_progress, and a person replies retry or stop in the session the notice links.

Sections 4 (gate integrity) shipped in #102. Sections 5 and 6 — orphan adoption, state-derived labels, the round log, plan conformance, and the E11 live proof — are the next pull request.

Effect on the merge gate

No change to what can merge, or to the evidence it needs. Verified rather than asserted:

  • The in-run merge block moved verbatim from run_item into _merge_step: 26 code lines, zero differences against main (comments aside). The reviewed head still comes from the derivation and never from a re-fetch, _merge_gate still fails closed without one, merge_ready_pr still pins --match-head-commit, and an unreviewed merge still downgrades the outcome.
  • _merge_step runs only when next_step returns merge, which happens only on a PASS verdict recorded against the head being merged, or from merge_requested (a merge an earlier pass already authorised). The kernel's merge authorisation is untouched: it still requires an accept bound to the exact artifact, base, context and head.
  • The verdict freeze does not weaken this. accept was already non-transitioning; what changed is that request_revision no longer moves the run, so the only way back to implementation is an explicit request_repair the journal records.
  • One refusal was tightened, not loosened: a reviewer that is the run's own implementer was always refused by the kernel, but the runner used to seat the reviewer from the wave's provider pick, so on a resumed run the verdict was silently thrown away. The runner now seats it from the journal.

Testing

  • bash batch/run-queue.sh --self-test passes on Linux
  • New behaviour I depend on has a self-test case
  • Where there is a guard, I showed it going red as well as green

Both runs are on omnigent-runner-bircher (Linux 6.12, bash 5.2, coreutils), against this branch, with the deployed bundle left on main and its working tree untouched:

  • as an unprivileged user, from a clean clone in a throwaway home: 111 checks, rc 0, no FAIL, no SKIP;
  • as root, which is how the runner itself runs it: 111 checks, rc 0, no FAIL, one SKIP.

That skip is the last commit here. One derivation test makes a findings file unremovable with chmod 500 and asserts the derivation fails closed. uid 0 ignores the write bit, so the premise cannot be staged, the derivation correctly succeeds, and the assertion read that success as a failure — and because the script exits on the first failure, the runner's own self-test has been stopping at check 29 of 111 on every deploy. It fails the same way on main (bb402d4) in the same container, so it is pre-existing rather than something this branch introduced. It is now skipped under uid 0 with a visible notice, in the convention the suite already uses for a missing timeout(1); the hazard still binds for every non-root run, which is where the suite is developed.

The macOS run skips the kill-escalation test for want of timeout(1); the Linux run exercises it. The Python suite is 1983 passed, 3 skipped (macOS).

Anything a reviewer should look at twice

  • The no-progress judgement on the review side. A finding's fingerprint is the first file path plus the first sentence. Two reviewers wording the same defect differently do not match, so review-driven repair is closer to unbounded in practice than the CI side is. That is the spec's own definition and it deserves live data before it changes.
  • Rounds are unbounded within a single pass, not only across waves. One item can hold the batch lock for several repair sessions. The usage ceiling and the wave timer are the outer bounds today; a budget_exhausted park inside the loop is owed (spec §3) and is not in this branch.
  • The advisory adapter. Bash cannot tell a refusal from a success, so the loop reads state back after the repair request and after the terminal fact. Anywhere it does not read back is a place a refusal is still invisible: two front-half exits in run_item still retire a queue file without checking, which is a follow-up.
  • When the journal names both providers as conflicted, which a pass dying between a repair's dispatch and its output can do, no reviewer is acceptable and the loop waits every wave rather than parking. It is the honest composition of the two fixes above, and it has no human-visible signal yet.
  • _resume_back_half reads several names from run_item by dynamic scope. The contract is stated in comments above both functions, and set -u would catch an omission only on the path that reads it.

The head now rides out on every CI colour (c23a9af), so --recover-pr
against a red or pending PR reached the review record with r_review
= na and submitted a non-verdict the kernel refuses. Wrap the
reviewer dispatch and record_review in the same *:pass|*:fail guard
the runner's own loop got.
@abedegno
abedegno merged commit 9a5978a into main Sep 20, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant