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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,22 @@ posted with its reason and a recommendation, and the PR leaves the watcher's
care until a maintainer enqueues it again. A round whose pick ended while it
ran, by a writer's push or enqueue, is not applied, and its comment says so.

The watcher also keeps the repository's flaky tests in `flaky.json` in its
state directory: each flaky verdict, across every PR, by the failure signature
the fixer named, with the `merge_group` jobs that showed it. Each failed
build's round is told the tests that flaked before and are not yet disabled, in
`.git/quest-land-flaky.json` in its checkout, which `quest land fix` passes to
the agent. One verdict alone only re-enqueues the PR, since one judgment could
hide a real bug. When a listed test flakes again, the fixer also commits its
disabling on a `disable` branch from the base: the skip in the test
framework's own form, naming the follow-up quest it adds to the repository's
m0 `flaky` epic, `quest/m0/flaky/<name>.md`, to fix or delete the test. The
watcher validates that commit as it does a base fix, requiring exactly one new
follow-up quest, pushes it to `quest-land/flaky-<sha>`, opens it as a pull
request with its Review recorded, and enqueues it like any other, with no
maintainer's pick. A refused disable waits for the test's next flake, and a
pull request GitHub refuses to enqueue says so and waits for a maintainer.

It shares `quest land serve`'s App key, state directory, private repository,
and locks, so the two never run for the same repository at once. Without a
fixer its token asks for `contents: read`, `pull_requests: write`,
Expand Down
31 changes: 31 additions & 0 deletions assets/land-fix.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,37 @@ A fixed head must be the tip of pr-<number>, descend from its pinned head, and
contain only that PR's fix and base history. Leave all other PR branches intact.
Keep the result file uncommitted.

## Flaky tests

A failure is flaky when it comes and goes with no cause in the PR or the base,
such as a timeout under load: requeue the PR unchanged and name the test in its
`failure`. The input may list the repository's known flaky tests, each by the
`failure` an earlier round named and the `merge_group` jobs that showed it.
When the test you judge flaky is one of them, name it exactly as listed, and
disable it in a commit of its own: create a `disable` branch from `base`, and
commit on it only

- the skip, in the test framework's own form, whose reason names the follow-up
quest, such as `#[ignore = "flaky: /quest/m0/flaky/<name>.md"]` in Rust,
`test.skip` with that path in a comment in JavaScript, or
`pytest.mark.skip(reason="flaky: /quest/m0/flaky/<name>.md")` in Python;
- that follow-up quest, `quest/m0/flaky/<name>.md`, with `<name>` the test's
name in lowercase kebab-case, whose Goal says to fix the test or delete it
and links each listed job and this failure's, which the failure log names;
- its link in the `Required` list of `quest/m0/flaky/README.md`, the epic
holding one quest per disabled test, created with a link in
`quest/m0/README.md`'s `Required` if it is missing.

Follow the repository's quest format, and run `quest check` when the
repository has it. A disabled test that is another quest's acceptance test
leaves that quest as it is: the test still counts as its work. Build the
package the skip is in, and add the branch's full SHA to the PR's entry:
{"number":123,"action":"requeue","reason":"healthy; flaky test","failure":"cargo test -p moq-relay: interop::render_timeout","disable":"full 40-character SHA"}
quest land opens the commit as a pull request of its own and enqueues it. Never
disable a test that is not listed. When the repository keeps no `quest/m0/`, or
the skip needs a protected path or more than a mechanical edit, requeue
without `disable`.

## Blame

