---
title: PR Janitor
---

# PR Janitor

## What it is

The **PR Janitor** is the nightly job that closes GitHub pull requests on `jlstradingco/Agent-Orchestrator` whose work is already 100% on `origin/master`, investigates everything it leaves flagged (read-only), starts a landing session for every PR still holding work that could land tonight, leaves a comment on every PR saying what happened to it, and ends its report in a single question widget holding every decision that is genuinely the operator's. It used to live entirely outside the repo — its program was a database row and a folder on one machine, its sessions ran in a project of their own, and the only way to see what it did was a chat message. Its program, its schedule, and a record of every run now live inside this repo and the Omniscio project that tends it, and an **operator seat** inside the **PR Merge Queue** panel shows that history and offers a dry run and a pause switch.

This page covers the in-app half — the schedule, the run ledger, and the panel. The closing rules themselves (what "100% landed" means, the content-match tiers, when a PR is flagged instead of closed) live in the engine's own docstring, [scripts/pr-janitor/analyze-prs.mjs](../../scripts/pr-janitor/analyze-prs.mjs), and in [pr-closing-contract.md](../../.claude/memory/contracts/pr-closing-contract.md).

## Where to find it

Settings → Features → **PR Merge Queue** must be on — the Janitor's seat lives inside that panel; it isn't a separate toolbar icon or sidebar row. Open the PR Merge Queue view and the **PR Janitor** section sits above the PR card list: a **Pause/Resume** button, a **Dry run** button, a "Last run" / "Next run" line, six count tiles for the latest settled run, and an expandable run-history list.

**Reach Dry run from that button, not from a keyboard shortcut.** The action ships with a **Ctrl+Shift+J** default binding, but that chord cannot reach it on a stock install: it is on the app's own OS-reserved list (`Ctrl+Shift+I/J` are rejected at the rebind dialog for exactly this reason), and it is also the default of the **Omni briefing** system-wide hotkey (`jarvisBriefingHotkey`, enabled out of the box). A system-wide hotkey is registered with the OS and runs first, so pressing it starts the Omni briefing rather than a Janitor dry run. If you want a key for it, rebind the dry run to a free combination in **Settings → Keyboard Shortcuts**, or pick a different chord for the Omni briefing — with one of the two moved, the Janitor's key works as labelled. See [keyboard-shortcuts.md](keyboard-shortcuts.md).

## How it behaves

### The nightly flow

A session cron named **PR Janitor** (`0 19 * * *`, spawning a session called "Nightly PR Janitor" into the Omniscio project) wakes on schedule and follows [scripts/pr-janitor/CRON-POINTER.md](../../scripts/pr-janitor/CRON-POINTER.md) — the short prompt actually stored in the cron job — which tells it to read the real procedure, [PROMPT.md](../../scripts/pr-janitor/PROMPT.md), off `master` through `git archive`. Both files exist so a mid-run failure to read one leaves the hard safety rules intact (CRON-POINTER.md repeats them on purpose).

