diff --git a/README.md b/README.md index 0efb93ff..fe3743b4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/cli/land-serve.ts b/src/cli/land-serve.ts index ffcffb40..233ece8a 100644 --- a/src/cli/land-serve.ts +++ b/src/cli/land-serve.ts @@ -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, @@ -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, @@ -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`); @@ -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 = { diff --git a/src/cli/land.ts b/src/cli/land.ts index 15b4dccc..3e74aec9 100644 --- a/src/cli/land.ts +++ b/src/cli/land.ts @@ -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 @@ -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 { await read(root, [ "fetch", @@ -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; diff --git a/src/cli/main.ts b/src/cli/main.ts index a8335bc2..a96317c5 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -534,7 +534,7 @@ export function main(argv: string[], out: Output): number | Promise { 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 , 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 , 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 ", "Base branch (default: repository default)") .option("--state ", "Private daemon state directory (default: $STATE_DIRECTORY or the XDG state home)") diff --git a/test/land.test.ts b/test/land.test.ts index 9c2d95bf..076fe6bf 100644 --- a/test/land.test.ts +++ b/test/land.test.ts @@ -49,6 +49,7 @@ import { keyPath, lockCheckout, lockRepository, + readPolicy, resumeFailure, serveBatch, } from "../src/cli/land-serve"; @@ -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) => { + 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" }); @@ -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 () => { @@ -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 () => {