Skip to content

ADR 0050: Run the merge check first, as a fail-fast precondition

Amendments.

  • Vocabulary: current spellings are done (formerly finish), accept (formerly graduate), update (formerly integrate), discern, the gate, or the bar for the retired product-category wording, and known/custom job (formerly gate capability / custom check); the decisions below are unchanged.

Status: accepted. ADR 0056 extends this pattern to the generated-artifact currency checks.

Context

§

discern done checked whether the branch contains the latest main — the merge check — as its last step, after the fix/build/check∥test stages, the scope gates, and the guidance/skills/fix_drift currency checks (finish.ts, the runGate ordering).

The merge check is nearly free: assertMainMerged (git.ts) runs show-ref --verify then merge-base --is-ancestor main HEAD, and a rev-list --count only when the branch is actually behind (purely to report how far). It self-skips in the main checkout and outside a worktree — there is nothing to update into. So a millisecond-scale precondition sat gated behind the entire expensive gate.

That ordering is a tax on discern's own headline workflow. With several agents in flight, main moves often. An agent on a branch behind main paid the full cost of the gate — fixers, build, the parallel check/test run — only to be told at the end to update main and re-run. And that work is wasted by construction: git merge main changes the tree, which forces a fresh done, so every result computed against the pre-integration tree is discarded unread. The gate spent its most expensive stages producing a throwaway answer.

Crucially, the merge-base relationship is invariant across a gate run: done never fetches and never commits, so neither HEAD nor main moves while it executes. Checking at the front therefore returns exactly the same verdict as checking at the back.

Decision

§

Move the merge check to the first step of runGate, fail-fast. When the branch is behind main, the gate sets failed_stage = "merge" and skips every downstream stage; the fix-set snapshot and the stage-group loop are guarded so nothing expensive runs. The result envelope is still assembled from the same plan, so the skipped jobs/scope-gates serialize as skipped steps — an honest record that they were never reached.

Because the verdict is invariant across the run (above), this is a pure reordering, not a change to what the gate decides — only to when, and to how much it spends before deciding. The --dry-run plan lists the merge-check step first to match the executed order.

Consequences

§
  • Behind main: the gate stops before any job runs, saving the whole fix/build/check∥test/scope-gate/currency sweep — the case that compounds across parallel agents.
  • Happy path in a worktree: one extra merge-base --is-ancestor at the front, and nothing else.
  • Main checkout / outside a worktree: unchanged — assertMainMerged self-skips, so the precondition is a no-op there exactly as the trailing check was.
  • The contract holds. failed_stage = "merge" and the DiscernResult shape are unchanged; only the moment it is determined moves. When behind, steps serialize as skipped rather than ok (they genuinely did not run).
  • The inner loop is unaffected. prepare (fix + check) and discern test never ran the merge check, so an agent behind main can still iterate locally; only done — the "am I done?" gate — fails fast.
  • Relationship to fix_drift: the strand check no longer runs before the merge check — the merge check now precedes it. 0047 is annotated accordingly.
  • Regression guard. A new engine test drives done in a worktree behind main and asserts failed_stage = "merge" with the expensive job reported skipped (not run) — pinning the fail-fast order so a future change cannot quietly drop the merge check back to the end.

Alternatives considered

§
  • Keep it last. Rejected — it is the status quo that burns the wasted run. Its apparent virtue ("see all your real failures first, update once at the end") is hollow: those failures must be re-surfaced against the updated tree on the forced re-run regardless, so checking them first against a doomed tree buys nothing.
  • Auto-update main inside done (git merge main when behind and clean, then gate the merged tree, so no re-run is needed). Rejected — it makes a pure check verb mutate the branch and can throw merge conflicts mid-gate, breaking discern's done-is-a-check / accept-is-the-mutation split. Fail-fast captures most of the win with none of the surprise.
  • Make it advisory (warn, but still run the stages). Rejected — a branch behind main is not "done", so the gate must stay red; and running the slow stages against a tree that is about to be replaced is the precise waste this removes.

Update — currency checks are preconditions too (consolidates ADR 0056)

§

ADR 0056 applied this same fail-fast-precondition pattern to the generated-artifact currency checks and is folded in here. The guidance and skills currency checks also run first, beside the merge check, before fix/build/check∥test/scope-gates: when a generated file is stale the gate sets failed_stage to guidance or skills and skips every downstream stage (which serialize as skipped). The --dry-run plan lists the currency gates right after the merge check, matching the run.

The verdict is invariant across a gate run for the same reason the merge check's is: the gate never runs discern refresh, and its fix stage formats source — never the guidance sources, the config, or the gitignored generated artifacts the checks read — so checking first returns the same answer as checking last. MISSING still does not block (only STALE does, per ADR 0034). The one check that stays after the fix stage is the fix-stage strand check: unlike the currency checks it reads a snapshot the fix stage produces, so it cannot move earlier. A regression test stales a generated file and asserts failed_stage = "guidance" with every step skipped.

choose openEsc close