Skip to content

Commit cd3ed83

Browse files
claude[bot]claude
andauthored
fix(pm): every patrol anchor states the cadence that makes a stalled Swept line decidable (#17841)
Four patrol writers stamp a `Swept TIMESTAMP` line on their anchor and then tell the reader to judge the patrol's liveness from it — a line that stops advancing means the standing caller died. None of them stated the interval that makes "stalled" decidable, and the four cadences differ by 4x, so a reader who carries one over from a neighbouring anchor reads a healthy 20-hour-old daily anchor as a dead caller, or a half-state patrol that has missed three consecutive runs as healthy. Each writer now renders its own schedule, interval and next-expected deadline beside its `Swept` stamp, and the heartbeat sentence points at that deadline instead of asking for a judgement whose only input the body withheld. No cron moved, no sweep predicate changed, no exit code or band changed, and every existing stamp field (run log, commit, trigger, result, gate exit) is intact. Fixes #17720 ## Acceptance notes ### Premises — all three hold on `origin/main` `e3411eda` 1. **Four writers, none stating its cadence — CONFIRMED.** `scripts/pm/check-half-states.mjs` :15203 (stamp) and :15205-15206 (heartbeat sentence); `scripts/render-release-coverage-anchor.mjs` :51-52 (sentence) and :81 (stamp), self-test pin at :283; `.github/workflows/platform-checklist-watchdog.yml` :51 (sentence, in a file comment) and :321 (stamp, inline `actions/github-script`); `.github/workflows/test-nightly-tiers.yml` :454 (stamp, inline `actions/github-script`). 2. **Zero interval spellings — CONFIRMED.** `git grep -n -iE "expected every|next expected|schedule 37"` over the six files returns nothing (exit 1). Control: `stops advancing` hits all four sites plus two workflow comments. 3. **No open PR touches any of the six files — CONFIRMED, re-measured by this seat.** All 21 open PRs' file lists pulled at 2026-09-12T10:5xZ (196 file rows); zero rows match any of the six paths. ### Cadence table, measured from the files (not from the card) | anchor / writer | workflow and its `cron:` line | cron (UTC) | interval | |:--|:--|:--|--:| | half-state patrol — `scripts/pm/check-half-states.mjs` | `half-state-patrol.yml` :134 | `37 1,7,13,19 * * *` | **6 h** | | release-coverage patrol — `scripts/render-release-coverage-anchor.mjs` | `release-coverage-patrol.yml` :105 | `19 4 * * *` | **24 h** | | platform-checklist watchdog — inline `github-script` | `platform-checklist-watchdog.yml` :99 | `51 2 * * *` | **24 h** | | nightly tiers — inline `github-script` | `test-nightly-tiers.yml` :81 | `29 5 * * *` | **24 h** | The spread is 4x, which is what makes a cadence inferred from a neighbouring anchor wrong in both directions. ### Route chosen per site, and why | site | route | why it is the cheapest here | |:--|:--|:--| | `check-half-states.mjs` | **literal beside the renderer — already present** | The file ALREADY declared `PATROL_CADENCE_HOURS = 6`, exported, with this exact cron quoted in its docblock. Promoting the cron string to a sibling `PATROL_CRON` is zero new plumbing; an env route would have added a second home for a fact the file already asserts. The script and `half-state-patrol.yml` are copied VERBATIM into sibling repos together (that workflow's header documents the three-file install), so the pair travels as a unit. A self-test case asserts `cronPeriodHours([PATROL_CRON]) === PATROL_CADENCE_HOURS`, so the two literals cannot drift apart from each other. | | `render-release-coverage-anchor.mjs` | **env from the workflow that declares the cron** (`PATROL_SCHEDULE` on the existing `Render the report` step `env:`) | One caller, which already passes `PROVENANCE` / `ADVISORY_CODE` / `STRICT_CODE` the same way. The cron keeps exactly one home, in the file that schedules it. Unset, `expectedCadence()` states `expected cadence UNSTATED` and names the unwired input rather than assuming a period. | | `platform-checklist-watchdog.yml` | **literal beside the caller** (workflow-level `env: PATROL_SCHEDULE`, beside the other single-sourced card strings) | The body is inline `github-script` no test reaches, so the honest route is the one with the least inline logic: one env read plus four lines of daily-cron arithmetic. | | `test-nightly-tiers.yml` | **literal beside the caller**, same shape | Same reason. The `report` job's in-place anchor rewrite is untouched — only the body text it composes changed. | **Parsing the workflow file was rejected everywhere.** For the two inline sites it would put a YAML read plus a regex inside a body nothing can test; for the two `.mjs` sites it would couple a caller-agnostic renderer to a caller's path and need a fallback for an unreadable file — and a silent fallback is exactly the shape this card is about. ⛔ No shared module was introduced: the two `.mjs` sites do not share one today, and the card forbids inventing one. ### Drift: what the restatement costs, and what pays for it Sites 3 and 4 now restate their own cron in the same file, ~250 lines from the `cron:` that declares it. Each `cron:` gained a comment naming the env that has to move with it. That is the mitigation this change can afford; see "Not done, and why" below for the gate route that was refused. ### Work item C — pinning the two inline `github-script` bodies The repo's existing pattern for pinning workflow prose is a static `check-*.mjs` run from `lint.yml`. Exactly one exists — `scripts/check-platform-checklist-watchdog.mjs`, which pins that workflow's **structure** (triggers, the `paths` filter, board-write `if:` guards, the package-script invocation), not its prose — and `test-nightly-tiers.yml` has no counterpart at all. Covering both inline sites would mean a new clause in one gate plus a **new gate** for the other, and the card rules out a new gate. So per C's own escape clause: **none applies, and both inline sites are left at the rendered literal.** The two `.mjs` sites are pinned in their own `--self-test`s, including the deadline computed from a fixed clock and the refusal branch. ### Reverse verification — both new pin sets were proven able to go red Run from the committed state, mutation proven on disk by literal occurrence counts, restored by `git checkout HEAD -- PATH` and verified byte-identical with `git hash-object` (and `git diff HEAD` empty at the end). Both legs carried an `EXIT INT TERM` trap. - **Leg 1** — `PATROL_CRON` changed from `37 1,7,13,19 * * *` to `11 3 * * *` (old literal count 1 to 0, new 0 to 1): `check-half-states.mjs --self-test` **exit 1, 9 cases red**, including `#17720 next fire: the first slot strictly after the clock (got 1787195460000, want 1787125020000)` and `#17720 cadence: the declared interval is rendered`. Restored: hash matches baseline. - **Leg 2** — the ` · ${expectedCadence({ schedule, sweptAt })}` fragment deleted from the release-coverage stamp (call sites 2 to 1): `render-release-coverage-anchor.mjs --self-test` **exit 1, 3 cases red** — one per render branch (did-not-run / clean / findings), which is the point of pinning it per branch. Restored: hash matches baseline. ### The `Swept` line on the two RED-only cards carries one extra reading, and says so `platform-checklist-watchdog.yml` and `test-nightly-tiers.yml` file or refresh their card **only when red**, and neither closes it on green. So a stalled `Swept` there means the caller died **or** the thing went green — two readings, both ending at that card. Those two bodies render `expected every 24h while this card stands` and state both readings, rather than borrowing the two pinned anchors' unambiguous wording. The watchdog's header comment (its "this workflow has no such tell" trade) was corrected in the same direction: no tell on green, a full heartbeat with its deadline on a card that stands. ### Changeset — none, `skip-changeset`, measured Root `package.json` is `private: true`, and of the **70** published packages scanned, **zero** carry a `files[]` entry reaching `scripts/` or `.github/`. Symbol search for everything this PR introduces (`renderCadenceExpectation`, `nextCronFire`, `PATROL_CRON`, `expectedCadence`, `PATROL_SCHEDULE`) across `packages/` and `examples/`: **zero hits** (grep exit 1). Positive control on the same command shape: `defineStack` in `packages/spec/src` — **612 hits**, exit 0. Nothing published moves. ### A FIFTH `Swept` writer exists — reported, ⛔ not edited `scripts/ci/scheduled-full-run.mjs` :352 renders a `_Swept ... _` stamp for the hourly-full-run card (`scheduled-full-run-card.yml`). It sits inside `scripts/`, so the card's stated search area covered it; it did not surface because the card's search was conjunctive and this writer carries **no** "stops advancing" sentence. It is **not** an instance of this defect: its body states its own cadence one line below the stamp ("runs the FULL battery on `main` every hour"). Left untouched. ### Not done, and why - ⛔ No cron changed, no sweep predicate touched, no exit code, band or `report`-job rewrite semantics changed. - ⛔ No new gate and no new clause in an existing one — including the cron-to-literal parity clause that `scripts/check-platform-checklist-watchdog.mjs` could cheaply carry for site 3. It would cover one of the two inline sites and leave the other needing a gate from scratch; both are outside this card. - ⛔ No other anchor, row or card edited. ### Gates All 66 commands derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` in this worktree were run; the per-command exit codes are in the report comment on #17720. `dispatch-gates --ran` reconciles: 66 derived, 66 run, 0 UNRUN. Plus the two self-tests the dispatch named, both exit 0. --- _Generated by [Claude Code](https://claude.ai/code/session_01MCLBsUgfykL74aU716rzVK)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6059b29 commit cd3ed83

5 files changed

Lines changed: 339 additions & 16 deletions

File tree

.github/workflows/platform-checklist-watchdog.yml

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,18 @@ name: Platform-Checklist Watchdog
4848
# directions:
4949
#
5050
# * Their heartbeat is what tells a reader the patrol is still alive — a
51-
# `Swept` timestamp that stops advancing is how a dead caller is noticed.
52-
# This workflow has no such tell, so a silent death here reads exactly like
53-
# a clean checklist. What stands in for it: this job goes RED (and the
54-
# Actions run list shows it) whenever the gate could not run or the report
55-
# could not be delivered, and the run history itself is the record.
51+
# `Swept` timestamp that stops advancing past the cadence the same line
52+
# states is how a dead caller is noticed (#17720: the cadence is rendered
53+
# because 「stalled」 is undecidable without it, and the four patrols here
54+
# differ by 4×). ON GREEN this workflow has no such tell at all, because it
55+
# has no card, so a silent death on a green tree reads exactly like a clean
56+
# checklist. What stands in for it: this job goes RED (and the Actions run
57+
# list shows it) whenever the gate could not run or the report could not be
58+
# delivered, and the run history itself is the record. A card that DOES
59+
# stand carries the heartbeat and its deadline like the other three — with
60+
# the one ambiguity this workflow's shape implies, stated in the card
61+
# itself: a stalled `Swept` there means the caller died OR the gate went
62+
# green, and nothing closes the card either way.
5663
# * In exchange, a green tree has no standing issue to prune, and the card the
5764
# devx seat grades is minted only when there is something to grade.
5865
#
@@ -96,6 +103,12 @@ on:
96103
# workflows queue behind everyone else's `:00` cron — and off the three
97104
# sibling patrols' minutes, so two patrols never contend for the same runner
98105
# minute.
106+
#
107+
# ⚠️ Two readers, one fact. This value is restated as `PATROL_SCHEDULE` in
108+
# the `env:` block below, where the report step computes the deadline the
109+
# card's own `Swept` line is judged against (#17720). Change the cron and
110+
# change that env in the same edit, or the card publishes a deadline nobody
111+
# schedules.
99112
- cron: '51 2 * * *'
100113
# The manual fire, and the smoke test this card exits on.
101114
workflow_dispatch: {}
@@ -154,6 +167,18 @@ env:
154167
# cards this watchdog mints. Never rewritten on a refresh — grading is the
155168
# devx seat's and a refresh must not undo it.
156169
ANCHOR_ROUTING_LABELS: 'pm:queue,tooling'
170+
# This workflow's own `cron:`, restated here so the report step can state the
171+
# cadence that makes the card's `Swept` line decidable (#17720). The three
172+
# sibling patrol anchors carry the same heartbeat reading and one of them
173+
# fires every six hours, so a reader who carries a cadence over from a
174+
# neighbour is wrong by 4× — which is why each anchor states its own rather
175+
# than leaving the reader to go and read a workflow file.
176+
#
177+
# ⛔ A literal, not a parse of this file: the report body is inline
178+
# `github-script` that no test reaches, so the cheapest honest route is the
179+
# one with the least inline logic. The cost is the restatement above, and the
180+
# comment at the `cron:` is what pays it.
181+
PATROL_SCHEDULE: '51 2 * * *'
157182

158183
jobs:
159184
watchdog:
@@ -313,12 +338,36 @@ jobs:
313338
? (closed.find((i) => i.state_reason !== 'duplicate') ?? null)
314339
: null;
315340
341+
// The cadence that makes the `Swept` line below decidable (#17720).
342+
// `PATROL_SCHEDULE` is this workflow's own `cron:`, restated in the
343+
// `env:` block beside the other single-sourced card strings.
344+
// ⛔ Only the daily `M H * * *` form is computed; anything else says
345+
// so in the body rather than publishing a deadline derived from a
346+
// schedule this did not read.
347+
const sweptAt = new Date();
348+
const schedule = String(process.env.PATROL_SCHEDULE ?? '').trim();
349+
const cronFields = /^(\d{1,2}) (\d{1,2}) \* \* \*$/.exec(schedule);
350+
let expectation = `⚠️ expected cadence UNSTATED — PATROL_SCHEDULE is \`${schedule}\`, which is not the daily \`M H * * *\` form this step computes a deadline from`;
351+
if (cronFields && Number(cronFields[1]) <= 59 && Number(cronFields[2]) <= 23) {
352+
const next = new Date(sweptAt);
353+
next.setUTCHours(Number(cronFields[2]), Number(cronFields[1]), 0, 0);
354+
if (next <= sweptAt) next.setUTCDate(next.getUTCDate() + 1);
355+
expectation = `expected every 24h while this card stands (cron \`${schedule}\` UTC) · next by ${next.toISOString().slice(0, 16)}Z`;
356+
}
357+
316358
const body = [
317359
`${marker} — machine-findable marker for this generated view. ⛔ Do not delete this line: it is how the watchdog finds this card instead of filing a new one every day.`,
318360
'',
319361
`# \`check:platform-checklist\` is RED on \`main\``,
320362
'',
321-
`_Swept ${new Date().toISOString()} · [run log](${runUrl}) · commit \`${process.env.GITHUB_SHA}\` · trigger \`${context.eventName}\` · gate exit ${gateExit}._`,
363+
`_Swept ${sweptAt.toISOString()} · ${expectation} · [run log](${runUrl}) · commit \`${process.env.GITHUB_SHA}\` · trigger \`${context.eventName}\` · gate exit ${gateExit}._`,
364+
'',
365+
'The `Swept` line above is this watchdog\'s heartbeat, and it states the cadence that makes',
366+
'「stalled」 decidable: while the gate stays red this card is refreshed on that schedule, so a',
367+
'timestamp still sitting there past the `next by` deadline means either the standing caller',
368+
'died or the gate went green — this watchdog files nothing and closes nothing on green, so',
369+
'both readings end at this card. ⛔ Do not carry a cadence over from a sibling patrol anchor:',
370+
'they differ by up to 4× and each states its own.',
322371
'',
323372
'The platform test checklist gate is red. It is **not** wired into per-PR CI (a standing',
324373
'maintainer decision — the checklist is a QA ledger, not a code gate), so this card is the',

.github/workflows/release-coverage-patrol.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ on:
102102
# half-state-patrol.yml's `37 1,7,13,19` so two patrols never contend for the
103103
# same runner minute.
104104
schedule:
105+
# ⚠️ Two readers, one fact. This value is also handed to the renderer as
106+
# `PATROL_SCHEDULE` (the "Render the report" step below) so the anchor body
107+
# can state the deadline its own heartbeat line is judged against (#17720).
108+
# Change the cron and change that env in the same edit, or the anchor
109+
# publishes a deadline nobody schedules.
105110
- cron: '19 4 * * *'
106111
workflow_dispatch: {}
107112
# Changes to the patrol itself get exercised before they merge — the same
@@ -238,6 +243,14 @@ jobs:
238243
env:
239244
ADVISORY_CODE: ${{ steps.sweep.outputs.advisory_code }}
240245
STRICT_CODE: ${{ steps.sweep.outputs.strict_code }}
246+
# This workflow's own `cron:`, handed to the renderer so the anchor
247+
# body states the cadence that makes its heartbeat line decidable
248+
# (#17720). It lives HERE rather than in the script because the
249+
# schedule is declared here: a second copy in the renderer would be a
250+
# second place to drift from, and a drifted deadline reads exactly
251+
# like a correct one. ⛔ Unset, the renderer says so in the body
252+
# rather than assuming a period — it never guesses.
253+
PATROL_SCHEDULE: '19 4 * * *'
241254
PROVENANCE: >-
242255
run [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
243256
· commit `${{ github.sha }}` · trigger `${{ github.event_name }}`

.github/workflows/test-nightly-tiers.yml

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ on:
7878
# patrol's minute, so two of them never contend for the same runner minute;
7979
# 05:29 sits after `rerun-safety-nightly` (04:00) and `coverage-nightly`
8080
# (05:00) have started and well before `showcase-smoke` (07:00).
81+
#
82+
# ⚠️ Two readers, one fact. This value is restated as `PATROL_SCHEDULE` in
83+
# the `env:` block below, where the report step computes the deadline the
84+
# card's own `Swept` line is judged against (#17720). Change the cron and
85+
# change that env in the same edit, or the card publishes a deadline nobody
86+
# schedules.
8187
- cron: '29 5 * * *'
8288
workflow_dispatch: {}
8389
# ⛔ PATHS-FILTERED TO THIS FILE AND THE SWITCH READER IT INVOKES, and that
@@ -121,6 +127,18 @@ env:
121127
CARD_MARKER: os-nightly-tiers
122128
# Applied on CREATE only, additively; never rewritten on a refresh.
123129
CARD_LABELS: 'bug,domain:devx,priority:p1'
130+
# This workflow's own `cron:`, restated here so the report step can state the
131+
# cadence that makes the card's `Swept` line decidable (#17720). The sibling
132+
# patrol anchors carry the same heartbeat reading and one of them fires every
133+
# six hours, so a reader who carries a cadence over from a neighbour is wrong
134+
# by 4× — which is why each card states its own rather than leaving the
135+
# reader to go and read a workflow file.
136+
#
137+
# ⛔ A literal, not a parse of this file: the report body is inline
138+
# `github-script` that no test reaches, so the cheapest honest route is the
139+
# one with the least inline logic. The cost is the restatement above, and the
140+
# comment at the `cron:` is what pays it.
141+
PATROL_SCHEDULE: '29 5 * * *'
124142

125143
jobs:
126144
tiers:
@@ -440,6 +458,23 @@ jobs:
440458
'',
441459
]);
442460
461+
// The cadence that makes the `Swept` line below decidable (#17720).
462+
// `PATROL_SCHEDULE` is this workflow's own `cron:`, restated in the
463+
// `env:` block beside the other single-sourced card strings.
464+
// ⛔ Only the daily `M H * * *` form is computed; anything else says
465+
// so in the body rather than publishing a deadline derived from a
466+
// schedule this did not read.
467+
const sweptAt = new Date();
468+
const schedule = String(process.env.PATROL_SCHEDULE ?? '').trim();
469+
const cronFields = /^(\d{1,2}) (\d{1,2}) \* \* \*$/.exec(schedule);
470+
let expectation = `⚠️ expected cadence UNSTATED — PATROL_SCHEDULE is \`${schedule}\`, which is not the daily \`M H * * *\` form this step computes a deadline from`;
471+
if (cronFields && Number(cronFields[1]) <= 59 && Number(cronFields[2]) <= 23) {
472+
const next = new Date(sweptAt);
473+
next.setUTCHours(Number(cronFields[2]), Number(cronFields[1]), 0, 0);
474+
if (next <= sweptAt) next.setUTCDate(next.getUTCDate() + 1);
475+
expectation = `expected every 24h while this card stands (cron \`${schedule}\` UTC) · next by ${next.toISOString().slice(0, 16)}Z`;
476+
}
477+
443478
const filesSection = failing.length > 0
444479
? ['## Failing files', '', ...failing.map((f) => `- \`${f}\``), '']
445480
: extractorExit !== '0'
@@ -451,7 +486,14 @@ jobs:
451486
'',
452487
`# ${title}`,
453488
'',
454-
`_Swept ${new Date().toISOString()} · [run log](${runUrl}) · commit \`${process.env.GITHUB_SHA}\` · trigger \`${context.eventName}\` · tiers job result \`${result}\`._`,
489+
`_Swept ${sweptAt.toISOString()} · ${expectation} · [run log](${runUrl}) · commit \`${process.env.GITHUB_SHA}\` · trigger \`${context.eventName}\` · tiers job result \`${result}\`._`,
490+
'',
491+
'The `Swept` line above is this nightly\'s heartbeat, and it states the cadence that makes',
492+
'「stalled」 decidable: while the tiers stay red this card is refreshed on that schedule, so a',
493+
'timestamp still sitting there past the `next by` deadline means either the standing caller',
494+
'died or the nightly went green — nothing here files or closes anything on green, so both',
495+
'readings end at this card. ⛔ Do not carry a cadence over from a sibling patrol anchor: they',
496+
'differ by up to 4× and each states its own.',
455497
'',
456498
'The `e2e` and `live` test tiers — the files named `*.e2e.test.*` and `*.live.test.*` — run here',
457499
'nightly on `main` under `OS_TEST_TIERS=nightly` and nowhere else (the per-PR and merge-queue',

0 commit comments

Comments
 (0)