Auto-Merge
When the Flywheel orchestrator is trusted to ship work without a human clicking MERGE, you can flip a single toggle and Overdeck will schedule eligible PRs for merge with a short cooldown window. During the cooldown an audible announcement fires, a cancellable banner appears in the dashboard, and the merge runs through the exact same code path as the MERGE button when the cooldown expires.Enabling
Auto-merge is gated by the globalrequire_uat_before_merge setting in the
dashboard’s app_settings store. Set it to false to turn auto-merge on. The
merge train must be on as well (merge_train_enabled: true): while it is off,
POST /api/merge-train/auto-merge/schedule refuses with
412 Merge train is disabled and the executor skips its tick.
There is no CLI for these settings. Change them in the dashboard or through
/api/merge-train/config:
GET returns all three settings: auto_pickup_backlog,
require_uat_before_merge, and merge_train_enabled. POST changes only the
keys in the body, and each value must be a boolean.
The same toggle is exposed in the dashboard’s Flywheel header as a checkbox.
The default is true (auto-merge OFF — operator UAT required before merge).
When the toggle is false, the dashboard schedules auto-merge for any issue
that satisfies the eligibility predicate. No flywheel run is needed: every
60 seconds, on the UAT batch-train reconciler tick, the primary dashboard walks
each project whose merge train is on and takes its open PRs with green checks
on a feature/<id> branch. A strike PR (strike/<id>) is never scheduled: the
operator merges it (PAN-3973). For each one it checks, in order:
- the issue is open in the tracker (the cached tracker row) and opted in to
auto-merge: its
auto-merge/hold-for-uatlabel, then the project default, then this toggle (no forge read); - the merge gate (
evaluateIssueMergeGate) says the PR is ready. Approval is a trustedoverdeck-verdictmarker comment whosesha=is the PR head, or a trusted reviewer’s standing GitHub review approving that exact commit, so a repo without branch protection still qualifies. “Trusted” is the marker rule:OWNER,MEMBERorCOLLABORATOR, or the identity Overdeck posts as. The App’s bot is the installed App’s own (<slug>[bot], from theapp-slugfile beside its credentials in~/.overdeck/github-app/, elseGET /app), matched as a GitHubBotaccount, never by login alone. Its reviews arrive asCONTRIBUTORand count through that match. With the GitHub App configured, an approving marker counts only from the App’s bot: agents hold the operator’sghcredentials and could post one as the owner. If the App’s slug cannot be read, no bot approval counts. A review from any other account never counts, and each reviewer’s latest review stands: a later “Request changes” or a dismissal withdraws an approval. An approval of an older commit, or a marker withoutsha=, never counts. A GitLab MR needs a named approver inapproved_by(whatglab mr approverecords); a green, conflict-free MR nobody approved is not approved, even where the project requires zero approvals; - the issue’s latest auto-merge entry allows a new one.
POST /api/merge-train/auto-merge/schedule.
GitLab projects are skipped, with one log line. OVERDECK_DISABLE_AUTO_MERGE=1
stops the scheduler as well as the executor.
Each entry records the PR head it was scheduled for. A pending or merging entry
is left alone. A cancelled entry stops the issue for good, whatever it is
pushed next: only re-scheduling it through the endpoint starts it again. A
failed entry (the merge was attempted, including the retry circuit breaker)
holds for that PR head only: a new push re-arms the issue. A blocked entry (the
executor refused it before merging, for example because checks went red or the
head moved during the cooldown) re-arms as soon as the merge gate passes again.
An entry written before heads were recorded never re-arms by itself. The insert
re-checks the latest entry, so a cancel that lands while the scheduler is
reading the forge sticks.
When the cooldown ends, the executor reads the tracker live and blocks the
entry if the issue was closed, and blocks it if the PR head moved. An entry
with no recorded head is blocked, never merged unpinned. The merge is bound to
the approved head and to the PR the gate judged:
- the final gate check reads the
feature/<id>PR fresh (no facts cache), and the merge refuses when the PR it would land is not that PR; - a clean PR merges directly only when its live head is the approved head,
pinned to it (
gh pr merge --match-head-commit, the App merge’ssha,glab mr merge --sha); - a PR that needs a rebase is rebased by the server only, and only when the
worktree and
origin/feature/<id>are both at the approved head. The server rebases exactly that commit in its own detached worktree, created at the approved head under a temporary path the work agent never uses and removed afterwards, so nothing the agent commits in its worktree meanwhile can become the rebased head. It moves the work agent’s worktree to the result for local verification (only while that worktree is still at the approved head), pushes the result by sha with a lease on the approved head, and pins the merge to it. It never hands the rebase to the work agent, whose push would merge unreviewed. The pushed head is new, so if verification or the merge then fails, the PR needs a fresh review before it can merge automatically again; - polyrepo merge sets and branches that track
.planning/files are left to the operator: an automatic merge refuses them; - a git read that fails while checking where the merge may start (a
fetchorrev-parsefailure) is retried within the merge retry budget instead of failing that head for good. A head that was read and differs fails it.
Per-project default
Every registered project’s HOME tab includes a Project settings panel, even when the project has no in-flight pipeline issues. Its auto-merge control offers ⚡ Auto, 🔒 Hold for UAT, and Global default, and the collapsed row shows the current auto-merge and swarming values at a glance. The project default applies only to issues without an explicit per-issue auto-merge setting. Changes are saved throughPOST /api/projects/:key/auto-merge-default and stored
in ~/.overdeck/projects.yaml.
Auto-merge vs. UAT batch trains. This toggle controls per-issue scheduled
auto-merge — Overdeck merging one eligible PR at a time without a human click.
The separate
merge_train_enabled setting enables UAT batch
trains, where ready features are assembled into one
tested batch you promote together. Auto-merge ships individual PRs on a
cooldown; batch trains assemble and promote a tested bundle. Auto-merge also
needs the merge train on: with merge_train_enabled off, nothing is scheduled
and nothing is merged.
Leave require_uat_before_merge at its default (true, auto-merge OFF) when you
want a human to UAT each batch before promoting it.Per-issue override
An issue can carry one of two tracker labels that take precedence over both the project default and the global setting:auto-merge: this issue auto-merges when eligible, whatever the project or global setting says.hold-for-uat: this issue waits for a human batch review, whatever the project or global setting says.
gh issue edit <n> --add-label auto-merge or --remove-label auto-merge). The dashboard reads it
from its cached tracker issues, so a change shows up after the next tracker
refresh.
Only you set these labels. Overdeck agents and the Flywheel cannot add or
remove auto-merge, hold-for-uat or released: their gh refuses the write
and tells them to ask you.
The dashboard’s auto-merge indicator (the ⚡ Auto / 🔒 Hold control on an
in-flight issue) shows the result of this resolution: issue label, then project
default, then global setting. It is read-only. Change the policy at the tier you
mean, not by clicking the indicator.
Eligibility
Overdeck stores no merge-ready flag. Eligibility is computed from the PR’s live facts each time it is checked (isAutoMergeEligible in
src/lib/cloister/auto-merge-eligibility.ts). A PR is eligible for auto-merge
only when all of these are true:
- The PR is open (not closed, not a draft, not already merged)
- The PR is approved on its exact head commit (a trusted reviewer’s standing
GitHub review of that commit, or a trusted verdict marker whose
sha=names it, posted by the GitHub App’s bot when the App is configured; on GitLab, a named approver inapproved_by), and no review or marker requests changes - The forge reports CI checks on the PR head, and all of them passed. Pending
checks, failing checks, and no reported checks each make the PR ineligible.
When the project runs
verification.tests: ci, the CI test job must also have run and passed on that head. - The forge reports the PR as
mergeable(no merge conflicts, branch protection satisfied). A PR whose mergeability the forge has not computed yet is not eligible. - The issue is not held for UAT (its label, then the project default, then the global setting; see above)
- None of the blocker labels are present on the issue:
needs-designneeds-discussiondo-not-merge
do-not-merge label
while the timer was running) gets surfaced as a blocked entry instead of
merged.
Cooldown
Cooldown is fixed at 5 minutes of wall-clock time from when the scheduler schedules the merge to when the executor fires. The countdown is persisted in SQLite, so it survives dashboard restarts — if the dashboard sleeps through the deadline, the executor still picks the entry up on the next tick and re-validates eligibility before firing. The cooldown is not configurable today; it’s a deliberate single-knob value that gives the operator a predictable cancel window.Cancelling
Three ways to cancel an in-flight auto-merge during its cooldown:- The Pending auto-merges card on the dashboard’s Flywheel page has a Cancel button.
-
DELETE /api/merge-train/auto-merge/:idagainst the dashboard. The route needs the dashboard’s internal token (thex-overdeck-internal-tokenheader) or a dashboard session. -
The CLI:
This calls the DELETE endpoint, removes the entry from the pending list, drops any waiting entry for the issue from the project merge queue, and emits an
auto-merge cancelled for PAN-123TTS announcement so the operator knows the abort took effect.
merging. Once the executor has started running
the merge there is nothing to cancel — at that point it’s the same code path
as clicking MERGE.
Queue interaction
The dashboard runs at most one merge at a time per project. An automatic merge never joins the project merge queue: when another merge holds the slot,triggerMerge() defers it, and the entry is requeued with a short backoff
(scheduledMergeAt bumped by 60 seconds, status reverted to pending) without
using its retry budget. The next executor tick re-runs every check (the gate,
the head, the labels, the tracker) and tries again. A queued merge would start
later with none of them. Stale merging rows are prevented by this requeue path; the executor
never leaves a row stuck mid-flight when triggerMerge() returns a
non-terminal status.
Inspecting state
Failure handling
When the executor fires and the merge fails (e.g. surface-level rebase conflict raised at merge time), the row is moved tofailed with the failure
reason captured, a TTS announcement plays, and the orchestrator surfaces the
entry as an investigate suggestion in its next tick snapshot. The operator
can then either fix the underlying problem and reschedule, or cancel the
failed entry to clear it.
Related
GET/POST /api/merge-train/config: read and writerequire_uat_before_merge,merge_train_enabled, andauto_pickup_backlog(no CLI)pan merge cancel <id>— cancel a pending auto-mergeroles/flywheel.md— the orchestrator’s contract; references the toggle when deciding whether to call the scheduler vs. emitting amergesuggestion for human action.