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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -548,8 +548,11 @@ belong to the host; these commands are executable paths, not shell expressions.
Wrappers must expose only the supplied tree and log, never the state directory
or its private bare repository.

The oldest queued PR is the base. Add up to floor(log2 n) further PRs that merge
cleanly in queue order, resolving quest links with `quest resolve`. Conflicting
Batches alternate between the oldest and the newest queued PR as the base. Add
up to floor(log2 n) further PRs that merge cleanly in that order, resolving
quest links with `quest resolve`. A failed batch halves the next batch's size,
down to one PR, and each batch that lands grows it back by one; the order and
size persist in the state directory. Conflicting
additions stay queued. A base conflict ejects only the base. The sandbox checks
the tip once: success lands one squash commit per PR, while failure ejects every
PR in that batch, without bisection. Ejection disables auto-merge, removes
Expand Down
25 changes: 24 additions & 1 deletion src/cli/land-serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { setTimeout as sleep } from "node:timers/promises";
import { GitHub, type GitHubDependencies, type Repository, object, string } from "./github";
import { forgetHeads, prepareHead, resolvePreparedQueue, landFallback, finishFallback } from "./land-head";
import {
type BatchPolicy,
build,
commandEnvironment,
eject,
Expand Down Expand Up @@ -295,6 +296,26 @@ async function mergePinned(github: GitHub, path: string, head: string, previous
}

/** One batch: check once, then squash through GitHub in order, verifying each resulting tree. */
const POLICY = "batch-policy.json";

/** The next batch's order and size; a missing or unreadable record starts oldest-first at full size. */
export function readPolicy(state: string): BatchPolicy {
try {
const data = JSON.parse(readFileSync(join(state, POLICY), "utf8")) as { order?: unknown; size?: unknown };
const order = data.order === "newest" ? "newest" : "oldest";
const size = typeof data.size === "number" && data.size >= 1 ? Math.floor(data.size) : Number.POSITIVE_INFINITY;
return { order, size };
} catch {
return { order: "oldest", size: Number.POSITIVE_INFINITY };
}
}

/** After a checked batch of `checked` PRs: flip the order, halve on failure, and grow by one on success. */
function nextPolicy(state: string, policy: BatchPolicy, checked: number, passed: boolean): void {
const size = passed ? checked + 1 : Math.max(1, Math.floor(checked / 2));
save(join(state, POLICY), { order: policy.order === "oldest" ? "newest" : "oldest", size });
}

export async function serveBatch(
root: string,
plan: Plan,
Expand All @@ -316,7 +337,8 @@ export async function serveBatch(
}
if (plan.queue.length === 0) return output;
const screened = readScreened(options.state);
const chain = await build(root, plan, read, (entry) => screened[entry.number] !== entry.enabledAt);
const policy = readPolicy(options.state);
const chain = await build(root, plan, read, (entry) => screened[entry.number] !== entry.enabledAt, policy);
// Each rejection is its own failure, so a fixer can repair it as it would a failed batch.
for (const { entry, commit, findings } of chain.rejected) {
const log = join(options.state, `check-${Date.now()}-${entry.number}.log`);
Expand Down Expand Up @@ -379,6 +401,7 @@ export async function serveBatch(
} finally {
rmSync(checkout, { recursive: true });
}
nextPolicy(options.state, policy, chain.links.length, code === 0);
if (code !== 0) {
const tail = readFileSync(log, "utf8").trimEnd().split("\n").slice(-100).join("\n");
failure = {
Expand Down
20 changes: 16 additions & 4 deletions src/cli/land.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,11 +486,20 @@ interface Chain {
overlaps: Overlap[];
}

/**
* How the next batch is chosen. Batches alternate between the oldest and the newest queued PR as their base, and a
* failed batch halves the next one's size, which each landed batch grows back toward 1 + floor(log2 n).
*/
export interface BatchPolicy {
order: "oldest" | "newest";
size: number;
}

/**
* Fetch the base and every queued head, then screen each head and squash the
* rest in turn onto the chain in a scratch worktree inside the git directory,
* resolving quest files with `quest resolve`. Stop at the batch limit, skipping
* conflicting additions; a conflicting base stops the batch.
* resolving quest files with `quest resolve`, in `policy`'s order. Stop at its batch
* limit, skipping conflicting additions; a conflicting base stops the batch.
*
* Screening squashes each head alone onto the base and rejects one that fails
* `quest check` there with findings the base lacks. Each head `fresh` accepts is
Expand All @@ -501,6 +510,7 @@ export async function build(
plan: Plan,
read: RemoteRead = readRemote,
fresh: (entry: Entry) => boolean = () => false,
policy: BatchPolicy = { order: "oldest", size: Number.POSITIVE_INFINITY },
): Promise<Chain> {
await read(root, [
"fetch",
Expand All @@ -513,9 +523,11 @@ export async function build(
return mergeWorktree(root, plan.base.sha, (dir, driver) => {
const chain: Chain = { links: [], ...screen(dir, driver, plan, fresh) };
const queue = plan.queue.filter((entry) => !chain.rejected.some((rejection) => rejection.entry === entry));
const ordered = policy.order === "newest" ? [...queue].reverse() : queue;
const limit = Math.max(1, Math.min(policy.size, 1 + Math.floor(Math.log2(queue.length))));
git(dir, ["reset", "--quiet", "--hard", plan.base.sha]);
for (const entry of queue) {
if (chain.links.length >= 1 + Math.floor(Math.log2(queue.length))) break;
for (const entry of ordered) {
if (chain.links.length >= limit) break;
const files = squash(dir, driver, entry);
if (files !== null) {
if (chain.links.length > 0) continue;
Expand Down
2 changes: 1 addition & 1 deletion src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,7 @@ export function main(argv: string[], out: Output): number | Promise<number> {
landCommand
.command("serve")
.description(
"Run one daemon per repository. Before batching, eject alone each PR whose squash onto the base fails quest check with findings the base lacks, and comment once on each newly queued PR that conflicts with PRs ahead of it. Check a base PR plus up to floor(log2 n) cleanly merging PRs once through the sandbox. Failed batches are ejected together, never bisected. 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. 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; invalid results, crashes, and timeouts are handed back. 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 is retried briefly; confirmed conflicts or moved heads eject the remaining batch. 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 and ejects the rest as tree_mismatch. Merged PRs drop out of the queue after restart.",
"Run one daemon per repository. Before batching, eject alone each PR whose squash onto the base fails quest check with findings the base lacks, and comment once on each newly queued PR that conflicts with PRs 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. Failed batches are ejected together, never bisected, and halve the next batch's size down to one PR; each landed batch grows it back by one. 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. 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; invalid results, crashes, and timeouts are handed back. 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 is retried briefly; confirmed conflicts or moved heads eject the remaining batch. 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 and ejects the rest as tree_mismatch. 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
45 changes: 43 additions & 2 deletions test/land.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
keyPath,
lockCheckout,
lockRepository,
readPolicy,
resumeFailure,
serveBatch,
} from "../src/cli/land-serve";
Expand Down Expand Up @@ -973,6 +974,41 @@ describe.skipIf(process.platform === "win32")("host daemon", () => {
expect((await build(forge.clone(), plan)).links).toHaveLength(1 + Math.floor(Math.log2(count)));
});

test("orders a batch newest-first and caps it at the policy's size", async () => {
const forge = new Forge();
for (let i = 0; i < 4; i++) forge.pull(`PR ${i}`, { [`${i}.txt`]: "ok" });
const plan = await resolveQueue(REPOSITORY, undefined, await forge.deps());
const numbers = async (order: "oldest" | "newest", size: number) =>
(await build(forge.clone(), plan, undefined, undefined, { order, size })).links.map(
({ entry }) => entry.number,
);
expect(await numbers("newest", Number.POSITIVE_INFINITY)).toEqual([4, 3, 2]);
expect(await numbers("newest", 1)).toEqual([4]);
expect(await numbers("oldest", 2)).toEqual([1, 2]);
});

test("alternates the batch order, halving after a failure and growing after a pass", async () => {
const forge = new Forge();
for (let i = 0; i < 5; i++) forge.pull(`PR ${i}`, { [`${i}.txt`]: "ok" });
const failing = sandbox("exit 1");
const round = async (options: ReturnType<typeof sandbox>) => {
const deps = await forge.deps();
const plan = await resolveQueue(REPOSITORY, undefined, deps);
const output = await serveBatch(forge.clone(), plan, options, deps, async () => deps.token);
forge.settle();
return output.map((line) => line.split(" ").slice(0, 2).join(" "));
};
// Oldest-first at the full 1 + floor(log2 5): three fail together, and the next batch halves.
expect(await round(failing)).toEqual(["ejected 1", "ejected 2", "ejected 3"]);
expect(readPolicy(failing.state)).toEqual({ order: "newest", size: 1 });
// Newest-first at one.
expect(await round(failing)).toEqual(["ejected 5"]);
expect(readPolicy(failing.state)).toEqual({ order: "oldest", size: 1 });
// The same state with a passing check: oldest-first at one lands, and the next grows.
expect(await round({ ...sandbox(), state: failing.state })).toEqual(["landed 4"]);
expect(readPolicy(failing.state)).toEqual({ order: "newest", size: 2 });
});

test("skips a conflicting addition and leaves it queued, adding a later clean PR", async () => {
const forge = new Forge();
forge.pull("One", { "code.txt": "one\nII\nthree\n" });
Expand Down Expand Up @@ -1011,7 +1047,11 @@ describe.skipIf(process.platform === "win32")("host daemon", () => {
"2": clash.autoMerge!.enabledAt,
});
expect(await serveBatch(forge.clone(), plan, options, deps, fail)).not.toContain("overlap 2 1");
expect(clash.comments).toHaveLength(1);
// The second batch runs newest-first and may eject #2; only the overlap report must not repeat.
const overlaps = clash.comments!.filter(({ body }) =>
body.includes("conflicts with a pull request queued ahead"),
);
expect(overlaps).toHaveLength(1);
});

test("ejects alone a PR that fails quest check on the current base, before batching", async () => {
Expand Down Expand Up @@ -1487,7 +1527,8 @@ mv '${result}.new' '${result}'
(await serveBatch(root, remaining, options, restarted, async () => restarted.token)).map((line) =>
line.split(" ").slice(0, 2).join(" "),
),
).toEqual(["landed 2", "landed 3"]);
// The first batch passed its check, so the next one runs newest-first.
).toEqual(["landed 4", "landed 3"]);
});

test("refreshes API tokens across a long check and after a 401", async () => {
Expand Down
Loading