Skip to content

Merge gates

Echo’s workers never merge. A worker opens its PR, runs an adversarial review, labels the PR echo:merge-ready, and stops with the item in AI reviewing. The merge itself happens on a trusted dispatcher sidegated_merge.sh, which runs at the top of every sweep — and only after it re-runs a set of deterministic gates as hard preconditions.

A worker’s review is LLM output judging LLM output. Treating it as the merge gate would let a compromised or hallucinating worker talk itself into merging. So the trust model splits in two:

  • Deterministic gates are enforced dispatcher-side. They are shell scripts that inspect the PR’s changed-file set and CI status — not model opinion. A worker cannot bypass them, because the worker has no merge command at all (it is mechanically denied one).
  • Adversarial-review findings are worker-advisory. The worker posts and files them, but they do not gate the merge. They surface work; they do not authorize it.

The handoff from worker to dispatcher is a single GitHub label, echo:merge-ready:

  1. The worker adds echo:merge-ready as its genuine last action (Step 9), after every commit — including any lesson or CLAUDE.md edit — has been pushed. It is added late on purpose: gated_merge.sh squash-merges and deletes the branch immediately on seeing the label (there is no --auto), so labelling early could merge-and-delete the branch mid-run and drop later commits.

  2. gated_merge.sh lists AI-reviewing items and, for each, looks for an open PR on <branchPrefix><issue#> (default agent/issue-<N>) carrying that label:

    gh pr list --head agent/issue-<N> --state open --label echo:merge-ready
  3. If it finds one, it runs the gates below. If it finds none, a stale-review guard checks whether the worker died before labelling (dead PID, past a grace period, an open-but-unlabelled PR exists) and escalates only then — every uncertain case is skipped, never falsely escalated.

gated_merge.sh runs these as hard preconditions. Any gate that trips — or errors — holds the item for a human (via flip_to_needs_human.sh) rather than merging. The order is fixed:

  1. Cross-repo sentinel. If checkout_supporting_repo.sh dropped <state_dir>/cross-repo-touched.<N> for this issue, the change spans repos and is held: a human merges the sibling PR(s) first. See Cross-repo changes.

  2. Auth / tenant-isolation (sensitive_path_gate.sh --pr <PR>). Matches the PR’s changed paths against the authIsolation regexes. Exit 0 means a path matched → hold (a human reviews every auth/isolation PR). Exit >= 2 (couldn’t fetch the diff, grep error) is fail-safe → hold. Exit 1 (clean) is the only pass.

  3. Test coverage (check_test_coverage.sh <PR>). Exit 20 means the diff exceeded the line threshold with no test file / ## Test justification and touched an auth/tenant path → hold. Exit 0 (under threshold or satisfied) and exit 10 (tripped but non-blocking — just tagged needs-tests) both pass; any other code is fail-safe → hold.

  4. CI status (gh pr checks <PR>). A non-zero exit holds, except the distinct “no checks reported on the <branch> branch” case — a project with no CI configured must still auto-merge, so that string is treated as a pass. Only genuinely failing or pending checks hold.

Because the gates run first and continue on a hold, a held PR always carries the specific gate reason. Only a PR that clears all four reaches the merge.mode decision.

After the gates pass, merge.mode decides the last step:

  • auto (the default) → gated_merge.sh squash-merges the PR with --delete-branch. reconcile.sh, running right after in the same sweep, routes the merged item onward.
  • human → the item is held for a human with reason “ready to merge — human merge requested”. Gate precedence still holds: a gate-failing PR was already held with its own reason before this check, so this reason only ever lands on an otherwise-clean PR.
// config: opt every merge to a human, gates still enforced first
"merge": { "mode": "human" }

Either way, if gh pr merge itself fails (branch protection, a conflict, an auth blip), the item is held rather than left silently unmerged.

A held PR sits in Needs Human with an open, echo:merge-ready-labelled branch. An allowlisted operator can merge it by replying approve (or merge) in Discord — route_replies.sh merges the open PR directly instead of re-queuing a worker that would just re-hit the gate. See Discord ops.

gated_merge.sh is a sweep leaf: it always exits 0, and any gate uncertainty holds — it never auto-merges on doubt. A gate that errors is treated identically to a gate that tripped. The worst case is a human glancing at a benign PR; the case the design refuses is auto-merging an auth/isolation change unseen.