Skip to main content

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.
Solo Overdeck workflows only. Auto-merge moves the merge-consent decision from per-PR click to a configuration toggle. That trade is appropriate when the operator is the only human in the loop. In a shared Overdeck instance it shifts consent away from the person who would normally click MERGE — not appropriate for team workflows.

Enabling

Auto-merge is gated by the global require_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:
  1. the issue is open in the tracker (the cached tracker row) and opted in to auto-merge: its auto-merge / hold-for-uat label, then the project default, then this toggle (no forge read);
  2. the merge gate (evaluateIssueMergeGate) says the PR is ready. Approval is a trusted overdeck-verdict marker comment whose sha= 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, MEMBER or COLLABORATOR, or the identity Overdeck posts as. The App’s bot is the installed App’s own (<slug>[bot], from the app-slug file beside its credentials in ~/.overdeck/github-app/, else GET /app), matched as a GitHub Bot account, never by login alone. Its reviews arrive as CONTRIBUTOR and count through that match. With the GitHub App configured, an approving marker counts only from the App’s bot: agents hold the operator’s gh credentials 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 without sha=, never counts. A GitLab MR needs a named approver in approved_by (what glab mr approve records); a green, conflict-free MR nobody approved is not approved, even where the project requires zero approvals;
  3. the issue’s latest auto-merge entry allows a new one.
It schedules through the same door as 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’s sha, 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 fetch or rev-parse failure) is retried within the merge retry budget instead of failing that head for good. A head that was read and differs fails it.
A commit made or pushed after the approval therefore fails the merge instead of landing code nobody approved.

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 through POST /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.
Add or remove the label in the tracker (for GitHub, 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 in approved_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-design
    • needs-discussion
    • do-not-merge
Eligibility is re-checked when the cooldown expires. A PR that became ineligible during the cooldown (e.g. someone added a 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:
  1. The Pending auto-merges card on the dashboard’s Flywheel page has a Cancel button.
  2. DELETE /api/merge-train/auto-merge/:id against the dashboard. The route needs the dashboard’s internal token (the x-overdeck-internal-token header) or a dashboard session.
  3. 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-123 TTS announcement so the operator knows the abort took effect.
Cancellation only succeeds during the cooldown window, before the executor has transitioned the row to 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

The Pending auto-merges card on the dashboard’s Flywheel page shows the pending list with a countdown and a Cancel button. The dashboard does not show the problems list; read it from the endpoint.

Failure handling

When the executor fires and the merge fails (e.g. surface-level rebase conflict raised at merge time), the row is moved to failed 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.
  • GET / POST /api/merge-train/config: read and write require_uat_before_merge, merge_train_enabled, and auto_pickup_backlog (no CLI)
  • pan merge cancel <id> — cancel a pending auto-merge
  • roles/flywheel.md — the orchestrator’s contract; references the toggle when deciding whether to call the scheduler vs. emitting a merge suggestion for human action.