Skip to content

Backlog

the four quantities that grow on their own — open pull requests, worktrees, branches, running servers; why GitHub can never merge a pull request of this repository and what lands one instead; the commands that measure and reduce each

Rendered from docs/BACKLOG.md — the same Markdown GitHub shows.

The five quantities in this repository that grow on their own, the commands that measure them, and the commands that reduce them. Behaviour as implemented and tested; where this document and the scripts disagree, the document is wrong and changes in the same commit. The rule is project.accumulation-is-measured; the gate is scripts/ci/backlog-check; the behavioural cases are test/cases/131_backlog_hygiene.sh and test/cases/276_disk_is_bounded.sh.

The finding this exists because of

On 2026-09-11 the operator reported one symptom — worktrees, processes and branches keep piling up and nothing is being deployed — and measurement found four separate quantities at once (the fifth, disk, filled the volume the next day and has its own section below):

QuantityMeasuredOf which
open pull requests1615 merged cleanly on any developer's machine
linked worktrees6447 with uncommitted work
local branches11191 not merged into origin/master
running majordomus servers19214 abandoned, one over a day old, 13 ports, ~150MB

The deploy pipeline was not broken: gh-pages was one commit behind master and the site was live. Nothing was failing. Every part was working as designed, and the result was still a standstill — which is the point of this document.

Why pull requests do not merge on GitHub, and never will

This repository commits its derived artifacts, and every one of them carries a fingerprint of the tree it was generated from. After a merge that tree is neither parent's, so both recorded fingerprints are wrong and no textual combination of them is right — the correct value only exists after the generator runs. .gitattributes therefore marks those paths merge=derived and scripts/merge-derived resolves them instead of conflicting.

A custom merge driver is an executable named by git config merge.<name>.driver. That configuration is per clone and cannot be committed — which is why just derive-merge-driver exists. GitHub's servers are a clone that has never run it. So GitHub performs a plain three-way merge over files that every branch regenerates, finds conflicts in all of them, and reports the pull request as CONFLICTING / DIRTY.

GitHub's CONFLICTING is not a false positive. The conflict is real for any clone that lacks the driver, and GitHub is always such a clone. The same branch, measured both ways:

git                               merge-tree --write-tree origin/master <branch>  # exit 0
git -c merge.derived.driver=false merge-tree --write-tree origin/master <branch>  # exit 1, 15 conflicts

So there are two honest answers to "does this merge", and which one you get depends on whether the driver is configured. The one that matters is the clone that will actually carry the result — a developer's machine — and there the answer was yes for fifteen of the sixteen open that day. The merge button is not slow or flaky; it is structurally unavailable, and it will not become available. A contributor who waits for it waits forever, and that wait is the whole backlog.

Ask git for the exit code, never grep its output. merge-tree's conflict wording is not a contract; a grep that misses it counts a conflicted merge as clean, which is the most expensive way this measurement can be wrong. majordomus prs uses the exit code.

The commands

QuantityMeasureReduce
pull requestsmajordomus prs statusmajordomus prs drain --refresh
worktrees, branchesmajordomus worktreemajordomus worktree cleanup, by hand for dirty ones
abandoned serversscripts/reap-orphansscripts/reap-orphans --kill
build output on diskscripts/reap-orphans --targetsscripts/reap-orphans --reclaim
the whole gatescripts/ci/backlog-checkscripts/ci/backlog-check --remote

majordomus prs

Pull-request integration is a typed subsystem of the executable (docs/INTEGRATION.md, ADR 0101). majordomus prs refresh observes the forge and fetches every open head; status, plan and explain classify each pull request against the current master with git and the derived driver — never the forge's mergeable — offline; drain merges the next ready one, verifies it landed, and observes again before anything else, one merge at a time. drain --refresh brings master into the first pull request that needs it when nothing is ready, one at a time, because merging anything else meanwhile would put it behind again.

It replaced scripts/land, which merged every clean pull request onto one integration branch: a batch whose regressions nobody could attribute, planned from a list that the first merge made stale.

The measurement runs on a clock: .github/workflows/queue.yml (model .ai/repo/ci/queue.yaml) runs majordomus prs refresh and majordomus prs status every thirty minutes and publishes the queue to the job summary, so its state is a measurement rather than something somebody remembers to ask for. It never runs drain or cleanup --apply, and its token cannot write: the state is automatic, the merge stays a decision. The read-only token cannot read the branch protection either, so the report says so (exit 10) and calls nothing ready; only an unusable measurement (exit 12) fails the job. test/cases/350_the_queue_measures_itself.sh holds both halves, and fails if the workflow acquires an acting command or a write permission.

scripts/reap-orphans

A majordomus serve or mcp outlives its client whenever the client dies without closing it: the parent is reaped, the server is re-parented to init, and it holds its port and its memory until the machine restarts. A server that answers is not a server anyone is talking to.

Sessions in this repository are each other's instruments, so a process is never matched by name. pkill -f majordomus would end the servers that live peers are attached to. A process is reaped only when all four hold, each re-checked immediately before the signal:

  • it is a majordomus serve or mcp process, by executable path;
  • its parent is init (ppid 1) — the session that started it is gone;
  • it has zero ESTABLISHED TCP connections — no client is attached;
  • it is older than --min-age (default 30m).

Dry-run by default; --kill acts. A server that acquires a client between the scan and the signal is skipped.

