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
20 changes: 20 additions & 0 deletions .changes/pr-pipeline/fix-address-feedback-earlier-fail-040eae83.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
bump: patch
---

### Fixed

- A lanes App pull request the merge gate refuses `verify:earlier-fail` no longer dead-ends once
its pr-verify fail-note threads are resolved. The dispatcher sends an address-feedback model
round naming the FAILed heads (`verify-earlier-fail-heads`, from the reader's `earlierFails`)
even with no unresolved thread. `address-feedback-select`, now with `actions: read`, reads
those FAILs' notes from their verdict artifacts as the model's work items, and stops
`feedback-verdict-unreadable` when it cannot. A push fixing a named note ends as any fix
round; no push with every note already addressed stops `feedback-already-addressed` with
`needs-session`.
- The dispatcher reads for an earlier FAIL on a PR with no unresolved thread only when a fail note
from another head is on it, at most three times per pass. A read that fails or times out is
not taken as "no earlier FAIL": the unsent sweep sends that head again on a later pass while its
newest review run completed within the last two hours. Nothing records an answer.
- `earlier-fail-notes.mjs` joins the hold floor as an enforcement path, since the merge gate's
code loads it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Fixed

- The `comments --ci` review-thread tool (`feedback-thread.sh`) takes the notes of an earlier pr-verify FAIL as work items: `notes` lists them, and `record` takes `VALID` or `already-addressed` (with evidence) for a `VNOTE_` id, with each note's path in the fix's scope. The pr-unblock lane uses this when the merge gate refuses a head `verify:earlier-fail` and the FAIL's threads are already resolved.
2 changes: 1 addition & 1 deletion .github/actions/collect-base-activity/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,7 @@ runs:
for file in threads.json run.json feedback-thread.sh "bin/**" "state/**"; do
denied+=("Edit(./.lane/feedback/$file)" "Write(./.lane/feedback/$file)" "NotebookEdit(./.lane/feedback/$file)")
done
preamble="$preamble The review-thread tool is $tool: run it by exactly this path as $tool list, $tool check-paths <path>... and $tool record <thread-id> <file>, with each disposition file in $lane/scratch. The tool posts and commits nothing: a later job pushes your uncommitted fix and posts what you record. To revert an executable bit the pull request added, run chmod -x on that file: the fix carries the mode, and only a mode back to the base's is pushed."
preamble="$preamble The review-thread tool is $tool: run it by exactly this path as $tool list, $tool notes, $tool check-paths <path>... and $tool record <thread-id> <file>, with each disposition file in $lane/scratch. The tool posts and commits nothing: a later job pushes your uncommitted fix and posts what you record. To revert an executable bit the pull request added, run chmod -x on that file: the fix carries the mode, and only a mode back to the base's is pushed."
# The fresh fixer round past the round cap: the select job's
# fixed brief (address-feedback.mjs FRESH_ROUND_BRIEF), never
# text a head, a finding or a model wrote.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1642,7 +1642,7 @@ test("run-claude-cli: the preamble names the review-thread tool's path and subco
const run = launchThreads();
assert.equal(run.status, 0, run.stdout);
const sentence =
`The review-thread tool is ${FEEDBACK_TOOL}: run it by exactly this path as ${FEEDBACK_TOOL} list, ` +
`The review-thread tool is ${FEEDBACK_TOOL}: run it by exactly this path as ${FEEDBACK_TOOL} list, ${FEEDBACK_TOOL} notes, ` +
`${FEEDBACK_TOOL} check-paths <path>... and ${FEEDBACK_TOOL} record <thread-id> <file>, with each disposition file in ${WORKSPACE}/.lane/scratch. ` +
"The tool posts and commits nothing: a later job pushes your uncommitted fix and posts what you record. " +
"To revert an executable bit the pull request added, run chmod -x on that file: the fix carries the mode, and only a mode back to the base's is pushed.";
Expand Down
6 changes: 5 additions & 1 deletion .github/actions/resolve-config/callers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5585,7 +5585,11 @@ test("pr-unblock.yml: the select job runs only base code with a read-only token
const { jobs } = workflow("pr-unblock.yml");
const select = jobs["address-feedback-select"];
assert.equal(select.uses, undefined, "a script job, never a runner that runs a model");
assert.deepEqual(select.permissions, { contents: "read", "pull-requests": "read", checks: "read" });
// actions read: an earlier FAIL's verdict artifact and its pr-verify run
// (earlier-fail-notes.mjs), every scope read-only.
assert.deepEqual(select.permissions, { contents: "read", "pull-requests": "read", checks: "read", actions: "read" });
assert.equal(select.steps.find((s) => s.id === "select").env.VERIFY_EARLIER_FAIL_HEADS, "${{ inputs.verify-earlier-fail-heads }}");
assert.equal(workflow("pr-unblock.yml").on.workflow_dispatch.inputs["verify-earlier-fail-heads"].type, "string");
assert.doesNotMatch(JSON.stringify(select), /secrets\./);
for (const step of select.steps.filter((s) => String(s.uses ?? "").startsWith("actions/checkout@"))) {
assert.equal(step.with.ref, "${{ github.event.repository.default_branch }}");
Expand Down
14 changes: 10 additions & 4 deletions .github/scripts/apply-patch/apply-patch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ import { pathToFileURL } from "node:url";
import { BODY_HOLD, LANES_APP_LOGIN } from "../pr-merge/merge-holds.mjs";
import { fixRefusal, parseConfig } from "../pr-unblock/dependency-classify.mjs";
import { stopSkip } from "../pr-unblock/escalation-routes.mjs";
import { THREAD_ID, threadTrailerLines } from "../pr-unblock/thread-scope.mjs";
import { NOTE_ID, THREAD_ID, threadTrailerLines } from "../pr-unblock/thread-scope.mjs";
import { PREDICATES } from "./predicates.mjs";
import { PROTECTED_REFUSALS, segments } from "./protected-paths.mjs";
import { retryFetch } from "./retry-fetch.mjs";
Expand Down Expand Up @@ -1191,13 +1191,19 @@ function need(env, keys) {

// The kept threads of a threads.json: each a path and, when a fix request
// named files, its named_paths; the files the PR already changes, its
// pr_paths; and the thread ids of the select job's shape, in its order.
// pr_paths; and the thread ids of the select job's shape, in its order. A
// pr-verify note of an earlier FAIL (verify_notes) counts as a thread on its
// path, by its note id.
export function keptThreadsFrom(file) {
if (!file) {
return undefined;
}
const { threads, pr_paths: prPaths = [] } = JSON.parse(readFileSync(file, "utf8")) ?? {};
const { threads: kept, verify_notes: notes = [], pr_paths: prPaths = [] } = JSON.parse(readFileSync(file, "utf8")) ?? {};
const plain = (value) => typeof value === "string" && value !== "";
if (!Array.isArray(notes) || !notes.every((note) => NOTE_ID.test(String(note?.id)) && plain(note.path))) {
throw new Error("KEPT_THREADS_FILE holds a malformed verify_notes list");
}
const threads = Array.isArray(kept) ? [...kept, ...notes.map(({ id, path }) => ({ id, path }))] : kept;
if (
!Array.isArray(threads) ||
!Array.isArray(prPaths) ||
Expand All @@ -1213,7 +1219,7 @@ export function keptThreadsFrom(file) {
paths: new Set(threads.map((thread) => thread.path)),
threads: threads.map(({ path, named_paths }) => ({ path, named_paths })),
prPaths: new Set(prPaths),
ids: threads.map((thread) => thread.id).filter((id) => typeof id === "string" && THREAD_ID.test(id)),
ids: threads.map((thread) => thread.id).filter((id) => typeof id === "string" && (THREAD_ID.test(id) || NOTE_ID.test(id))),
};
}

Expand Down
18 changes: 18 additions & 0 deletions .github/scripts/apply-patch/apply-patch.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1099,6 +1099,24 @@ test("keptThreadsFrom keeps each thread id of the select job's shape, in the fil
assert.deepEqual(keptThreadsFrom(file).ids, ["PRRT_2", "PRRT_1"]);
});

// #12809: an earlier-fail round keeps no thread, only the FAIL's notes, each a
// path and a VNOTE_ id: the fix's scope and its Lane-Thread trailer take them
// as threads.
test("keptThreadsFrom takes an earlier FAIL's notes as threads on their paths, by note id", (t) => {
const file = join(scratch(t), "threads.json");
writeFileSync(file, JSON.stringify({ threads: [], verify_notes: [{ id: "VNOTE_1", head: "b".repeat(40), path: "src/a.js", body: "x" }, { id: "VNOTE_2", head: "b".repeat(40), path: "docs/b.md", body: "y" }], pr_paths: ["src/a.js"] }));
const kept = keptThreadsFrom(file);
assert.deepEqual(kept.paths, new Set(["src/a.js", "docs/b.md"]));
assert.deepEqual(kept.ids, ["VNOTE_1", "VNOTE_2"]);
const replies = join(scratch(t), "replies.jsonl");
writeFileSync(replies, [replyLine("VNOTE_2", "VALID"), replyLine("VNOTE_1", "already-addressed")].join("\n"));
assert.deepEqual(fixedThreadsFrom(replies, kept), ["VNOTE_2"]);
for (const notes of [[{ id: "VNOTE_0", path: "a" }], [{ id: "PRRT_1", path: "a" }], [{ id: "VNOTE_1" }], "VNOTE_1"]) {
writeFileSync(file, JSON.stringify({ threads: [], verify_notes: notes }));
assert.throws(() => keptThreadsFrom(file), /KEPT_THREADS_FILE/, JSON.stringify(notes));
}
});

// replies.jsonl as the model job's scan job uploads it, one reply per line.
const replyLine = (thread, disposition) => JSON.stringify({ thread, disposition, evidence: ["a:1"], fix_sha: "", note: "n" });

Expand Down
1 change: 1 addition & 0 deletions .github/scripts/pr-merge/merge-holds.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ export const HOLD_PATH_CLASSES = Object.freeze([
[".github/scripts/pr-unblock/fix-ci-context.mjs", "enforcement"],
[".github/scripts/pr-unblock/feedback-dispatch.mjs", "enforcement"],
[".github/scripts/pr-unblock/verify-notes.mjs", "enforcement"],
[".github/scripts/pr-unblock/earlier-fail-notes.mjs", "enforcement"],
[".github/scripts/pr-unblock/limit-stop.mjs", "enforcement"],
[".github/scripts/lib/lane-outcome.mjs", "enforcement"],
[".github/scripts/pr-unblock/dispatch-record.mjs", "enforcement"],
Expand Down
134 changes: 118 additions & 16 deletions .github/scripts/pr-unblock/address-feedback.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -983,6 +983,59 @@ export function sessionQueued({ labels = [], issueComments, headSha, isLanesApp
});
}

// The files listing stops at FILES_CAP, so a longer diff could hide a
// .github/workflows/ path and is treated as touching one.
const touchesProtected = (files) => files.length >= FILES_CAP || files.some((file) => isProtectedPath(file.filename) || isProtectedPath(file.previous_filename ?? ""));

// The rounds the PR has had: round markers or model check runs on its
// commits, whichever count more, and those check runs.
async function roundsSoFar(api, pr, issueComments, appId) {
const commits = await api.listPullCommits(pr);
const checkRuns = [];
for (const commit of commits) {
checkRuns.push(await api.listCheckRuns(commit.sha, MODEL_CHECK));
}
return { rounds: Math.max(countRounds(issueComments, appId), countCheckRounds(checkRuns)), checkRuns };
}

// A round for the notes of the FAILs behind a verify:earlier-fail refusal:
// no review thread, the notes as work items (`verify_notes`, each an id, the
// FAILed head, the file its note comment would go on, and that comment's
// body), which the model fixes or reports already addressed
// (publish-feedback.mjs). Notes that cannot be read stop the round failing
// (feedback-verdict-unreadable), never as a success; past the round cap and
// the fresh fixer's round it stops feedback-verifier-open.
async function earlierFailRound({ api, pr, headSha, repository, appId, files, issueComments, heads, labeled, maxRounds, earlierFailHeads, readFailNotes, ci, log }) {
if (touchesProtected(files)) {
return { decision: "escalate", escalationReason: "feedback-protected-path", threads: [] };
}
let notes;
try {
notes = await readFailNotes({ heads: earlierFailHeads, files: files.map((file) => file.filename) });
} catch (error) {
log(`::warning::#${pr}: the notes of pr-verify's FAIL at ${earlierFailHeads.join(", ")} could not be read from its verdict artifact (${String(error?.message ?? error).slice(0, 300)})`);
return { decision: "escalate", escalationReason: "feedback-verdict-unreadable", threads: [] };
}
const { rounds } = await roundsSoFar(api, pr, issueComments, appId);
const bound = maxRounds + FRESH_FIXER_ROUNDS;
if (rounds >= bound) {
return { decision: "escalate", escalationReason: "feedback-verifier-open", rounds, maxRounds: bound, threads: [] };
}
log(`earlier-fail: ${notes.length} note item(s) from pr-verify's FAIL at ${earlierFailHeads.join(", ")}`);
return {
decision: "proceed",
modelRung: retryRung(await api.listCheckRuns(headSha, REPLY_CHECK)),
rounds,
maxRounds: bound,
// A gated PR returned awaiting-human before any thread was kept.
humanGate: labeled ? "lane-escalation" : "",
threads: [],
file: { version: 1, repository, pr, head_sha: headSha, pr_paths: prPathsOf(files), threads: [], verify_notes: notes },
reviewedHeads: [...heads].sort(),
ci: await ci({ headSha, files }),
};
}

export async function select({
api,
pr,
Expand All @@ -996,6 +1049,13 @@ export async function select({
maxRounds = DEFAULT_MAX_ROUNDS,
adoption = NO_ADOPTION,
ci = async () => undefined,
// The FAILed heads behind the merge gate's verify:earlier-fail refusal
// (input verify-earlier-fail-heads), and the reader of their notes
// (earlier-fail-notes.mjs), for a round with no thread left to work.
earlierFailHeads = [],
readFailNotes = async () => {
throw new Error("no reader of the FAILs' verdict artifacts");
},
log = () => {},
}) {
// Serial reads: element 6.
Expand Down Expand Up @@ -1081,24 +1141,27 @@ export async function select({
if (untaken && sessionQueued({ labels: pull.labels, issueComments, headSha, isLanesApp })) {
return { decision: "skip", skipReason: "awaiting-session", threads: kept };
}
return untaken
? { decision: "escalate", escalationReason: "feedback-untaken-threads", threads: kept }
: { decision: "skip", skipReason: "stop-resolved", resolved: true, threads: kept };
}
// The files listing stops at FILES_CAP, so a longer diff could hide a
// .github/workflows/ path and is treated as touching one.
if (
files.length >= FILES_CAP ||
files.some((file) => isProtectedPath(file.filename) || isProtectedPath(file.previous_filename ?? ""))
) {
return { decision: "escalate", escalationReason: "feedback-protected-path", threads: kept };
if (untaken) {
return { decision: "escalate", escalationReason: "feedback-untaken-threads", threads: kept };
}
// The merge gate refuses the head verify:earlier-fail, so a round must
// move it; the FAIL's notes come from its verdict artifact, since their
// threads are resolved (#12809).
// Its feedback-already-addressed stop releases the head (a reply job's
// lane-stopped), so a stop of this head already queued for a session
// builder stands down here too, before any read or model turn.
if (earlierFailHeads.length > 0 && sessionQueued({ labels: pull.labels, issueComments, headSha, isLanesApp })) {
return { decision: "skip", skipReason: "awaiting-session", threads: kept };
}
if (earlierFailHeads.length > 0) {
return earlierFailRound({ api, pr, headSha, repository, appId, files, issueComments, heads, labeled, maxRounds, earlierFailHeads, readFailNotes, ci, log });
}
return { decision: "skip", skipReason: "stop-resolved", resolved: true, threads: kept };
}
const commits = await api.listPullCommits(pr);
const checkRuns = [];
for (const commit of commits) {
checkRuns.push(await api.listCheckRuns(commit.sha, MODEL_CHECK));
if (touchesProtected(files)) {
return { decision: "escalate", escalationReason: "feedback-protected-path", threads: kept };
}
const rounds =Math.max(countRounds(issueComments, appId), countCheckRounds(checkRuns));
const { rounds, checkRuns } = await roundsSoFar(api, pr, issueComments, appId);
// The most automatic rounds a PR gets: the cap and the fresh fixer's round.
const bound = maxRounds + FRESH_FIXER_ROUNDS;
let worked = kept;
Expand Down Expand Up @@ -1187,6 +1250,7 @@ export function runRecord(file, reviewedHeads, ci) {
})),
...(file.stale_notes ? { stale_notes: file.stale_notes } : {}),
...(file.superseded_head ? { superseded_head: file.superseded_head } : {}),
...(file.verify_notes ? { verify_notes: file.verify_notes.map(({ id, head, path }) => ({ id, head, path })) } : {}),
...(reviewedHeads ? { reviewed_heads: reviewedHeads } : {}),
...(ci ? { ci } : {}),
};
Expand Down Expand Up @@ -1415,6 +1479,18 @@ export function createGitHubApi({ token, repository, apiUrl, graphqlUrl, fetch:
async getCommit(sha) {
return (await request(`${repo}/commits/${sha}`)).data;
},
// A GET of `path` under the API root, for earlier-fail-notes.mjs (actions
// and contents read).
readPath: async (path) => (await request(`${apiUrl}${path}`)).data,
// An artifact's zip, which redirects to a short-lived download URL; fetch
// follows it.
async downloadArtifact(id) {
const response = await fetch(`${repo}/actions/artifacts/${id}/zip`, { headers });
if (!response.ok) {
throw Object.assign(new Error(`GET artifact ${id} zip returned ${response.status}`), { status: response.status });
}
return Buffer.from(await response.arrayBuffer());
},
// A file's text at `ref` through the raw media type, which serves files
// over 1 MB (docs.github.com/en/rest/repos/contents#get-repository-content,
// as of 2026-10-21; recheck when that section changes its size limits);
Expand Down Expand Up @@ -1630,6 +1706,25 @@ export function reviewerIdsFrom(text) {
.map((id) => positive(id, "an id in REVIEWER_IDS"));
}

// VERIFY_EARLIER_FAIL_HEADS, the FAILed heads a dispatcher run named
// (space-separated commit ids, at most 10, none twice); none when unset. Only
// a dispatcher run sends it, and never with a pr-verify pass head.
export function earlierFailHeadsFrom(env) {
const heads = String(env.VERIFY_EARLIER_FAIL_HEADS ?? "")
.split(" ")
.filter(Boolean);
if (heads.length === 0) {
return [];
}
if (heads.length > 10 || new Set(heads).size !== heads.length || !heads.every((sha) => SHA.test(sha) && sha !== env.HEAD_SHA)) {
throw new Error("VERIFY_EARLIER_FAIL_HEADS is not a list of other heads' commit ids");
}
if (!env.DISPATCHER_RUN_ID || env.VERIFY_PASS_HEAD) {
throw new Error("only a dispatcher run's model round names earlier FAILed heads");
}
return heads;
}

export async function main(argv, env, { api, log = (line) => console.log(line) } = {}) {
const command = argv[0];
need(env, ["GITHUB_TOKEN", "GITHUB_REPOSITORY", "GITHUB_OUTPUT", "PR_NUMBER", "HEAD_SHA", "APP_SENDER_ID"]);
Expand All @@ -1648,6 +1743,7 @@ export async function main(argv, env, { api, log = (line) => console.log(line) }
fetch: await countedFetch(),
});
const output = (key, value) => appendFileSync(env.GITHUB_OUTPUT, `${key}=${value}\n`);
const earlierFailHeads = earlierFailHeadsFrom(env);
const selectThreads = async () => {
need(env, ["TRUSTED_ACTORS"]);
return select({
Expand All @@ -1663,6 +1759,12 @@ export async function main(argv, env, { api, log = (line) => console.log(line) }
maxRounds: maxRoundsFrom(env.REVIEW_ROUNDS),
adoption: await ownerAdoption(env, log),
ci: ({ headSha, files }) => ciSnapshot({ api: client, headSha, files }),
earlierFailHeads,
// Loaded only for such a round: it reads pr-verify's scripts.
readFailNotes: async ({ heads, files }) => {
const { earlierFailNotes } = await import("./earlier-fail-notes.mjs");
return earlierFailNotes({ actions: { read: client.readPath, download: client.downloadArtifact }, repository: env.GITHUB_REPOSITORY, pr, heads, files });
},
log,
});
};
Expand Down
Loading
Loading