---
title: PR Merge Queue (part 2)
---

# PR Merge Queue (part 2)

## What it is

This is part 2 of the [PR Merge Queue](pr-merge-queue.md) page. That page covers what the queue is, where to find it, and the day-to-day path — reading the lanes, starting a merge, archiving a PR, batching several. This half carries the machinery behind it: what the feature persists, the guards and instructions that shape a merge prompt, the modules triage runs in, and the auto-merge daemon.

## Where to find it

Everything here is reached from the same two places as the [main page](pr-merge-queue.md) — the Settings → PR Merge Queue form, which holds the stored config, the standing instructions and the editable prompts, and the queue view itself, whose Merge and Merge batch together buttons the sections below describe.

## How it behaves

### What gets persisted

- `AppSettings.prMergeQueueRepos: PrMergeQueueRepoConfig[]` — the repo configs. Lives in `config.json`, encrypted via OS keyring only if individual fields are sensitive (no secrets in the config shape itself — `gh` auth lives in the system's `gh` config). Each config now carries an optional `standingInstructions: string` (the per-repo standing merge guidance, capped at 4000 chars) plus optional per-repo prompt overrides `mergePromptTemplate` / `batchPromptTemplate` (capped 20,000 chars each — see **Editing the merge prompt**).
- `AppSettings.prMergeQueueMergePromptTemplate` / `prMergeQueueBatchPromptTemplate` — the **global** editable merge / batch prompt overrides (capped 20,000 chars each). Unset = use the built-in default. See **Editing the merge prompt**.
- `AppSettings.prMergeQueueArchivedPrs: Record<projectId, prNumber[]>` — the PRs you've **archived** (personally dismissed), keyed per repo. Lives in `config.json`. Archived PRs are hidden from the lanes and excluded from **both** the Inbox `reviewNeededCount` **and** `stuckCount` (the 2026-06-02 `archive-clears-both-inbox-counts` reversal — archiving now clears a PR everywhere, stuck merges included). Zod-capped at 500 PRs per repo (and 100 repos in the map). See **Archiving (dismissing) a PR**.
- Per-merge: a regular Omniscio session row with `source: 'pr-merge-queue'` (single PR) or `source: 'pr-merge-queue-batch'` (batch). Worktree path, name (`Merge PR #N: <title>` or `Merge batch: #a #b …`), and the standard session lifecycle. Daemon-spawned merges use `source: 'pr-merge-queue-auto'`.
- Per-repo snapshot cache: one row per configured repo in `pr_merge_queue_snapshots` (PK = `project_id`), written by the background poll. Holds the full last-fetched snapshot JSON plus the two attention counts (`reviewNeededCount`, `stuckCount`), a `fetchOk` flag, and `fetchedAt`. This is what makes the view open instantly; the per-PR Inbox rows are derived from it by enriching each snapshot **on read** with the actual review-needed / stuck PR numbers (no GitHub round-trip — see **Inbox nudges**). It's an ephemeral derived cache — deleting it just forces the next poll to rebuild it.

### A PR being handled is never re-spawned (in-flight dedup)

Once a merge session is **live** for a PR, that PR is treated as **being handled** and the queue will not spawn — or re-offer — a second merge session for it. This holds across all three ways a merge can start: the automatic daemon, the per-PR **Merge** button, and the **Merge batch together** button. Without this, a refresh or an app restart could spawn a duplicate "Merge PR #N" session for the same still-open PR.

What "live" means: a merge session exists for the PR and has not reached a terminal state. A session is **no longer** live once it **ends, errors out, is archived, or gives up** (writes its give-up file). Only then does the PR become actionable again — so a merge that genuinely failed or was abandoned is correctly re-offered, while one that's still working is left alone. (Pausing, running, "needs you", and similar in-progress states all still count as live.)

How each entry point behaves when a PR is already being handled:

- **Daemon (auto-merge).** Skips the PR for that tick and logs the reason — exactly as before.
- **Merge button (single PR).** Instead of spawning a duplicate, the response carries an `alreadyInFlight` flag pointing at the **existing** merge session already running for that PR. As with a fresh spawn, Omniscio does NOT auto-focus it (no takeover, `a-spawned-merge-never-steals-the-active-session`) — the success toast's **Open** action takes you to that running session if you want.
- **Merge batch together.** A batch applies all its PRs together in one worktree, so overlapping with a separate live merge for any member is unsafe. If **any** PR in the batch already has a live merge session, Omniscio **refuses the whole batch** with a message naming the in-flight PR numbers ("Cannot batch-merge: PR(s) #12, #15 already have a running merge session. Wait for them to finish or resolve them first."). Nothing is spawned and no audit rows are written — wait for those merges to finish (or resolve them) and try the batch again.

This is enforced by a single shared guard (`getActiveRunForPR`) keyed on the run's session liveness, so the daemon and the two manual paths can never disagree about whether a PR is in flight.

### Custom merge instructions

You can attach your own guidance to merges at **two levels**. Both are rendered into the merge prompt as a single advisory **"Operator instructions"** block placed _after_ the hard Rules — it is explicitly **advisory and never overrides the merge-safety rules** (no force-push, no skipped hooks, intent-based conflict resolution, etc., all still hold).

- **(a) Per-repo standing instructions.** A **Standing merge instructions** textarea in the repo's settings (Settings → PR Merge Queue, in the repo's add/edit form). This text is appended to **every** merge for that repo — manual, automatic (daemon), and batch. Example placeholder: _"Always squash-merge. Prefer rebasing onto base before merge."_
- **(b) Per-merge / per-batch extra instructions.** An **opt-in** box revealed by the **Add instructions** toggle on each PR card (and on the batch bar). Collapsed and empty by default, so the common case stays one-click; what you type applies only to that one merge (or that one batch).

