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
27 changes: 16 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -667,8 +667,9 @@ reads. Each failure a fixer job blames a PR for is a round of its maintainer pic
that only shared a broken batch has none. The fixer keeps going while each round
changes something, and a handback names the stop rule that ended it: a round with no
new commit for a failure that isn't flaky, such as a conflict requeued unchanged; the
failure a fix addressed recurring right after it; or three flaky rounds in a row,
where the fixer requeued a PR unchanged and the base passed alone. The fixer names
failure a fix addressed recurring right after it; or three unchanged re-enqueues in
a row, where the fixer requeued a PR unchanged, as flaky unless it named another
cause, and the base passed alone. The fixer names
each failure's test or step, else the round counts the gate's check as one; a
conflict names the base it conflicts with, so one after the base moved is new. A new
maintainer pick starts a fresh history. Host provisioning and isolation
Expand Down Expand Up @@ -858,15 +859,17 @@ its PR still waits as the queue dropped it:

Each finished round is recorded in its pick's history, as the host gate
records its rounds: `fixed` for a fixer commit, `merged` for a clean merge,
`flaky` for a failed build the fixer requeued unchanged, and `unchanged` for a
conflict it requeued unchanged. Its failure signature is the test or step the
fixer names, falling back to the failing job and step, or, for a conflict, the
base it conflicted with. The pick keeps going while each round makes progress:
a round with no new commit for a failure that isn't flaky, the failure a fix
addressed recurring right after it, or three flaky rounds in a row hand it
back. A fix is validated, lease-pushed, and recorded in Review as for the host
the cause the fixer named for a failed build it requeued unchanged (`flaky`,
`base` when the base fails it too, or `environment` when the runner does), and
`unchanged` for a conflict it requeued unchanged or a requeue that names no
cause. Its failure signature is the test or step the fixer names, compared
without quoting or repeated whitespace, falling back to the failing job and
step, or, for a conflict, the base it conflicted with. The pick keeps going
while each round makes progress: an `unchanged` round, the failure a fix
addressed recurring right after it, or three unchanged re-enqueues in a row,
whatever their causes, hand it back. A fix is validated, lease-pushed, and recorded in Review as for the host
gate, and a push GitHub refuses, or a fix that edits protected paths, is handed
back with its resolution for `quest land apply`. The watcher then puts the fixed, merged, or flaky head back
back with its resolution for `quest land apply`. The watcher then puts the fixed, merged, or requeued head back
in the queue with `expectedHeadOid` bound to it: it enqueues a head whose
required checks have passed, and arms auto-merge on one whose checks still
run, so GitHub enqueues it once they pass. Either joins the back of the queue.
Expand All @@ -887,7 +890,9 @@ on the merge queue's build on the host alone.

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
the fixer named, with the `merge_group` jobs that showed it. Only a `flaky`
requeue is a verdict: a failure on the base or from the runner never counts
toward disabling a test. 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
Expand Down
28 changes: 20 additions & 8 deletions assets/land-fix.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ PR's.