1. **Refresh** — copies `scripts/pr-janitor/` from `master` into `%LOCALAPPDATA%\Omniscio\pr-janitor` via `git archive`, overwriting only the program files. `repo-override.path` (which checkout the engine judges against) and `runs\` (its report history) live in that same folder and survive the refresh.
2. **Open the run record** — mints a run id and `POST`s it to `/pr-janitor/runs` as started, before touching a single PR, so a crash mid-run leaves a visible unsettled row instead of nothing.
3. **Run the engine** — `analyze-prs.mjs --execute` is the only thing that ever closes a PR. It fetches `origin` read-only and closes whatever it can prove is 100% on `origin/master`; everything else is flagged.
4. **Investigate** — reads the PR reconcile ledger and the auto-lander's events (both read-only) to work out the real disposition of every flagged PR: already landed locally and just waiting to reach GitHub ("queued to close"), stuck on a conflict, waiting on a person, or genuinely still open.
5. **Land the value** — starts a session for every PR still holding unlanded work, not only the jammed ones. Five tiers feed the landable set: a **stale hold** whose stated reason no longer holds (the sibling PR landed, the "missing" file is now on master), a **partial land** with residual value nobody is coming back for, a **stuck** branch the lander hit a conflict on, a **stalled** branch the lander gave up on, and a **long-open** PR with nothing blocking it and nothing moving it. The old fixed cap of three becomes a budget derived from how loaded the box is — 12 on a quiet box, 2 on a saturated one, 3 when the live session count cannot be read (fail safe, never fail wide) — with a floor of 2 so a busy night still drains the backlog. De-duplicated by head SHA, capped at three attempts per PR, and never started for a PR a live session is already working. Those sessions reconcile onto local `master` and tag ready-to-merge; they never push, merge, or close anything themselves.

   **A PR held on a judgement call is never handed to a session** — risky ground (auth, permissions, migrations, money, security), a disproven premise, a product-direction call, an unapproved author, or a dependency change. Landing proactively clears what is merely _stale_; it never overrides a decision the operator was deliberately asked to make. Those go to the question widget instead.

6. **Content-verify and close superseded PRs** — for PRs whose work looks squash- or rebase-merged but drifted too far for the git checks to confirm it, gathers evidence and hands a vetted list back to the same engine (`--close-verified`, still the only closer).
7. **Tell every PR what happened** — posts a comment on each PR it judged, written for that PR's author, who cannot see the nightly report. Before this, the only comment any PR ever received was the one attached to its close, so a PR that was queued, being landed, stuck or held got nothing at all. The nightly session SELECTS; [post-comments.mjs](../../scripts/pr-janitor/post-comments.mjs) renders, deduplicates, paces and posts, so the copy stays consistent and nothing is said 157 times a night.
8. **Report** — posts one Markdown reply, verdict first, ending in a single question widget that holds every decision that is genuinely the operator's: grouped so decisions taking the same answer are one question, capped at five, lettered so a night can be cleared by clicking. A night with no decisions says so in one line rather than inventing a question.
9. **Settle the run record** — same run id, final outcome, the checkout the engine actually resolved (`resolvedCheckout`), and a disposition row for every PR it judged, including the ones it skipped or could not determine.

### The operator seat

Everything the panel does is either a read of the run ledger or a request that can only ever ask the engine "what would you do" — never one that can close anything. Four IPC channels back it (declared in `src/shared/ipc-channels/git.ts`, wired in [pr-janitor-handlers.ts](../../src/main/ipc/pr-janitor-handlers.ts)):

- **`pr-janitor:list-runs`** — newest-first run history (default 20, capped at 100) plus the janitor's next scheduled run, already resolved to `null` server-side for every "not scheduled" case (no matching cron job, an inactive one, or the operator paused it) so the UI never has to guess.
- **`pr-janitor:get-run`** — one run's per-PR dispositions, loaded on demand when a history row is expanded.
- **`pr-janitor:dry-run`** — spawns `analyze-prs.mjs` **without** `--execute`. The handler does not wait for it to finish (a full run over this repo's PR set was measured at over 50 minutes, and the engine is allowed up to two hours); it races a 500ms grace window and reports either the real exit code (a fast failure) or `started: true` (still running in the background). It writes **no run record** — a dry run has none of the read-only investigation the nightly job does, so a stored row for it would either be a false "closed nothing" for a night that never happened, or duplicate the nightly row's shape with numbers nobody vetted.
- **`pr-janitor:set-paused`** — flips the `prJanitorPaused` setting (part of `PrMergeQueueSettings`, default `false`) and tries to actually stop the schedule: pausing always deactivates the matching cron job, while resuming only re-arms it if that job's `approvalStatus` is already `approved` or `not_required` — a job still waiting on a person stays off. The response's `scheduleChanged` flag tells the UI whether the underlying job actually moved, so "setting saved" and "schedule changed" can never be conflated.

**The two buttons are desktop-only; the history is not.** On a paired phone you can open the PR Janitor section and read everything in it — the Last run / Next run line, the six tiles, and the whole run history with each run's per-PR dispositions — but **Dry run** and **Pause/Resume** only work in the desktop app. Tapping them on a phone does nothing.

Why: a dry run spawns the engine as a real process on the machine that runs Omniscio, and it can run for hours with no wall-clock bound of its own (see **Where it bites** above) — so a paired phone could start a long, unattended job on the host and start another. Pause/Resume flips the nightly schedule itself. Web Access authorises a _device_ once, at pairing, and has no per-action confirmation, so both moved behind the desktop; the read-only half stayed exactly where it was. This mirrors the PR Merge Queue's own rule — see **Starting a merge is desktop-only** on [pr-merge-queue.md](pr-merge-queue.md).

The six count tiles (**Closed**, **Queued to close**, **Stuck**, **Needs you**, **Could not determine**, **Skipped**) are the latest settled run's `pr_janitor_dispositions`, rolled up. "No runs recorded yet" and "a settled run whose counts are genuinely all zero" are different states — the panel tells them apart by array length, never by the counts themselves.

### Where it bites

- **The engine's checkout is not the shared repo checkout, and that is deliberate.** `repo-override.path` in the machine-local run directory points the engine at its own fast clone; pointing it at the shared checkout once cost roughly two-thirds of a second per object lookup and made every candidate blow its time budget, so the content-verified tier closed nothing for a day. See [pr-janitor-in-app-contract.md](../../.claude/memory/contracts/pr-janitor-in-app-contract.md) C2.
- **The program is read via `git archive` from `master`, never off the working tree**, because the shared checkout routinely drifts from its own HEAD.
- **The scan resumes, and it bounds its own wall clock.** Deriving every PR's git facts means walking a `origin/master` of 130,972 commits, so a full pass over the open backlog is hours of work on a loaded box: measured 2026-09-16/17 it took 56 minutes quiet and was still running at 3h27m loaded, when it exited without writing its report at all. The engine now spends at most two hours deriving, keeps every result in `runs\scan-checkpoint.json`, reuses a PR's result on a later run while its head SHA is unchanged and `origin/master` has gained nothing that PR touches, and always writes its report — naming the PRs it did not reach in a `## Not reached this run` section. A shortened run is not a failure: the next one reuses what was decided and starts with what was missed. This is also why the dry-run handler races a grace window instead of awaiting the process.
- **Editing the cron job's schedule or prompt resets its approval status to `pending`**, same as any other cron job — it stops firing until a person approves it again from the inbox.
- **Commenting is the one GitHub write outside the engine, and it is not a closing path.** It goes through `post-comments.mjs` only, never a hand-run `gh pr comment`, and that helper may call `gh pr comment` and no other verb — enforced by shape rather than a blacklist, so a verb nobody thought to ban still fails the build. A PR closed this run is deliberately _not_ commented on: the close already carries its own comment, and one event gets one comment.
- **A PR whose story has not changed is not re-told.** The dedup key is verdict + reason + head commit, so a PR sitting queued for sixty nights gets one comment, not sixty — but a new push is new news and does earn a fresh one. Without that, "say what happened" would bury the real news under a nightly re-post.
- **The count tiles are a headline summary, not a partition.** There is no `landing` count and never was an `open` one, so a PR being landed shows in the run's per-PR dispositions but not in the tiles. Adding a count field is not the fix — the server rejects one.
- **Nothing here can close a PR.** Not the panel, not the dry run, not the IPC handlers — `analyze-prs.mjs --execute` (STEP 1) and `--close-verified --execute` (STEP 2.6) are the only two paths that ever do, and both run from the nightly session, never from the app.

