Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -611,10 +611,17 @@ stays queued. `quest status` reads the hidden reason marker. Full logs stay in t
`$STATE_DIRECTORY` or the XDG state home, never inside the checkout. A check
that runs past `--timeout` minutes (60 by default) is killed and fails its batch.

Every squash the daemon builds, and every base it merges into a PR head, drops
each `Required`, `Related`, or `Closes` entry linking to a quest the base
deleted since the PR branched, as `quest rm` would have. The PR wrote that link
while the quest was open, so the base finishing it is no fault of the PR's: a
sibling PR that links a quest another sibling finishes lands in the same batch.
A quest the base moved is a rename, not a deletion, and a link in prose stays.

As a batch is filled, each PR it reaches is squashed alone onto the current
base, and `quest check` runs on the result; PRs the batch does not reach wait
for a later round, so a long queue costs only each batch's candidates. A PR with findings the base lacks, such as
a link to a quest the base has since finished, is parked alone for the fixer, blamed
a mistyped link, is parked alone for the fixer, blamed
for those findings, so it never fails a batch; the fixer starts from that squash.
Without a fixer it is ejected as `failed_checks` with those findings. This check runs
in the daemon, never the sandbox: it parses quest files read from git objects
Expand Down
1 change: 0 additions & 1 deletion quest/m0/helpers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,4 @@ commands from Sep-Oct. Session counts in each child come from that window.

- [quest merge --land gaps](/quest/m0/helpers/merge-land.md) - squash on squash-only repositories, PR URLs outside a checkout, labels released after direct merges, and a warning when auto-merge outlives its head
- [Headless agents](/quest/m0/helpers/headless-agents.md) - `/quest-spawn api` launches, resumes, and stops billed agents through one command auto mode allows
- [Sibling PRs through a server-side merge](/quest/m0/helpers/queue-siblings.md) - children of one epic land back to back through a merge queue without conflicting on its link list
- [moq-dev/quest lands through quest land](/quest/m0/helpers/land-adopt.md) - this repository's `main` changes only through its own gate
57 changes: 0 additions & 57 deletions quest/m0/helpers/queue-siblings.md

This file was deleted.

