Skip to content

A train of stacked pull requests lands in one run of the required check, each link on the verdict its head already has

A train of stacked pull requests lands in one run of the required check, each link on the verdict its head already has

majordomus://adr/adr-0129proposed integrationgitgithubcithroughput

it references

  • put_in_force apps/majordomus-cli/src/integration/classify.rs (file)
  • put_in_force apps/majordomus-cli/src/integration/drain.rs (file)
  • put_in_force apps/majordomus-cli/src/integration/mod.rs (file)
  • put_in_force apps/majordomus-cli/src/integration/model.rs (file)
  • put_in_force apps/majordomus-cli/src/integration/relation.rs (file)
  • put_in_force apps/majordomus-cli/src/integration/repair.rs (file)
  • put_in_force apps/majordomus-cli/src/integration/tests.rs (file)
  • put_in_force adr-0101
  • put_in_force docs/INTEGRATION.md (document)
  • put_in_force I2298
  • put_in_force project.integration-follows-the-current-master@3
  • put_in_force project.land-and-publish@1
  • put_in_force test/cases/1040_a_stacked_head_lands_on_the_verdict_it_has.sh (test)

referenced by

Context

ADR 0101 made integration one merge at a time and named its price: "one pull request lands per CI run of its refreshed head", with "parallel speculative refreshes — a merge queue" left as "a later decision" that needed evidence first. This is that decision, and this is the evidence, measured on 2026-10-10.

  • A pull request's validate run costs 398 job-minutes (run 38054235798) and a push to master 521 (run 38043095416). The wall clock is 85 to 100 minutes and the slowest suite shard sets it: 83 minutes for shard 1 beside 41 for shard 3 in the same run.
  • The executor's trail in the primary checkout holds 15 verified merges in four days: 3, 7, 2 and 3 a day. Fifty-two pull requests were open, and 23 of them were needs_refresh: finished, clean with the derived driver, and behind master only because something else landed.
  • Every merge moves the derived artifacts every other branch carries (ADR 0101), so a head refreshed onto master is behind again as soon as any other pull request lands. Refreshing two at once wastes the second one's run. The pipeline is one deep for that reason and no other.

On 2026-10-10 the owner approved a stacked train, and one session has run it by hand since: each head is brought up to date on the prepared head of the one before it, not on master. When the lower one lands, merging master into the upper changes no file. That was checked on the first real link, where the merge of master after #880 into the head prepared for #881 wrote that head's own tree. Asked of git for the whole lane the same afternoon: each of the eleven prepared heads (refs/prep/*) lacked three commits of master 815f444f77, all three of them merge commits, and the merge of each wrote its own tree.

The queue could not say so. Its relation to master knew two answers for a clean merge: the head contains master (up_to_date), or it does not (behind). The upper link does not contain master's merge commit, so it was behind, its next action was a refresh, and a refresh there is a merge commit that changes nothing, a new head, and a second run of the required check for a tree the first run had judged. The alternative the lane had was to ask the owner each time before passing the freshness gate by hand, and a gate passed by hand is not a gate.

A dispatched run is no way round it either. On 2026-10-10 a workflow_dispatch run of validate concluded success on the head of pull request #866, with a ci check run from the app the branch protection requires, and the forge still refused the merge (issue I2282). So the verdict has to be the one the pull request's own event produced, on the head that merges.