## For agents

### What gets persisted

Two tables, added by [migration 20260912154225](../../src/main/db/migrations/20260912154225-create-pr-janitor-runs.ts):

- **`pr_janitor_runs`** — one row per run: `id`, `started_at`, `finished_at` (`NULL` until settled), `mode` (`live` / `dry-run`), `resolved_checkout`, `outcome` (`ok` / `failed` / `unknown`, only meaningful once settled), the six counts, `report_path`, `note`.
- **`pr_janitor_dispositions`** — one row per PR a run judged: `run_id`, `pr_number`, `verdict` (`closed` / `queued-to-close` / `landing` / `stuck` / `needs-human` / `undetermined` / `skipped` / `open`), `reason`, `head_sha`. Unique on `(run_id, pr_number)`, so a retried settle POST upserts instead of duplicating.

Both tables are written only by the CLI routes in [cli-server-pr-janitor-routes.ts](../../src/main/services/cli/cli-server-pr-janitor-routes.ts) (`POST /pr-janitor/runs`, `GET /pr-janitor/runs`, `GET /pr-janitor/runs/:id`) — the nightly session is the only caller that ever writes to them, and it only ever writes what already happened. Records older than 30 days are purged (`purgePrJanitorRunsOlderThan`), but only settled runs — an unsettled row is either still running or the evidence of a crash, and is never aged out on age alone.

## Related

[pr-merge-queue.md](pr-merge-queue.md) is the panel this section lives inside. [pr-auto-close-pipeline.md](../../.claude/memory/pr-auto-close-pipeline.md) is the separate, deterministic closer this one backstops. [pr-janitor-in-app-contract.md](../../.claude/memory/contracts/pr-janitor-in-app-contract.md) records the rules this move had to keep true, and [pr-janitor-map.md](../../.claude/memory/pr-janitor-map.md) is the engineering map of its moving parts, main flow, and where it bites.
