From 688d6083dca072c2157b0b5ab467d83d25d6d76a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:37:24 -0500 Subject: [PATCH 1/3] perf(pr-merge): let a wake refuse on an unmoved refusing report without a fresh check A PR-scoped pr-merge wake whose last sweep's report does not stand ran the post-merge check fresh plus `settle --idle-tick --fresh` (64-154 GITHUB_TOKEN calls), and in 6 of 8 sampled wakes that fresh check refused and the wake armed nothing. keep-report now asks `babysit_verify_evidence.py refusal-stands` first, for a last report from a check that judged (exit 0, 1 or 5). It stands, and the wake refuses with no fresh check, only when: - the report refuses on what it found: exit 1 (readable, failing), or the idle refusal needs-dispatch, unattributed, quarantines or excused. Not stale-soon or dispatch-deadline (time), not a hold (only on an unreadable report), not reruns (a red run can leave the _lane_reruns lookback with nothing moving), and not a report missing pins or outstandingSince or carrying a failed lane re-run read; - nothing it was read from moved: age within SHARED_MAX_AGE, the same identity and lane, main's tip, config commit and demotion (_moved, a tripped probe read again), the quarantine issues and pins (idle_marks_refusal), and no verifying run created, re-run or updated since computedAt whatever its status (_verifying_runs_unchanged with the new `running` keyword; its default, which ignores in-progress runs, is unchanged for the sweep). Otherwise the fresh check runs exactly as before. The skip only refuses: the report is removed and the arm step stays skipped, as on a refused fresh check, so a wake never arms on a stale report. The notice names the report (exit code, computedAt) and its refusal; the pr-merge-wake outcome line reads "the last sweep's report still refuses () and nothing it read has moved". The arm step's env now carries BABYSIT_POSTMERGE_ATTEMPTS, pointing at the attempts.json kept beside the report it reads (a sweep's artifact, the last sweep's on a kept wake, and a fresh wake's check now copies its file there), so the room's recompute answers completed attempts with no read. keep-report also carries BABYSIT_RUN_CACHE at the arm step's path, so its check and settle can share run-cache answers with the room. Call delta per wake whose last report does not stand: +5 to +10 calls for refusal-stands (branch, config, head, demotion probe, two run listings, quarantine listing, pins), and -64 to -154 when it stands. Co-Authored-By: Claude Opus 5.5 --- .../perf-wake-skip-unmoved-report-5c3e91a7.md | 7 + .github/scripts/pr-merge/workflow.test.mjs | 95 ++++++- .github/workflows/pr-merge.yml | 32 ++- docs/conventions/pr-pipeline/README.md | 6 +- .../babysit-prs/scripts/babysit_postmerge.py | 108 +++++++- .../scripts/babysit_verify_evidence.py | 58 ++++- .../scripts/tests/test_babysit_lane_room.py | 245 ++++++++++++++++++ 7 files changed, 525 insertions(+), 26 deletions(-) create mode 100644 .changes/source-control/perf-wake-skip-unmoved-report-5c3e91a7.md diff --git a/.changes/source-control/perf-wake-skip-unmoved-report-5c3e91a7.md b/.changes/source-control/perf-wake-skip-unmoved-report-5c3e91a7.md new file mode 100644 index 00000000000..54ae7e6be29 --- /dev/null +++ b/.changes/source-control/perf-wake-skip-unmoved-report-5c3e91a7.md @@ -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. diff --git a/.github/scripts/pr-merge/workflow.test.mjs b/.github/scripts/pr-merge/workflow.test.mjs index cd5b9c86242..7b2fa1cdf24 100644 --- a/.github/scripts/pr-merge/workflow.test.mjs +++ b/.github/scripts/pr-merge/workflow.test.mjs @@ -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"); @@ -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"), @@ -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}" @@ -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 = "") => @@ -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. @@ -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); @@ -690,8 +706,42 @@ 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`); } }); @@ -699,6 +749,18 @@ 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. @@ -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); @@ -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" }), diff --git a/.github/workflows/pr-merge.yml b/.github/workflows/pr-merge.yml index 74e7ae42e56..f24fabe1255 100644 --- a/.github/workflows/pr-merge.yml +++ b/.github/workflows/pr-merge.yml @@ -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 @@ -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 "$@" @@ -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" @@ -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" @@ -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 @@ -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. @@ -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 @@ -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 diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index 97dd293411e..088d7535f5f 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -1964,8 +1964,10 @@ or pass-notes verdict wakes it for that one pull request: `pr-verify.yml`'s `wak dispatches a PR-scoped `pr-merge.yml` run (`-f pr=`, run name `pr-merge: arm #`) 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 diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py index 291a86eaabf..5412f415700 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py @@ -4359,14 +4359,21 @@ def post_merge(floor: datetime, identity: str) -> policy.PostMergeGate: def _verifying_runs_unchanged( - repo: str, branch: str, *, created: datetime, since: datetime + repo: str, + branch: str, + *, + created: datetime, + since: datetime, + running: bool = False, ) -> bool: """Whether no full or canary run on `branch` created at or after `created` completed, or was re-run, at or after `since`, from one listing per verifying workflow: every workflow's runs on a busy branch fill more than one page in an hour or two. A run started since and still running - changes nothing yet. A listing that is not whole raises - `PostMergeReadError`.""" + changes nothing yet, unless `running`: then any run created, started + again or updated since counts, whatever its status, since a fresh check + reads a red run re-run since as pending. A listing that is not whole + raises `PostMergeReadError`.""" owner, name = _split_repo(repo) floor = quote( f">={created.astimezone(UTC).strftime('%Y-%m-%dT%H:%M:%SZ')}", safe="" @@ -4387,7 +4394,7 @@ def _verifying_runs_unchanged( raise PostMergeReadError(f"{workflow} runs: the listing is not whole") if any( run.get("path") in VERIFYING_WORKFLOWS - and run.get("status") == "completed" + and (running or run.get("status") == "completed") and _time(run.get("updated_at")) >= since for run in map(json_object, runs) ): @@ -4578,9 +4585,11 @@ def idle_runs_refusal( branch: str, *, confirm_wait_minutes: int, + running: bool = False, ) -> str | None: """`idle_refusal`'s verifying runs, in two one-page listings - (`_verifying_runs_unchanged`), for a report `idle_report_refusal` passed.""" + (`_verifying_runs_unchanged`, with `running`), for a report + `idle_report_refusal` passed.""" report = shared.gate.report try: created = shared.computed_at - ( @@ -4592,7 +4601,9 @@ def idle_runs_refusal( if report["outstandingSince"] is not None: created = min(created, _time(report["outstandingSince"])) since = shared.computed_at - policy.SINCE_CLOCK_MARGIN - if not _verifying_runs_unchanged(repo, branch, created=created, since=since): + if not _verifying_runs_unchanged( + repo, branch, created=created, since=since, running=running + ): return "runs-changed" except (PostMergeReadError, ValueError, KeyError) as exc: return f"unreadable: {exc}" @@ -4615,6 +4626,91 @@ def idle_marks_refusal(repo: str, shared: policy.SharedPostMerge) -> str | None: return None +# `idle_report_refusal`'s refusals a check reading the same inputs gives +# again: what the report found, not its age, a deadline or a failed read. A +# hold comes only with an unreadable report, and `reruns` can lapse with +# nothing moved once its red run leaves the `_lane_reruns` lookback. +CONTENT_REFUSALS = frozenset( + {"needs-dispatch", "unattributed", "quarantines", "excused"} +) + + +def content_refusal( + shared: policy.SharedPostMerge, *, now: datetime | None = None +) -> str | None: + """Why a readable report refuses on what it found (`failing` for exit 1, + else one of `CONTENT_REFUSALS`), or None: it passes the idle tick, its + refusal is age or time (`stale-soon`, `dispatch-deadline`), or it lacks + what `refusal_moved` reads again (pins, `outstandingSince`, a + `_lane_reruns` read that did not fail), or it asks for a re-run. Reads + nothing.""" + report = shared.gate.report + if ( + report.get("readable") is not True + or json_array(report.get("reruns")) + or not is_json_object(report.get("pins")) + or "outstandingSince" not in report + or any( + str(w).startswith("lane-rerun-unreadable") + for w in json_array(report.get("warnings")) + ) + ): + return None + if report.get("ok") is not True: + return "failing" + refusal = idle_report_refusal(shared, now=now) + return refusal if refusal in CONTENT_REFUSALS else None + + +def refusal_moved( + repo: str, + shared: policy.SharedPostMerge, + branch: str, + *, + identity: str, + confirm_wait_minutes: int, + now: datetime | None = None, +) -> str | None: + """What a report refusing on its content (`content_refusal`) was read + from that has moved since, or None when a check run now would read the + same and refuse it too: no older than `SHARED_MAX_AGE` (else `stale`), + bound to `identity` and the lane, the default branch head, its config + commit and the demotion unchanged (`policy._moved`, a tripped demotion + probe read again), no verifying run created, re-run or updated since + (`idle_runs_refusal` with `running`), and the quarantine issues and pins + as they were (`idle_marks_refusal`). A failed read is moved. Past + `computedAt` the check's time rules only refuse more: a pending merge + past `DISPATCH_DEADLINE` or an expired excuse fails.""" + age = (now or datetime.now(UTC)) - shared.computed_at + if not timedelta(0) <= age <= policy.SHARED_MAX_AGE: + return "stale" + if _login_key(shared.identity) != _login_key(identity): + return "identity-differs" + if not shared.lane_merges_only: + return "lane-differs" + try: + moved = policy._moved( + repo, + branch, + identity, + shared, + lambda answer, default: ( + idle_runs_refusal( + repo, + answer, + default, + confirm_wait_minutes=confirm_wait_minutes, + running=True, + ) + is None + ), + reread_demotion=True, + ) + except (RuntimeError, ValueError, OSError) as exc: + return f"unreadable: {exc}" + return moved or idle_marks_refusal(repo, shared) + + def read_shared(path: str) -> policy.SharedPostMerge | None: """The run's shared answer from this script's own report at `path`, or None when it is missing, malformed, unreadable as a verdict, or has no diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py index 93d1fa8bd3b..a384f4ff924 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py @@ -4795,6 +4795,49 @@ def idle_tick(refusal: str | None) -> str | None: ) +def _refusal_stands(repo: str, path: str, code_sha: str) -> int: + """`refusal-stands`: whether an earlier tick's post-merge report at `path`, + its check run by the code at commit `code_sha`, refuses on what it found + (`postmerge.content_refusal`) with nothing it was read from moved since + (`postmerge.refusal_moved`), so a check run now would refuse too and need + not run. The content is judged before any read. Prints `{"stands", + "refusal", "computedAt", "reason"}`, `reason` why it does not stand; + exit 0 when it stands, else 1, and the caller runs the check.""" + shared = postmerge.read_shared(path) + refusal: str | None = None + reason: str | None + if shared is None: + reason = "post-merge answer missing or unreadable" + elif not merge_policy.check_code_unchanged(code_sha): + reason = "post-merge answer code-changed" + elif (refusal := postmerge.content_refusal(shared)) is None: + reason = "no refusal of its own content" + else: + try: + default = merge_policy._default_branch(repo, shared=True) + config = merge_policy._default_branch_config(repo, default) + reason = postmerge.refusal_moved( + repo, + shared, + default, + identity=config.agent_identity, + confirm_wait_minutes=config.confirm_wait_minutes, + ) + except (RuntimeError, ValueError, OSError) as exc: + reason = f"unreadable: {exc}" + print( + json.dumps( + { + "stands": reason is None, + "refusal": refusal, + "computedAt": shared and shared.computed_at.isoformat(), + "reason": reason, + } + ) + ) + return 0 if reason is None else 1 + + # GitHub refuses a comment body over 65536 characters; the findings are cut # to leave room for the framing (judgment). FINDINGS_MAX = 60000 @@ -6038,7 +6081,12 @@ def main(argv: list[str] | None = None, *, now: datetime | None = None) -> int: again (`_settle`). With `--idle-tick`, post-merge-dispatch's reuse of an earlier tick's report, which must also pass `postmerge.idle_refusal`; `--fresh` with it, for a report this job's check just made, skips that - rule's verifying runs (`idle_runs_refusal`).""" + rule's verifying runs (`idle_runs_refusal`). + + `refusal-stands --repo R --postmerge-result --code-sha `: + whether an earlier tick's report refuses on its own content with nothing + it read moved since, so pr-merge's PR-scoped wake refuses as it does + without running the check (`_refusal_stands`).""" parser = argparse.ArgumentParser( prog="babysit_verify_evidence.py", allow_abbrev=False ) @@ -6078,7 +6126,11 @@ def main(argv: list[str] | None = None, *, now: datetime | None = None) -> int: settle.add_argument("--code-sha", required=True) settle.add_argument("--idle-tick", action="store_true") settle.add_argument("--fresh", action="store_true") - legacy = sub.add_parser("legacy-fails", allow_abbrev=False) + stands = sub.add_parser("refusal-stands", allow_abbrev=False) + stands.add_argument("--repo", required=True) + stands.add_argument("--postmerge-result", required=True) + stands.add_argument("--code-sha", required=True) + legacy =sub.add_parser("legacy-fails", allow_abbrev=False) legacy.add_argument("--repo", required=True) legacy.add_argument("--branch", default="main") legacy.add_argument("--since", required=True) @@ -6115,6 +6167,8 @@ def main(argv: list[str] | None = None, *, now: datetime | None = None) -> int: idle=args.idle_tick, fresh=args.fresh, ) + if args.command == "refusal-stands": + return _refusal_stands(args.repo, args.postmerge_result, args.code_sha) if args.command == "lane-room": shared = postmerge.read_shared(args.postmerge_result) answer = merge_policy.lane_room( diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_lane_room.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_lane_room.py index 7e50018f6ee..1a1f4778845 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_lane_room.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_lane_room.py @@ -1280,6 +1280,251 @@ def test_a_pin_that_reads_differently_refuses(self) -> None: self.assertEqual(failed, "unreadable: #5 pin: 502") +MERGED = FLOOR - timedelta(hours=1) +# The last sweep's report refusing on what it found: lane merge #5, merged an +# hour before, has no full run covering it yet. +REFUSING = { + **SETTLED, + "needsDispatch": True, + "autoMerges": [{"number": 5, "verified": False, "mergedAt": MERGED.isoformat()}], +} +# The same report from a check that exited 1: a red run charged to #5. +FAILING = { + **REFUSING, + "ok": False, + "needsDispatch": False, + "failures": [{"signal": "dispatch", "number": 5}], +} + + +class RefusalStands(unittest.TestCase): + """`content_refusal` and `refusal_moved`: a wake skips its fresh check + only when the last report refuses on its own content and nothing a fresh + check reads has moved. Expected values are the brief's three conditions: + a content refusal (not age, not a failed read), main's tip and the + idle tick's moved evidence unchanged, and no verifying run created, re-run + or still running since computedAt.""" + + def moved( + self, + report: dict[str, object] = REFUSING, + *, + now: datetime = NOW, + head: str = MAIN, + runs: list[dict[str, object]] = (), # type: ignore[assignment] + issues: list[dict[str, object]] = (), # type: ignore[assignment] + pins: dict[int, str | None] | None = None, + demotion_probe: bool = False, + demotion: policy.Demotion | None = None, + identity: str = IDENTITY, + ) -> str | None: + self.calls: list[str] = [] + + def read(args: list[str], label: str) -> object: + self.calls.append(args[1]) + if "/actions/workflows/" in args[1]: + return {"total_count": len(runs), "workflow_runs": list(runs)} + return list(issues) + + answer = shared(report=report) + with ( + mock.patch.object(pm, "_read", side_effect=read), + mock.patch.object( + pm, "read_pins", return_value={5: SHA_PINNED} if pins is None else pins + ), + mock.patch.object( + policy, "_default_branch_head", return_value=("main", head) + ), + mock.patch.object(policy, "newest_config_commit", return_value=MAIN), + mock.patch.object(policy, "_demotion_changed", return_value=demotion_probe), + mock.patch.object(policy, "permission_reader"), + mock.patch.object( + policy, "read_demotion", return_value=demotion or answer.demotion + ) as self.read_demotion, + ): + return pm.refusal_moved( + REPO, + answer, + "main", + identity=identity, + confirm_wait_minutes=CONFIRM_WAIT, + now=now, + ) + + def test_a_refusal_on_its_content_is_named(self) -> None: + self.assertEqual( + pm.content_refusal(shared(report=REFUSING), now=NOW), "needs-dispatch" + ) + self.assertEqual(pm.content_refusal(shared(report=FAILING), now=NOW), "failing") + for reason, change in ( + ("unattributed", {"unattributed": ["https://run/1/attempts/1"]}), + ("quarantines", {"quarantines": [{"suite": "x"}]}), + ): + with self.subTest(reason): + report = {**SETTLED, **change} + self.assertEqual( + pm.content_refusal(shared(report=report), now=NOW), reason + ) + + def test_no_refusal_of_its_own_content_is_none(self) -> None: + cases = { + "it passes": SETTLED, + "unjudged (exit 3)": {**REFUSING, "readable": False, "ok": False}, + "it asks for a re-run": {**REFUSING, "reruns": [{"runId": 9}]}, + "a lane re-run read failed": { + **REFUSING, + "warnings": ["lane-rerun-unreadable: 502"], + }, + "no pins recorded": {k: v for k, v in REFUSING.items() if k != "pins"}, + "no outstandingSince": { + k: v for k, v in REFUSING.items() if k != "outstandingSince" + }, + } + for why, report in cases.items(): + with self.subTest(why): + self.assertIsNone(pm.content_refusal(shared(report=report), now=NOW)) + # Refused only on age: idle_report_refusal says stale-soon. + late = FLOOR + policy.SHARED_MAX_AGE - pm.IDLE_HEADROOM + timedelta(minutes=1) + answer = shared(report=SETTLED) + self.assertEqual(pm.idle_report_refusal(answer, now=late), "stale-soon") + self.assertIsNone(pm.content_refusal(answer, now=late)) + + def test_nothing_moved_lets_the_refusal_stand(self) -> None: + for report in (REFUSING, FAILING): + with self.subTest(report["ok"]): + self.assertIsNone(self.moved(report)) + # Two run listings, one quarantine issue listing. + self.assertEqual(sum("/actions/workflows/" in c for c in self.calls), 2) + self.read_demotion.assert_not_called() + # A run that finished before computedAt (less the clock margin) moved + # nothing. + before = FLOOR - policy.SINCE_CLOCK_MARGIN - timedelta(seconds=1) + done = run( + "pr-require-checks.yml", status="completed", updated=before, created=MERGED + ) + self.assertIsNone(self.moved(runs=[done])) + + def test_main_moved_runs_the_check(self) -> None: + self.assertEqual(self.moved(head=NEW_MAIN), "head-moved") + self.assertEqual(self.calls, []) + + def test_a_verifying_run_created_or_re_run_since_runs_the_check(self) -> None: + after = FLOOR + timedelta(minutes=1) + cases = { + "created since, still running": run( + "pr-require-checks.yml", + status="in_progress", + updated=after, + created=after, + ), + "created since, queued": run( + "post-merge-detect-silent-revert.yml", + status="queued", + updated=after, + created=after, + ), + "created before, re-run since and running": run( + "pr-require-checks.yml", + status="in_progress", + updated=after, + created=MERGED, + ), + "completed since": run( + "pr-require-checks.yml", + status="completed", + updated=after, + created=MERGED, + ), + } + for why, row in cases.items(): + with self.subTest(why): + self.assertEqual(self.moved(runs=[row]), "runs-moved") + + def test_marks_or_demotion_that_changed_run_the_check(self) -> None: + marked = {"number": 3, "body": ""} + self.assertEqual(self.moved(issues=[marked]), "quarantine-changed") + self.assertEqual(self.moved(pins={5: None}), "pins-changed") + # A tripped demotion probe is read again: the same demotion stands. + self.assertIsNone(self.moved(demotion_probe=True)) + self.read_demotion.assert_called_once() + other = policy.Demotion(True, "record-open", (7,)) + self.assertEqual( + self.moved(demotion_probe=True, demotion=other), "demotion-changed" + ) + + def test_age_and_binding_bound_it(self) -> None: + self.assertEqual( + self.moved(now=FLOOR + policy.SHARED_MAX_AGE + timedelta(seconds=1)), + "stale", + ) + self.assertIsNone(self.moved(now=FLOOR + policy.SHARED_MAX_AGE)) + self.assertEqual(self.moved(identity="other[bot]"), "identity-differs") + self.assertEqual(self.calls, []) + + def command( + self, report: dict[str, object], *, code_unchanged: bool = True, moved=None + ) -> tuple[int, dict]: + out = io.StringIO() + with ( + mock.patch.object( + ve.postmerge, "read_shared", return_value=shared(report=report) + ), + mock.patch.object( + policy, "check_code_unchanged", return_value=code_unchanged + ), + mock.patch.object(policy, "_default_branch", return_value="main"), + mock.patch.object(policy, "_default_branch_config", return_value=config()), + mock.patch.object( + ve.postmerge, "refusal_moved", return_value=moved + ) as self.refusal_moved, + contextlib.redirect_stdout(out), + ): + code = ve.main( + [ + "refusal-stands", + "--repo", + REPO, + "--postmerge-result", + "/tmp/x.json", + "--code-sha", + MAIN, + ] + ) + return code, json.loads(out.getvalue()) + + def test_the_command_exits_0_only_when_the_refusal_stands(self) -> None: + self.assertEqual( + self.command(REFUSING), + ( + 0, + { + "stands": True, + "refusal": "needs-dispatch", + "computedAt": FLOOR.isoformat(), + "reason": None, + }, + ), + ) + self.refusal_moved.assert_called_once() + self.assertEqual( + self.refusal_moved.call_args.kwargs, + { + "identity": IDENTITY, + "confirm_wait_minutes": config().confirm_wait_minutes, + }, + ) + code, out = self.command(REFUSING, moved="runs-moved") + self.assertEqual((code, out["stands"], out["reason"]), (1, False, "runs-moved")) + # A report with no content refusal, or changed check code, is + # refused before any read. + code, out = self.command(SETTLED) + self.assertEqual((code, out["reason"]), (1, "no refusal of its own content")) + self.refusal_moved.assert_not_called() + code, out = self.command(REFUSING, code_unchanged=False) + self.assertEqual((code, out["reason"]), (1, "post-merge answer code-changed")) + self.refusal_moved.assert_not_called() + + class CheckCodeUnchanged(unittest.TestCase): """`check_code_unchanged`: whether the check's code (the babysit-prs scripts, docs/conventions/pr-pipeline.yaml and the yaml-subset.awk parser From a6b6298cacbc902c2d7111b934f5dbaad9038c59 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:43:20 -0500 Subject: [PATCH 2/3] chore(pr-pipeline): add the no-release fragment for the wake's refusal skip The README change under docs/conventions/pr-pipeline/ needs a pr-pipeline fragment; it changes only the wake's GitHub calls, so no release. Co-Authored-By: Claude Opus 5.5 --- .../pr-pipeline/perf-wake-skip-unmoved-report-5c3e91a7.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/pr-pipeline/perf-wake-skip-unmoved-report-5c3e91a7.md diff --git a/.changes/pr-pipeline/perf-wake-skip-unmoved-report-5c3e91a7.md b/.changes/pr-pipeline/perf-wake-skip-unmoved-report-5c3e91a7.md new file mode 100644 index 00000000000..eac284575dd --- /dev/null +++ b/.changes/pr-pipeline/perf-wake-skip-unmoved-report-5c3e91a7.md @@ -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. From 9c875caab75c7344120bd26adf54998ca8f03892 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:48:34 -0500 Subject: [PATCH 3/3] style(source-control): space the legacy-fails parser assignment Co-Authored-By: Claude Opus 5.5 --- .../skills/babysit-prs/scripts/babysit_verify_evidence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py index a384f4ff924..6e642ac36a5 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py @@ -6130,7 +6130,7 @@ def main(argv: list[str] | None = None, *, now: datetime | None = None) -> int: stands.add_argument("--repo", required=True) stands.add_argument("--postmerge-result", required=True) stands.add_argument("--code-sha", required=True) - legacy =sub.add_parser("legacy-fails", allow_abbrev=False) + legacy = sub.add_parser("legacy-fails", allow_abbrev=False) legacy.add_argument("--repo", required=True) legacy.add_argument("--branch", default="main") legacy.add_argument("--since", required=True)