When both are present, **standing instructions come first, the per-merge/per-batch text second**. Each field is capped at **4000 characters**. If neither is set, no operator block is added to the prompt at all. (The auto-merge daemon deliberately passes no per-merge text, so daemon merges get standing-only guidance.)

### Editing the merge prompt

The **Operator instructions** above are _advisory_ and ride on top of the hard rules. If you want to change the rules themselves — the entire prompt the merge session receives — you can **edit the prompt directly**.

In **Settings → PR Merge Queue → Merge prompts** there are two editable boxes:

- **Single-PR merge prompt** — sent on every **Merge** click.
- **Batch merge prompt** — sent when you use **Merge batch together**.

Each box is pre-filled with the built-in default text. Edit it freely and it saves when you click away; a **Reset to default** button restores the built-in version (the one that prepares the worktree and never touches your base branch). PR-specific details are injected wherever a `{{placeholder}}` appears — the legend under each box lists them (`{{pr_number}}`, `{{pr_title}}`, `{{base_branch}}`, `{{worktree_path}}`, plus `{{pr_list}}` / `{{pr_count}}` for batches, etc.). A `{{token}}` you delete simply doesn't get filled; an unknown token is left as-is so you can spot a typo. PR data is substituted in a single pass, so a `{{token}}`-looking string inside a PR title can't itself be re-substituted.

**Per-repo override.** Each repo's add/edit form has an **Override the merge prompt for this repo (advanced)** disclosure with its own single-PR and batch boxes. A non-blank override wins over the global prompt for that repo; leave it blank to inherit the global one. Resolution at merge time is **per-repo override → global custom → built-in default**.

**Two cautions.**

- The boxes edit the **whole** prompt, including the safety rules. The built-in default is the version that never advances or pushes your base branch (`master`); if you delete those rules, nothing else stops a merge session from doing so. A small "custom prompt in use" warning shows whenever an override is active.
- A few pieces are **required** and cannot be edited away: the immutable-head check, the never-touch-the-base and never-push rules, and — since 2026-09-23 — the **bounded merge** (`npm run pr:merge-bounded`) and the **merge-scope check** (`npm run audit:pr-merge-scope`) that go with them. If your saved prompt is missing any of them, the box flags it in red and names exactly what is missing, and Omniscio quietly keeps using the built-in default instead of your text until you put the lines back. A prompt customised before this date that predates the two merge lines therefore needs **one** re-save; the warning above the box tells you when.
- A saved custom prompt is a **frozen copy** — if a future Omniscio update improves the built-in default (e.g. a new safety rule), your custom text will **not** pick it up automatically. **Reset to default** re-syncs you to the current built-in.
- The built-in default also tells the session to **end with a plain-language "What this PR does" summary** (plus an "anything to watch out for" line) for a non-technical reader, above the technical details (contract `the-default-prompts-demand-a-plain-language-report`). If you rewrite the prompt and want that plain-English write-up, keep that section.