4 changes: 0 additions & 4 deletions quest/m0/merge-driver-upkeep.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,3 @@ Decided 2026-11-01 (user, /quest-plan of the follow-ups from the
added the `quest-pinned` input, which only a manual `nix flake update
quest-pinned` bumps. Pick how it stays current, such as Dependabot's nix
updates, a step in the release checklist, or a recurring quest, and do it.

## Related

- [Merge queue siblings](/quest/m0/helpers/queue-siblings.md) - the landing-time drop this quest brings to local merges
56 changes: 53 additions & 3 deletions src/cli/land.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";

import { type Finding, MERGE_LABEL, check, formatFinding } from "../core";
import { type Finding, MERGE_LABEL, check, formatFinding, isQuest, remove } from "../core";
import { repositoryPath } from "./claims";
import {
GitHub,
Expand All @@ -16,7 +16,7 @@ import {
string,
} from "./github";
import { classifyCheck } from "./status";
import { snapshot } from "./tree";
import { committedSources, snapshot } from "./tree";

/** The check a gated base requires. Nothing reports it on a PR head, so GitHub's auto-merge only waits. */
export const LAND_CHECK = "quest land";
Expand Down Expand Up @@ -575,10 +575,56 @@ function squash(dir: string, driver: string, entry: Entry): string[] | null {
git(dir, ["reset", "--quiet", "--hard", "HEAD"]);
return files;
}
prune(dir, entry.head);
git(dir, ["commit", "--quiet", "--allow-empty", "-m", `${entry.title} (#${entry.number})`]);
return null;
}

/**
* Finish a clean merge of `head` into `HEAD`, staged in the index, by dropping each list entry
* that links to a quest `HEAD` finished since `head` branched, as `quest rm` drops links to a
* quest it deletes. The PR wrote that link while the quest was open, so the base finishing it
* leaves the link dangling through no fault of the PR, and `quest check` would reject it.
* Deletion since the merge base tells a finished quest from a mistyped link, and a quest the base
* moved is a rename, not a deletion. A link in prose stays, for `quest check` to report. Both the
* chain and a prepared head merge through here, so the head lands the tree the chain tested.
* Returns the documents it changed.
*/
function prune(dir: string, head: string): string[] {
const since = git(dir, ["merge-base", "HEAD", head]);
const finished = new Set(
git(dir, [
"diff",
"--no-ext-diff",
"--find-renames",
"--diff-filter=D",
"--name-only",
"-z",
since,
"HEAD",
"--",
"quest",
])
.split("\0")
.filter(isQuest),
);
if (finished.size === 0) return [];
let tree: ReturnType<typeof committedSources>;
try {
tree = committedSources(dir, git(dir, ["write-tree"]), false);
} catch {
// A tree `quest check` cannot read is its finding to report, not this edit's.
return [];
}
const modes = new Map(tree.sources.map((source) => [source.path, source.mode]));
const texts = remove(tree.sources, (path) => finished.has(path) && !tree.exists(path));
for (const [path, text] of texts) {
const oid = git(dir, ["hash-object", "-w", "--no-filters", "--stdin"], { input: text });
git(dir, ["update-index", "--cacheinfo", `${modes.get(path)},${oid},${path}`]);
}
return [...texts.keys()];
}

/** How many queued PRs ahead of a newly queued one its conflict report covers: those landing soonest. */
const OVERLAP_AHEAD = 4;

Expand Down Expand Up @@ -680,9 +726,13 @@ export function mergeBase(
const result = tryGit(dir, ["-c", `merge.quest.driver=${driver}`, "merge", "--no-ff", "--no-commit", head]);
if (result.status !== 0)
return { reason: "merge_conflict", detail: `${result.stdout}\n${result.stderr}`.trim() };
const pruned = prune(dir, head);
const tree = git(dir, ["write-tree"]);
const message = pruned.length
? `quest land: merge tested base\n\nDrop links to quests the base finished from ${pruned.join(", ")}.\n`
: "quest land: merge tested base\n";
const commit = git(dir, ["commit-tree", tree, "-p", head, "-p", base], {
input: "quest land: merge tested base\n",
input: message,
env: {
GIT_AUTHOR_NAME: "moq-bot",
GIT_AUTHOR_EMAIL: "moq-bot@users.noreply.github.com",
Expand Down
2 changes: 1 addition & 1 deletion src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@ export function main(argv: string[], out: Output): number | Promise<number> {
landCommand
.command("serve")
.description(
"Run one daemon per repository. As a batch is filled, park alone for the fixer (eject alone, without one) each PR it reaches whose squash onto the base fails quest check with findings the base lacks, and comment once on each newly queued PR that conflicts with the four PRs landing soonest ahead of it. Check a base PR plus up to floor(log2 n) cleanly merging PRs once through the sandbox, alternating the oldest and the newest queued PR as the base. A failed batch is never bisected: its PRs are parked for the fixer, which blames the ones at fault, or ejected together without a fixer. It halves the next batch's size down to one PR; each landed batch grows it back by one. When the fixer blames none and the base fails its check alone, the queue halts until a moved base passes, and the fixer proposes a fix of the base as a PR. Requires quest on PATH and flock. Sandbox and fixer wrappers MUST run code as a different user from the App key owner (for example in nspawn containers); environment filtering alone cannot isolate /proc or host files. The sandbox receives <checkout> <base-sha>, with the base also in QUEST_LAND_BASE and present in the standalone shallow clone; the optional tracked background fixer receives the log path followed by PR numbers, or none for a base that fails alone. Neither receives credentials. State retains logs, fixer jobs, and one mechanical attempt per maintainer pick across restarts. Fixer results are validated, lease-pushed, recorded in Review, and pinned for requeue, and the comment a result carries for its PR is posted there; invalid results, crashes, and timeouts are handed back. A validated fix whose push GitHub refuses is handed back with its resolution for `quest land apply`. After revalidating the queue, merge the base into each behind-base PR head with quest resolve, verify the tested tree, and lease-push that head before the pinned GitHub squash API merge. A permissions-refused branch push falls back to a leased fast-forward of the tested squash to the base, closing the PR with a linked-commit comment and removing quest:merge. A recorded daemon merge atop the original pin is recognized after restart; other moved heads require maintainer requeueing. Pending mergeability, and GitHub still showing the pin after the daemon's own push, are retried briefly; a confirmed conflict parks only that PR for the fixer and a moved head ejects only that PR, and the rest of the batch stays queued. A refusal that would repeat, such as squash being disabled, ejects as merge_refused without the fixer. An unexpected squash parent rebuilds the queue without ejecting; a tree mismatch logs CRITICAL, ejects only a PR whose prepared tree differs as tree_mismatch (an already squashed PR stays merged), and leaves the rest of the batch queued to be rebuilt on the new base. Merged PRs drop out of the queue after restart.",
"Run one daemon per repository. Every squash, and every base merged into a PR head, drops each list entry linking to a quest the base deleted since the PR branched, as quest rm would. As a batch is filled, park alone for the fixer (eject alone, without one) each PR it reaches whose squash onto the base fails quest check with findings the base lacks, and comment once on each newly queued PR that conflicts with the four PRs landing soonest ahead of it. Check a base PR plus up to floor(log2 n) cleanly merging PRs once through the sandbox, alternating the oldest and the newest queued PR as the base. A failed batch is never bisected: its PRs are parked for the fixer, which blames the ones at fault, or ejected together without a fixer. It halves the next batch's size down to one PR; each landed batch grows it back by one. When the fixer blames none and the base fails its check alone, the queue halts until a moved base passes, and the fixer proposes a fix of the base as a PR. Requires quest on PATH and flock. Sandbox and fixer wrappers MUST run code as a different user from the App key owner (for example in nspawn containers); environment filtering alone cannot isolate /proc or host files. The sandbox receives <checkout> <base-sha>, with the base also in QUEST_LAND_BASE and present in the standalone shallow clone; the optional tracked background fixer receives the log path followed by PR numbers, or none for a base that fails alone. Neither receives credentials. State retains logs, fixer jobs, and one mechanical attempt per maintainer pick across restarts. Fixer results are validated, lease-pushed, recorded in Review, and pinned for requeue, and the comment a result carries for its PR is posted there; invalid results, crashes, and timeouts are handed back. A validated fix whose push GitHub refuses is handed back with its resolution for `quest land apply`. After revalidating the queue, merge the base into each behind-base PR head with quest resolve, verify the tested tree, and lease-push that head before the pinned GitHub squash API merge. A permissions-refused branch push falls back to a leased fast-forward of the tested squash to the base, closing the PR with a linked-commit comment and removing quest:merge. A recorded daemon merge atop the original pin is recognized after restart; other moved heads require maintainer requeueing. Pending mergeability, and GitHub still showing the pin after the daemon's own push, are retried briefly; a confirmed conflict parks only that PR for the fixer and a moved head ejects only that PR, and the rest of the batch stays queued. A refusal that would repeat, such as squash being disabled, ejects as merge_refused without the fixer. An unexpected squash parent rebuilds the queue without ejecting; a tree mismatch logs CRITICAL, ejects only a PR whose prepared tree differs as tree_mismatch (an already squashed PR stays merged), and leaves the rest of the batch queued to be rebuilt on the new base. Merged PRs drop out of the queue after restart.",
)
.option("--base <branch>", "Base branch (default: repository default)")
.option("--state <path>", "Private daemon state directory (default: $STATE_DIRECTORY or the XDG state home)")
Expand Down
27 changes: 21 additions & 6 deletions src/cli/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,24 @@ export function checkout(root: string): Snapshot {
* every path inside a submodule exists rather than being read from it.
*/
export function snapshot(root: string, commit: string, submodules = true): Snapshot {
const tree = committedSources(root, commit, submodules);
return { docs: tree.sources.map((source) => parse(source.path, source.text)), exists: tree.exists };
}

/** A quest document as committed: its source text and its file mode. */
export interface CommittedSource extends Source {
mode: string;
}

/**
* Every quest document's source at `commit`, a commit or a tree, in tree order, read from git
* objects alone, as `snapshot` reads them, and whether a repository-relative path exists there.
*/
export function committedSources(
root: string,
commit: string,
submodules = true,
): { sources: CommittedSource[]; exists(path: string): boolean } {
const tree = committed(root, commit, submodules);
const quests = tree.entries.filter(
({ mode, type, path }) => type === "blob" && mode !== "120000" && path.startsWith("quest/") && isQuest(path),
Expand All @@ -82,17 +100,14 @@ export function snapshot(root: string, commit: string, submodules = true): Snaps
// One `cat-file --batch` reads every document: `<oid> blob <size>\n<content>\n` each, in input order.
const batch = git(root, ["cat-file", "--batch"], `${quests.map((quest) => quest.oid).join("\n")}\n`);
let offset = 0;
const docs = quests.map(({ path }) => {
const sources = quests.map(({ path, mode }) => {
const header = batch.indexOf(10, offset);
const size = Number(batch.toString("utf8", offset, header).split(" ")[2]);
const text = decode(batch.subarray(header + 1, header + 1 + size), path);
offset = header + size + 2;
return { path, text };
return { path, mode, text };
});
return {
docs: docs.sort((a, b) => comparePaths(a.path, b.path)).map((source) => parse(source.path, source.text)),
exists: tree.exists,
};
return { sources: sources.sort((a, b) => comparePaths(a.path, b.path)), exists: tree.exists };
}

interface Entry {
Expand Down
Loading
Loading