Omniscio documentation
Browse all documentation
  1. Getting Started13
  2. Sessions & Agents115
  3. Inbox & Notifications59
  4. Projects & Tasks95
  5. Automation & Scheduling75
  6. Knowledge & Memory26
  7. AI Features60
  8. Integrations100
  9. Plugins & Marketplace33
  10. Cloud & Teams56
  11. Settings & Customization58
  12. Account & Billing28
  13. Troubleshooting84
  14. CLI & API Reference22
  15. Legal & Policies4
  16. Uncategorised22

PR Merge Queue (part 2)

The deeper half of the PR Merge Queue page: what the feature persists, the in-flight guard that stops a PR being merged twice, custom and per-repo merge instructions, editing the merge prompt, the pure-logic triage modules, the lane decision tree, the IPC channels, the auto-merge daemon and AI summaries.

What it is

This is part 2 of the PR Merge Queue 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 — 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.
    • 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 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 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.

Last verified 2026-09-23