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 side — gated_merge.sh,
which runs at the top of every sweep — and only after it
re-runs a set of deterministic gates as hard preconditions.
Why merge authority moved off the worker
Section titled “Why merge authority moved off the worker”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 merge-ready handoff
Section titled “The merge-ready handoff”The handoff from worker to dispatcher is a single GitHub label, echo:merge-ready:
-
The worker adds
echo:merge-readyas its genuine last action (Step 9), after every commit — including any lesson orCLAUDE.mdedit — has been pushed. It is added late on purpose:gated_merge.shsquash-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. -
gated_merge.shlists AI-reviewing items and, for each, looks for an open PR on<branchPrefix><issue#>(defaultagent/issue-<N>) carrying that label:gh pr list --head agent/issue-<N> --state open --label echo:merge-ready -
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.
The deterministic gates, in order
Section titled “The deterministic gates, in order”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:
-
Cross-repo sentinel. If
checkout_supporting_repo.shdropped<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. -
Auth / tenant-isolation (
sensitive_path_gate.sh --pr <PR>). Matches the PR’s changed paths against theauthIsolationregexes. Exit0means a path matched → hold (a human reviews every auth/isolation PR). Exit>= 2(couldn’t fetch the diff, grep error) is fail-safe → hold. Exit1(clean) is the only pass. -
Test coverage (
check_test_coverage.sh <PR>). Exit20means the diff exceeded the line threshold with no test file /## Test justificationand touched an auth/tenant path → hold. Exit0(under threshold or satisfied) and exit10(tripped but non-blocking — just taggedneeds-tests) both pass; any other code is fail-safe → hold. -
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.
merge.mode — auto vs. human
Section titled “merge.mode — auto vs. human”After the gates pass, merge.mode decides the last step:
auto(the default) →gated_merge.shsquash-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.
Approving a held PR from Discord
Section titled “Approving a held PR from Discord”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.
Fail-safe posture
Section titled “Fail-safe posture”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.
See also
Section titled “See also”- The run lifecycle — where the gated merge sits in the sweep.
- Cross-repo changes — the sentinel and forced human-merge hold.
- Discord ops — the
approve→ merge path. - Config reference —
merge,authIsolation,testCoverage,gatedPaths.