The supplied PRs stay queued while you work. First decide which of them caused
Expand Down
8 changes: 4 additions & 4 deletions quest/m0/land-fixer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ moq-dev/rust2, moq-dev/rind and moq-dev/quest.
| 17 | conflicts in protected paths (`flake.nix`, `.github/`), mostly mechanical; resolved by hand on moq-dev/moq#7344, #7315, #7318 | blocked by design; fixer-written protected edits become a ready commit ([Protected edits as ready commits](/quest/m0/land-fixer/protected-commit.md)) |
| 17 of the last 40 jobs | `cargo` fails offline ("failed to get `uniffi`"), so Rust fixes were requeued uncompiled: moq-dev/moq#7357 sank three batches, and #6985's lockfile could not be regenerated | fixed by [moq-dev/quest#356](https://github.com/moq-dev/quest/pull/356): the prompt allows dependency fetches and requires a build and test |
| 2 | GitHub refused the push to a fork because the merge touches workflows: moq-dev/moq#7439, #7467 | handed back with a ready commit for `quest land apply` by [moq-dev/quest#358](https://github.com/moq-dev/quest/pull/358) |
| several batches | a flaky ffmpeg timeout ([moq-dev/moq#7480](https://github.com/moq-dev/moq/pull/7480)) ejected whole batches | [Flaky failures](/quest/m0/queue-watcher/flaky.md) |
| several batches | a flaky ffmpeg timeout ([moq-dev/moq#7480](https://github.com/moq-dev/moq/pull/7480)) ejected whole batches | the watcher re-enqueues flaky failures and disables a test seen flaking twice ([moq-dev/quest#406](https://github.com/moq-dev/quest/pull/406)) |
| ~15 | design conflicts, correctly handed back | none |

Decision prompts, selected options marked ✅:
Expand All @@ -66,7 +66,7 @@ Decision prompts, selected options marked ✅:
- Protected paths: the fixer first could edit them (moq-dev/quest#357), then not; see the 2026-11-01 audit below.
- Fork pushes GitHub refuses: ✅ hand back a ready commit, which `quest land apply` rebuilds
for a maintainer to push (Grant moq-bot the `workflows` permission · Leave it manual).
- Flaky tests: see [Flaky failures](/quest/m0/queue-watcher/flaky.md).
- Flaky tests: the watcher re-enqueues them, and disables a test seen flaking twice in a separate PR ([moq-dev/quest#406](https://github.com/moq-dev/quest/pull/406)).
- Milestone for the new quests: ✅ m0 · m1
- Restated 2026-11-01 (user, a second /quest-plan): "the merge queue can
autonomously fix most issues. The fixer inspects the batch and tries to
Expand Down Expand Up @@ -136,8 +136,8 @@ Decision prompts, selected options marked ✅:
[moq-dev/quest#384](https://github.com/moq-dev/quest/pull/384)),
local mergeability and CI-failure parking (then
Bad picks, dropped 2026-11-02: a pick GitHub refuses fails `quest merge`
instead, and the watcher only enqueues armed picks GitHub never queued), and flaky tests (now
[Flaky failures](/quest/m0/queue-watcher/flaky.md), without the rerun).
instead, and the watcher only enqueues armed picks GitHub never queued), and flaky tests (done
without the rerun in [moq-dev/quest#406](https://github.com/moq-dev/quest/pull/406)).
Deleted: the batch quest check, pre-checks, and throughput tuning, which
only served batches (the fixer cap and reasoning effort moved to the
watcher, as `quest land watch --fixer-jobs` and `--fixer-effort`); and queue status in
Expand Down
3 changes: 1 addition & 2 deletions quest/m0/queue-watcher/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Decision prompts, selected options marked ✅:
- Flaky failures:
- ✅ The fixer re-enqueues them unchanged, and a test seen flaking twice is
disabled in a separate PR with a follow-up quest; the rerun before blame
(#375) is dropped. See [Flaky failures](/quest/m0/queue-watcher/flaky.md).
(#375) is dropped. Done in [moq-dev/quest#406](https://github.com/moq-dev/quest/pull/406).
- Pick record (decided 2026-11-02, replacing "Picks GitHub refuses to
enqueue: ✅ The watcher fixes them"):
- ✅ GitHub's records only. Drop the comment and `pick.ts`. The watcher
Expand Down Expand Up @@ -129,7 +129,6 @@ Shared shape, for the children to follow:

## Required

- [Flaky failures](/quest/m0/queue-watcher/flaky.md) - a flaky failure is re-enqueued unchanged, and a test seen flaking twice is disabled in a separate PR with a follow-up quest
- [Watcher on the host](/quest/m0/queue-watcher/host.md) - kixelated/nixos runs the watcher for moq-dev/moq beside its fixer container
- [The other repositories follow](/quest/m0/queue-watcher/follow.md) - condition: quest, rind and rust2 are cut over; rind and rust2 each land a PR, and a fork PR lands
- [Harden the queue runners](/quest/m0/queue-watcher/runners.md) - each queue runner runs only its repository's queue workflow, and every outside fork PR needs approval
Expand Down
53 changes: 0 additions & 53 deletions quest/m0/queue-watcher/flaky.md

This file was deleted.

58 changes: 51 additions & 7 deletions src/cli/land-fix.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Untrusted agent side. The host wrapper supplies only a model endpoint and its client key; the agent holds no
// GitHub credential, and the daemon posts the comments it writes into its result.
import { spawn, spawnSync } from "node:child_process";
import { constants, closeSync, fstatSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { constants, closeSync, existsSync, fstatSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import prompt from "../../assets/land-fix.md";
import { programEnvironment, requireProgram } from "./program";
Expand All @@ -13,16 +13,52 @@ export class FixerAgentError extends Error {
}

export const FIX_RESULT = "quest-land-result.json";
export function readFixResult(root: string): string {
const fd = openSync(join(root, FIX_RESULT), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
/**
* The repository's known flaky tests, which a daemon may write in the checkout's `.git` before a round,
* where no commit can carry it: a JSON array of `KnownFlaky`.
*/
export const FLAKY_INPUT = "quest-land-flaky.json";

/** A test the fixer judged flaky before, by the `failure` it named, with the `merge_group` jobs that showed it. */
export interface KnownFlaky {
failure: string;
jobs: string[];
}

/** A regular file of at most 100 KB, never through a link. */
function readBounded(path: string, what: string): string {
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const stat = fstatSync(fd);
if (!stat.isFile() || stat.size > 100_000) throw new Error("fixer result must be a regular file under 100 KB");
if (!stat.isFile() || stat.size > 100_000) throw new Error(`${what} must be a regular file under 100 KB`);
return readFileSync(fd, "utf8");
} finally {
closeSync(fd);
}
}

export function readFixResult(root: string): string {
return readBounded(join(root, FIX_RESULT), "fixer result");
}

/** The known flaky tests the daemon left in `root`'s `.git`, if any, as the agent's input lists them. */
function knownFlaky(root: string): KnownFlaky[] {
const path = join(root, ".git", FLAKY_INPUT);
if (!existsSync(path)) return [];
const value = json(readBounded(path, "known flaky tests"));
if (
!Array.isArray(value) ||
!value.every(
(item) =>
item &&
typeof item.failure === "string" &&
Array.isArray(item.jobs) &&
item.jobs.every((job: unknown) => typeof job === "string"),
)
)
throw new Error("invalid known flaky tests");
return value as KnownFlaky[];
}
/** The longest Markdown comment a result may ask the daemon to post on its PR. */
export const COMMENT_LIMIT = 4000;
/** The longest failure signature a result may name, short enough to quote in a handback. */
Expand All @@ -32,10 +68,12 @@ const FAILURE_LIMIT = 200;
* The fixer's verdict on one PR. `blame` names a PR whose own change caused the failure; a blamed PR
* is fixed or handed back, never requeued. A handback's `log` cites the failure log's lines, first
* to last, that show the failure, which its comment quotes. `failure` names the failing test or step,
* the signature the stop rules compare across a pick's rounds.
* the signature the stop rules compare across a pick's rounds. A requeue of a known flaky test's
* failure may carry `disable`: the tip of the fixer's `disable` branch, a commit on the base that skips
* the test and adds its follow-up quest, which the daemon opens as a PR of its own.
*/
export type FixResult = { agent?: string; model?: string; blame?: boolean; comment?: string; failure?: string } & (
| { number: number; action: "requeue"; reason: string }
| { number: number; action: "requeue"; reason: string; disable?: string }
| { number: number; action: "fixed"; head: string; reason: string }
| { number: number; action: "handback"; diagnosis: string; recommendation: string; log?: [number, number] }
);
Expand Down Expand Up @@ -99,6 +137,11 @@ function validate(item: any, pr: boolean): void {
throw new Error("invalid fixer failure");
metadata.push("failure");
}
if (pr && "disable" in item) {
if (item.action !== "requeue" || typeof item.disable !== "string" || !/^[a-f0-9]{40}$/.test(item.disable))
throw new Error("invalid fixer disable");
metadata.push("disable");
}
// One optional Markdown comment per PR: multiline, but no other control characters.
if (pr && "comment" in item) {
if (
Expand Down Expand Up @@ -179,6 +222,7 @@ export async function fix(
if (!args && (!process.env.OPENAI_BASE_URL || !process.env.OPENAI_API_KEY))
throw new Error("fix needs OPENAI_BASE_URL and OPENAI_API_KEY from the container wrapper");
readFileSync(log); // Fail before starting an agent when the wrapper did not expose the log.
const flaky = numbers.length ? knownFlaky(root) : [];
// The daemon pinned each PR's head on its pr-<n> branch; name it so the agent need not look it up.
const prs = numbers.map((number) => {
const ref = `pr-${number}`;
Expand Down Expand Up @@ -240,7 +284,7 @@ export async function fix(
child.once("error", reject);
child.stdin.on("error", () => {});
child.stdin.end(
`${prompt}\n\nFailure log: ${JSON.stringify(log)}\nPRs:${prs.length ? `\n${prs.join("\n")}` : " none; the base fails alone"}\n`,
`${prompt}\n\nFailure log: ${JSON.stringify(log)}\nPRs:${prs.length ? `\n${prs.join("\n")}` : " none; the base fails alone"}\n${flaky.length ? `Known flaky tests: ${JSON.stringify(flaky)}\n` : ""}`,
);
child.once("close", (code) => (code === 0 ? done() : reject(new FixerAgentError(code ?? 1))));
});
Expand Down
2 changes: 1 addition & 1 deletion src/cli/land-fixer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,7 +692,7 @@ export function author(result?: Pick<FixResult, "agent" | "model">): string {
);
}
/** The PR body with the fix recorded in its `## Review` section; the line names the agent, and the author's text stays as written. */
function review(body: string, head: string, reason: string, attribution: string): string {
export function review(body: string, head: string, reason: string, attribution: string): string {
const line = `mechanical fix by ${attribution}: ${fixerText(reason)} (head ${head})`;
if (body.includes(line)) return body;
const section = /^## Review[ \t]*\r?$/m.exec(body);
Expand Down
Loading
Loading