feat(pm): read the last event=schedule run of every scheduled non-blocking workflow and raise a RED one into the label inbox (H57) - #17543
Merged
Conversation
…cking workflow (H57) The PM sweep gains one row. For each scheduled, non-blocking workflow on the swept repo it reads the LATEST run whose `event` is `schedule` and reports it RED when that run did not conclude `success`, when it is older than the period derived from the workflow's own cron plus one day, or when the schedule has never produced a run at all. A green and fresh run emits nothing. Two refusals are structural rather than documented. No self-reporting automation: this change adds no workflow, no step and no schedule — the reader is external, because a workflow that dies at `Set up job` never reaches a step that could report itself. And no `status=success` count: every runs read is built by `scheduledRunsPath`, which cannot be spelled without `event=schedule`, a source audit proves no other line in the file can build one, and the pass runs offline in the self-test behind a spy that throws on an unfiltered read. Population is derived, not listed: the workflows endpoint for `state`, the workflow file's own `on:` block off the local checkout for the schedule, and "declares none of pull_request / pull_request_target / merge_group" as the structural reading of non-blocking. Measured 2026-09-10 — objectstack 36 files, 22 scheduled, 7 non-blocking; objectui 37 files, 9 scheduled, 7 non-blocking. Cost is one workflows listing plus one runs read per judged workflow. Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK Co-authored-by: Claude <noreply@anthropic.com>
A bare `rows[0][1]` throws while evaluating a `t()` argument when the ablation empties the row list, which aborts the whole suite before it prints a verdict — the failure mode `selfTest`'s own row-wrapper note describes. Row access now goes through a total accessor, so removing the `event=schedule` filter produces named red cases instead of a TypeError. Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK Co-authored-by: Claude <noreply@anthropic.com>
…heduled-workflow-reader
… a directory literal `check:pm-dispatch-gates` caught this: a `.github/workflows/` string literal in this file is read as a WATCH SURFACE, which placed `check:pm-half-states` by path on every card touching a workflow — and CI runs this file only as `--self-test`, which reads no workflow file at all. That is the placement #15753 removed through the noise-floor constant, and ROOT_WALK_RESIDUE_LEDGER's row for this family records why; the gate reds on the ledger row going stale. A regex over the path's extension carries the same meaning, classifies this fleet identically (measured: the objectui pass produces the same two rows and the same eight-request accounting), and claims no directory this gate never reads. Pinned so the next author does not spell it back. Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK Co-authored-by: Claude <noreply@anthropic.com>
…heduled-workflow-reader
This was referenced Sep 10, 2026
os-litant
marked this pull request as ready for review
September 10, 2026 20:57
os-litant
enabled auto-merge
September 10, 2026 20:57
This was referenced Sep 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #17132
The PM sweep gains one row, H57. For each scheduled, non-blocking workflow on the swept repo it reads the latest run whose
eventisscheduleand reports it RED when that run did not concludesuccess, when it is older than the period derived from the workflow's own cron plus one day, or when the schedule has never produced a run at all. A green and fresh run emits nothing.One file changed:
scripts/pm/check-half-states.mjs. No workflow, no step, no schedule, and noSKILL.md— the diff is insertions in localised regions so the three cards queued behind this one on the same file apply cleanly.The ruling
objectui#8402 comment
5582374721, director seat, decision batch #88, maintainer authority. Quoted verbatim on the card:Both refusals are structural here rather than documented:
Set up jobnever reaches a step that could open an issue, so a workflow watching itself is silent at exactly the moment there is something to say. The reader is external, and this change adds no automation of any kind — the row rides the sweep that already runs.status=successcount. Every runs read is built byscheduledRunsPath, which cannot be spelled withoutevent=schedule.h57RunsPathAuditscans this file's own source and proves no other line can build one. And the pass runs offline in the self-test behind a spy that throws on any runs read reaching the transport unfiltered.A RED row lands in the label inbox exactly the way every other finding does: it is a
findings.pushrow, rendered into the patrol anchor's body by the same renderers, read there by the triage seat. The script writes no card — measured: no row in this file does, and this one does not change that.The population, measured today
Derived from the API and the tree, never from a hand list. Both repos at
origin/main, 2026-09-10:objectstack-ai/objectstackobjectstack-ai/objectuicodeql.yml,coverage-nightly.yml,publish-smoke.yml,release.yml,rerun-safety-nightly.yml,showcase-smoke.yml,stale.yml.changeset-release.yml,check-links.yml,merge-queue-head-patrol.yml,node-esm-load-gate.yml,published-dist-gate.yml,shadcn-check.yml,spec-range-floors.yml.Two readings the card asked to have checked rather than assumed:
stale.ymlis gone. It is absent fromorigin/main's tree and absent from the workflows listing. objectui#8126's subject no longer exists there. objectstack'sstale.ymldoes exist, is in the population, and its latest scheduled run is green.check-links.ymltoo, and it is a different workflow. Its triggers areworkflow_dispatchandpull_requestwith no schedule, so it is out of this population. The one the ruling names is objectui's.The "listed but absent" column is a real reading, not noise: GitHub keeps listing a workflow as
activeafter its file leaves the default branch, while its runs still exist. Those rows are counted as UNJUDGED in the summary clause rather than silently classified — on objectstack they areadr-merge-approval.ymlandmatrix-aggregate-experiment.yml.The row's contract
Inputs, and what each costs.
GET /repos/OWNER/REPO/actions/workflows— one request for the swept repo, forid,path,nameandstate. Onlystateactiveis judged; any other state is held out, counted, and named in the summary clause, because GitHub disables a scheduled workflow after 60 days of repository inactivity and that disablement is itself a death this row must not swallow.on:block, read off the local checkout — zero requests.localCheckoutServesis the guard that keeps that honest: the runner leg (GITHUB_REPOSITORYequals the swept repo) answers on every real patrol run, the terminal leg reads this checkout'sorigin, and anything else printsunresolvedand the row reads nothing. Classifying through the contents API instead would cost one request per workflow — 36 on this repo — for bytes the disk already holds.scheduledRunsPath— one request per judged workflow,event=scheduleandper_page=1.Request bound: one workflows listing plus one runs read per judged workflow. Measured live: 8 on each repo. A repo the install cannot read prints
unresolved; there is no retry loop on any status."Non-blocking", stated as the reading it is. A workflow declaring none of
pull_request,pull_request_targetormerge_group. A required status check is matched by check-run name on a pull request or in the merge queue, so a workflow that runs on neither can produce no required context and can block nothing: the set of required workflows is a subset of the set declaring a PR-gating trigger, and this row asserts the contrapositive. It is deliberately narrower than the ruleset's own answer — a workflow that declarespull_requestand is not required is held out — and the shortfall is printed in the summary clause rather than left implicit.Period derivation.
cronPeriodHoursenumerates the union of the workflow's crons over an 800-day horizon and returns the widest gap between consecutive fires. The verdict is "older than the period plus one day", so the period has to be the longest wait the schedule itself produces; a cron firing Monday and Tuesday has a narrowest gap of 24h and a widest of 144h, and taking the narrow one would report RED every Wednesday for a schedule doing exactly what it declares. Declaring a second, denser cron shortens the answer, which is the "shortest interval among the crons" the card asks for, arrived at structurally rather than by special case. A cron this file refuses to parse, or one too sparse to show two fires in the horizon, returnsnull— the staleness leg is then UNJUDGED and says so, never a default period.Verdict reasons, and they compose.
event=schedulehas produced no runsuccessA run that has not concluded carries
conclusion: null, which is the absence of a conclusion and not a non-success one; it is held out of the conclusion leg and kept in the staleness one, so a run wedged inqueuedstill ages into a finding while an ordinary in-flight run does not produce a false RED.The live sweep, this row's own output
Full sweep of
objectstack-ai/objectstack, report mode, 2026-09-10, exit 0. Three H57 rows:.github/workflows/codeql.yml— latestevent=schedulerun34075617802, started 2026-09-07T02:14:14Z, 89h ago, concludedcancelled. Cron0 2 * * 1gives 168h + 24h grace, so not stale; RED on the conclusion..github/workflows/publish-smoke.yml— run34084559243, 86h ago,failure. Cron47 4 * * 1..github/workflows/rerun-safety-nightly.yml— run34435568710, 15h ago,failure. Cron0 4 * * *.Its summary clause on that run:
The acceptance criterion, run against objectui. The shipped pass driven against objectui's live Actions API with its workflow sources read from objectui's
origin/main, 8 requests:objectstack-ai/objectui·.github/workflows/check-links.yml— the latest run whoseeventisscheduleis run34011606950, started 2026-09-06T04:28:58Z, 111h ago, concludedfailure. Declared17 4 * * 0, so 168h + 24h grace: not stale, RED on the conclusion.changeset-release.yml, latest scheduled runfailure.The 217-run trap, re-measured on that workflow. All four readings taken in the same minute:
?status=successworkflow_dispatchand fourpushruns from 2026-01-28, triggers it no longer declares?event=schedulefailure, weekly since 2026-08-09workflow_dispatchfrom 2026-09-07?event=schedule&status=successThe third row is the trap in one reading: without the filter the newest run is green and 3 days old, so the workflow reads healthy while every one of its scheduled runs has failed for five weeks.
Four-axis reasoning, one pass per judgement call
Every axis below is 实际业务需求 / 项目长远合理性 / 防 AI 写代码犯错 / 创业阶段不扩散需求, in that order.
1. "Non-blocking" read structurally, not from the branch ruleset. The alternative is
GET /repos/OWNER/REPO/rules/branches/mainplus a mapping from each required context back to a workflow file. 业务: the measured population is covered exactly by the structural reading — both workflows the ruling names are in it, and no workflow in either repo today declares both a schedule and a PR-gating trigger while being outside the required set in a way that matters. 长远: a required context is a job's check-run name (Test (shard 1/4)), which a matrix computes at run time; mapping it back to a file means guessing at job names, and the workflow file's ownon:block is the declaration an author actually edits. 防错: the guessed mapping fails silently and confidently, which is the one failure mode this file spends its length refusing; the structural reading cannot be silently wrong, only narrow. 不扩散: it buys no request and no parser for a population of zero. The narrowing is printed in the coverage clause, so it is a stated bound rather than a quiet one.2. The period is the widest gap, not the narrowest. 业务: the verdict is a staleness threshold, and the narrowest gap makes a lawful Mon+Tue schedule RED every Wednesday — a false alarm on a real board. 长远: the widest gap is the property the verdict actually needs, and taking it makes "shortest interval among the crons" fall out of the union rather than being a second rule. 防错: an underivable period returns
nulland the leg says UNJUDGED; a default period would date every later reading to a schedule nobody declared. 不扩散: it is one bounded enumeration, not a cron library.3. The
on:block is read off the local checkout, not the contents API. 业务: the patrol runs in the repo it sweeps and a depth-1 checkout holds every file — this is free, and the API route costs 36 requests on this repo to answer the same question. 长远: it reuses the git channelreadTrackedFilesalready proves exists, andlocalCheckoutServesmakes the one way it could lie (a checkout of a different repo) a loud refusal rather than a wrong report — the mirror image of the disease #11217 fixed. 防错: three states, two definite and one refusal, with the refusal printingunresolvedand reading nothing. 不扩散: no new transport, no new credential; the cross-repo credential the execution clause could be read to imply is exactly what #11217's adoption model rules out, and fleet coverage comes from the patrol already being installed per repo.4. A bounded line scanner, not a YAML parser. 业务: the corpus is 73 workflow files across two repos and the scanner reads all 73 with no refusals. 长远: this file is copied verbatim into sibling repos and imports node builtins only, so a dependency it cannot assume present is a dependency it cannot have. 防错: the scanner is written to refuse rather than interpret — anything it cannot resolve returns no events and a reason, which the row reports as UNJUDGED and counts. 不扩散: four spellings, every one of them pinned, and no ambition to be a YAML implementation.
5. The band is
stall. 业务: losing the row to the body trim restores exactly the silence the ruling was made about; objectui#8126 sat at 234 scheduled runs and zero successes for eight months with nothing on any board saying so. 长远:gateis refused on its own first clause — that band's subject is a gate in the required set, and this population is defined by the ruling as not being one; readinggateoff its second clause alone ("its absence reads as a green light") would make the band mean "anything protective". 防错: keepinggatenarrow is the tighter contract; widening the top band is the lenient shape. 不扩散:stall's criterion — stopped, and nothing else moves it — is satisfied more completely here than by any card, since a card at least has a board presence a person can stumble on.stateandinventoryare refused in the code, each on its own criterion.6. An in-flight run is not judged on its absent conclusion. 业务:
stale.ymlfires daily and the sweep four times a day, so readingconclusion: nullas non-success would produce false REDs on a real schedule. 长远: this is the file's standing doctrine — an unread input must never present as a verdict — applied one field over. 防错: the distinction is pinned in both directions, including a run wedged inqueuedpast its period, which still fires. 不扩散: five lines, no new state.7. The workflow-file discriminator is a regex over the extension, not a
.github/workflows/prefix. This one was found by a gate rather than reasoned out first, and it is worth stating plainly: a directory-shaped string literal in this file is read byscripts/pm/dispatch-gates.mjsas a watch surface, which placedcheck:pm-half-statesby path on every card touching a workflow — while CI runs this file only as--self-test, which reads no workflow file at all. That is the placement #15753 removed, andROOT_WALK_RESIDUE_LEDGER's row for this family records why;check:pm-dispatch-gatesreds on the ledger row going stale. 业务: both readings classify this fleet identically, measured — the objectui pass produces the same two rows and the same 8-request accounting either way. 长远: the extension test asserts the property that matters (the listing row names a YAML file) and claims no directory this gate never reads. 防错: the regex is pinned as a regex, with the reason, so the next author does not spell it back. 不扩散: the repair stayed inside this card's declared file, with no edit to the gate or its ledger.Verification
Everything below was captured with the exit code taken before any pipe, and every verdict line is the gate's own.
Gate families. Derived from the final diff with
node scripts/pm/dispatch-gates.mjs --commands— no paths, the script derives its own change set: 1 path,scripts/pm/check-half-states.mjs, three-dot against merge baseefa2533dd; 39 families. All 39 run, all exit 0. Reconciled:The one that mattered:
pnpm check:pm-dispatch-gatesfailed on the first pass with a single red case —That is judgement call 7 above. After the repair:
✓ dispatch-gates self-test: 1678 cases pass.(exit 0).The gate
lint.ymlruns on this file, before and after, with case counts:154 new cases, both directions of the acceptance among them: a green and fresh latest scheduled run is silent (predicate and whole-pass legs), a non-success one fires, a stale one fires, a never-ran one fires, the two reasons compose, a failed runs read is UNJUDGED and not "never ran", an in-flight run is not judged on its absent conclusion, and every refusal reads nothing rather than reporting a clean board.
Repo-wide lint, run as a union rather than narrowed — at final head
85922611:Ablation — the
event=schedulefilter dropped, then restored. Run from the committed state, with the mutation proven on disk (git hash-objectbefore8908cb88…, after9a884da2…, one line changed, the filtered spelling going 1 → 0 and the unfiltered 0 → 1) and a restore trap on absolute paths.Ablated, two legs:
Leg 2 is the trap itself: with the filter gone,
check-links.ymlproduces no row at all, because its newest unfiltered run is that greenworkflow_dispatch.Restored, same two legs:
✓ check-half-states self-test: 3367 cases pass.(exit 0) andROWS (2),check-links.ymlamong them. Restore proven byte-identical —git hash-objectback to8908cb88…,git diff HEADempty,git status --porcelainempty.An intermediate commit belongs to this evidence and is worth naming: the first ablation aborted the suite with a
TypeErrorinstead of printing red cases, because a barerows[0][1]throws while evaluating at()argument once the row list is empty. Row access now goes through a total accessor, which is why the leg above is readable.Path face.
node scripts/pm/check-governed-merges.mjs --pr PRNUMBER— recorded in the report comment on the card; expected NOT governed (scripts/pm/**is not a governed surface).Not measured here, by design: CI's own farm. This card's local scope is the 39 derived families plus the lint union; the artifact-roster, wide-population, pending-changeset and path-scheduled populations the derivation names are outside it and are CI's.
Acceptance notes
activeafter its file leaves the default branch — 2 such on objectstack (adr-merge-approval.yml,matrix-aggregate-experiment.yml) and 6 on objectui. The row counts them UNJUDGED rather than classifying them, which is the correct handling; the listing behaviour is GitHub's, not a defect here. Who touches it next: nobody queued — the next workflow-inventory audit, if one is ever filed.codeql.ymlcancelled,publish-smoke.ymlandrerun-safety-nightly.ymlfailing) are the row's own first findings. By the ruling they reach a person through the label inbox, which is the mechanism this PR builds; filing them here would duplicate it. Who touches it next: the owning lane's seat, through the patrol anchor.stale.ymlis gone, so objectui#8126's subject no longer exists. That card's disposition is objectui's triage seat's call, not this PR's, and this PR ⛔ does not comment there.half-state-patrol.ymlis held out of its own population on both repos because it declarespull_request. Even were it not, a row about itself would be void — a dead patrol runs no sweep. The anchor'sSweptheartbeat line is what watches the patrol, and it already says so.Clause-②: no— internal PM tooling: no spec or contract accept/reject behaviour changes and no published surface widens, exactly as the ruling states.skip-changeset— no package changes.scripts/pm/**publishes nothing from any released package'sfiles[], so there is no changeset to write; the label is applied on this PR.Generated by Claude Code