A consent also selects its subject. With no --servers/--targets, --kill examines servers alone and --reclaim build output alone; with no flags at all both are reported and nothing is touched. This is not cosmetic. A --reclaim run once printed a table of live servers under the words would reap, which invites the reader to believe that the consent they gave for build output is about to end their server — or has. A consent named for one subject never tables the other's.

A sweep that stops before its summary says INCOMPLETE and names which of the two subjects it examined and which it never reached. A column header with rows beneath it has the shape of an answer, and the exit status is the only thing that says otherwise — which nobody reads off a terminal. On 2026-09-13 a run printed a header, one server, and died of SIGPIPE at exit 141 for want of a reader that read its stream to the end; it was taken for a report (project.a-verdict-states-its-subject, test/cases/318_a_partial_sweep_says_so.sh).

Build output, and the floor under a build

On 2026-09-12 this machine reached zero bytes free on a 926GiB volume. Every session on it died in the same minute — not gracefully, but with the harness unable to write its own task files, which reads to each session like broken tooling rather than like a full disk, and df could not be run either. ~/dev/prismatic-majordomus-wt held 180GB, of which ~145GB was apps/majordomus-cli/target in worktrees whose branches were already merged. One had landed hours before and still held 32GiB. Nothing in finishing a piece of work removes the build directory it produced, so nothing ever would have.

The same reaper answers it, with a second subject and a second consent (--reclaim, never --kill). It removes target/ and never a worktree: a false positive costs somebody a rebuild and cannot cost a byte of source. A build directory is reclaimed only when all four hold — merged into origin/master, nobody working in that worktree, git status empty, and the directory provably cargo's own and inside that worktree. The primary checkout is excluded by name rather than by predicate, because every other checkout borrows its executable.

Liveness is read from the machine (ps, and every process's working directory) and never from the peer board: an announcement belongs to a connection, and a worker that reconnects keeps its work while losing its place on the board — exactly the window a reaper runs in. Merged-ness alone would have been a disaster, and the instance was on the machine that morning: a worktree holding 11.8GB with a live peer in it read as fully merged, because the session working in it had not committed yet and its tip was still where the merge left it. An mtime is never a predicate here (it is the one whose missing input becomes an extreme value, and it cleaned three active worktrees once), and the reaper refuses as a whole — reclaiming nothing, not everything — when liveness or origin/master cannot be read.

The other half is a bound rather than a warning. A df in doctor helps the next person and cannot help the person already stuck. mj_rust_space_check in lib/rust_bin.sh refuses to start a build below 5120MB free, and the three paths that start one — the launcher, scripts/derive, just build — all ask it. The floor is measured: a full cargo build --locked of the crate into an empty target/ wrote 2.45GiB, and the floor is two of those. MAJORDOMUS_MIN_FREE_MB=0 lifts it. A bound whose own input cannot be measured says so and lets the build run — the deliberate opposite of the sweep, because refusing every build on a machine whose df is unreadable stops all work to prevent a hypothetical.

The derived trees that were never declared

merge=derived only helps a path that is marked. scripts/generate-site-data deletes and regenerates fourteen whole sections under site/content/ on every run — its rm -rf "$CONTENT_ROOT/..." lines are the list — and on 2026-09-11 thirteen of the fourteen were not declared in .gitattributes. Only registry was.

So every integration conflicted over guarantees/, commands/, use-cases/, why/, plan/, skills/ and the rest: content no branch owns, that the generator replaces wholesale, and that a person then resolved by hand, per merge, forever. This was the single largest mechanical cause of the backlog after the forge constraint itself, and it surfaced here only because landing six pull requests hit it on the very first merge of origin/master.

All fourteen are declared now, and scripts/ci/backlog-check reads the list out of the generator rather than keeping one by hand — a hand-kept list falls behind the first time a section is added, which is exactly how thirteen omissions survived unnoticed.

Why servers were abandoned in the first place

majordomus serve --idle N stops when no peer has been attached for N seconds, and DEFAULT_IDLE_SECONDS (900) is the constant the shared server is started with — scripts/ci/entry-converges holds that contract for the server serve ensure starts, which is the path a person takes.

It is not the path that leaked. Raw serve's --idle defaults to 0 — run until stopped, and four cases in the suite backgrounded a server without it. A case that is interrupted before its cleanup trap fires — a killed run, a spend limit, a closed laptop — leaves that server holding its port until the machine restarts. That is where the abandoned servers came from, and it is why the same contract is now held for the suite's own spawns by scripts/ci/backlog-check rather than only for the entry path. The two halves of one rule, checked in one direction only, is the recurring shape here.

Worktrees and branches

majordomus worktree derives the topology from git (project.worktree-topology) and marks which branches are eligible for cleanup. The number eligible is normally far below the number that look disposable: on the day above, 5 of 111. Zero unique commits is not proof that a branch is safe to delete — a merged branch can hold uncommitted and untracked authored work, and 47 of 64 worktrees were dirty. Classify the working tree, not only the history, and push before removing anything.

What the gate holds

A CI runner has no worktrees, no abandoned servers and no backlog of its own, so backlog-check does not measure the quantities. It holds together the things whose absence let them accumulate unseen: that each quantity still has its command, that the reaper is safe by construction, that .gitattributes and its installer still agree — a declaration checked in one direction only is how this class of defect survives — and that no document sends a reader to the merge button. --remote adds the measurement itself, and fails when more than MJ_BACKLOG_MAX (default 10) pull requests are landable and still open.

Searches the titles and descriptions of every page this site publishes.