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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
bump: none
---

No release: pr-merge's PR-scoped wake refuses with the last sweep's post-merge report when that report refuses on what it found and nothing it read has moved, instead of running the check again; any other report still gets the fresh check, so only the wake's GitHub calls change.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Changed

- `babysit_verify_evidence.py refusal-stands` says whether an earlier tick's post-merge report refuses on what it found (exit 1, or needs-dispatch, unattributed, quarantines or excused; not its age, a re-run or a failed read) while nothing it was read from has moved: main's tip, the config commit, the demotion, the quarantine issues, the pins, and every verifying run, including one created, re-run or still running since. pr-merge's PR-scoped wake then refuses as that report does without running the post-merge check again.
95 changes: 81 additions & 14 deletions .github/scripts/pr-merge/workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -573,11 +573,13 @@ test("pr-merge takes a pr input, runs every 30 minutes and names a PR-scoped run
// the post-merge check fresh, as post-merge-dispatch does on a refused idle
// tick, and keeps the fresh report only when the check passed (exit 0 or 5)
// and settle --idle-tick --fresh keeps it too (the idle rule less the
// verifying runs the check just read). Anything else removes it, so the arm
// step is skipped and nothing arms.
// verifying runs the check just read). A last report that refuses on its own
// content with nothing it read moved (refusal-stands exits 0) is removed with
// no fresh check. Anything else removes it, so the arm step is skipped and
// nothing arms.
const KEEP_STEP = "Keep the last sweep's report only while nothing it read has moved";
const FRESH_SHA = "f".repeat(40);
const keepReport = ({ code = "0", settles = [0], fresh = 0, files = true, attempts = false } = {}) => {
const keepReport = ({ code = "0", settles = [0], standing = 1, fresh = 0, files = true, attempts = false } = {}) => {
const arm = job("enable-auto-merge");
const body = new RegExp(`- name: ${KEEP_STEP.replace(/[.']/g, "\\$&")}\\n([\\s\\S]*?)(?=\\n {6}- name: )`).exec(arm)?.[1];
assert.ok(body, "a PR-scoped run settles the last sweep's report");
Expand All @@ -588,7 +590,8 @@ const keepReport = ({ code = "0", settles = [0], fresh = 0, files = true, attemp
const args = join(dir, "args");
writeFileSync(args, "");
// Each settle call takes the next exit code from `settles` and prints
// settle's JSON; the fresh check prints a marked report and exits `fresh`.
// settle's JSON; refusal-stands exits `standing`; the fresh check prints a
// marked report and exits `fresh`.
writeFileSync(join(dir, "settles"), settles.join("\n") + "\n");
writeFileSync(
join(bin, "python3"),
Expand All @@ -597,6 +600,11 @@ echo "$@" >>'${args}'
case "$1" in
*babysit_postmerge.py) echo '{"fresh":true}'; exit ${fresh} ;;
esac
if [ "$2" = refusal-stands ]; then
if [ ${standing} = 0 ]; then reason=null; else reason='"head-moved"'; fi
echo "{\\"stands\\": $([ ${standing} = 0 ] && echo true || echo false), \\"refusal\\": \\"needs-dispatch\\", \\"computedAt\\": \\"2026-10-01T00:00:00+00:00\\", \\"reason\\": $reason}"
exit ${standing}
fi
next=$(head -n1 '${join(dir, "settles")}'); sed -i 1d '${join(dir, "settles")}'
if [ "\${next:-1}" = 0 ]; then echo '{"reuse": true, "reason": null}'; else echo '{"reuse": false, "reason": "idle tick needs-dispatch"}'; fi
exit "\${next:-1}"
Expand Down Expand Up @@ -638,6 +646,7 @@ exit "\${next:-1}"
codeFile: readKept("post-merge-code"),
shaFile: readKept("post-merge-code-sha"),
attempts: existsSync(attemptsPath) ? readFileSync(attemptsPath, "utf8") : null,
keptAttempts: readKept("attempts.json"),
};
};
const SETTLE = (sha, fresh = "") =>
Expand All @@ -648,12 +657,15 @@ const SETTLE = (sha, fresh = "") =>
const SETTLE_FRESH = (sha) => SETTLE(sha, " --fresh");
const FRESH_CHECK =
/^\.base\/plugins\/source-control\/skills\/babysit-prs\/scripts\/babysit_postmerge\.py --repo o\/r --agent-identity lanes-bot --lane-merges-only$/;
// Whether the last report's own refusal stands, at the commit whose code made it.
const REFUSAL_STANDS =
/^\.base\/plugins\/source-control\/skills\/babysit-prs\/scripts\/babysit_verify_evidence\.py refusal-stands --repo o\/r --postmerge-result \S+\/post-merge\/post-merge\.json --code-sha c{40}$/;

test("a PR-scoped run keeps the last sweep's report only when settle --idle-tick keeps it", () => {
const kept = keepReport();
assert.equal(kept.status, 0);
assert.equal(kept.kept, true);
assert.equal(kept.output, "stands=true\nfresh=\nfresh-reason=\n");
assert.equal(kept.output, "stands=true\nfresh=\nfresh-reason=\nrefusal-stands=\n");
assert.match(kept.body, /^ {8}id: keep-report\n/);
// The same call post-merge-dispatch makes for a reused report, and no
// fresh check.
Expand All @@ -671,17 +683,21 @@ test("a PR-scoped run keeps the last sweep's report only when settle --idle-tick
// check runs, then settle --idle-tick --fresh on the fresh report at this checkout's
// commit, and only both passing lets the arm step run on it.
test("a PR-scoped run whose report does not stand runs the post-merge check fresh and keeps it when both checks pass", () => {
for (const [why, options] of [
["settle refuses the last report", { settles: [1, 0] }],
["the last check could not judge", { code: "3", settles: [0] }],
["the last check failed", { code: "1", settles: [0] }],
["no last report", { files: false, settles: [0] }],
["the fresh check finds a merge not yet verified", { settles: [1, 0], fresh: 5 }],
// `before`: the calls on the last report ahead of the fresh check. A last
// report whose refusal could stand (exit 0, 1 or 5) is asked, and here
// something it read has moved; an unjudged or missing one is not.
const LAST_SETTLE = SETTLE("c{40}");
for (const [why, options, before] of [
["settle refuses the last report and it has moved", { settles: [1, 0] }, [LAST_SETTLE, REFUSAL_STANDS]],
["the last check could not judge", { code: "3", settles: [0] }, []],
["the last check failed and it has moved", { code: "1", settles: [0] }, [REFUSAL_STANDS]],
["no last report", { files: false, settles: [0] }, []],
["the fresh check finds a merge not yet verified", { settles: [1, 0], fresh: 5 }, [LAST_SETTLE, REFUSAL_STANDS]],
]) {
const run = keepReport(options);
const fresh = String(options.fresh ?? 0);
assert.equal(run.status, 0, why);
assert.equal(run.output, `fresh-started=true\nstands=true\nfresh=${fresh}\nfresh-reason=\n`, why);
assert.equal(run.output, `fresh-started=true\nstands=true\nfresh=${fresh}\nfresh-reason=\nrefusal-stands=\n`, why);
assert.equal(run.kept, true, why);
assert.equal(run.json, '{"fresh":true}\n', `${why}: the room is the fresh report, not the last one`);
assert.equal(run.codeFile, `${fresh}\n`, why);
Expand All @@ -690,15 +706,61 @@ test("a PR-scoped run whose report does not stand runs the post-merge check fres
assert.ok(check >= 0, `${why}: the fresh check ran`);
assert.match(run.calls[check + 1] ?? "", SETTLE_FRESH(FRESH_SHA), `${why}: settle --idle-tick --fresh checked the fresh report`);
// The last report's settle, when it ran, never skips the runs test.
if (check > 0) assert.match(run.calls[0], SETTLE("c{40}"), why);
assert.equal(check, before.length, why);
before.forEach((call, i) => assert.match(run.calls[i], call, why));
assert.equal(run.calls.length, check + 2, why);
// What moved is logged whenever the refusal was asked about.
assert.equal(run.stdout.includes('"reason": "head-moved"'), before.includes(REFUSAL_STANDS), why);
}
});

// A last report that refuses on what it found, with nothing it read moved,
// would be refused by a fresh check too: none runs, the report is removed and
// the arm step stays skipped, with a notice naming the report and why.
test("a PR-scoped run whose last report's refusal stands runs no fresh check and arms nothing", () => {
for (const [why, options, before] of [
["an idle refusal on its content", { settles: [1], standing: 0 }, [SETTLE("c{40}"), REFUSAL_STANDS]],
["a failed check", { code: "1", standing: 0 }, [REFUSAL_STANDS]],
["a check not yet verified that needs a full run", { code: "5", settles: [1], standing: 0 }, [SETTLE("c{40}"), REFUSAL_STANDS]],
]) {
const run = keepReport(options);
assert.equal(run.status, 0, why);
assert.equal(run.kept, false, `${why}: no report is left for the arm step`);
assert.equal(run.output, "stands=false\nfresh=\nfresh-reason=\nrefusal-stands=needs-dispatch\n", why);
assert.equal(run.calls.length, before.length, `${why}: no fresh check`);
before.forEach((call, i) => assert.match(run.calls[i], call, why));
assert.match(
run.stdout,
new RegExp(
`::notice::the last sweep's post-merge report \\(exit ${options.code ?? 0}, computed 2026-10-01T00:00:00\\+00:00\\) refuses on its own content \\(needs-dispatch\\) and nothing it read has moved since`,
),
why,
);
}
// An unjudged report is never asked: its refusal is a failed read.
for (const code of ["3", "75"]) {
const run = keepReport({ code, standing: 0 });
assert.ok(!run.calls.some((c) => REFUSAL_STANDS.test(c)), `exit ${code}`);
assert.ok(run.calls.some((c) => FRESH_CHECK.test(c)), `exit ${code}: the fresh check ran`);
}
});

test("the fresh check answers the last check's completed attempts with no read", () => {
const run = keepReport({ settles: [1, 0], attempts: true });
assert.equal(run.attempts, '{"attempts":[]}');
assert.match(run.body, /\n {10}BABYSIT_POSTMERGE_ATTEMPTS: \$\{\{ runner\.temp \}\}\/post-merge-attempts\.json\n/);
// Kept beside the fresh report, where a sweep's artifact holds them, for
// the arm step's room.
assert.equal(run.keptAttempts, '{"attempts":[]}');
});

// The room's recompute in the arm step answers completed attempts from the
// file kept beside the report it reads, on a sweep and on a wake alike.
test("the arm step hands the room's post-merge check the attempts kept beside its report", () => {
const arm = job("enable-auto-merge");
const step = /- name: Arm and disarm auto-merge\n([\s\S]*?)(?=\n {6}- name: )/.exec(arm)[1];
assert.match(step, /\n {10}BABYSIT_POSTMERGE_ATTEMPTS: \$\{\{ runner\.temp \}\}\/post-merge\/attempts\.json\n/);
assert.match(step, /\n {10}POSTMERGE_RESULT: \$\{\{ runner\.temp \}\}\/post-merge\/post-merge\.json\n/);
});

// (b) Any refusal on the fresh path leaves no report, so nothing arms.
Expand All @@ -712,7 +774,7 @@ test("a PR-scoped run whose fresh check or fresh settle refuses keeps no report"
const run = keepReport(options);
assert.equal(run.status, 0, why);
assert.equal(run.kept, false, why);
assert.equal(run.output, `fresh-started=true\nstands=false\nfresh=${options.fresh}\nfresh-reason=${reason}\n`, why);
assert.equal(run.output, `fresh-started=true\nstands=false\nfresh=${options.fresh}\nfresh-reason=${reason}\nrefusal-stands=\n`, why);
assert.ok(run.calls.some((c) => FRESH_CHECK.test(c)), `${why}: the fresh check ran`);
assert.equal(run.calls.some((c) => SETTLE_FRESH(FRESH_SHA).test(c)), settled, why);
assert.match(run.stdout, new RegExp(`::notice::the fresh post-merge check \\(exit ${options.fresh}\\) does not stand for this run`), why);
Expand Down Expand Up @@ -753,6 +815,11 @@ test("a PR-scoped run's outcome names the fresh check's refusal and which report
assert.match(wakeOutcome({ ...base, FRESH: "5" }), /refused the fresh post-merge report \(no reason read\)$/);
assert.match(wakeOutcome({ ...base, STANDS: "true", FRESH: "0", OUTCOME: "armed" }), /report=fresh outcome=armed$/);
assert.match(wakeOutcome({ ...base, STANDS: "true", OUTCOME: "armed" }), /report=last outcome=armed$/);
// A last report whose own refusal stood: no fresh check ran.
assert.match(
wakeOutcome({ ...base, REFUSAL_STANDS: "needs-dispatch" }),
/report=last outcome=skipped: the last sweep's report still refuses \(needs-dispatch\) and nothing it read has moved$/,
);
// A fresh check that timed out or was cancelled writes no stands or fresh output.
assert.match(
wakeOutcome({ KILL: "true", WHO: "true", FRESH_STARTED: "true" }),
Expand Down
32 changes: 30 additions & 2 deletions .github/workflows/pr-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2498,7 +2498,13 @@ jobs:
# 5) and `settle --idle-tick` keeps, so a verifying run or canary that
# moved since its computedAt, changed quarantine markers or pins, or
# any hold the report carries refuses it (postmerge.idle_refusal).
# A report that does not stand is never reused: the step removes it
# A report that refuses on what it found (exit 1, or an idle refusal
# such as needs-dispatch, not its age or a failed read) while nothing
# it read has moved, no verifying run created or re-run since included
# (`refusal-stands`), stands only as that refusal: a fresh check would
# refuse too, so none runs, and the step removes it, so the wake arms
# and disarms nothing, as on a refused fresh check. No other report
# that does not stand is reused: the step removes it
# and runs the sweep's post-merge check fresh, as post-merge-dispatch
# does when its idle tick refuses, with this job's read-only token (the
# check writes nothing without --remediate). The fresh report stands
Expand Down Expand Up @@ -2527,12 +2533,14 @@ jobs:
# The completed attempts the last check read, which the fresh check
# answers with no read (babysit_postmerge.py ATTEMPTS_ENV).
BABYSIT_POSTMERGE_ATTEMPTS: ${{ runner.temp }}/post-merge-attempts.json
# The arm step's run cache, so its room shares this step's answers.
BABYSIT_RUN_CACHE: ${{ runner.temp }}/babysit-run-cache.json
REPO: ${{ github.repository }}
run: |
set -uo pipefail
last=$RUNNER_TEMP/post-merge
scripts=.base/plugins/source-control/skills/babysit-prs/scripts
kept=false fresh='' reason=''
kept=false fresh='' reason='' refusal='' standing='' last_code='' last_sha=''
settle() {
python3 "$scripts/babysit_verify_evidence.py" settle --idle-tick --repo "$REPO" \
--postmerge-result "$last/post-merge.json" --code-sha "$@"
Expand All @@ -2546,7 +2554,17 @@ jobs:
fi
if [ "$kept" = true ]; then
echo "the last sweep's post-merge report still stands: nothing it reads has moved"
elif [[ $last_code =~ ^[015]$ ]] &&
standing=$(python3 "$scripts/babysit_verify_evidence.py" refusal-stands --repo "$REPO" \
--postmerge-result "$last/post-merge.json" --code-sha "$last_sha"); then
refusal=$(jq -r '.refusal // ""' <<<"$standing" 2>/dev/null) || refusal=''
refusal=${refusal:-unnamed}
computed=$(jq -r '.computedAt // ""' <<<"$standing" 2>/dev/null) || computed=''
rm -rf "${last:?}"
echo "::notice::the last sweep's post-merge report (exit $last_code, computed $computed) refuses on its own content ($refusal) and nothing it read has moved since, so a fresh check would refuse it too and is not run; nothing is armed or disarmed, and the next sweep checks again"
else
# Why the last report's refusal does not stand, when it refused.
[ -z "$standing" ] || echo "$standing"
[ ! -f "$last/attempts.json" ] || cp "$last/attempts.json" "$BABYSIT_POSTMERGE_ATTEMPTS"
rm -rf "${last:?}"
mkdir -p "$last"
Expand All @@ -2564,6 +2582,9 @@ jobs:
echo "$fresh" >"$last/post-merge-code"
sha=$(git -C .base rev-parse HEAD) || sha=''
echo "$sha" >"$last/post-merge-code-sha"
# Beside the report, as a sweep's artifact holds them, for the
# arm step's room.
[ ! -f "$BABYSIT_POSTMERGE_ATTEMPTS" ] || cp "$BABYSIT_POSTMERGE_ATTEMPTS" "$last/attempts.json"
if [[ $fresh =~ ^[05]$ ]]; then
settled=$(settle "$sha" --fresh) && kept=true
echo "$settled"
Expand All @@ -2581,6 +2602,7 @@ jobs:
echo "stands=$kept"
echo "fresh=$fresh"
echo "fresh-reason=${reason//[$'\r\n']/ }"
echo "refusal-stands=${refusal//[$'\r\n']/ }"
} >>"$GITHUB_OUTPUT"

# While merge.report-only is anything but false it reports each
Expand Down Expand Up @@ -2617,6 +2639,9 @@ jobs:
RUNG_CAP: ${{ vars.CLAUDE_LANES_MERGE_RUNG_CAP }}
# The lane records read above, for the room's post-merge check.
BABYSIT_POSTMERGE_LANE_RECORDS: ${{ runner.temp }}/lane-records.json
# The completed attempts the report's check read, kept beside it,
# which the room's check answers with no read (ATTEMPTS_ENV).
BABYSIT_POSTMERGE_ATTEMPTS: ${{ runner.temp }}/post-merge/attempts.json
# Name the two tokens' rate-limit readings for the reader's gh calls
# (api-budget.bash): the lane token by default, the job token for
# the Actions reads that carry it.
Expand Down Expand Up @@ -2744,6 +2769,7 @@ jobs:
FRESH: ${{ steps.keep-report.outputs.fresh }}
FRESH_REASON: ${{ steps.keep-report.outputs.fresh-reason }}
FRESH_STARTED: ${{ steps.keep-report.outputs.fresh-started }}
REFUSAL_STANDS: ${{ steps.keep-report.outputs.refusal-stands }}
CALLS: ${{ steps.budget-end.outputs.calls }}
run: |
if [ -z "$OUTCOME" ]; then
Expand All @@ -2753,6 +2779,8 @@ jobs:
OUTCOME="skipped: the who step stopped it"
elif [ "$SUPERSEDED" = true ]; then
OUTCOME="skipped: a waiting run does this work"
elif [ "$STANDS" = false ] && [ -n "$REFUSAL_STANDS" ]; then
OUTCOME="skipped: the last sweep's report still refuses ($REFUSAL_STANDS) and nothing it read has moved"
elif [ -z "$FRESH" ] && [ "$FRESH_STARTED" = true ]; then
OUTCOME="skipped: the fresh post-merge check did not finish (timeout or cancel)"
elif [ "$STANDS" = false ] && [ "$FRESH" = 75 ]; then
Expand Down
6 changes: 4 additions & 2 deletions docs/conventions/pr-pipeline/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1965,8 +1965,10 @@ reads the pull request once and, for a lanes App one that is not already armed a
`do-not-merge`, dispatches a PR-scoped `pr-merge.yml` run (`-f pr=<n>`, run name `pr-merge: arm #<n>`) that
reads and arms only that pull request, its lane room from the last sweep's post-merge report,
and skips the merge job, `post-merge-dispatch` and every other sweep job. That report stands only
while `settle --idle-tick` keeps it; when it does not, the wake never reuses it but runs the
post-merge check fresh on its own read-only token, and arms only when that check passes (exit 0
while `settle --idle-tick` keeps it. When it refuses on what it found and nothing it read has
moved, no verifying run created or re-run since included (`refusal-stands`), the wake refuses with
it and arms nothing, since a fresh check would refuse too. Otherwise the wake never reuses it
but runs the post-merge check fresh on its own read-only token, and arms only when that check passes (exit 0
or 5) and `settle --idle-tick` keeps the fresh report too, since the wake starts no full run or
re-run. Its `api-calls` totals, which report the room came from (`report=last`, `fresh` or `none`) and
the outcome land in one `pr-merge-wake` notice. The 30-minute schedule is the backstop that arms
Expand Down
Loading
Loading