fix(running-in-ci): pin the CI-monitor poll to the SHA the run pushed - #837
fix(running-in-ci): pin the CI-monitor poll to the SHA the run pushed#837tend-agent wants to merge 8 commits into
Conversation
The CI Monitoring loop polled `gh pr view --json statusCheckRollup`, which describes the PR's current head. A concurrent push retargeted the gate mid-loop, so a run could report another commit's green as its own and never fire the gated follow-up. Query the rollup by commit OID instead, surface the failing checks alongside the pending count, and state a moved head rather than absorbing it. Closes #829
tend-agent
left a comment
There was a problem hiding this comment.
Querying the rollup by commit OID is the right shape, and the own-run / same-workflow filters carry over faithfully (the checkSuite.workflowRun.workflow.name hop is correct — a CheckRun's name really is the job name). One correctness issue in how PINNED_SHA is derived, plus two smaller gaps; all three have inline suggestions.
git rev-parse HEAD is the wrong SHA in exactly the session the comment above it names. The review workflow checks out refs/pull/N/merge — see the Resolve PR checkout ref step in .github/workflows/tend-review.yaml, which only falls back to refs/pull/N/head when the merge ref 404s on a conflicting PR. So in a review session HEAD is an ephemeral merge commit, and a merge commit carries no check rollup at all. The loop then breaks on iteration 1 with {"pending":0,"failed":[]} and reports green without ever observing the commit the approval sits on. That's the same false-green this PR is fixing, relocated from "the PR head moved" to "the pinned SHA was never the right one" — and it lands on the approve-then-dismiss gate, which is the one case the PR body says PINNED_SHA covers.
Since shell state doesn't persist between Bash calls, ${HEAD_SHA:-…} won't rescue it either — HEAD_SHA from the review skill's step 1 is gone by the time this block runs. The derivation has to be spelled out per session type.
Verified from inside this review run
git rev-parse HEAD here is 4897ea376798028919212298b4138991f0df896f, a merge of the PR head 8582f53499b8bbce01391f91c237e50a53cfdacd into 4672f809cedd2f206e109761072641798aa0184e. Running the PR's own rollup() query against each:
--- 4897ea37 (git rev-parse HEAD) ---
statusCheckRollup: null, 0 contexts -> {"pending":0,"failed":[]}
--- 8582f534 (PR head) ---
5 contexts: review IN_PROGRESS, test/test-proxy/test-worker/lint SUCCESS
The merge commit's {"pending":0,"failed":[]} is indistinguishable from a settled-green result, so nothing downstream can tell the gate never ran.
Smaller, both inline: STARTUP_FAILURE and ACTION_REQUIRED are terminal CheckConclusionState values that land in neither bucket (not pending, not failed), so a job that fails to start reads as green; and the cap-expiry signal is gone — the old block ended echo "CI still running after 9 minutes"; exit 1, while the new one falls through to the same echo "$R" whether the loop settled or timed out, always exiting 0. Numbered item 4 still asks the run to report still-pending checks as unverified, so it's worth keeping something that says which case it's in.
Not blocking, and leaving review's pre-APPROVE peek to #821 reads right — it's still open and touching that line.
…ap signal A review session checks out refs/pull/N/merge, and that merge commit carries no statusCheckRollup at all, so `git rev-parse HEAD` broke the loop on iteration 1 with a false green — the same failure this PR set out to fix, on the approve-then-dismiss gate. Spell both derivations out so the block stays runnable verbatim either way. Add STARTUP_FAILURE and ACTION_REQUIRED to the failed bucket; both are terminal CheckConclusionState values that previously landed in neither bucket, so a job that never started read as green. CANCELLED stays out. Replace the single trailing `echo "$R"` with an explicit three-way outcome so numbered items 3 and 4 have something to trigger on.
tend-agent
left a comment
There was a problem hiding this comment.
The three fixes from the last round hold up: the two-derivation PINNED_SHA block is applied verbatim, STARTUP_FAILURE/ACTION_REQUIRED are in the live CheckConclusionState enum and belong in the failed bucket, and the three-way outcome line is a better answer than the suggestion it replaced — [ "$PENDING" -gt 0 ] alone really would have fired on the break-on-red path. Resolving those threads.
Two things left, both about the loop reporting a state it didn't observe.
The break-on-red exit makes review's settled-rollup path unreachable. That path names this loop as the mechanism for waiting out a mid-flight FAILURE, and #821 adds a second branch that presumes the loop can reach its cap with a FAILURE present. The early break returns after one iteration instead. Inline on the break line, with the tradeoff.
The else branch reports green when the rollup query returned nothing at all. Inline, with the reproduction.
Both are inline suggestions. No approval — self-authored.
…lup green Drop the break-on-red. `review`'s pre-APPROVE path routes a FAILURE that co-exists with in-flight checks back through this loop to be waited out, because that shape is usually a stale cancellation-cascade artifact; the early break returned ~60s in with the same mid-flight snapshot, so the caller re-peeked an identical rollup. The push path still exits as soon as everything is terminal. Guard the outcome line against a failed graphql call: with $R empty, `read` left both vars unset, both `[` tests errored to stderr, and the `else` announced green on a rollup that was never received.
tend-agent
left a comment
There was a problem hiding this comment.
The break-on-red removal is right, and the comment above it now says why the loop has to outlast a mid-flight FAILURE — review's settled-rollup path is reachable again. Resolving that thread.
The empty-$R fix is the half of the false-green it can reach, but the guard it added can't see the larger half. rollup()'s // [] turns every "this response carries no rollup" shape into {"pending":0,"failed":[]} — byte-identical to a settled green, and non-empty, so [ -z "$R" ] never fires. Three ways in, all verified against the live API from this run: a GraphQL errors envelope (rate limit, transient server error — gh writes the body to stdout and jq happily reduces it), an OID GraphQL can't resolve, and a commit whose checks haven't registered yet. Each one breaks the loop on iteration 1 and prints green on $PINNED_SHA for a rollup the run never received. That's the PR's own thesis — reporting a state you didn't observe — surviving in the branch meant to catch it.
The second thread stays open rather than resolved, because the truly-empty case isn't fully closed either: the in-loop [ "$PENDING" -gt 0 ] / -eq 0 tests still error on an empty string, so neither continue nor break fires and each iteration costs 90 s instead of 60 s. Nine of those is 810 s against the 600 s Bash cap this block is explicitly sized for — the call is killed mid-loop and the verdict block never runs, so a persistent API failure still ends with no verdict at all rather than "unverified, not green".
Three inline suggestions; together they route all of it into the guard you already wrote. No approval — self-authored.
Verified: the three no-rollup shapes are indistinguishable from green
Live responses, this run:
$ gh api graphql -f query='... object(oid:"deadbeef…") { ... statusCheckRollup … }'
{"data":{"repository":{"object":null}}} # exit 0
$ gh api graphql -f query='query { nonexistentField }'
{"errors":[{"message":"Field 'nonexistentField' doesn't exist on type 'Query'"…}]} # exit 1, body on stdout
statusCheckRollup is schema-nullable ({"name":"statusCheckRollup","type":{"kind":"OBJECT","name":"StatusCheckRollup"}} — no NON_NULL wrapper), so a commit with no checks yet is the same shape.
Each fed through the PR's own jq filter:
errors envelope -> {"pending":0,"failed":[]}
unresolvable OID -> {"pending":0,"failed":[]}
statusCheckRollup:null -> {"pending":0,"failed":[]}
settled green -> {"pending":0,"failed":[]} <- same bytes
With the suggestions applied, simulating the loop against a stubbed rollup():
persistent API failure calls=9 R=[] -> no rollup returned — unverified, not green
settled green calls=2 -> green
settled red calls=2 -> red
pending -> green calls=3 -> green
red while pending -> green calls=3 -> green (stale cascade waited out)
one blip -> green calls=3 -> green
never settles calls=9 -> cap hit — unverified
The failure case is 9 x 60 s = 540 s, inside the 600 s cap, so the verdict block still prints.
…d guard
`// []` collapsed all three no-rollup shapes — a GraphQL errors envelope,
an unresolvable OID, and a nullable `statusCheckRollup` — to
{"pending":0,"failed":[]}, byte-identical to settled green and non-empty,
so the `[ -z "$R" ]` guard could never see them. `// empty` emits nothing
instead, so all three reach it.
Guard both in-loop queries on an empty $R as well. The `[` tests errored
on an empty string and returned 2, so neither continue nor break fired and
each failed iteration fell through to the grace sleep at 90s rather than
60s; nine of those is 810s against the 600s Bash cap this block is sized
for, so the call was killed and the verdict never printed. Retrying keeps
a persistent failure at 540s and leaves $R empty for the guard.
tend-agent
left a comment
There was a problem hiding this comment.
The // empty rewrite and both [ -z "$R" ] guards close the no-rollup half — I re-ran the three shapes through the committed filter (statusCheckRollup: null, object: null, errors envelope) and all three now emit nothing, so they route to no rollup returned … unverified, not green instead of the byte-identical green. Resolving those threads.
One finding in the new code, and it's the mirror of this PR's own thesis rather than a repeat of it. The outcome line tests FAILED before PENDING. That ordering was right when it was written in e6362ee, because the loop could still break on red with checks in flight and the disposition there was "go diagnose". 0ca2c53 removed that break — so FAILED > 0 alongside PENDING > 0 is now reachable only on cap expiry, which is precisely the shape the comment three lines above says not to judge ("a FAILURE alongside in-flight checks is often a stale cancellation-cascade artifact … Judge the settled rollup"). A rollup that never settled now prints red on $PINNED_SHA — diagnose the failures above, indistinguishable from a settled red.
That lands on the gate review delegates here. Its pre-APPROVE path routes $FAILED set + $PENDING > 0 back through this loop specifically to wait the cascade out; if the replacement matrix outlasts the 9-iteration cap, the caller reads "red", skips the approve or dismisses one — for a commit whose checks are still running and will likely go green. Item 4 of the numbered list ("if the cap hits with checks still running, comment the still-pending checks as unverified") also has nothing left to fire on, since the red branch has already claimed the case.
Swapping the two tests is safe: the loop only breaks when PENDING is 0, so a settled red always has PENDING == 0 and still reports red. Inline suggestion below. No approval — self-authored.
Simulated against a stubbed rollup(), sleeps counted rather than slept
Committed ordering:
stale cascade: FAILURE + in-flight, never settles -> red (540s)
plain never-settles, no failure -> cap hit / unverified (540s)
settled red -> red (90s)
settled green -> green (90s)
persistent API failure -> no rollup / unverified (540s)
With PENDING tested first:
stale cascade: FAILURE + in-flight, never settles -> cap hit / unverified (540s)
plain never-settles, no failure -> cap hit / unverified (540s)
settled red -> red (90s)
settled green -> green (90s)
persistent API failure -> no rollup / unverified (540s)
Only the first row moves; the failing check names and run URLs are still printed by the echo "$R" immediately above, so nothing is lost from the cap-hit report.
The // empty check, same run, each shape fed through the committed filter:
statusCheckRollup: null -> (no output)
object: null -> (no output)
errors envelope -> (no output)
empty nodes array -> {"pending":0,"failed":[]}
mixed real nodes -> {"pending":1,"failed":["lint …/runs/222/job/1","never-started …/runs/444/job/4"]}
The last row also confirms STARTUP_FAILURE still buckets as failed, CANCELLED stays excluded, and both the own-run and same-workflow filters hold.
Removing the break-on-red left `FAILED > 0` with `PENDING > 0` reachable only on cap expiry — the stale-cancellation-cascade shape the loop comment says to wait out, and the case `review`'s pre-APPROVE path routes here on purpose. Testing FAILED first reported it as `red`, indistinguishable from a settled red, so a caller would skip an approve or dismiss one over checks still running. Test PENDING first; a settled red still reports red because the loop only breaks once PENDING is 0.
…nst tend before filing upstream (#891) ## Problem Two dedup blocks were blind in two different ways, and the cited duplicate needed both fixed. **State filter.** `review-runs` Step 5 and `review-reviewers` Step 4 both deduped against PRs with `gh pr list --state open`. A merged PR is never returned by that query, so a finding whose fix already landed reads as undeduped and gets filed again. `running-in-ci`'s PR-creation dedup recheck already gets this right ("with `--state all` so closed and merged siblings show up"); these two recipes contradicted it. **Repo scope.** `review-runs` is a generated workflow ([`generator/src/tend/config.py:24`](https://github.com/max-sixty/tend/blob/f65f49f/generator/src/tend/config.py#L24) lists it in the enabled set), so it runs in each adopter's checkout and an unqualified `gh pr list` returns *the adopter's* PRs. Step 6 routes bundled-skill defects upstream to tend, but neither Step 5 nor any of `running-in-ci`'s dedup recipes — all local-repo — told the agent to dedup in the target repo before filing there. `--state all` alone does not close this: the adopter's PR list never contained the upstream fix at any state. `review-reviewers` is unaffected by the second half. It runs in `max-sixty/tend` and files onto tend, so its unqualified `gh pr list` already resolves to the right repo; only the state filter was wrong there. This bites hardest on tend specifically, because of the pinning model: adopters call `max-sixty/tend/<harness>@X.Y.Z`, so a merged skill fix stays dormant on their repos until the next release tags. The bug keeps reproducing after the fix merges — which is exactly the window in which the analysis legs are looking at it, and exactly when the dedup queries are blind to the fix. ## What happened `max-sixty/cargo-affected`'s `tend-review-runs` run [31160677649](https://github.com/max-sixty/cargo-affected/actions/runs/31160677649) (08:11:33Z → 08:21:41Z) hit the `| last` evidence-log mis-selection: it appended ~12 KB of run evidence into the nightly's unrelated comment on target [#73](max-sixty/cargo-affected#73), noticed on its post-verify read, restored comment `5188771252`, and re-appended to the real log `5150650688`. Good recovery. It then filed [#883](#883) upstream, whose "Proposed fix" is a `## Run ` heading predicate on the comment selector. [#875](#875) merged that exact fix at 07:34:40Z — 46 minutes before the issue was filed — as `test("^## Run [0-9]")` on the same selector, in the same file. #883 is a duplicate of a merged PR. The run made three dedup queries before filing (`gh issue list --state all --search "tracking issue comment append"`, a broader `gh issue list --state all` title regex, and a final `gh issue list --state open` recheck). All three were `gh issue list`, which never returns PRs — and all three ran against `max-sixty/cargo-affected`. Even had it run Step 5's PR line verbatim, it would not have returned #875, for both reasons: the state filter excluded merged PRs, and the query's repo was the adopter's, not tend's. ## The fix - Both skills: `gh pr list --state open` → `--state all`, projecting `state,mergedAt` so a merged hit is legible. - `review-runs` only: add the cross-repo pair (`gh pr list`/`gh issue list --repo max-sixty/tend --state all`) so a finding heading upstream under Step 6 is deduped against tend first. - `review-runs` only: the pinning note is scoped to the upstream repo, since in that skill the reader is the adopter and the local `gh pr list` above it has nothing to do with pinned refs. `review-reviewers` keeps the original wording, where tend is the reader and "on adopters" is the correct direction. Both added commands were run against this repo to confirm they parse and return the expected shape. ## Gate assessment - **Evidence level**: High. **Occurrences: 1** direct, verified end to end (session log, both dedup query sets, #875's merge time and diff, #883's body). - **Structural, not stochastic.** `gh pr list --state open` deterministically cannot return a merged PR, and a query scoped to the adopter's repo deterministically cannot return a tend PR; replayed ten times it misses #875 ten times. There is no decision point. - **Change type**: targeted fix — query lines plus one sentence naming the pinning consequence. It brings both recipes into line with a rule the same plugin already states in `running-in-ci`, rather than introducing new policy. - **Why act at one occurrence**: the six-PR batch merged at 07:34:40Z ([#875](#875), [#834](#834), [#868](#868), [#818](#818), [#877](#877), [#858](#858)) is all unreleased, so six distinct bugs remain observable on every adopter until the next release. The first analysis leg after that batch produced the first duplicate. The exposure is six-wide and standing, not one-off. ## Not covered by No open PR touches either dedup block. [#845](https://github.com/max-sixty/tend/pull/845)/[#850](https://github.com/max-sixty/tend/pull/850)/[#838](https://github.com/max-sixty/tend/pull/838) are the run-window cluster; [#849](#849), [#856](#856), [#857](#857), [#864](#864), [#869](#869), [#876](#876), [#809](#809), [#837](#837), [#821](#821), [#836](#836) are elsewhere. #883 stays open as a maintainer call — it is redundant with #875 but the option-2 half of its body (finish the gist migration for `review-runs`) is not. Evidence: https://gist.github.com/dca23a6e6a0d8cae2665944ba31676fb --------- Co-authored-by: tend-agent <270458913+tend-agent@users.noreply.github.com>
…t one by position (#899) A session verifying its own skill edit closed a live maintainer-facing issue and posted a false status comment. The bot caught it and self-healed within ~2.5 minutes, but the close/reopen is permanent in the timeline and the deleted comment had already fired a workflow run. The guidance that produced the behaviour is still unguarded, so the next session hits the same shape. ## What happened Run [31257558939](https://github.com/max-sixty/tend/actions/runs/31257558939) (`tend-mention`, `repository_dispatch`) was answering a review question on #898 — a change to `plugins/tend-ci-runner/skills/nightly/SKILL.md`. To verify the edit it extracted a code block from that file **by fence ordinal** and ran it: ````bash awk '/^```bash$/{n++} n==3 && !/^```/{print} /^```$/{if(n==3) exit}' \ plugins/tend-ci-runner/skills/nightly/SKILL.md > /tmp/step3.sh bash /tmp/step3.sh ```` The third `bash` fence in that file is not the step-3 recipe it was aiming at. It is the drift-issue closer: ```bash gh issue list --state open --author '@me' \ --search '"configuration drift" in:title' \ --json number --jq '.[].number' \ | xargs -r -I {} gh issue close {} --comment 'tend check now passes.' ``` Issue #822 (`tend check: configuration drift on max-sixty/tend`) matched. It was closed at `12:38:56Z` with the comment `tend check now passes.` — false; the check still fails. The session noticed, reopened at `12:39:04Z`, deleted the comment, and [left a correction on the thread](#822 (comment)) plus a flag in its PR reply. Its own words from the session log: *"My mistake — I ran an extracted block without reading it first."* What did not recover: `#822`'s timeline still shows `closed by tend-agent` / `reopened by tend-agent`, and the deleted comment had already fired `issue_comment`, so run [31257716552](https://github.com/max-sixty/tend/actions/runs/31257716552) exists and always will. ## Root cause Two things compose, and only the second is a slip: 1. **Structural — the verification mandate has no write carve-out.** [`running-in-ci`'s "Verifying external-tool behavior"](https://github.com/max-sixty/tend/blob/03f8e0d/plugins/tend-ci-runner/skills/running-in-ci/SKILL.md#L610) says *"verify by running the code"* and ranks running the tool above reading the source, with an example scolding a session for trusting docs instead of running the thing. That instruction is right, and the session was obeying it. Nothing anywhere says the recipes it points at include `gh issue close`, `gh pr comment`, and `git push`, or that "run it" needs to mean something different when the recipe writes. Every adopter's `nightly` skill ships that same closer, so this is a bundled gap, not a tend-local one. 2. **Stochastic — selecting the block by ordinal and not reading it.** A different session might have read it. But the ordinal is a trap independent of judgment: it moves with every edit to the file, so the block that runs is by construction not guaranteed to be the one under test. ## The change One paragraph plus a two-line example in the section that created the pressure. It names the write surface, kills positional extraction, and gives the read-half recipe. No new section, no reorganisation. ## Gate assessment - **Evidence level: Critical, 1 occurrence** — this is the gate table's own example, *"clearly wrong outcome (closed wrong issue)"*, which acts at 1. It is also genuinely new: every one of the 124 prior windows in the evidence log records `0 reopened issue` under negative signals, so there is no historical count to add to. - **Classification: structural pressure, stochastic trigger.** I'm not claiming the failure is fully structural — a session that read the block first would have avoided it. What is structural is the instruction that sent it there with no mention of side effects, and the ordinal extraction that makes "the block I meant" unknowable. - **Magnitude: targeted fix.** Gate 2 would put a *new* section at the 3+ bar. This is a guard attached to an existing instruction that demonstrably produced a public wrong write, held to one paragraph to stay proportionate. Flagging the tension rather than hiding it: if you read this as a new-paragraph change wanting 3 occurrences, the counter-argument is that the three occurrences would each be another wrongly-closed issue. - **Dedup:** no open or closed issue/PR covers it. The three open `running-in-ci` PRs (#837, #870, #876) are all the CI-monitor poll, a different section. ## Verified, not inferred - Both claims in the new text were checked against this incident rather than assumed: `#822`'s events API shows the close and the reopen both retained; run `31257716552` fired from the since-deleted comment and is still listed. - The extraction command and `bash /tmp/step3.sh` are read verbatim from the session log's `tool_use` entries, not reconstructed from the bot's summary. - The third-fence block is reproduced by re-running the same `awk` against `nightly/SKILL.md` at `03f8e0d`. Evidence log: https://gist.github.com/e08f6e62d6478163cb425a75648eb7e4 --------- Co-authored-by: tend-agent <270458913+tend-agent@users.noreply.github.com>
# Conflicts: # plugins/tend-ci-runner/skills/running-in-ci/SKILL.md
tend-agent
left a comment
There was a problem hiding this comment.
The ordering swap in 9c87a82 is right, and it's the last of the four rounds' findings — every thread on this branch is now resolved.
One new finding, and it arrived with the base merge rather than with your commit. c5b7977 brought #821 in, which established that a concurrency-cancelled run leaves both check runs on the commit, so a rollup has to be reduced to the latest entry per check name and workflow before it is read. review's pre-APPROVE block now does that reduction; the loop in this PR — the one review delegates to for exactly that case — does not.
It lands harder here than it did there, because this PR is what introduces the failed bucket and the red on $PINNED_SHA verdict. A superseded FAILURE never leaves the commit, so once the replacement run settles the loop breaks with PENDING == 0, reads the stale red out of failed, and prints red on $PINNED_SHA — diagnose the failures above. Step 6's follow-up then dismisses an approval over a check whose replacement went green — the PR's own thesis, reporting a state you didn't observe, reaching the one gate review hands to this loop.
Two inline suggestions. startedAt / createdAt have to be in the selection set before anything can sort on them, then the same latest-per-(name, workflow) reduction #821 uses. Keying on the workflow name as well as the check name matters for the same reason it does there: two workflows can register the same check name, and collapsing those would hide a genuine red behind an unrelated green.
No approval — self-authored.
Verified: the OID-pinned query returns duplicates un-collapsed; the reduction drops only the superseded entry
GitHub does not collapse same-named check runs in statusCheckRollup.contexts. This PR's own commits carry live examples — e6362ee returns 17 contexts of which four are handle / tend-mention, three are relay, three are verify:
--- e6362eea2313065817a85cb215c29b4db59d3acb ---
{"total":17,"dupes":[{"n":"handle","w":"tend-mention",...},{"n":"handle","w":"tend-mention",...},
{"n":"handle","w":"tend-mention",...},{"n":"handle","w":"tend-mention",...},
{"n":"relay","w":"tend-mention",...} x3, {"n":"verify","w":"tend-mention",...} x3]}
Those all conclude SKIPPED / SUCCESS, so they cost nothing today; the point is that the duplicates arrive. Fed a synthetic rollup where the superseded entry is the red one — check-ok-to-merge FAILURE at 10:00 from run 111, replaced by SUCCESS at 10:05 from run 222, plus a genuine check-ok-to-merge FAILURE from a different workflow, a CANCELLED/SUCCESS pair, a STARTUP_FAILURE, and a codecov/patch status context:
committed: {"pending":0,"failed":["check-ok-to-merge","lint","codecov/patch","check-ok-to-merge"]}
deduped: {"pending":0,"failed":["check-ok-to-merge","codecov/patch","lint"]}
The committed filter reports the superseded red. The deduped one drops it, keeps the other workflow's genuine check-ok-to-merge red (which is what the workflowName half of the key buys), and leaves STARTUP_FAILURE, CANCELLED-excluded, and the status-context handling exactly as they were.
startedAt and createdAt were confirmed by introspection rather than assumed — CheckRun exposes startedAt (and completedAt), StatusContext exposes createdAt. startedAt is populated while a run is still going, not only at completion: the live rollup on this PR's head shows review IN_PROGRESS with startedAt: 2026-08-12T06:49:06Z, so an in-flight replacement still wins max_by against a settled predecessor.
With both suggestions applied, the block passes bash -n and runs live:
e6362eea (17 contexts, 10 of them duplicates) -> {"pending":0,"failed":[]}
c5b79778 (live head, this run in flight) -> {"pending":1,"failed":[]}
06b57475 (base tip) -> {"pending":0,"failed":[]}
The // empty guard still fires — group_by sits after the array construction, so an unresolvable OID and an all-zeros OID both still emit nothing and route to no rollup returned … unverified, not green. pending is unaffected either way: a superseded check run is COMPLETED, so it was never in that bucket.
…er check and workflow A concurrency-cancelled run's check runs stay on the commit, and an `if: always()` merge-gate omnibus whose dependency was cancelled concludes FAILURE rather than CANCELLED, so the failed bucket doesn't exclude it. Once the replacement settles the loop broke with PENDING == 0, read the superseded red, and reported red on a commit whose replacement went green — the gate review's pre-APPROVE path delegates here for exactly that shape. Match the reduction review's pre-APPROVE block got in #821: group by check name and workflow, take max_by(startedAt // createdAt). startedAt is populated at queue time, so a still-queued replacement wins over its settled predecessor and the pending signal survives.
tend-agent
left a comment
There was a problem hiding this comment.
Both suggestions applied verbatim, and the reduced filter holds up when I run it rather than read it: fed a rollup carrying a superseded check-ok-to-merge FAILURE alongside its replacement's SUCCESS, plus a genuine same-named FAILURE from a second workflow, a codecov/patch status context, and a CANCELLED leg whose replacement is still QUEUED, it returns {"pending":1,"failed":["check-ok-to-merge …/runs/333/…","codecov/patch …"]} — superseded red dropped, the other workflow's red kept, and the queued replacement still holding the loop open. The startedAt-at-queue-time claim checks out against a live QUEUED check run too (started_at non-null while status is still queued), so the pending signal survives the reduction. Both threads resolved.
One inline note on the block's framing, not on its behaviour — a judgment call for you rather than a finding, and fine to land as-is.
No approval — self-authored.
Two independent runs of a workflow with no concurrency group both stay live on the commit and neither supersedes the other, so the reduction discards one run's real result. Point at the per-run conclusion check for when that has to be exact.
…n window (#965) Cuts the four complexity hotspots from the recent bot-PR audit back to the #960 vision — mechanism only where it prevents wrong outward actions — and moves the surviving logic under real tests. Everything loaded or executed in production shrinks (~420 lines across the template, generated workflows, and skill prose); the growth is tests for scripts that previously had none. - **`mention.yaml.j2` verify gates: 4 → 2.** The stacked review-path skip gates collapse into two structural rules: a bot-authored review summons a session only as the reviewer→author handoff (fresh content on a PR the bot authored), and a contentless approval is terminal whoever submitted it. GitHub rejects self-approvals, so the empty-body-APPROVED gate was already subsumed by an author-keyed rule. Outward behavior is unchanged — verified case-by-case twice (by hand, and independently by the review sweep's verifier); each rendered workflow drops ~52 lines. - **`running-in-ci`: 869 → 677 lines.** The two markdown poll recipes become tested scripts. `poll-pr-checks.sh` polls by commit OID, never the PR head, fixing the false green a concurrent push caused (closes #829) and carrying the edge cases #837's five review rounds surfaced — null merge-ref rollups, superseded check runs, STARTUP_FAILURE/ACTION_REQUIRED bucketing — so it supersedes #837. `rerun-failed-jobs.sh` owns the rerun and finds the new attempt's jobs by `run_attempt`, so a fast rerun no longer reads as "nothing re-queued" and an unregistered one can't report stale conclusions as fresh. The rarely-taken skill-PR mechanics move to `references/skill-pr-workflow.md`. - **`list-recent-runs.sh`: 207 → 113 lines.** The completion window becomes "floor = the last successful run's start, clamped at 6h, else 6h with a coverage-gap warning". Any successful run's window opens at or before its own start, so consecutive windows overlap and never gap — which makes the cron parsing, tick tiling, dropped-tick recovery, and the schedule-event restriction all unnecessary, at any cadence. The retry wrapper goes too: a transient failure now fails loud, and the next tick's floor reaches back past the lost window. This supersedes the window-anchoring half of #845; its cadence-cut half stands alone and needs no script support under anchor-based windows. - **`review-reviewers` Non-issues: 7 carve-outs → 3 structural rules** (designed no-ops at whatever layer catches them, designed silence, reviewer-role independence), with the closed menu folded into Gate 3's cost classification. Also: pre-commit's shellcheck now covers `plugins/**/*.sh` (it covered none of them), and the fake-`gh` test scaffold consolidates into `generator/tests/__init__.py` (five copies → one). ## Testing The scripts are exercised end-to-end under pytest with fake `gh`/`date`/`sleep` binaries, with the scripts' own jq filters doing the reductions: 21 cases for the poll scripts (false-green guards: >100-context page, null rollup, superseded runs, moved head), 11 for the window logic (anchoring, clamping, self-exclusion, loud failure), and the mention gate consolidation is pinned by the reworked `test_generate.py` assertions plus regenerated snapshots. Full suite green. The verify gate itself is still string-shape-tested rather than executed — extracting it to a `.sh` template like `review-gate.sh` is the natural follow-up, left out here to keep this diff reviewable. <details><summary>Review-hardening notes (later commits)</summary> An 8-angle verified code-review of the first commit found the substantive gaps in the new scripts themselves, all fixed and pinned by tests: the GraphQL query's one 100-node page could hide a red check past the cap (now routes to UNVERIFIED); a nullable `startedAt` could let a stale settled check run outrank its QUEUED replacement (a group with any non-terminal entry now reads pending); a transient API blip on a late poll iteration discarded the pending list the cap report names (the last usable rollup is kept); rerun discovery by job status misread fast reruns (now keyed on `run_attempt` advancing); and overlapping workflow prefixes double-counted runs (`unique_by`). The known remaining limit: latest-wins can't distinguish two independent runs of a group-less workflow on one SHA — documented in the script header and the skill, with the instruction to read the specific run's conclusion where that distinction must be exact. A further tend-review round on the PR added: explicit `--repo`/owner arguments on the three `gh` calls that leaned on cwd remote detection (with test assertions pinning the flags), a Step 1 sentence in `review-reviewers` covering a failing `list-recent-runs.sh` call (retry once, then record a coverage gap — the run still concludes green, so the next tick anchors past the span), and the zero-checks commit named in `poll-pr-checks.sh`'s exit-2 docs. </details> > _This was written by Claude Code on behalf of max-sixty_ --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
Superseded by #965, which lands the same fix as a tested script rather than a markdown recipe: The edge cases this PR's review rounds surfaced are carried over into the script and pinned by pytest: null rollups on ephemeral merge-ref commits, superseded check runs from a cancellation cascade, and the STARTUP_FAILURE / ACTION_REQUIRED conclusions that fall into neither the pending nor the failed bucket if forgotten. Cases the recipe didn't reach are covered too — a commit with more contexts than the query's one 100-node page now routes to UNVERIFIED instead of reading green. Closing rather than rebasing: the recipe this branch edits no longer exists.
|
Problem
The CI Monitoring loop in
running-in-cigated ongh pr view <number> --json statusCheckRollup, which describes the PR's current head rather than the commit the session pushed. Any other actor pushing while the loop slept retargeted the gate: the loop began waiting on the new commit, exited when those checks settled, and the closinggh pr checks <number>printed the new commit's results. The run then reported its own push as green without ever observing a terminal state for it — and the gated follow-up the recipe exists to drive (fix the failure, dismiss the approval) never fired.The loop was also blind to an early failure:
pending()counted only in-flight items, so a check reachingfailurewhile siblings ran didn't break the loop, and nothing in the exit path named it.Reported in #829, with a concrete instance on
max-sixty/cargo-affectedPR #54.Solution
Query the same rollup by commit OID (GraphQL
repository.object(oid:).statusCheckRollup) instead of through the PR. One call keeps everything the PR-scoped version had — check-runs and legacy status contexts, the own-run URL filter, and the same-workflow filter (the workflow name lives oncheckSuite.workflowRun.workflow, since a CheckRun'snameis the job name) — while staying pinned to the SHA the run is accountable for.The helper now returns
{pending, failed}rather than a bare count, so the loop breaks as soon as its own SHA is red and the exit path names the failing checks with their run URLs. After the loop,headRefOidis re-read: a moved head is reported as a distinct outcome rather than silently absorbed.PINNED_SHAcovers both gated cases, and the block spells out a derivation for each rather than naming one. After your own push it'sgit rev-parse HEAD. In a review session it is not:tend-reviewchecks outrefs/pull/N/merge, and that ephemeral merge commit carries nostatusCheckRollupat all — the query returnsnull, the loop breaks on iteration 1 with{"pending":0,"failed":[]}, and the approval is reported green without the commit it sits on ever being observed. That is this PR's own bug relocated onto the approve-then-dismiss gate, so the review-session derivation (gh pr view <number> --json headRefOid) ships alongside, commented.Testing
Replayed the reported instance against the live API.
551e059onmax-sixty/cargo-affectedPR #54, whose head has since advanced to6955352:The old form reports zero failures; the pinned form surfaces the
lintfailure that the session missed, and flags the moved head.The merge-commit case was then confirmed against this PR's own review run:
4897ea37(itsgit rev-parse HEAD) returnsstatusCheckRollup: null, while the PR head8582f53carries eight contexts — so the merge commit's{"pending":0,"failed":[]}is indistinguishable from settled green. Bucketing was exercised against synthetic nodes covering every conclusion:STARTUP_FAILUREandACTION_REQUIREDnow count as failed (they previously landed in neither bucket, so a job that never started read as green),CANCELLEDstays excluded, and both the own-run and same-workflow filters still hold. The three-way outcome line was exercised across all of red, cap-hit, and green. The own-run and same-workflow filters were exercised in that replay too (GITHUB_RUN_ID=30889113519,GITHUB_WORKFLOW=tend-review— thereviewcheck-run was correctly excluded). The block as committed passesbash -n.Review rounds
Five rounds of review on this branch found the same false-green class surviving inside the fix itself; all are addressed:
PINNED_SHAderivation.git rev-parse HEADis wrong in a review session —tend-reviewchecks outrefs/pull/N/merge, and that merge commit'sstatusCheckRollupisnull, so the loop broke on iteration 1 with a false green on the approve-then-dismiss gate. Both derivations are now spelled out, one commented.Failure buckets.
STARTUP_FAILUREandACTION_REQUIREDare terminalCheckConclusionStatevalues that landed in neither bucket, so a job that never started read as green.CANCELLEDstays excluded.Cap signal. The single trailing
echo "$R"exited 0 whether the loop settled or timed out. Replaced with an explicit outcome line naming red, cap-hit, no-rollup, and green.Break-on-red removed.
review's pre-APPROVE path routes aFAILUREco-existing with in-flight checks back through this loop to be waited out, because that shape is usually a stale cancellation-cascade artifact. An early break returned ~60 s in with the same mid-flight snapshot, so the caller re-peeked an identical rollup.Every no-rollup response now reaches the guard.
// []collapsed a GraphQLerrorsenvelope, an unresolvable OID, and a nullablestatusCheckRollupall to{"pending":0,"failed":[]}— byte-identical to settled green.// emptyemits nothing so they route tono rollup returned … unverified, not green. Both in-loop queries also guard on an empty$R: the[tests errored on empty strings and fired neithercontinuenorbreak, costing 90 s per iteration — 810 s against the 600 s Bash cap, which killed the call before the verdict printed. Now 540 s.Superseded check runs. The rollup was read un-reduced. A concurrency-cancelled run's check runs stay on the commit forever, and an
if: always()merge-gate omnibus whose dependency was cancelled concludesFAILURErather thanCANCELLED, so the bucket doesn't exclude it — once the replacement settled the loop broke withPENDING == 0, read the stale red, and printedred on $PINNED_SHA, dismissing an approval over a check whose replacement went green. Now reduced to the latest entry per (check name, workflow), matching the blockreview's pre-APPROVE path got in fix(review): dedupe pre-APPROVE rollup by name and decide cap-expiry on provenance #821 — which is what delegates here for exactly that shape.Outcome ordering. Removing the break left
FAILED > 0withPENDING > 0reachable only at cap expiry — the same unsettled shape the loop is told to wait out — yet it printedred.PENDINGis now tested first, so an unsettledFAILUREstays unverified; a settled red still reports red because the loop only breaks oncePENDINGis 0.Round four's reduction was verified by extracting the committed block and running its jq filter verbatim against a synthetic rollup: a superseded
check-ok-to-mergeFAILURE(run 111) drops out while a same-namedFAILUREfrom a different workflow (run 333) survives, a settledFAILUREsuperseded by a still-QUEUEDreplacement moves fromfailedintopending, andSTARTUP_FAILURE,CANCELLED-exclusion, the own-run filter, the same-workflow filter, and thecodecov/patchstatus context are all unchanged. That queued case rests onstartedAtbeing set at queue time, checked against liveQUEUEDcheck runs (pytorch/pytorch) via both REST and GraphQL rather than assumed. Live commits still answer as before, and an unresolvable OID still emits nothing and routes tono rollup returned.tend-mention's group-lessrelay/verify/handleare that shape, and this branch's head carries tworelayentries tied to the same second, wheremax_byfalls back to input order. Kept the reduction (the recurring stale red is the case worth fixing) and documented the limit, pointing at the per-run conclusion check for when the distinction has to be exact.Loop behaviour was simulated against a stubbed
rollup()withsleepcounting seconds rather than sleeping: settled green 90 s / 2 calls, transient blip then green 150 s / 3, stale cascade red then green 210 s / 4, genuine red 210 s / 4, never settles 540 s / 9 (cap hit), persistent API failure 540 s / 9 (no rollup returned).Not included
review's pre-APPROVE bullet still says to re-peek the PR rollup after polling, which has the same head-move exposure. #821 is currently rewriting that exact line, so the one-line change is left out to avoid a conflict; it should adoptPINNED_SHA=$HEAD_SHAonce #821 lands.Closes #829 — automated triage