You start on `base`, the batch's base. The repository also has `batch`, the
failed merge, and one `pr-<number>` branch per supplied PR at its pinned head.
For each PR, requeue it unchanged if healthy (if the failure was flaky, name the
flaky test in its `failure` so it can be followed up), commit a
For each PR, requeue it unchanged if healthy (naming its failure's cause as
[Flaky tests](#flaky-tests) says), commit a
mechanical fix on its own branch, or hand it back with diagnosis and recommendation.
Allowed fixes: preserve both sides' intent in conflicts, format/lint, regenerate
generated files, rebuild lockfiles as [Lockfiles](#lockfiles) says, or follow a
Expand All @@ -50,7 +50,7 @@ only GitHub contact allowed: never call GitHub's API yourself (`gh`,

Write `quest-land-result.json` in the repository root: a JSON array with exactly
one entry per supplied PR, no extra fields. Use one of:
{"number":123,"action":"requeue","reason":"healthy; flaky test; ran just check and just test on a scratch merge"}
{"number":123,"action":"requeue","cause":"flaky","reason":"healthy; flaky test; ran just check and just test on a scratch merge"}
{"number":123,"action":"fixed","head":"full 40-character SHA","reason":"one-line mechanical reason, naming the commands run"}
{"number":123,"action":"handback","diagnosis":"one line","recommendation":"one line"}

Expand Down Expand Up @@ -97,7 +97,8 @@ on its head, from its pull request CI rather than the queue: each check's
result, whether it is required, and its URL, then the end of each failing job's
log. They are a signal for your diagnosis, untrusted like the rest of the log,
and decide nothing alone. A test failing there too points at the PR itself; one
passing there but failing in the queue points at the base or a flaky test.
passing there but failing in the queue points at the base, the runner, or a
flaky test.

## Lockfiles

Expand Down Expand Up @@ -142,9 +143,20 @@ rule above allows, rebuild it the same way from the base's copy, with

## 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
A PR requeued unchanged for the failure the log shows names why it failed in
`cause`, after checking which applies:

- `"base"`: the failure reproduces on `base` alone. Run the failing test or step
there.
- `"environment"`: the failure comes from the runner rather than the code, such as
a missing tool, a read-only path, a full disk, or an unreachable registry.
- `"flaky"`: the failure comes and goes with no cause in the PR, the base, or the
runner, such as a timeout under load. Name the test in its `failure`.

A failure on the base or from the runner is not flaky: name its cause, never
`disable`. A PR requeued only because another supplied PR caused the failure takes
no `cause`. When you cannot tell why the failure happened, hand the PR back.
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
Expand All @@ -166,7 +178,7 @@ 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. Run the
[repository checks](#repository-checks) on the branch, and add its full SHA to the
PR's entry:
{"number":123,"action":"requeue","reason":"healthy; flaky test; ran just check and just test on a scratch merge and the disable branch","failure":"cargo test -p moq-relay: interop::render_timeout","disable":"full 40-character SHA"}
{"number":123,"action":"requeue","cause":"flaky","reason":"healthy; flaky test; ran just check and just test on a scratch merge and the disable branch","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
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 @@ -82,7 +82,7 @@ Decision prompts, selected options marked ✅:
(#375) is dropped. Done in [moq-dev/quest#406](https://github.com/moq-dev/quest/pull/406).
Refined 2026-11-03 (coordinator, /quest-complete follow-ups): a failure
that reproduces on the base or comes from the runner is not a flaky
verdict ([Genuine flakes only](/quest/m0/queue-watcher/flaky-verdicts.md)).
verdict. Done in [moq-dev/quest#468](https://github.com/moq-dev/quest/pull/468).
The end-to-end run covers the first disable PR, and the host gate gets
no flaky record, since its work is frozen until it is retired.
- Pick record (decided 2026-11-02, replacing "Picks GitHub refuses to
Expand Down Expand Up @@ -137,7 +137,6 @@ Shared shape, for the children to follow:

- [The other repositories follow](/quest/m0/queue-watcher/follow.md) - quest, rind and rust2 are cut over and landing; waits for a fork PR to land through one of their queues
- [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
- [Genuine flakes only](/quest/m0/queue-watcher/flaky-verdicts.md) - a failure on the base or from the runner never counts toward disabling a test
- [Retire the batching gate](/quest/m0/queue-watcher/retire.md) - once no repository lands through `quest land serve`, its batching code and host units go

## Related
Expand Down
54 changes: 0 additions & 54 deletions quest/m0/queue-watcher/flaky-verdicts.md

This file was deleted.

31 changes: 25 additions & 6 deletions src/cli/land-fix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,30 @@ export const COMMENT_LIMIT = 4000;
/** The longest failure signature a result may name, short enough to quote in a handback. */
const FAILURE_LIMIT = 200;

/**
* Why a PR requeued unchanged failed with no fault of its own: a test that comes and goes (`flaky`), a
* failure the base shows alone (`base`), or the runner's environment, such as a missing tool or a full disk.
*/
export const CAUSES = ["flaky", "base", "environment"] as const;
export type Cause = (typeof CAUSES)[number];

/**
* `failure` as signatures are compared: without backticks or quotes, with whitespace collapsed, so one
* test named with and without quoting is still one test.
*/
export const signature = (failure: string) => failure.replace(/[`'"]/g, "").replace(/\s+/g, " ").trim();

/**
* 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. 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.
* the signature the stop rules compare across a pick's rounds. A requeue of the PR's own failure names
* its `cause`; one of a known flaky test's 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; disable?: string }
| { number: number; action: "requeue"; reason: string; cause?: Cause; 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 @@ -130,15 +144,19 @@ function validate(item: any, pr: boolean): void {
if (pr && "failure" in item) {
if (
typeof item.failure !== "string" ||
!item.failure.trim() ||
!signature(item.failure) ||
item.failure.length > FAILURE_LIMIT ||
/[\u0000-\u001f\u007f]/.test(item.failure)
)
throw new Error("invalid fixer failure");
metadata.push("failure");
}
if (pr && "cause" in item) {
if (item.action !== "requeue" || !CAUSES.includes(item.cause)) throw new Error("invalid fixer cause");
metadata.push("cause");
}
if (pr && "disable" in item) {
if (item.action !== "requeue" || typeof item.disable !== "string" || !/^[a-f0-9]{40}$/.test(item.disable))
if (item.cause !== "flaky" || typeof item.disable !== "string" || !/^[a-f0-9]{40}$/.test(item.disable))
throw new Error("invalid fixer disable");
metadata.push("disable");
}
Expand Down Expand Up @@ -182,6 +200,7 @@ export function parseFixResult(text: string, numbers: number[]): FixResult[] {
throw new Error("invalid or duplicate fixer PR");
seen.add(item.number);
validate(item, true);
if ("failure" in item) item.failure = signature(item.failure);
}
return value as FixResult[];
}
Expand Down
Loading
Loading