From e9419c35901e2d01a594416da4acd434512dbfb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 10:43:30 +0000 Subject: [PATCH] fix(pm): every patrol anchor states the cadence that makes a stalled Swept line decidable Four patrol writers stamp a `Swept ` line and tell the reader that a line which stops advancing means the standing caller died. None 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 is wrong in both directions. Each writer now renders the schedule, the interval and the next-expected deadline beside its own `Swept` stamp, and the heartbeat sentence points at that deadline instead of asking for a judgement it withheld the input for. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01MCLBsUgfykL74aU716rzVK --- .../workflows/platform-checklist-watchdog.yml | 61 +++++++- .github/workflows/release-coverage-patrol.yml | 13 ++ .github/workflows/test-nightly-tiers.yml | 44 +++++- scripts/pm/check-half-states.mjs | 143 +++++++++++++++++- scripts/render-release-coverage-anchor.mjs | 94 +++++++++++- 5 files changed, 339 insertions(+), 16 deletions(-) diff --git a/.github/workflows/platform-checklist-watchdog.yml b/.github/workflows/platform-checklist-watchdog.yml index 4f2f8e84d2..a16ce34df5 100644 --- a/.github/workflows/platform-checklist-watchdog.yml +++ b/.github/workflows/platform-checklist-watchdog.yml @@ -48,11 +48,18 @@ name: Platform-Checklist Watchdog # directions: # # * Their heartbeat is what tells a reader the patrol is still alive — a -# `Swept` timestamp that stops advancing is how a dead caller is noticed. -# This workflow has no such tell, so a silent death here reads exactly like -# a clean checklist. What stands in for it: this job goes RED (and the -# Actions run list shows it) whenever the gate could not run or the report -# could not be delivered, and the run history itself is the record. +# `Swept` timestamp that stops advancing past the cadence the same line +# states is how a dead caller is noticed (#17720: the cadence is rendered +# because 「stalled」 is undecidable without it, and the four patrols here +# differ by 4×). ON GREEN this workflow has no such tell at all, because it +# has no card, so a silent death on a green tree reads exactly like a clean +# checklist. What stands in for it: this job goes RED (and the Actions run +# list shows it) whenever the gate could not run or the report could not be +# delivered, and the run history itself is the record. A card that DOES +# stand carries the heartbeat and its deadline like the other three — with +# the one ambiguity this workflow's shape implies, stated in the card +# itself: a stalled `Swept` there means the caller died OR the gate went +# green, and nothing closes the card either way. # * In exchange, a green tree has no standing issue to prune, and the card the # devx seat grades is minted only when there is something to grade. # @@ -96,6 +103,12 @@ on: # workflows queue behind everyone else's `:00` cron — and off the three # sibling patrols' minutes, so two patrols never contend for the same runner # minute. + # + # ⚠️ Two readers, one fact. This value is restated as `PATROL_SCHEDULE` in + # the `env:` block below, where the report step computes the deadline the + # card's own `Swept` line is judged against (#17720). Change the cron and + # change that env in the same edit, or the card publishes a deadline nobody + # schedules. - cron: '51 2 * * *' # The manual fire, and the smoke test this card exits on. workflow_dispatch: {} @@ -154,6 +167,18 @@ env: # cards this watchdog mints. Never rewritten on a refresh — grading is the # devx seat's and a refresh must not undo it. ANCHOR_ROUTING_LABELS: 'pm:queue,tooling' + # This workflow's own `cron:`, restated here so the report step can state the + # cadence that makes the card's `Swept` line decidable (#17720). The three + # sibling patrol anchors carry the same heartbeat reading and one of them + # fires every six hours, so a reader who carries a cadence over from a + # neighbour is wrong by 4× — which is why each anchor states its own rather + # than leaving the reader to go and read a workflow file. + # + # ⛔ A literal, not a parse of this file: the report body is inline + # `github-script` that no test reaches, so the cheapest honest route is the + # one with the least inline logic. The cost is the restatement above, and the + # comment at the `cron:` is what pays it. + PATROL_SCHEDULE: '51 2 * * *' jobs: watchdog: @@ -313,12 +338,36 @@ jobs: ? (closed.find((i) => i.state_reason !== 'duplicate') ?? null) : null; + // The cadence that makes the `Swept` line below decidable (#17720). + // `PATROL_SCHEDULE` is this workflow's own `cron:`, restated in the + // `env:` block beside the other single-sourced card strings. + // ⛔ Only the daily `M H * * *` form is computed; anything else says + // so in the body rather than publishing a deadline derived from a + // schedule this did not read. + const sweptAt = new Date(); + const schedule = String(process.env.PATROL_SCHEDULE ?? '').trim(); + const cronFields = /^(\d{1,2}) (\d{1,2}) \* \* \*$/.exec(schedule); + let expectation = `⚠️ expected cadence UNSTATED — PATROL_SCHEDULE is \`${schedule}\`, which is not the daily \`M H * * *\` form this step computes a deadline from`; + if (cronFields && Number(cronFields[1]) <= 59 && Number(cronFields[2]) <= 23) { + const next = new Date(sweptAt); + next.setUTCHours(Number(cronFields[2]), Number(cronFields[1]), 0, 0); + if (next <= sweptAt) next.setUTCDate(next.getUTCDate() + 1); + expectation = `expected every 24h while this card stands (cron \`${schedule}\` UTC) · next by ${next.toISOString().slice(0, 16)}Z`; + } + const body = [ `${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.`, '', `# \`check:platform-checklist\` is RED on \`main\``, '', - `_Swept ${new Date().toISOString()} · [run log](${runUrl}) · commit \`${process.env.GITHUB_SHA}\` · trigger \`${context.eventName}\` · gate exit ${gateExit}._`, + `_Swept ${sweptAt.toISOString()} · ${expectation} · [run log](${runUrl}) · commit \`${process.env.GITHUB_SHA}\` · trigger \`${context.eventName}\` · gate exit ${gateExit}._`, + '', + 'The `Swept` line above is this watchdog\'s heartbeat, and it states the cadence that makes', + '「stalled」 decidable: while the gate stays red this card is refreshed on that schedule, so a', + 'timestamp still sitting there past the `next by` deadline means either the standing caller', + 'died or the gate went green — this watchdog files nothing and closes nothing on green, so', + 'both readings end at this card. ⛔ Do not carry a cadence over from a sibling patrol anchor:', + 'they differ by up to 4× and each states its own.', '', 'The platform test checklist gate is red. It is **not** wired into per-PR CI (a standing', 'maintainer decision — the checklist is a QA ledger, not a code gate), so this card is the', diff --git a/.github/workflows/release-coverage-patrol.yml b/.github/workflows/release-coverage-patrol.yml index 48e9caa99b..d3f5c5b1dc 100644 --- a/.github/workflows/release-coverage-patrol.yml +++ b/.github/workflows/release-coverage-patrol.yml @@ -102,6 +102,11 @@ on: # half-state-patrol.yml's `37 1,7,13,19` so two patrols never contend for the # same runner minute. schedule: + # ⚠️ Two readers, one fact. This value is also handed to the renderer as + # `PATROL_SCHEDULE` (the "Render the report" step below) so the anchor body + # can state the deadline its own heartbeat line is judged against (#17720). + # Change the cron and change that env in the same edit, or the anchor + # publishes a deadline nobody schedules. - cron: '19 4 * * *' workflow_dispatch: {} # Changes to the patrol itself get exercised before they merge — the same @@ -238,6 +243,14 @@ jobs: env: ADVISORY_CODE: ${{ steps.sweep.outputs.advisory_code }} STRICT_CODE: ${{ steps.sweep.outputs.strict_code }} + # This workflow's own `cron:`, handed to the renderer so the anchor + # body states the cadence that makes its heartbeat line decidable + # (#17720). It lives HERE rather than in the script because the + # schedule is declared here: a second copy in the renderer would be a + # second place to drift from, and a drifted deadline reads exactly + # like a correct one. ⛔ Unset, the renderer says so in the body + # rather than assuming a period — it never guesses. + PATROL_SCHEDULE: '19 4 * * *' PROVENANCE: >- run [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) · commit `${{ github.sha }}` · trigger `${{ github.event_name }}` diff --git a/.github/workflows/test-nightly-tiers.yml b/.github/workflows/test-nightly-tiers.yml index b399cb95e2..2eb37dc1b6 100644 --- a/.github/workflows/test-nightly-tiers.yml +++ b/.github/workflows/test-nightly-tiers.yml @@ -78,6 +78,12 @@ on: # patrol's minute, so two of them never contend for the same runner minute; # 05:29 sits after `rerun-safety-nightly` (04:00) and `coverage-nightly` # (05:00) have started and well before `showcase-smoke` (07:00). + # + # ⚠️ Two readers, one fact. This value is restated as `PATROL_SCHEDULE` in + # the `env:` block below, where the report step computes the deadline the + # card's own `Swept` line is judged against (#17720). Change the cron and + # change that env in the same edit, or the card publishes a deadline nobody + # schedules. - cron: '29 5 * * *' workflow_dispatch: {} # ⛔ PATHS-FILTERED TO THIS FILE AND THE SWITCH READER IT INVOKES, and that @@ -121,6 +127,18 @@ env: CARD_MARKER: os-nightly-tiers # Applied on CREATE only, additively; never rewritten on a refresh. CARD_LABELS: 'bug,domain:devx,priority:p1' + # This workflow's own `cron:`, restated here so the report step can state the + # cadence that makes the card's `Swept` line decidable (#17720). The sibling + # patrol anchors carry the same heartbeat reading and one of them fires every + # six hours, so a reader who carries a cadence over from a neighbour is wrong + # by 4× — which is why each card states its own rather than leaving the + # reader to go and read a workflow file. + # + # ⛔ A literal, not a parse of this file: the report body is inline + # `github-script` that no test reaches, so the cheapest honest route is the + # one with the least inline logic. The cost is the restatement above, and the + # comment at the `cron:` is what pays it. + PATROL_SCHEDULE: '29 5 * * *' jobs: tiers: @@ -440,6 +458,23 @@ jobs: '', ]); + // The cadence that makes the `Swept` line below decidable (#17720). + // `PATROL_SCHEDULE` is this workflow's own `cron:`, restated in the + // `env:` block beside the other single-sourced card strings. + // ⛔ Only the daily `M H * * *` form is computed; anything else says + // so in the body rather than publishing a deadline derived from a + // schedule this did not read. + const sweptAt = new Date(); + const schedule = String(process.env.PATROL_SCHEDULE ?? '').trim(); + const cronFields = /^(\d{1,2}) (\d{1,2}) \* \* \*$/.exec(schedule); + let expectation = `⚠️ expected cadence UNSTATED — PATROL_SCHEDULE is \`${schedule}\`, which is not the daily \`M H * * *\` form this step computes a deadline from`; + if (cronFields && Number(cronFields[1]) <= 59 && Number(cronFields[2]) <= 23) { + const next = new Date(sweptAt); + next.setUTCHours(Number(cronFields[2]), Number(cronFields[1]), 0, 0); + if (next <= sweptAt) next.setUTCDate(next.getUTCDate() + 1); + expectation = `expected every 24h while this card stands (cron \`${schedule}\` UTC) · next by ${next.toISOString().slice(0, 16)}Z`; + } + const filesSection = failing.length > 0 ? ['## Failing files', '', ...failing.map((f) => `- \`${f}\``), ''] : extractorExit !== '0' @@ -451,7 +486,14 @@ jobs: '', `# ${title}`, '', - `_Swept ${new Date().toISOString()} · [run log](${runUrl}) · commit \`${process.env.GITHUB_SHA}\` · trigger \`${context.eventName}\` · tiers job result \`${result}\`._`, + `_Swept ${sweptAt.toISOString()} · ${expectation} · [run log](${runUrl}) · commit \`${process.env.GITHUB_SHA}\` · trigger \`${context.eventName}\` · tiers job result \`${result}\`._`, + '', + 'The `Swept` line above is this nightly\'s heartbeat, and it states the cadence that makes', + '「stalled」 decidable: while the tiers stay red this card is refreshed on that schedule, so a', + 'timestamp still sitting there past the `next by` deadline means either the standing caller', + 'died or the nightly went green — nothing here files or closes anything on green, so both', + 'readings end at this card. ⛔ Do not carry a cadence over from a sibling patrol anchor: they', + 'differ by up to 4× and each states its own.', '', 'The `e2e` and `live` test tiers — the files named `*.e2e.test.*` and `*.live.test.*` — run here', 'nightly on `main` under `OS_TEST_TIERS=nightly` and nowhere else (the per-PR and merge-queue', diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index 10ab9d89ba..274b22eaaf 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -12274,6 +12274,58 @@ export function cronPeriodHours(crons, fromMs = Date.now()) { return widest / 3_600_000; } +/** + * The first fire of the UNION of these crons STRICTLY AFTER `fromMs`, or `null` + * when no cron here can be read or none fires inside the horizon. + * + * The same expansion `cronPeriodHours` walks, asked a different question: that + * one measures the widest gap, this one names the next instant. A reader of a + * `Swept` line cannot act on a period alone — 「stalled」 is decidable only + * against a DEADLINE — so the renderer stamps this beside the period rather + * than leaving the arithmetic to whoever is reading the anchor at 03:00. + * + * ⛔ `null` is rendered as a refusal by the caller, never as a default: a + * deadline nobody declared would make the heartbeat reading a fiction, which is + * the defect this whole line exists to close. + * + * @param {string[]} crons + * @param {number} fromMs — the instant to search forward from, UTC. + * @returns {number|null} + */ +export function nextCronFire(crons, fromMs) { + const list = Array.isArray(crons) ? crons : []; + if (list.length === 0 || !Number.isFinite(fromMs)) return null; + const parsed = list.map(parseCron); + if (parsed.some((p) => !p)) return null; + const day = new Date(fromMs); + day.setUTCHours(0, 0, 0, 0); + let best = null; + for (let d = 0; d < H57_CRON_HORIZON_DAYS; d++) { + const at = new Date(day.getTime() + d * 86_400_000); + const month = at.getUTCMonth() + 1; + const dom = at.getUTCDate(); + const dow = at.getUTCDay(); + for (const cron of parsed) { + if (!cron.month.includes(month)) continue; + // POSIX cron, the same rule `cronPeriodHours` applies: with BOTH day + // fields restricted the day matches when EITHER does. + const domHit = cron.dom.includes(dom); + const dowHit = cron.dow.includes(dow); + const dayHit = + cron.domRestricted && cron.dowRestricted ? domHit || dowHit : domHit && dowHit; + if (!dayHit) continue; + for (const h of cron.hour) { + for (const m of cron.minute) { + const fire = at.getTime() + h * 3_600_000 + m * 60_000; + if (fire > fromMs && (best === null || fire < best)) best = fire; + } + } + } + if (best !== null) return best; + } + return null; +} + /** * Is this workflow in H57's population at all, and if not, why not — one * three-valued answer so the sweep never has to re-derive the reason for the @@ -14548,6 +14600,42 @@ export function normalizeProvenance(text) { .slice(0, 300); } +/** + * The EXPECTATION stamped beside the `Swept` timestamp — the schedule, the + * interval, and the deadline the next healthy heartbeat has to beat. + * + * The heartbeat sentence under the stamp asks the reader to judge whether the + * `Swept` line 「stops advancing」. That judgement takes an input the body used + * to withhold: this patrol fires every 6h while three of its sibling anchors + * fire daily, so a reader who learns one cadence from a neighbour reads a + * healthy anchor as a dead caller in one direction and three missed runs as + * healthy in the other. Rendering the deadline is what turns the instruction + * into something a reader can act on without going and reading a workflow file. + * + * ⛔ A cron this file cannot read refuses LOUDLY rather than falling back to + * `PATROL_CADENCE_HOURS` alone: a deadline computed from a schedule nobody + * could parse would be a fiction with a timestamp on it, which is worse than + * the silence this line replaces. + * + * `cron` is a parameter with the constant as its default for ONE reason: the + * refusal branch below is unreachable while `PATROL_CRON` parses, and a branch + * no case can enter is a branch nothing pins. The self-test drives it through + * this seam; every caller uses the default. + * + * @param {Date} sweptAt — the same clock the stamp is rendered from. + * @param {string} [cron] — the schedule to compute the deadline from. + * @returns {string} one ` · `-joined fragment, with no leading separator. + */ +export function renderCadenceExpectation(sweptAt, cron = PATROL_CRON) { + const schedule = `expected every ${PATROL_CADENCE_HOURS}h (cron \`${cron}\` UTC)`; + const next = nextCronFire([cron], sweptAt.getTime()); + if (next === null) { + return `${schedule} · ⚠️ next fire UNCOMPUTED — \`${cron}\` did not parse, so this body states` + + ' no deadline and the heartbeat above is NOT decidable from it'; + } + return `${schedule} · next by ${new Date(next).toISOString().slice(0, 16)}Z`; +} + // --------------------------------------------------------------------------- // The ROW-FAMILY REGISTRY and the trim's priority order (#13947) // @@ -15200,11 +15288,14 @@ export function renderMarkdown(findings, counts, options = {}) { ' state. Each predicate and the protocol clause it enforces are documented in' + ' `scripts/pm/check-half-states.mjs`.', '', - `_Swept ${sweptAt.toISOString()}${provenance ? ` · ${provenance}` : ''}_`, + `_Swept ${sweptAt.toISOString()} · ${renderCadenceExpectation(sweptAt)}` + + `${provenance ? ` · ${provenance}` : ''}_`, '', - 'The timestamp above is the patrol\'s own heartbeat: a `Swept` line that stops advancing means the' + - ' standing caller died, which is the failure this anchor was created to make visible. Read it' + - ' before you read the rows.', + 'The timestamp above is the patrol\'s own heartbeat, and the line states the cadence that makes' + + ' 「stalled」 decidable: a `Swept` line still sitting there past the `next by` deadline beside it' + + ' means the standing caller died, which is the failure this anchor was created to make visible.' + + ' ⛔ Do not carry a cadence over from a sibling anchor — the four patrols differ by 4× and each' + + ' states its own. Read it before you read the rows.', '', ]; @@ -16444,6 +16535,23 @@ export function sweepOverlap(coverageDays, cadenceHours = PATROL_CADENCE_HOURS) /** The scheduled patrol's period — `cron: '37 1,7,13,19 * * *'` in the workflow. */ export const PATROL_CADENCE_HOURS = 6; +/** + * The standing caller's schedule, verbatim, as `half-state-patrol.yml` declares + * it. Stated here rather than passed in like `--provenance`, and the split is + * deliberate: provenance is the CALLER'S IDENTITY (run 123, commit abc) which + * this repo-agnostic sweeper genuinely cannot know, while the cadence is a fact + * about the sweep itself that this file ALREADY asserts one line above — a + * second route for the same fact would be a second place to drift from. + * + * ⚠️ Its one obligation: `half-state-patrol.yml` is copied VERBATIM into + * sibling repos together with this script, so the pair travels as a unit and + * the two stay equal by construction. Change one and change the other in the + * same edit — this constant and `PATROL_CADENCE_HOURS` are what the anchor + * body's own heartbeat deadline is computed from, and a stale one publishes a + * deadline nobody schedules. + */ +export const PATROL_CRON = '37 1,7,13,19 * * *'; + // --------------------------------------------------------------------------- // The shared window vocabulary (#13606) — what DID fall out of converting the // remaining count-shaped windows, and what deliberately did not. @@ -23037,6 +23145,33 @@ async function selfTest() { t('provenance: present is stamped after the timestamp', renderMarkdown([], counts, { provenance: 'run 7' }).includes(' · run 7_'), true); t('markdown: the sweep timestamp is the patrol heartbeat', renderMarkdown([], counts, { sweptAt: new Date('2026-08-19T06:00:00Z') }).includes('_Swept 2026-08-19T06:00:00.000Z'), true); + // ── The heartbeat's EXPECTATION (#17720) ────────────────────────────────── + // 「a `Swept` line that stops advancing means the caller died」 is an + // instruction no reader can act on while the body withholds the interval that + // makes 「stalled」 decidable — and this patrol fires 4× more often than the + // three sibling anchors that carry the same sentence, so a cadence carried + // over from a neighbour is wrong in both directions. These cases pin the + // deadline itself, computed from a FIXED clock: a rendered interval nothing + // pins is prose again the first time the schedule moves. + t('#17720 next fire: the first slot strictly after the clock', nextCronFire([PATROL_CRON], Date.parse('2026-08-19T06:00:00Z')), Date.parse('2026-08-19T07:37:00Z')); + t('#17720 next fire: STRICTLY after — standing on a fire returns the NEXT one, never itself', nextCronFire([PATROL_CRON], Date.parse('2026-08-19T01:37:00Z')), Date.parse('2026-08-19T07:37:00Z')); + t('#17720 next fire: the last slot of the day rolls to tomorrow', nextCronFire([PATROL_CRON], Date.parse('2026-08-19T19:38:00Z')), Date.parse('2026-08-20T01:37:00Z')); + t('#17720 next fire: a month boundary rolls too', nextCronFire([PATROL_CRON], Date.parse('2026-08-31T23:00:00Z')), Date.parse('2026-09-01T01:37:00Z')); + t('#17720 next fire: a daily cron reads the same way', nextCronFire(['19 4 * * *'], Date.parse('2026-08-19T06:00:00Z')), Date.parse('2026-08-20T04:19:00Z')); + t('#17720 next fire: ⛔ an unreadable cron refuses rather than guessing a period', nextCronFire(['every six hours'], Date.parse('2026-08-19T06:00:00Z')), null); + t('#17720 next fire: ⛔ no cron at all refuses too', nextCronFire([], Date.parse('2026-08-19T06:00:00Z')), null); + t('#17720 cadence: the declared interval is rendered', renderCadenceExpectation(new Date('2026-08-19T06:00:00Z')).includes('expected every 6h (cron `37 1,7,13,19 * * *` UTC)'), true); + t('#17720 cadence: …with the deadline computed from that same clock', renderCadenceExpectation(new Date('2026-08-19T06:00:00Z')).includes('next by 2026-08-19T07:37Z'), true); + t('#17720 cadence: the deadline MOVES with the clock — it is computed, not a literal', renderCadenceExpectation(new Date('2026-08-19T14:00:00Z')).includes('next by 2026-08-19T19:37Z'), true); + t('#17720 cadence: ⛔ an unreadable schedule states NO deadline and says so', renderCadenceExpectation(new Date('2026-08-19T06:00:00Z'), 'every six hours').includes('next fire UNCOMPUTED'), true); + t('#17720 cadence: ⛔ …and never renders a `next by` it could not compute', renderCadenceExpectation(new Date('2026-08-19T06:00:00Z'), 'every six hours').includes('next by'), false); + t('#17720 cadence: the cron literal equals the interval it is declared beside', cronPeriodHours([PATROL_CRON], Date.parse('2026-08-19T00:00:00Z')), PATROL_CADENCE_HOURS); + const heartbeat17720 = renderMarkdown([], counts, { sweptAt: new Date('2026-08-19T06:00:00Z'), provenance: 'run 7' }); + t('#17720 stamp: the expectation rides the `Swept` line itself', heartbeat17720.includes('_Swept 2026-08-19T06:00:00.000Z · expected every 6h (cron `37 1,7,13,19 * * *` UTC) · next by 2026-08-19T07:37Z · run 7_'), true); + t('#17720 stamp: …and the provenance field is still last, intact', heartbeat17720.includes(' · run 7_'), true); + t('#17720 sentence: the heartbeat instruction now POINTS at the rendered deadline', heartbeat17720.includes('past the `next by` deadline beside it'), true); + t('#17720 sentence: …and warns off the sibling anchors\' 4×-different cadences', heartbeat17720.includes('Do not carry a cadence over from a sibling anchor'), true); + // -- H17 section rendering, both media (#10034) --------------------------- // The section has three states and two of them read identically if you are // careless, so each is pinned by the sentence that distinguishes it. diff --git a/scripts/render-release-coverage-anchor.mjs b/scripts/render-release-coverage-anchor.mjs index 3e9cad98e4..62f64408b8 100644 --- a/scripts/render-release-coverage-anchor.mjs +++ b/scripts/render-release-coverage-anchor.mjs @@ -48,9 +48,52 @@ const PREAMBLE = [ ].join('\n'); const HEARTBEAT_NOTE = - 'The `Swept` line above is this patrol\'s heartbeat: a timestamp that stops advancing means the ' - + 'standing caller died, which is the failure this anchor exists to make visible. Read it before ' - + 'you read the findings.'; + 'The `Swept` line above is this patrol\'s heartbeat, and it states the cadence that makes ' + + '「stalled」 decidable: a timestamp still sitting there past the `next by` deadline beside it means ' + + 'the standing caller died, which is the failure this anchor exists to make visible. ⛔ Do not ' + + 'carry a cadence over from a sibling patrol anchor — they differ by up to 4× and each states its ' + + 'own. Read it before you read the findings.'; + +/** + * The EXPECTATION stamped beside the `Swept` timestamp — the schedule, the + * interval, and the deadline the next healthy heartbeat has to beat (#17720). + * + * The heartbeat note above asks the reader to judge whether the timestamp + * 「stops advancing」, and until this line existed the body withheld the only + * input that judgement takes. This patrol is nightly; `half-state-patrol.yml`'s + * anchor carries the same sentence and fires every six hours, so a reader who + * infers one cadence from the other is wrong by 4× in whichever direction they + * guessed. + * + * ## Why the schedule is an INPUT and not a literal here + * + * `release-coverage-patrol.yml` declares the cron, and it is the only caller. + * Restating it in this file would be a second declaration of one fact, free to + * drift into publishing a deadline nobody schedules — which is this card's own + * defect with a timestamp on it. The workflow passes its own `cron:` down + * instead, so the value has exactly one home, in the file that schedules it. + * + * ⛔ Only the DAILY form is computed, and anything else refuses loudly rather + * than falling back to a period nobody declared. The refusal is the safe + * direction: a wrong deadline reads exactly like a right one. + * + * @param {{ schedule: string, sweptAt: string }} input + * @returns {string} one ` · `-joined fragment, with no leading separator. + */ +export function expectedCadence({ schedule, sweptAt }) { + const text = String(schedule ?? '').trim(); + const fields = /^(\d{1,2}) (\d{1,2}) \* \* \*$/.exec(text); + const at = new Date(sweptAt); + if (!fields || Number(fields[1]) > 59 || Number(fields[2]) > 23 || Number.isNaN(at.getTime())) { + return '⚠️ expected cadence UNSTATED — the caller passed `PATROL_SCHEDULE=' + + `${text}\`, which is not the daily \`M H * * *\` form this renderer computes a deadline from,` + + ' so the heartbeat above is NOT decidable from this body'; + } + const next = new Date(at); + next.setUTCHours(Number(fields[2]), Number(fields[1]), 0, 0); + if (next <= at) next.setUTCDate(next.getUTCDate() + 1); + return `expected every 24h (cron \`${text}\` UTC) · next by ${next.toISOString().slice(0, 16)}Z`; +} /** * The advisory run's exit code is the INSTRUMENT verdict; the `--strict` run's @@ -74,11 +117,14 @@ export function verdict({ advisoryCode, strictCode }) { * @param {string} input.errText stderr of the advisory run * @param {string} input.provenance * @param {string} input.sweptAt ISO timestamp + * @param {string} input.schedule the caller's own `cron:`, for the heartbeat deadline * @returns {string} */ -export function renderBody({ advisoryCode, strictCode, report, errText, provenance, sweptAt }) { +export function renderBody({ advisoryCode, strictCode, report, errText, provenance, sweptAt, schedule }) { const state = verdict({ advisoryCode, strictCode }); - const stamp = `_Swept ${sweptAt} · ${provenance}_`; + // The expectation rides the stamp itself, between the timestamp it qualifies + // and the provenance fields, so a reader cannot see one without the other. + const stamp = `_Swept ${sweptAt} · ${expectedCadence({ schedule, sweptAt })} · ${provenance}_`; if (state === 'did-not-run') { const classified = (errText || report || '(no output captured)').trim(); @@ -233,6 +279,7 @@ function selfTest() { report: 'check-release-section-coverage: 2 finding(s)', errText: '', provenance: 'run [1](http://x/1)', + schedule: '19 4 * * *', sweptAt: '2026-08-24T00:00:00.000Z', }; @@ -282,8 +329,41 @@ function selfTest() { + 'either', body.startsWith(MARKER) && body.includes('Swept 2026-08-24T00:00:00.000Z') && body.includes(HEARTBEAT_NOTE), ); + // #17720: the heartbeat without its cadence is an instruction no reader can + // act on, and this renderer has THREE branches — a stamp that carried the + // expectation in two of them would be the #4690 shape one field along. + check( + `${name} — … and the cadence the heartbeat is judged against, on the Swept line itself`, + body.includes('_Swept 2026-08-24T00:00:00.000Z · expected every 24h (cron `19 4 * * *` UTC) · next by 2026-08-24T04:19Z · run [1](http://x/1)_'), + ); } + // ── The deadline is COMPUTED from the passed clock, not a literal ───────── + check( + 'cadence — the deadline is the caller\'s own cron slot after the sweep, same day when it is ' + + 'still ahead', + expectedCadence({ schedule: '19 4 * * *', sweptAt: '2026-08-24T00:00:00.000Z' }) + === 'expected every 24h (cron `19 4 * * *` UTC) · next by 2026-08-24T04:19Z', + ); + check( + 'cadence — … and tomorrow\'s once the day\'s slot has passed, which is the reading a scheduled ' + + 'run always takes', + expectedCadence({ schedule: '19 4 * * *', sweptAt: '2026-08-24T04:19:07.000Z' }) + === 'expected every 24h (cron `19 4 * * *` UTC) · next by 2026-08-25T04:19Z', + ); + check( + 'cadence — ⛔ an UNWIRED caller states no deadline and names the unwired input, rather than ' + + 'inventing a period', + expectedCadence({ schedule: '', sweptAt: '2026-08-24T00:00:00.000Z' }).includes('expected cadence UNSTATED') + && !expectedCadence({ schedule: '', sweptAt: '2026-08-24T00:00:00.000Z' }).includes('next by'), + ); + check( + 'cadence — ⛔ a schedule outside the daily form refuses too: a six-hourly cron computed as if ' + + 'it were daily would publish a deadline 4× wrong', + expectedCadence({ schedule: '37 1,7,13,19 * * *', sweptAt: '2026-08-24T00:00:00.000Z' }) + .includes('expected cadence UNSTATED'), + ); + // Counted, never a literal: a hard-coded total silently stops matching the // moment a case is added, and a self-test that misreports its own size is the // first thing a reader stops trusting. @@ -368,6 +448,10 @@ function main(argv) { report: read('report.txt'), errText: read('report.err'), provenance: process.env.PROVENANCE || '(no provenance)', + // The caller's own `cron:`. Absent, `expectedCadence` refuses in the body + // rather than inventing a period — an unwired caller must be visible in the + // anchor, not papered over by a default. + schedule: process.env.PATROL_SCHEDULE || '', sweptAt: new Date().toISOString(), })); process.stdout.write('\n');