Stored as `AppSettings.prMergeQueueMergePromptTemplate` / `prMergeQueueBatchPromptTemplate` (global) and `PrMergeQueueRepoConfig.mergePromptTemplate` / `batchPromptTemplate` (per-repo), each capped at 20,000 characters — the textarea itself stops at the cap, so an over-long paste is trimmed as you paste instead of being refused on save with a generic "Could not save that setting". The standing-instructions box is bounded the same way at 4,000. Both numbers live in one place, `PR_MERGE_PROMPT_TEMPLATE_MAX_LENGTH` / `PR_MERGE_STANDING_INSTRUCTIONS_MAX_LENGTH` in `src/shared/types/pr-merge-queue.ts`, read by both the input bound and the Zod rule so the two cannot drift. The default text + placeholder legend live in `src/shared/pr-merge-queue-prompt-templates.ts`; token substitution + compose in `src/main/services/pr-merge-queue/prompt.ts`.

### How it works

The implementation is split between four pure-logic modules (testable in pure Node) and an I/O boundary:

- **`src/main/services/pr-merge-queue/triage.ts`** — `profilePR(repo, summary, { log, fetchFullPR })` enriches a PR list summary with size / risk / CI status / overlapping-PR detection. `detectOverlaps(profiles)` and `detectDependencies(profiles)` run a second pass to wire `overlapsWith[]` and `dependsOn[]` between PRs in the snapshot.
- **`src/main/services/pr-merge-queue/orchestration.ts`** — `planLanes(profiles, repo, { log })` runs the 8-lane decision tree. `packBatch()` / `nextExecutableGroup()` group the non-overlapping batch members (now wired — they back the **Merge batch together** button).
- **`src/main/services/pr-merge-queue/minimatch-lite.ts`** — a tiny glob matcher used for `riskyPaths` matching. Avoids pulling in `minimatch` as a dependency for one code path.
- **`src/main/services/pr-merge-queue/prompt.ts`** — `composeMergePrompt({ pr, lane, baseBranch, worktreePath, extraInstructions, ... })` builds the single-PR merge instructions; `composeBatchMergePrompt({ prs, baseBranch, worktreePath, extraInstructions })` builds the batch instructions (PRs rendered in apply order, "test once" rules). Both pure functions; both render the operator-instructions block from a byte-identical shared header. What Claude sees is what these return.
- **`src/main/services/pr-merge-queue/spawn-merge-session.ts`** — `spawnMergeSession()` and `spawnBatchMergeSession()`. The single source that reads `repo.standingInstructions` and concats standing-first + per-merge/per-batch-second into the operator block (D8). Omniscio runs **no git** — it names the worktree via `computeWorktreeTarget` (pure) + records it with `setSessionWorktree`, and the spawned agent builds it (prompt RULE 1) then brings the PR in per the rule carried in the prompt (see the note under **How to use it**). (Pre-2026-06-02 Omniscio built the worktree itself in-process; that 17k-file checkout timed out under load — contract `the-agent-builds-its-own-worktree`.)
- **`src/main/services/pr-merge-queue/auto-merge-daemon.ts`** — despite the name, this is the **queue-poll daemon**: each tick refreshes + persists every repo's snapshot (always, when the feature is on) and then runs the gated auto-merge step for autonomous repos. See **Background auto-refresh** and **Auto-Merge Daemon**.
- **`src/main/services/pr-merge-queue/attention-summary.ts`** — the single source for "what counts as needing a human": `countReviewNeeded(snapshot, archived?)` (the review-needed lane count, minus any archived PRs) and `recomputeAndPersistFromCache(repo, archived)` (re-derive a repo's counts from the saved snapshot and push-on-change, with **no** GitHub fetch — what makes archiving quiet the Inbox instantly). Pure of config-store; the archived set is always passed in. See **Archiving (dismissing) a PR**.
- **I/O boundary:** `src/main/services/pr-merge-queue/gh-cli.ts` wraps `gh pr list` / `gh pr view` with timeouts; `src/main/services/pr-merge-queue/queue-service.ts` is the top-level orchestrator (`refreshQueue(cfg)` and `fetchOnePR(slug, n)`); `src/main/db/queries-pr-merge-queue-snapshots.ts` owns the snapshot-cache reads/writes; `src/main/ipc/pr-merge-queue-handlers.ts` is the IPC handlers (`LIST_REPOS` / `REFRESH` / `MERGE_PR` / `MERGE_BATCH` / `DETECT_REPO_INFO` / `LIST_SNAPSHOTS`).

The merge spawn is the interesting one, and its whole point is what it does **not** do. `spawnMergeSession()`:

1. Creates the session with **`isolationOverride: false`** and no `initialPrompt` — deliberately WITHOUT Omniscio worktree isolation, so it spawns idle and Omniscio never runs the checkout.
2. **Names** (does not build) the worktree via `computeWorktreeTarget(...)` and records it with `setSessionWorktree(...)`. Pure — no git, no fs.
3. Attaches the give-up monitor **before** sending the prompt (it polls, so attaching before the directory exists is fine) — an immediate give-up is never missed.
4. Concats the operator block standing-first / per-merge-second (D8), composes the prompt, and sends it via `sessionService.sendResponse({ sessionId, text: prompt })`.

The spawned agent then builds the worktree itself and checks out the PR branch, per prompt RULE 1. **Omniscio runs no git for the merge at all** — no `git fetch`, no `git switch`, no `git worktree add`. Pre-2026-06-02 it did build the worktree in-process; that ~17k-file checkout timed out under load and made merges fail to even start (contract **`the-agent-builds-its-own-worktree`**). Do not "restore" it.

**Pure-logic injection:** the three pure modules don't import `electron-log` directly — they take an injected `MqLogger` via a parameter. `queue-service.ts` wires the real `electron-log` instance in. This lets the unit tests run in pure Node without Electron's main process in scope.

### Lanes — the decision tree (summary)

1. **reject** — `mergeStateStatus === 'DIRTY'` or `mergeStateStatus === 'BLOCKED'`, or CI failing, or `isDraft`.
2. **hold** — `dependsOn[]` is non-empty (another open PR must merge first).
3. **conflict-likely** — `overlapsWith[]` is non-empty AND the overlapping PR is older / has more activity.
4. **risky** — any file in `filesChanged[]` matches a `riskyPaths` glob.
5. **escalate** — `sizeLines > ESCALATION_THRESHOLD` or `riskScore > ESCALATION_THRESHOLD`.
6. **batch** — small PR that pairs cleanly with another small PR for batched merge.
7. **fast** — small, CI green, no risky paths, trustLevel `auto` → auto-merge candidate.
8. **standard** — anything that doesn't trip the above rules.

(Thresholds live in `orchestration.ts` — read the source for exact numbers.)

### IPC channels

- `PR_MERGE_QUEUE_LIST_REPOS` — read `AppSettings.prMergeQueueRepos`. Returns `{ repos: PrMergeQueueRepoConfig[] }`.
- `PR_MERGE_QUEUE_REFRESH({ projectId })` — fetch a snapshot for one repo. Returns `PrMergeQueueSnapshotDTO` with `profiles[]` + `decisions[]` + `partialFetches[]` (PR numbers whose enrichment fetch failed).
- `PR_MERGE_QUEUE_MERGE_PR({ projectId, prNumber, extraInstructions? })` — spawn the single-PR merge session. `extraInstructions` (optional, ≤4000 chars) is the per-merge guidance from the card's **Add instructions** box. Returns `{ sessionId, alreadyInFlight? }` — when a merge session for this PR is **already live**, it does NOT spawn a second one and instead returns the existing `sessionId` with `alreadyInFlight: true` (the renderer focuses that session). See **A PR being handled is never re-spawned**.
- `PR_MERGE_QUEUE_MERGE_BATCH({ projectId, prNumbers, extraInstructions? })` — spawn ONE session that merges a related group together. `prNumbers` requires **≥2** (a 1-PR batch is just `MERGE_PR`) and is capped at 100; `extraInstructions` (optional, ≤4000) is the per-batch guidance. Returns `{ sessionId }`. If **any** member already has a live merge session, the call is **refused** (`{ success: false, error: '…already have a running merge session…' }`) without spawning — see **A PR being handled is never re-spawned**.
- `PR_MERGE_QUEUE_DETECT_REPO_INFO({ projectId })` — local-git inspection of an Omniscio project's folder. Returns `{ ghSlug, defaultBranch, projectName }` with any field nullable. Fired by the Settings form when the user picks a project in create mode, never throws — non-GitHub remotes, missing folders, and git failures all resolve to `{ null, null, null }`. Pure git, no `gh` auth needed. Logic lives in `src/main/services/pr-merge-queue/detect-repo-info.ts` with pure-function URL/ref parsers (`parseGitHubSlug`, `parseDefaultBranchRef`) covered by unit tests.
- `PR_MERGE_QUEUE_LIST_SNAPSHOTS({ projectIds? })` — read each repo's last-persisted snapshot summary from the cache (no GitHub fetch), used to hydrate the view's freshness/count state on open.
- `PR_MERGE_QUEUE_CHANGED` — push event signalling the repo set / config changed so the view + sidebar reload.
- `PR_MERGE_QUEUE_GIVE_UP` — push event emitted when a merge session gives up (so the UI can surface the give-up).
- `PR_MERGE_QUEUE_SNAPSHOT_UPDATED` — push event emitted by the background poll when a repo's persisted snapshot summary changes (or on its first-ever row). Payload: `{ projectId, reviewNeededCount, stuckCount, fetchOk, fetchedAt }`. Drives the open view's live-update + freshness label and prompts the Inbox to reload its per-PR rows (re-fetching the enriched snapshot). Emitted **on change only**, so an idle repo doesn't wake the renderer.
- `PR_MERGE_QUEUE_REFRESH_PROGRESS` — push event streamed during a manual Refresh: `{ enriched, total }` per enriched PR, surfaced as the "Refreshing… (_enriched/total_)" count on the Refresh button.
- `PR_MERGE_QUEUE_AUTO_MERGE_FIRED` — push event emitted by the auto-merge daemon every time it spawns a fast-lane merge session (also fired in dry-run mode with `dryRun: true`). Payload: `{ ghSlug, prNumber, lane, sessionId?: string, dryRun: boolean, reason?: string }`. UI consumers can subscribe to flash a toast or refresh the view.

### Auto-Merge Daemon

The daemon lives in `src/main/services/pr-merge-queue/auto-merge-daemon.ts`. Boot starts it from `src/main/index.ts` after `startRecipeScheduler()`; graceful shutdown calls `stopAutoMergeDaemon()` before `stopAllGiveUpMonitors()`.

> **⚠️ Behavior note (2026-05-30 master-safety change).** Spawned merge sessions now **prepare a worktree and stop** — they never advance, merge into, or push the base branch (see **What it is**). The daemon still _spawns_ a session for fast-lane PRs (one per PR by default, or one per group when **auto-batch** is on), but that session no longer completes the merge, so the PR stays open and a later tick can spawn for it again. **Recommendation: keep the Auto-Merge Daemon OFF** (its default) until it is reworked into a prepare-and-notify flow — auto-spawning sessions that can't close their own PR just burns budget. The background auto-refresh + Inbox layer is independent of this and remains useful on its own.

This is the same periodic task as the **Background auto-refresh** poll — each tick first refreshes + persists every repo's snapshot (always, when the feature is on), then runs the gated auto-merge step below **only for autonomous repos with the master flag on**, reusing the snapshot the refresh step already fetched (it never re-fetches). The auto-merge gate chain is byte-for-byte the prior daemon's logic; the refresh/persist/Inbox layer is what's new around it.

**Gate chain per repo per tick** (failing any gate logs the reason and moves on — never throws):

1. **Master flag check** — `AppSettings.prMergeQueueAutoMergeEnabled` must be `true`. The flag default is `false`.
2. **Trust check** — repo's `trustLevel` must be `'autonomous'`. The two other levels (`'pause-and-ask'`, `'fast-lane-only'`) never trigger an auto-spawn even though `'fast-lane-only'` sounds related — that level is reserved for a future per-card "click to merge fast lane only" UX.
3. **Snapshot** — reuses the snapshot the tick's refresh step already fetched (the same `refreshQueue(toRepoQueueConfig(repo))` the view calls) rather than re-fetching, so the always-on poll and the auto-merge step share one `gh` round-trip per tick.
4. **Fast-lane pick** — only `decisions[].lane === 'fast'` PRs are auto-mergeable. `pickAutoMergeable(decisions)` filters the list and is exported for testing.

   **4a. Target selection — sharding + auto-batch.** Both shipped 2026-08-16 and both default to `false`, so with default settings this step is a pass-through and steps 5–8 read exactly as written. With either on, `selectAutoMergeTarget({ decisions, profiles, shard, autoBatch, isInFlight })` runs BEFORE the per-PR gates below and changes what "the target" even is:
   - **Sharding** splits the queue across machines: this box only auto-spawns for PRs whose number modulo the shard count equals its shard index. An eligible PR another shard owns is a benign no-op (`notThisShard`), not a failure, so an idle minority shard never trips the stall alert. Mechanism + settings: [settings-reference.md](settings-reference.md).
   - **Auto-batch** combines several eligible (fast-lane, non-overlapping, this-shard, not-in-flight) PRs into ONE merge session that applies them together and runs the gate ONCE — up to `prMergeQueueAutoBatchMaxSize` (default 8). It returns `selected.kind === 'batch'` and takes the `spawnAutoBatch` path: claims all, budget-checks once, spawns one session, records N audit rows. So with auto-batch on, step 8's single `spawnMergeSession` is not what runs.

   Manual merges (`MERGE_PR` / `MERGE_BATCH`) are never sharded or auto-batched.

5. **Active-run check** — `getActiveRunForPR(slug, prNumber)` queries `pr_merge_queue_runs` for an in-progress row: outcome 'spawned', not yet completed, AND whose session is still **live** (a real session row that isn't deleted and isn't `ended`/`error`/`archived`). Skips if found. This same guard is shared by the manual Merge and Batch buttons — see **A PR being handled is never re-spawned** below.
6. **Budget check** — `getDailySpendForRepo(slug, startOfDayUtcIso())` sums the spend of the DISTINCT sessions the daemon spawned for this repo since the start of the UTC day, reading each session's own `cost_micro_usd` (what `updateSessionCost` meters a merge turn onto), scoped to `triggered_by = 'auto'` so a manual `MERGE_PR` spawn doesn't eat the daemon's budget. Skips if `dailySpend >= dailyBudgetUsd`.

   > **This gate used to be INERT, and this page described the broken version as current truth.** The original query joined `pr_merge_queue_runs` to `api_cost_log` filtering `source = 'pr-merge-queue-auto'` — a source label **nothing ever writes**, because a merge session's spend is metered onto the `sessions` row, never into `api_cost_log`. The JOIN matched zero rows, the SUM was ~0, and `spent >= cap` never tripped: the advertised daily autonomous-spend ceiling did not exist. Two things worth carrying from that: merge spend is **not** in `api_cost_log`, so don't reach for that table to reconcile it; and the sum is over DISTINCT SESSIONS on purpose, because an auto-batch spawns ONE session but writes N run rows (one per PR, sharing a `session_id`), which a per-run sum would count N times against the cap.

7. **Pre-flight `gh pr view` re-fetch** — guards against staleness. If `state !== 'OPEN'` (closed/merged since refresh), or `isDraft` flipped on, or `mergeStateStatus` is **anything other than `'CLEAN'`**, skip.

   > **"Anything other than CLEAN" is wider than the four states this page used to list** (`DIRTY` / `BLOCKED` / `BEHIND` / `UNKNOWN`). The code is `if (preflight.mergeStateStatus !== 'CLEAN')`, so it ALSO skips `HAS_HOOKS` and `UNSTABLE`. Whitelisting-by-omission is the right safety posture — a state we have not reasoned about should not auto-merge — but it has a consequence worth knowing: GitHub documents **`HAS_HOOKS` as a MERGEABLE state** ("mergeable with passing commit status and pre-receive hooks"), so **a repo with pre-receive hooks never auto-merges anything**. The operator sees a permanently idle daemon and a `skipped-conflict` audit row with no hint why. If that is you, the fix is not to widen this gate blindly — it is to decide deliberately whether a hook-gated repo should auto-merge at all.

8. **Spawn (or dry-run log)** — `dryRun: true` emits the push event with `dryRun: true`, logs, returns. Otherwise calls `spawnMergeSession({ repo, prNumber, lane, laneReasons, triggeredBy: 'auto' })`, inserts a `pr_merge_queue_runs` row with `outcome: 'spawned'`, emits `PR_MERGE_QUEUE_AUTO_MERGE_FIRED`, calls `trackEvent('pr_merge_queue_auto', 'fired', { ghSlug, lane })`.

**Crash recovery.** On daemon startup, `reconcileCrashedRuns()` finds `pr_merge_queue_runs` rows with `outcome: 'spawned'` AND `completed_at IS NULL` whose linked session has reached a terminal status (`ended` / `error` / `archived`) or was soft-deleted, and closes them by setting `completed_at`. It's a tidy-up of the audit ledger; the active-run gate itself no longer depends on it to avoid wedging, because the gate (`getActiveRunForPR`) is **session-aware** — a run whose session is terminal/deleted/missing is already treated as not-in-flight even before reconcile runs, so a crash mid-merge can't permanently block the PR.

**Cost tracking.** Daemon-spawned merge sessions carry `triggered_by: 'auto'` on their `pr_merge_queue_runs` row (a manual `MERGE_PR` spawn does not), which is what makes the daily-budget query unambiguous. Their SPEND lives on the `sessions` row as `cost_micro_usd`, metered by `updateSessionCost` — **not** in `api_cost_log`. This paragraph used to claim "both sources land in the same `api_cost_log` table"; they do not, and that belief is what made the budget cap inert (see the note on gate 6).

**Settings shape** — the daemon-facing settings, all optional in `AppSettings`, all exposed in Settings → PR Merge Queue. Ranges below are the **enforced** Zod bounds from `ipc-schemas/settings/agent-tools-settings.ts`; [settings-reference.md](settings-reference.md) is the exhaustive per-setting reference for the whole app.

| Field                              | Type      | Default | Range |
| ---------------------------------- | --------- | ------- | ----- |
| `prMergeQueueAutoMergeEnabled`     | `boolean` | `false` | —     |
| `prMergeQueueAutoMergePollMinutes` | `number`  | `10`    | 1–120 |
| `prMergeQueueAutoMergeDryRun`      | `boolean` | `false` | —     |
| `prMergeQueueDailyBudgetUsd`       | `number`  | `5.0`   | 0–100 |
| `prMergeQueueShardEnabled`         | `boolean` | `false` | —     |
| `prMergeQueueShardCount`           | `number`  | `2`     | 1–64  |
| `prMergeQueueShardIndex`           | `number`  | `0`     | 0–63  |
| `prMergeQueueAutoBatchEnabled`     | `boolean` | `false` | —     |
| `prMergeQueueAutoBatchMaxSize`     | `number`  | `8`     | 2–32  |

**Multi-machine split (sharding).** With `prMergeQueueShardEnabled` on **and** `prMergeQueueShardCount >= 2`, this install only auto-spawns for PRs whose number modulo the shard count equals `prMergeQueueShardIndex` — so two or more independent installs partition the same repo's open PRs with no overlap and never both merge the same PR. Give each box a **distinct** index (box A = 0, box B = 1). An index outside `0 <= index < count` is fail-safe: that box auto-spawns **nothing** rather than risk a double-merge — which is also why a misconfigured index looks like "the daemon does nothing". Sharding off = pass-through. Locked by the rule **`the-shard-gate-filters-the-daemon-only`** in `pr-merge-queue-contract.md`. The config is also exported to a small local flag file (`shard-export.ts`) so the pr-janitor cron honors the same split.

**Auto-batch.** With `prMergeQueueAutoBatchEnabled` on, the daemon groups several eligible (fast-lane, non-overlapping, this-shard, not-in-flight) PRs into **ONE** merge session that applies them together and runs the gate once, instead of one session per PR — cutting the duplicated test/build cost. Only PRs with disjoint file sets are grouped, which is what makes the single gate run trustworthy. `prMergeQueueAutoBatchMaxSize` caps the group (default 8); a tick may pack fewer. Off = today's one-PR-per-tick behavior. Locked by invariant **`auto-batch-packs-one-session`**.

**Manual merges are never sharded or auto-batched** — an explicit human click (`MERGE_PR` / `MERGE_BATCH`) always works, on every box.

Daemon idempotency: `startAutoMergeDaemon()` is safe to call multiple times — the second call no-ops if a periodic task is already running. `stopAutoMergeDaemon()` is also idempotent.

**DB shape.** Migration v210 adds `pr_merge_queue_runs` (id, gh_slug, pr_number, session_id?, outcome, reason?, triggered_by, lane, dry_run, created_at, completed_at?, cost_usd?) with indexes on `(gh_slug, pr_number)` and `created_at`. All mutations live in `src/main/db/queries-pr-merge-queue-runs.ts`. Manual merges (from the view's Merge button) ALSO insert a row here so the audit table reflects every queue-driven merge — `triggered_by: 'manual'` distinguishes them.

`pr-merge-queue-handlers.ts` holds exactly **six** `wrapHandler`-wrapped `ipcMain.handle` calls, one per invoke channel (`LIST_REPOS` / `LIST_SNAPSHOTS` / `REFRESH` / `MERGE_PR` / `MERGE_BATCH` / `DETECT_REPO_INFO`); the `PR_MERGE_QUEUE_CHANGED` / `GIVE_UP` / `SNAPSHOT_UPDATED` / `REFRESH_PROGRESS` / `AUTO_MERGE_FIRED` channels above are push events, not handlers.

They are wired by **auto-discovery**, not an explicit call: `registerDiscoveredHandlers()` in `src/main/ipc/register.ts` picks up every `*-handlers.ts` module, so the exported `registerPrMergeQueueHandlers()` has **no call site anywhere in `src/`**. Grepping `register.ts` for it finds nothing — that is expected, not dead code. Zod schemas live in `src/shared/ipc-schemas/pr-merge-queue.ts` (re-exported through the `src/shared/ipc-schemas.ts` barrel).

### AI summaries

When the PR Merge Queue fetches a repo's open PRs, it can generate a **one-line plain-English summary** of each PR and show it as the pill headline instead of the raw GitHub PR title. The summary distills what the change actually does — making it easier to triage a list of PRs at a glance without clicking into each one.

**Where you see it.** Each PR pill in the view shows `#num · <summary>` as its headline when a summary is available; if no summary exists (feature off, generation failed, or not yet fetched), the pill falls back to the PR title unchanged.

**Caching — one generation per head SHA.** Summaries are persisted in a `pr_merge_queue_summaries` table (PK `gh_slug, pr_number`). Once a summary is generated for a given commit SHA, subsequent refreshes return it instantly with no model call. A new commit to the PR branch (new `headRefOid`) invalidates the cached entry and triggers a fresh generation on the next refresh.

**Turning it on/off.** The setting is `prMergeQueueAiSummariesEnabled` (default `true`). Toggle it at Settings → PR Merge Queue → "AI PR summaries". With the setting off, `maybeCreatePrSummarizer` returns `undefined`, no model calls are made, and the view shows PR titles everywhere.

**Privacy note.** When a summary is generated, Omniscio sends the PR title, description, and changed-file list to the lightweight AI provider configured in your Omniscio settings. If your repo's PR bodies contain sensitive information, turn the setting off.

**Graceful degradation.** A model failure during summarization never breaks the poll: `createPrSummarizer` returns the stale cached summary when one exists, or `null` otherwise — it never throws. A `null` result means the profile's `aiSummary` stays `undefined` and the UI silently falls back to the PR title. The refresh completes normally regardless.

**Cost tracking.** Summary generation uses `llmProviderService.chat` with `source: 'pr-merge-queue-summary'`, so each call's cost lands in the `api_cost_log` table under that source label. There is no separate per-repo budget cap for summaries — they are generated once per head SHA and are cheap; the on/off toggle is the primary control.

The rules `summaries-cache-per-head-commit`, `summaries-never-break-the-poll`, `summaries-ride-existing-read-paths` and `summary-spend-is-labelled` in `pr-merge-queue-contract.md` lock the caching, gating, read-path, and cost-tracking behavior.

## For agents

The source paths named inline throughout this half are the ones an agent needs to change this feature: `src/main/services/pr-merge-queue/` holds the triage, orchestration, prompt, spawn and daemon modules, `src/main/db/` holds the run and snapshot ledgers, and `src/main/ipc/pr-merge-queue-handlers.ts` is the IPC surface.

## Related

[PR Merge Queue](pr-merge-queue.md) is the user-facing half of this feature — the lanes, the merge buttons, the Inbox nudges and the archiving rules. The contracts named above hold the invariants behind what this half describes.