Decision

  1. A third answer for a clean merge. RelationToMaster::CarriesMaster is a head that does not contain master although master holds nothing it lacks. Git decides both halves in the object database. The merge writes the head's own tree: git merge-tree --write-tree master head is clean and prints the tree head^{tree} names. And every commit master has and the head lacks is a merge commit: git rev-list --count --no-merges head..master is zero. Nothing else makes it so. Not the stacking, not a declaration, not a record of how the head was built.

  2. Why both, and why they are enough. If the merge writes the head's tree, master changed no path since the merge base that the head does not already hold in the same state. So no path is merged at all: no driver decides anything, the forge's own merge writes the same tree without the derived driver, and the tree the required checks judged is the tree master becomes. The tree is not everything they judged. Derived output is composed from history too: the changelog lists every commit that is not a merge (git log --no-merges, release/commits.rs). A master that took a change and reverted it has the tree it had and two commits no changelog on the head ever listed, so the first half alone would land a stale changelog. The suite said so before any pull request did: the first draft of this relation asked for the tree only, and two existing tests, a reverted cherry-pick and an empty commit on master, came out carries_master. With only merge commits missing, every commit a generator lists is one the head has. That pair is the property up_to_date was trusted for. Containing master is one way to have it and not the only one.

  3. The freshness gate passes both. A carries_master head with its required checks passed is ready, and its first reason is carries_master where an up-to-date one says contains_master, so the trail tells the two apart. prs repair has nothing to repair on it. While its check is pending on a head the executor pushed, it holds the refresh pipeline exactly as an up-to-date one does.

  4. A train is stacked heads and declared order. Link k+1 is brought up to date on link k's refreshed head and says Stacked on #k in its body. The dependency gate already reads that line (ADR 0101), so a link is waiting_for_dependency until the one below it has merged, whatever its checks say. The order is a gate. Every link's checks run at once, each on a head that holds everything below it, and the links land one after another with no refresh and no second run.

  5. A red link costs what no train costs. The links above it wait on their dependency. When it is fixed and lands, master holds a change their heads never saw, they are behind, and each takes one ordinary refresh. A link closed without a merge leaves the ones above it blocked with dependency_closed_unmerged, for a person. In no case is a tree merged that no run judged.

  6. A cached relation belongs to the vocabulary that decided it. The relation cache kept an entry forever under its pair of commit ids. behind, written before carries_master existed, may be either, so the cache now records which vocabulary decided its entries and drops the relations of another one. The shapes beside them stay: no relation decides a shape.

  7. The executor does not build the train yet. It decides every link on what git says, whoever stacked it. prs drain --refresh still refreshes one pull request onto master and waits. Refreshing the next candidate onto the top link's head, to a depth declared in .ai/repo/ci/queue.yaml, with the stacking recorded on the trail and the declaration written by the executor, is a separate issue that this decision makes possible and does not contain.

Consequences

A train of n links lands in about one run of the required check instead of n, as far as runners allow: every link's run is 398 job-minutes whether they run in turn or together, so the train shortens the wall clock and spends no fewer minutes. The push to master that every merge starts still runs in full, 521 job-minutes for a tree a pull request's run has just judged. Whether that run may rest on the same proof is not decided here.

A link's pull request shows the links below it in its diff until they land, and a reviewer of link k+1 reads link k too. Stacked on #k says why.

An upper link whose body does not name the one below is not held by the dependency gate. If its head also contains master it is up_to_date today, as it was before this decision, and could land first with the lower one's commits inside it: an inferred dependency is evidence and never a block (ADR 0101). The declaration is therefore part of stacking a head, and the issue that lets the executor build a train has it write the line.

The first observation after an upgrade asks git again for every relation of the current master, once.

One thing here is argued and not yet measured. A generator that listed merge commits would make a carried head's derived output differ from master's. None is known: the changelog excludes them, and master's own run passes generate --check on a merge commit no pull request's derivation ever saw, which a generator listing merges could not survive. The first link that lands carried is the measurement: the run its merge starts on master must pass generate --check and the structure gates. If it does not, the relation is wrong and is withdrawn, at the cost of the refresh it saved.

Alternatives rejected

  • Pass the freshness gate by hand for a known train. A person's word for a property git can decide in milliseconds, asked again for every link, and absent from the trail.
  • Merge master into the link after the lower one lands. That is today's refresh: it changes no file, makes a new head, and buys a second run of 85 to 100 minutes.
  • Judge a speculative ref with a dispatched run and move the pull request's head to it. The forge did not take a dispatched ci as the verdict on #866 (I2282), and the push that moves the head starts the pull request's own run regardless.
  • The forge's merge queue. Its merges cannot run the derived driver any more than its mergeable flag can (ADR 0101), so its speculative merges would conflict on the same derived files. Not tried here; rejected on that reasoning.
  • Stop committing derived output in pull requests. It would remove the conflict every merge causes, and it changes what a commit of this repository is. A larger decision than a relation, and not this one.
  • Trust the stacking. "It was built on #k, so it is safe after #k" is true until either head moves. The relation is asked of git on the commits that are there.

Where this comes from

canonical file
.ai/repo/adrs/0129-a-train-of-pull-requests-lands-in-one-run-of-the-required-check.md
identity
adr-0129
also served as
MCP resource majordomus://adr/adr-0129 · majordomus entity show adr/adr-0129 · GET /api/v1/entity?uri=majordomus://adr/adr-0129
in the Cockpit
/cockpit/objects/adr/adr-0129

This page is generated from the file above by scripts/generate-site-data; editing the page changes nothing, editing the file changes every surface. The relations above are edges of docs/generated/graph.json, which the executable writes from the same index.

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