Skip to content

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
os-litant merged 5 commits into
mainfrom
claude/issue-17132-scheduled-workflow-reader
Sep 10, 2026
Merged

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
os-litant merged 5 commits into
mainfrom
claude/issue-17132-scheduled-workflow-reader

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

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 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.

One file changed: scripts/pm/check-half-states.mjs. No workflow, no step, no schedule, and no SKILL.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:

Ruled. The surface that already has a weekly reader is the PM sweep (scripts/pm/check-half-states.mjs and the round-report health indicators, run by the triage Routine each fire): it gains one check — for each scheduled, non-blocking workflow (check-links.yml, stale.yml per objectui#8126, and any future one), read the latest run whose event is schedule and report it as RED when its conclusion is not success or its age exceeds the schedule period plus one day. ⛔ It never reads status=success counts (the 217-run trap on this card). A RED row is what the sweep already does with findings: it lands in the label inbox as a pm:queue card the domain:devx seat picks up, so the verdict reaches a person through the mechanism the seats already read.

Both refusals are structural here rather than documented:

  • ⛔ No self-reporting automation. A workflow that dies at Set up job never 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.
  • ⛔ Never a status=success count. Every runs read is built by scheduledRunsPath, which cannot be spelled without event=schedule. h57RunsPathAudit scans 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.push row, 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:

repo workflow files declare a schedule scheduled + non-blocking held out (PR-gating) listed but absent from the tree
objectstack-ai/objectstack 36 22 7 15 2
objectstack-ai/objectui 37 9 7 2 6
  • objectstack's seven: codeql.yml, coverage-nightly.yml, publish-smoke.yml, release.yml, rerun-safety-nightly.yml, showcase-smoke.yml, stale.yml.
  • objectui's seven: 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:

  • ⚠️ objectui's stale.yml is gone. It is absent from origin/main's tree and absent from the workflows listing. objectui#8126's subject no longer exists there. objectstack's stale.yml does exist, is in the population, and its latest scheduled run is green.
  • ⚠️ objectstack has a check-links.yml too, and it is a different workflow. Its triggers are workflow_dispatch and pull_request with 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 active after 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 are adr-merge-approval.yml and matrix-aggregate-experiment.yml.

The row's contract

Inputs, and what each costs.

  1. GET /repos/OWNER/REPO/actions/workflows — one request for the swept repo, for id, path, name and state. Only state active is 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.
  2. The workflow file's own on: block, read off the local checkout — zero requests. localCheckoutServes is the guard that keeps that honest: the runner leg (GITHUB_REPOSITORY equals the swept repo) answers on every real patrol run, the terminal leg reads this checkout's origin, and anything else prints unresolved and 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.
  3. scheduledRunsPath — one request per judged workflow, event=schedule and per_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_target or merge_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 declares pull_request and is not required is held out — and the shortfall is printed in the summary clause rather than left implicit.

Period derivation. cronPeriodHours enumerates 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, returns null — the staleness leg is then UNJUDGED and says so, never a default period.

Verdict reasons, and they compose.

reason fires when
never ran on schedule the schedule is declared and event=schedule has produced no run
non-success the latest scheduled run concluded, and not as success
stale by N h that run started longer ago than the period plus the 24h grace

A 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 in queued still 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 — latest event=schedule run 34075617802, started 2026-09-07T02:14:14Z, 89h ago, concluded cancelled. Cron 0 2 * * 1 gives 168h + 24h grace, so not stale; RED on the conclusion.
  • .github/workflows/publish-smoke.yml — run 34084559243, 86h ago, failure. Cron 47 4 * * 1.
  • .github/workflows/rerun-safety-nightly.yml — run 34435568710, 15h ago, failure. Cron 0 4 * * *.

Its summary clause on that run:

Scheduled non-blocking workflows (H57): 22 workflow(s) on the swept repo declare a schedule; 7 were judged against their latest event=schedule run and 0 are UNJUDGED rather than clean because that read failed. Held out: 15 declaring a PR-gating trigger … 0 for a non-active workflow state. A further 2 workflow file(s) carried an on: block this file refused to read, so whether they belong to this population is UNJUDGED. Cost 8 request(s) …

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:

  • RED, naming the workflow the ruling names: objectstack-ai/objectui · .github/workflows/check-links.yml — the latest run whose event is schedule is run 34011606950, started 2026-09-06T04:28:58Z, 111h ago, concluded failure. Declared 17 4 * * 0, so 168h + 24h grace: not stale, RED on the conclusion.
  • Also RED: changeset-release.yml, latest scheduled run failure.
  • Nothing at all for the five green-and-fresh workflows in the same pass, which is the silent half of the acceptance on live data.

The 217-run trap, re-measured on that workflow. All four readings taken in the same minute:

query answer
?status=success 218 — its newest five are one workflow_dispatch and four push runs from 2026-01-28, triggers it no longer declares
?event=schedule 5, and all five concluded failure, weekly since 2026-08-09
unfiltered, newest first a green workflow_dispatch from 2026-09-07
?event=schedule&status=success 0 — the count the ruling refuses

The 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/main plus 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 own on: 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 null and 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 channel readTrackedFiles already proves exists, and localCheckoutServes makes 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 printing unresolved and 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. 长远: gate is 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; reading gate off its second clause alone ("its absence reads as a green light") would make the band mean "anything protective". 防错: keeping gate narrow 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. state and inventory are refused in the code, each on its own criterion.

6. An in-flight run is not judged on its absent conclusion. 业务: stale.yml fires daily and the sweep four times a day, so reading conclusion: null as 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 in queued past 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 by scripts/pm/dispatch-gates.mjs as a watch surface, which placed check:pm-half-states by 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, and ROOT_WALK_RESIDUE_LEDGER's row for this family records why; check:pm-dispatch-gates reds 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 base efa2533dd; 39 families. All 39 run, all exit 0. Reconciled:

$ node scripts/pm/dispatch-gates.mjs --ran RECORD_FILE --repo objectstack-ai/objectstack
Run reconciliation — 39 derived, 39 run, 0 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 39 derived famil(ies) accounted for — 39 run, 0 NOT-MEASURED
  (a DERIVED zero — all 39 recorded an exit code and none of them is 3).

The one that mattered: pnpm check:pm-dispatch-gates failed on the first pass with a single red case —

✗ every gate CI runs whose own source sweeps the repo root is DECLARED whole-tree, declared
  path-less, derivable by path, or a justified row in ROOT_WALK_RESIDUE_LEDGER
  — listed but no longer a member: check:pm-half-states
✗ dispatch-gates self-test: 1 of 1678 case(s) failed.

That is judgement call 7 above. After the repair: ✓ dispatch-gates self-test: 1678 cases pass. (exit 0).

The gate lint.yml runs on this file, before and after, with case counts:

before  $ node scripts/pm/check-half-states.mjs --self-test
        ✓ check-half-states self-test: 3213 cases pass.        exit 0
after   $ pnpm check:pm-half-states
        ✓ check-half-states self-test: 3367 cases pass.        exit 0

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:

$ npx eslint . --no-inline-config --format json
files linted: 6586 | errors: 0 | warnings: 0        exit 0
$ npx eslint --no-inline-config scripts/pm/check-half-states.mjs
(no output)                                          exit 0

Ablation — the event=schedule filter dropped, then restored. Run from the committed state, with the mutation proven on disk (git hash-object before 8908cb88…, after 9a884da2…, 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 1  node scripts/pm/check-half-states.mjs --self-test        exit 1
       ✗ H57 path: the ONE spelling carries the filter (got false, want true)
       ✗ H57 path: every runs path in this file is filtered (got 1, want 0)
       ✗ H57 pass: …and every runs read it saw carried the filter (got 1, want 0)
       ✗ H57 pass: …so no runs read was refused by the spy (got 1, want 0)
       ✗ H57 pass: the failing scheduled run becomes ONE row (got 0, want 1)
       (10 of 3362 case(s) failed)
LEG 2  the shipped pass against objectui, live                  ROWS (0)

Leg 2 is the trap itself: with the filter gone, check-links.yml produces no row at all, because its newest unfiltered run is that green workflow_dispatch.

Restored, same two legs: ✓ check-half-states self-test: 3367 cases pass. (exit 0) and ROWS (2), check-links.yml among them. Restore proven byte-identical — git hash-object back to 8908cb88…, git diff HEAD empty, git status --porcelain empty.

An intermediate commit belongs to this evidence and is worth naming: the first ablation aborted the suite with a TypeError instead of printing red cases, because a bare rows[0][1] throws while evaluating a t() 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

  • noted, not filed: GitHub lists a workflow as active after 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.
  • noted, not filed: the three objectstack workflows this row finds red today (codeql.yml cancelled, publish-smoke.yml and rerun-safety-nightly.yml failing) 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.
  • noted, not filed: objectui's stale.yml is 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.
  • noted, not filed: half-state-patrol.yml is held out of its own population on both repos because it declares pull_request. Even were it not, a row about itself would be void — a dead patrol runs no sweep. The anchor's Swept heartbeat 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's files[], so there is no changeset to write; the label is applied on this PR.


Generated by Claude Code

…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>
… 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>
@os-litant os-litant added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 10, 2026 — with Claude
@os-litant
os-litant marked this pull request as ready for review September 10, 2026 20:57
@os-litant
os-litant enabled auto-merge September 10, 2026 20:57
@os-litant
os-litant added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 2662a08 Sep 10, 2026
38 checks passed
@os-litant
os-litant deleted the claude/issue-17132-scheduled-workflow-reader branch September 10, 2026 21:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pm sweep: read the last event=schedule run of every scheduled non-blocking workflow and raise a RED one into the label inbox

2 participants