From 57fd195a35a11021c08dcc0d1025c071cf8a916c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 18:52:01 +0000 Subject: [PATCH 1/3] ci(dogfood): the three Dogfood Regression Gate shards run on merge_group Only merge_group leaves the dogfood job's event deny-list; pull_request keeps the fast set, and dogfood-verify, temporal-conformance and ui-published-artifacts stay full-battery only. THE TWO SPEEDS and every comment that restated the old split now say what runs where. The two gates that read job if: spellings learn the queue-and-full clause (pull_request excluded alone): check-expected-skips no longer calls such a job "full battery only", and dispatch-gates keeps the dogfood path population and tells the dev the PR skips the shards and the merge-queue build runs them first. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2ebbZkr2QAEG1x7UC8 --- .github/workflows/ci.yml | 66 +++++++++++------ .github/workflows/release.yml | 5 +- .github/workflows/upgrade-harness-optin.yml | 6 +- AGENTS.md | 4 +- docs/agents/multi-agent-discipline.md | 5 +- scripts/check-required-contexts.mjs | 4 + scripts/ci/scheduled-full-run.mjs | 5 +- scripts/pm/check-expected-skips.mjs | 17 ++++- scripts/pm/dispatch-gates.mjs | 82 +++++++++++++-------- scripts/pm/dispatch-gates.self-test.mjs | 76 ++++++++++++++++--- 10 files changed, 197 insertions(+), 73 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9da3da349e7..60df042f413 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,26 +114,41 @@ concurrency: cancel-in-progress: true # ── THE TWO SPEEDS (#22998) ────────────────────────────────────────────────── -# A `pull_request` or `merge_group` run carries the FAST SET: `Build Core`, -# `Test Core` (affected-only) and `Build Docs`. `main`'s 4-hourly `schedule` run, -# its `push` runs and an on-demand `workflow_dispatch` carry the FULL BATTERY: -# the fast set plus the four jobs below that spell +# A `pull_request` run carries the FAST SET: `Build Core`, `Test Core` +# (affected-only) and `Build Docs`. A `merge_group` run carries the fast set +# plus the three `dogfood` shards (#30461, ruling record 6663883327, letter B), +# whose job alone spells +# +# github.event_name != 'pull_request' +# +# in its `if:`, so a dogfood red shows on the queue build of the entry that +# carries it, before the merge rather than on `main`'s push run after it. +# `main`'s 4-hourly `schedule` run, its `push` runs and an on-demand +# `workflow_dispatch` carry the FULL BATTERY: all of that plus the three jobs +# below that spell # # github.event_name != 'pull_request' && github.event_name != 'merge_group' # -# in their `if:` — `temporal-conformance`, `dogfood` (three shards), -# `dogfood-verify` and `ui-published-artifacts`. Nothing is deleted: the jobs -# moved off the per-PR path, the last one never had one (#26717). A red in one of them is a main-red for the owning lane, and the -# publish lane reads the full battery before its approval (release.yml's -# `full-battery` job, `scripts/ci/scheduled-full-run.mjs --release-gate`). +# in their `if:` — `temporal-conformance`, `dogfood-verify` and +# `ui-published-artifacts`. Nothing is deleted: the jobs moved off the per-PR +# path, the last one never had one (#26717). A red in one of them is a main-red +# for the owning lane, and the publish lane reads the full battery before its +# approval (release.yml's `full-battery` job, +# `scripts/ci/scheduled-full-run.mjs --release-gate`). ⛔ No affected-only +# dogfood filter on the queue: the shards run on every entry, through the +# `core` output, which is 'true' on `merge_group` (letter C was not ruled). +# The queue enforces only the ruleset's required set, and `Dogfood Regression +# Gate` is outside it since #22998 (check:required-contexts' tombstone); this +# file does not decide whether it rejoins. # -# Spelled as a DENY-list of the two per-PR events on purpose — the filter +# Spelled as a DENY-list of the per-PR events on purpose — the filter # contract's half 1 below, applied to events: a trigger added to `on:` later # RUNS these jobs, and the failure direction of a wrong guess is a slower PR, # never a full battery that silently stopped running. `dogfood-gate` keeps -# `if: always()` (check:shard-attestation pins it): with both legs skipped it -# counts zero expected attestations and concludes success, exactly as on a -# filter-skipped diff. +# `if: always()` (check:shard-attestation pins it): with both legs skipped (a +# pull request) it counts zero expected attestations and concludes success, +# exactly as on a filter-skipped diff; on a queue build it counts the three +# shards' credentials and `dogfood-verify`'s skip as zero expected. jobs: filter: @@ -158,8 +173,9 @@ jobs: # 'true'` did. docs: ${{ github.event_name != 'merge_group' && steps.changes.outputs.docs || 'true' }} # Read by `test-plan`, `test` (ORed with `crosspkg`) and `build-core`, - # and on push / schedule by `dogfood`, `dogfood-verify` and - # `temporal-conformance` — see the `core` filter below and each job's `if:`. + # on merge_group / push / schedule by `dogfood`, and on push / schedule + # by `dogfood-verify` and `temporal-conformance` — see the `core` filter + # below and each job's `if:`. core: ${{ github.event_name != 'merge_group' && steps.changes.outputs.core || 'true' }} # Gates the `test` job ONLY, ORed with `core` — see the `crosspkg` # filter below and the `test` job's `if:` (#9829, #10015). @@ -2390,9 +2406,11 @@ jobs: # below — the shard count can change without touching it. name: Dogfood Regression Gate (${{ matrix.shard }}/3) needs: filter - # See THE FILTER CONTRACT on the filter job's outputs (#4928). Full battery - # only: see THE TWO SPEEDS above `jobs:` (#22998). - if: ${{ !cancelled() && needs.filter.outputs.core != 'false' && github.event_name != 'pull_request' && github.event_name != 'merge_group' }} + # See THE FILTER CONTRACT on the filter job's outputs (#4928). The queue + # build and the full battery, never a pull request: see THE TWO SPEEDS + # above `jobs:` (#22998; `merge_group` left this deny-list by #30461, + # ruling record 6663883327). `dogfood-verify` below keeps both events. + if: ${{ !cancelled() && needs.filter.outputs.core != 'false' && github.event_name != 'pull_request' }} runs-on: ubuntu-latest # Backstop only — the stall guard on the test step is the primary detector # for a #4250-style hang (see Test Core). 30 min is ~6× a shard (~5 min; @@ -2704,11 +2722,13 @@ jobs: overwrite: true dogfood-gate: - # Stable check name for a SHARDED job (#3622 follow-up). Since #22998 its - # legs run in the full battery only (THE TWO SPEEDS above `jobs:`) and the - # context leaves the required set; on a PR or queue build both legs skip - # and this gate counts zero expected attestations. The history below is - # why the name and the `if: always()` stay. + # Stable check name for a SHARDED job (#3622 follow-up). Since #22998 the + # context is outside the required set (THE TWO SPEEDS above `jobs:`). On a + # PR both legs skip and this gate counts zero expected attestations; on a + # queue build (since #30461) the three dogfood shards run and are counted, + # while `dogfood-verify` skips and is judged with zero expected; the full + # battery runs and counts both. The history below is why the name and the + # `if: always()` stay. # # Branch protection required the context "Dogfood Regression Gate". Once # the job became a 2-way matrix its checks publish as "Dogfood Regression diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b197935d7d9..e0bcc5c942f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1496,8 +1496,9 @@ jobs: # ══════════════════════════════════════════════════════════════════════════ full-battery: name: Full battery green at the version commit - # A PR or queue build carries ci.yml's FAST SET since #22998; the dogfood - # and temporal legs run only on main's push, 4-hourly schedule and + # A PR or queue build carries ci.yml's FAST SET since #22998 (a queue + # build adds the three dogfood shards since #30461); `dogfood-verify` and + # the temporal leg run only on main's push, 4-hourly schedule and # dispatch runs. So "tested before a release" is read HERE, as a gate: the # newest completed `schedule` or `push` runs of `CI` and `Lint & Type # Check` whose head is the version commit or a first-parent ancestor of it diff --git a/.github/workflows/upgrade-harness-optin.yml b/.github/workflows/upgrade-harness-optin.yml index 3d9391184c9..084c7d92a15 100644 --- a/.github/workflows/upgrade-harness-optin.yml +++ b/.github/workflows/upgrade-harness-optin.yml @@ -8,9 +8,9 @@ # its data" — a check each of the line's PRs passes BEFORE it merges. The # harness that answers R2 is a dogfood test, # `packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts`, and the -# dogfood jobs in `ci.yml` run only on `main`'s full battery (THE TWO SPEEDS, -# #22998): they skip `pull_request` and `merge_group`. So without this workflow -# the line's PRs get their first R2 answer after the merge. +# dogfood shards in `ci.yml` skip `pull_request` (THE TWO SPEEDS, #22998; they +# run on `merge_group` since #30461). So without this workflow the line's PRs +# get their first R2 answer in the merge queue, after the PR is enqueued. # # This workflow adds the missing TRIGGER for that ONE file, and nothing else. # The test, its fixture and its assertions stay where they are, and the full diff --git a/AGENTS.md b/AGENTS.md index 624d3be9923..9a614e0398c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -510,8 +510,8 @@ Even inside your own worktree, operate defensively: `Build & E2E UI` and `Live E2E UI`; a check outside those ten is advisory ([why that matters](docs/agents/multi-agent-discipline.md#advisory-checks-ride-through)). A required context is matched by check-run name, so a rename detaches its gate silently — treat those ten names as contract. - **Two speeds** — PR and queue runs carry the fast set; `main`'s scheduled and push runs carry the full - battery ([detail](docs/agents/multi-agent-discipline.md#two-speeds-which-runs-carry-which-battery)). + **Two speeds** — PR runs carry the fast set, queue runs add the dogfood shards; `main`'s scheduled and push + runs carry the full battery ([detail](docs/agents/multi-agent-discipline.md#two-speeds-which-runs-carry-which-battery)). **Main-red:** a full-battery red is its lane's first work — triage files it under the main-red convention, the lane's next PR fixes it, and ⛔ no lane PR arms the queue meanwhile. diff --git a/docs/agents/multi-agent-discipline.md b/docs/agents/multi-agent-discipline.md index 8f39ad43c6f..b48eeab38df 100644 --- a/docs/agents/multi-agent-discipline.md +++ b/docs/agents/multi-agent-discipline.md @@ -133,7 +133,10 @@ A check outside those ten is advisory and rides through, and an advisory red **Two speeds** (ruling record 6120820653): PR and queue runs carry the fast set, 10 minutes of wall per queue entry; `main`'s scheduled (4-hourly) and push runs carry the full battery — dogfood, e2e, `Temporal Conformance (live PG + MySQL)` — read by the publish lane before - any approval. + any approval. Queue runs also carry the three `Dogfood Regression Gate` shards (ruling record + 6663883327), so a dogfood red shows on the queue build before the merge; `Dogfood Verify CLI`, + Temporal Conformance and `UI Published Artifacts` stay full-battery only. The `Dogfood Regression + Gate` context is outside the required set, so the queue does not hold an entry on it. ## Re-arm awareness diff --git a/scripts/check-required-contexts.mjs b/scripts/check-required-contexts.mjs index 8ddd39bdbdf..e63dbcff17f 100644 --- a/scripts/check-required-contexts.mjs +++ b/scripts/check-required-contexts.mjs @@ -385,6 +385,10 @@ const UNATTRIBUTED_BATTERY = '(no battery open)'; * Gate` tombstone above gives: neither name is dead — both jobs still publish * these check-runs on every full-battery run, and the instruction files name * them correctly as full-battery jobs. + * Since #30461 (ruling record 6663883327, letter B) the three dogfood shards + * also run on `merge_group`, so a queue build publishes `Dogfood Regression + * Gate` over their credentials; the context stays out of this registry and + * of the ruleset until a ruling puts it back, which that ruling did not do. */ export const REQUIRED_CONTEXTS = [ { diff --git a/scripts/ci/scheduled-full-run.mjs b/scripts/ci/scheduled-full-run.mjs index 47bf195f7c6..1df6b985f67 100644 --- a/scripts/ci/scheduled-full-run.mjs +++ b/scripts/ci/scheduled-full-run.mjs @@ -798,8 +798,9 @@ export function renderBody({ // --------------------------------------------------------------------------- // // A PR or queue build carries the FAST SET since #22998 (ci.yml's THE TWO -// SPEEDS); the dogfood and temporal legs run only on `main`'s -// push, 4-hourly schedule and dispatch runs. "Tested before a release" is +// SPEEDS; a queue build adds the three dogfood shards since #30461); +// `dogfood-verify` and the temporal leg run only on `main`'s push, 4-hourly +// schedule and dispatch runs. "Tested before a release" is // therefore a statement about those runs, and this half makes it a gate: // release.yml's `full-battery` job, which `publish` needs, so a refusal here // means no deployment is queued at the `release` environment at all. diff --git a/scripts/pm/check-expected-skips.mjs b/scripts/pm/check-expected-skips.mjs index a18825ff471..a2ca2618e92 100644 --- a/scripts/pm/check-expected-skips.mjs +++ b/scripts/pm/check-expected-skips.mjs @@ -539,7 +539,14 @@ function gatedReason(workflow, wf, gate, matrix) { } if (t.kind === 'pr-event') { const where = [t.events.includes('pull_request') && 'every PR head', t.events.includes('merge_group') && 'every queue build'].filter(Boolean).join(' and '); - return `full battery only: its \`if:\` excludes ${tick(t.events)}, so it skips on ${where} and is answered by main's push and scheduled runs`; + // "Full battery only" is said only when BOTH per-PR events are excluded: + // since #30461 ci.yml's `dogfood` shards exclude `pull_request` alone and + // run on every queue build, so the queue build answers them first. + if (PR_EVENTS.every((e) => t.events.includes(e))) { + return `full battery only: its \`if:\` excludes ${tick(t.events)}, so it skips on ${where} and is answered by main's push and scheduled runs`; + } + const answered = t.events.includes('pull_request') ? "the merge-queue build and main's push and scheduled runs" : "every PR head and main's push and scheduled runs"; + return `its \`if:\` excludes ${tick(t.events)} alone, so it skips on ${where} and is answered by ${answered}`; } if (t.kind === 'event-action') { return `its \`if:\` excludes the ${tick(t.actions)} pull_request event(s); each such event is its own run on the same head, beside the run that concluded`; @@ -1284,6 +1291,8 @@ function fixtureRun(name, conclusion, suite = 1, extra = {}) { const FILTER_WIDENED = (o) => `\${{ github.event_name != 'merge_group' && steps.changes.outputs.${o} || 'true' }}`; const CORE_IF = "${{ !cancelled() && needs.filter.outputs.core != 'false' }}"; const FULL_BATTERY_IF = "${{ !cancelled() && needs.filter.outputs.core != 'false' && github.event_name != 'pull_request' && github.event_name != 'merge_group' }}"; +// ci.yml's `dogfood` shards since #30461: the queue build runs them, a PR head skips them. +const QUEUE_AND_FULL_IF = "${{ !cancelled() && needs.filter.outputs.core != 'false' && github.event_name != 'pull_request' }}"; const EVENT_ACTION_IF = "github.event.action != 'labeled' && github.event.action != 'unlabeled' && github.event.action != 'edited'"; const optInIf = (label) => `contains(github.event.pull_request.labels.*.name, '${label}') && (github.event.action != 'labeled' || github.event.label.name == '${label}')`; const FIXTURE_TREE = Object.freeze({ @@ -1307,7 +1316,7 @@ const FIXTURE_TREE = Object.freeze({ }, 'test-gate': { name: 'Test Core', needs: ['test', 'filter'], if: 'always()' }, 'temporal-conformance': { name: 'Temporal Conformance (live PG + MySQL)', needs: 'filter', if: FULL_BATTERY_IF }, - dogfood: { name: 'Dogfood Regression Gate (${{ matrix.shard }}/3)', needs: 'filter', if: FULL_BATTERY_IF, strategy: { matrix: { shard: [1, 2, 3] } } }, + dogfood: { name: 'Dogfood Regression Gate (${{ matrix.shard }}/3)', needs: 'filter', if: QUEUE_AND_FULL_IF, strategy: { matrix: { shard: [1, 2, 3] } } }, 'dogfood-verify': { name: 'Dogfood Verify CLI', needs: 'filter', if: FULL_BATTERY_IF }, 'dogfood-gate': { name: 'Dogfood Regression Gate', needs: ['dogfood', 'dogfood-verify', 'filter'], if: 'always()' }, 'build-core': { name: 'Build Core', needs: 'filter', if: CORE_IF }, @@ -1531,6 +1540,7 @@ export function selfTest() { t('jobGate: …nor always(), an absent if:, a non-string, or two ${{ }} blocks', [jg('always()'), jg(undefined), jg(true), jg("${{ needs.filter.outputs.a != 'false' }} && ${{ needs.filter.outputs.b != 'false' }}")].every((x) => x === null), true); t('jobGate: …nor an if: whose parentheses do not balance', jg("!cancelled() && (needs.filter.outputs.core != 'false'"), null); t('spelling: a gate renders in one short line', gateTermsSpelling(jg(FULL_BATTERY_IF).terms), 'filter core + pr-event pull_request|merge_group'); + t('jobGate: the queue-and-full shape (#30461) is one pr-event term excluding pull_request alone', JSON.stringify(jg(QUEUE_AND_FULL_IF)?.terms.find((x) => x.kind === 'pr-event')?.events), JSON.stringify(['pull_request'])); // ---- the derivation, on the frozen fixture tree ---------------------------- const fx = fixtureRoster(); @@ -1556,6 +1566,9 @@ export function selfTest() { t("reason: a queue-widened output says the merge-queue build runs it", fxRow('Build Core')?.reason.includes("merge-queue build widens it to 'true'"), true); t("reason: a ui output says the queue reads its own diff", fxRow('Build UI Deps')?.reason.includes("against the queue's base"), true); t('reason: a full-battery job says so', fxRow('Dogfood Verify CLI')?.reason.includes('full battery only'), true); + const queueRow = fxRow('Dogfood Regression Gate (${{ matrix.shard }}/3)'); + t('reason: a job excluding pull_request alone is NOT called full battery only (#30461)', queueRow?.reason.includes('full battery only'), false); + t('reason: …it says only PR heads skip it and the merge-queue build answers it', queueRow?.reason.includes('skips on every PR head and is answered by the merge-queue build') && !queueRow?.reason.includes('every queue build'), true); t('reason: an opt-out label names its label', fxRow('Check Changeset')?.reason.includes('`skip-changeset` label skips it'), true); t('reason: a matrix job names its raw template', fxRow('Test Core (${{ matrix.shard }}/6)')?.reason.includes('raw `name:` template'), true); t('reason: unread conjuncts are counted, never hidden', fxRow('Build UI Deps')?.reason.includes('1 further condition(s)'), true); diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index d6c49755618..8066854885c 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -1188,11 +1188,18 @@ export function extractTriggerPaths(workflowText) { * longer runs such a job, this derivation is the only place a card's dev hears * of it before the red lands on `main`. Refusing it would drop the console and * dogfood leads this function exists to give. Any other event term, a reordered - * clause or one event alone is still refused whole. + * clause or `merge_group` alone is still refused whole. + * + * ⭐ So is the QUEUE-AND-FULL clause, `&& github.event_name != 'pull_request'` + * alone, under the same two conditions (#30461, ruling record 6663883327): + * ci.yml's `dogfood` shards carry it since `merge_group` left their deny-list. + * It too discriminates events, never paths, and it says a different WHEN: the + * job skips the PR and runs on the merge-queue build and the full battery. * * ⛔ Stripped is not FORGOTTEN (#24073). The clause decides nothing about paths, - * but it decides WHEN: such a job never runs on the PR or in the merge queue. - * `jobIfReading` below returns that fact beside the refs, and every row a + * but it decides WHEN: such a job never runs on the PR, and under the + * full-battery clause never in the merge queue either. `jobIfReading` below + * returns both facts beside the refs, and every row a * path-scheduled job renders carries it to the line the reader reads. Measured * before it did: a `packages/runtime/src/route-ledger.ts` change derived the * Dogfood Regression Gate as "scheduled BY YOUR PATHS" with nothing saying the @@ -1202,6 +1209,7 @@ export function extractTriggerPaths(workflowText) { const JOB_IF_STATUS_TERM = /(?:^|\s)!\s*cancelled\(\)\s*&&\s*/g; const JOB_IF_FULL_BATTERY_TERM = /\s*&&\s*github\.event_name\s*!=\s*'pull_request'\s*&&\s*github\.event_name\s*!=\s*'merge_group'\s*$/; +const JOB_IF_QUEUE_AND_FULL_TERM = /\s*&&\s*github\.event_name\s*!=\s*'pull_request'\s*$/; const JOB_FILTER_OUTPUT_TERM = /^needs\.([A-Za-z_][\w-]*)\.outputs\.([A-Za-z_][\w-]*)\s*(?:!=\s*(['"])false\3|==\s*(['"])true\4)$/; @@ -1216,13 +1224,16 @@ export function jobFilterOutputRefs(ifExpression) { } /** - * The whole reading of a job `if:` — `{ refs, fullBatteryOnly }`, or null on - * exactly the refusals `jobFilterOutputRefs` makes, because that function IS - * this one with the second half dropped. `fullBatteryOnly` is true when the - * full-battery clause was stripped as the last conjunct: the job runs on - * `main`'s push and hourly runs and NEVER on a `pull_request` or `merge_group` - * event. It is read in the same pass as the refs, from the same text, so the - * population and the event fact cannot describe two different `if:`s. + * The whole reading of a job `if:` — `{ refs, fullBatteryOnly, notOnPullRequest }`, + * or null on exactly the refusals `jobFilterOutputRefs` makes, because that + * function IS this one with the event facts dropped. `fullBatteryOnly` is true + * when the full-battery clause was stripped as the last conjunct: the job runs + * on `main`'s push and hourly runs and NEVER on a `pull_request` or + * `merge_group` event. `notOnPullRequest` is true when either clause was + * stripped: the job never runs on the PR (alone, the queue-and-full clause — + * the merge-queue build runs it). Both are read in the same pass as the refs, + * from the same text, so the population and the event facts cannot describe + * two different `if:`s. */ export function jobIfReading(ifExpression) { if (typeof ifExpression !== 'string') return null; @@ -1230,8 +1241,12 @@ export function jobIfReading(ifExpression) { const wrapped = /^\$\{\{([\s\S]*)\}\}$/.exec(expr); if (wrapped) expr = wrapped[1].trim(); expr = expr.replace(JOB_IF_STATUS_TERM, ' ').trim(); - const eventFree = expr.replace(JOB_IF_FULL_BATTERY_TERM, '').trim(); - const fullBatteryOnly = eventFree !== expr; + const fullFree = expr.replace(JOB_IF_FULL_BATTERY_TERM, '').trim(); + const fullBatteryOnly = fullFree !== expr; + // Only when the full-battery clause was NOT there: stripping both would read + // `!= 'pull_request' && != 'pull_request'` as one clause. + const eventFree = fullBatteryOnly ? fullFree : expr.replace(JOB_IF_QUEUE_AND_FULL_TERM, '').trim(); + const notOnPullRequest = eventFree !== expr; expr = eventFree; // One balanced outer paren pair at a time — `(A || B)` is the live spelling. for (;;) { @@ -1253,7 +1268,7 @@ export function jobIfReading(ifExpression) { if (!m) return null; refs.push({ job: m[1], output: m[2] }); } - return refs.length ? { refs, fullBatteryOnly } : null; + return refs.length ? { refs, fullBatteryOnly, notOnPullRequest } : null; } /** @@ -1452,15 +1467,16 @@ const EXTGLOB_CONSTRUCT = /[!?*+@]\(/; * The path population a job's `if:` resolves to, or null — the whole of * direction 1, assembled from the three readings above. * - * Returns `{ paths, outputs, dropped, fullBatteryOnly }`. `paths` is a UNION: - * the `if:` is an OR of filter outputs, so CI schedules the job when ANY of them - * matched. `fullBatteryOnly` is `jobIfReading`'s event fact, carried so a row - * built from this population can say WHEN CI schedules it (#24073). + * Returns `{ paths, outputs, dropped, fullBatteryOnly, notOnPullRequest }`. + * `paths` is a UNION: the `if:` is an OR of filter outputs, so CI schedules the + * job when ANY of them matched. The two flags are `jobIfReading`'s event facts, + * carried so a row built from this population can say WHEN CI schedules it + * (#24073, #30461). */ export function jobFilterPopulation(job, filterStepsByOutputSource) { const reading = jobIfReading(job.if); if (!reading) return null; - const { refs, fullBatteryOnly } = reading; + const { refs, fullBatteryOnly, notOnPullRequest } = reading; const paths = []; const outputs = []; let dropped = 0; @@ -1479,12 +1495,12 @@ export function jobFilterPopulation(job, filterStepsByOutputSource) { } outputs.push(`${ref.job}.${ref.output}`); } - return paths.length ? { paths, outputs, dropped, fullBatteryOnly } : null; + return paths.length ? { paths, outputs, dropped, fullBatteryOnly, notOnPullRequest } : null; } /** * Every job in one workflow whose `if:` resolves to a path population, WITH the - * job's own text: `[{ job, name, text, outputs, paths, dropped, fullBatteryOnly }]`. + * job's own text: `[{ job, name, text, outputs, paths, dropped, fullBatteryOnly, notOnPullRequest }]`. * * ⛔ No check filter here, and that is the whole reason this is its own * function. The caller below drops a job that invokes no discoverable check @@ -2848,8 +2864,10 @@ export function alwaysRunSteps(entries) { * full-battery clause carries `fullBatteryOnly: true`, read by `jobIfReading` * in the same pass as the population: CI schedules that job for your path on * `main`'s push and hourly runs ONLY — never on this PR, never in the merge - * queue. Both renderings print it on the row's own line, so "scheduled BY YOUR - * PATHS" cannot be read as "this PR runs it". + * queue. A row whose clause was the queue-and-full one carries + * `notOnPullRequest: true` alone (#30461): never on this PR, first on the + * merge-queue build. Both renderings print the fact on the row's own line, so + * "scheduled BY YOUR PATHS" cannot be read as "this PR runs it". * * ⛔ NOT claimed: that a row is a test, a build or a setup step. * `alwaysRunLines` refuses that classification for its own rows and the reason @@ -2938,7 +2956,7 @@ export function jobFilteredSteps(entries, paths) { counts.named += 1; rows.push({ workflow: file, job: job.name, outputs: job.outputs, hits, dropped: job.dropped, - fullBatteryOnly: job.fullBatteryOnly === true, steps, + fullBatteryOnly: job.fullBatteryOnly === true, notOnPullRequest: job.notOnPullRequest === true, steps, }); } } @@ -11572,17 +11590,23 @@ export function alwaysRunLines(rows, counts) { * on that row's own line (#24073) — or the empty string when the row's job runs * on the PR like any other. * - * Read off `row.fullBatteryOnly`, the fact `jobIfReading` took from the job's - * own `if:`; ⛔ never keyed on a job name, so a job that gains or loses the - * full-battery clause gains or loses the clause here with nothing to update. + * Read off `row.fullBatteryOnly` and `row.notOnPullRequest`, the facts + * `jobIfReading` took from the job's own `if:`; ⛔ never keyed on a job name, so + * a job that gains or loses either event clause gains or loses the clause here + * with nothing to update. A queue-and-full row (#30461) says the PR skips the + * job and the merge-queue build is its first run. * The reader it exists for: a dev whose path schedules the Dogfood Regression * Gate, told only "scheduled BY YOUR PATHS", inferred that the PR would run its * pins — and the first run was `main`'s push battery, red. */ export function fullBatteryOnlyClause(row) { - if (row?.fullBatteryOnly !== true) return ''; - return '⛔ does NOT run on this PR or in the merge queue: its `if:` excludes the pull_request and merge_group events,' + - " so its first run on this change is `main`'s push battery, AFTER the merge"; + if (row?.fullBatteryOnly === true) { + return '⛔ does NOT run on this PR or in the merge queue: its `if:` excludes the pull_request and merge_group events,' + + " so its first run on this change is `main`'s push battery, AFTER the merge"; + } + if (row?.notOnPullRequest !== true) return ''; + return '⛔ does NOT run on this PR: its `if:` excludes the pull_request event,' + + ' so its first run on this change is the merge-queue build, after the PR is enqueued'; } /** diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index fa826a6ac2a..87c3a8adb12 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -5726,8 +5726,22 @@ export function selfTest({ tier = 'full' } = {}) { jobFilterOutputRefs("${{ needs.f.outputs.a != 'false' && github.event_name != 'merge_group' && github.event_name != 'pull_request' }}") === null, ); t( - '...one excluded event alone refuses — it is not the full-battery clause', - jobFilterOutputRefs("${{ needs.f.outputs.a != 'false' && github.event_name != 'pull_request' }}") === null, + '...merge_group excluded alone refuses — it is neither event clause', + jobFilterOutputRefs("${{ needs.f.outputs.a != 'false' && github.event_name != 'merge_group' }}") === null, + ); + // #30461: pull_request excluded alone is the QUEUE-AND-FULL clause ci.yml's + // `dogfood` shards carry — stripped as the last conjunct, read as its own fact. + t( + '...pull_request excluded alone is the queue-and-full clause: stripped, the job still judges its filter output', + jobFilterOutputRefs("${{ !cancelled() && needs.filter.outputs.core != 'false' && github.event_name != 'pull_request' }}")?.map((r) => `${r.job}.${r.output}`).join() === 'filter.core', + ); + t( + '...but only as the LAST conjunct: in front of the filter output it refuses', + jobFilterOutputRefs("${{ github.event_name != 'pull_request' && needs.f.outputs.a != 'false' }}") === null, + ); + t( + '...and pull_request excluded twice is not read as either clause', + jobFilterOutputRefs("${{ needs.f.outputs.a != 'false' && github.event_name != 'pull_request' && github.event_name != 'pull_request' }}") === null, ); t( '...a positive event term refuses — it says when the job runs, not that a PR never does', @@ -5748,6 +5762,20 @@ export function selfTest({ tier = 'full' } = {}) { '...and an if: with no event clause reads fullBatteryOnly FALSE — the fact is read off the text, never defaulted on', jobIfReading("${{ !cancelled() && needs.filter.outputs.console != 'false' }}")?.fullBatteryOnly === false, ); + t( + 'jobIfReading: the full-battery clause also reads notOnPullRequest TRUE — neither event runs the job', + fullBatteryReading?.notOnPullRequest === true, + ); + const queueAndFullReading = jobIfReading("${{ !cancelled() && needs.filter.outputs.core != 'false' && github.event_name != 'pull_request' }}"); + t( + 'jobIfReading: the queue-and-full clause (#30461) reads notOnPullRequest TRUE and fullBatteryOnly FALSE, beside the same refs', + queueAndFullReading?.notOnPullRequest === true && queueAndFullReading?.fullBatteryOnly === false + && queueAndFullReading?.refs.map((r) => `${r.job}.${r.output}`).join() === 'filter.core', + ); + t( + '...and an if: with no event clause reads notOnPullRequest FALSE', + jobIfReading("${{ !cancelled() && needs.filter.outputs.console != 'false' }}")?.notOnPullRequest === false, + ); const fixturePops = jobPathPopulations(jobFilterWf, 'fixture.yml'); t( @@ -12263,10 +12291,12 @@ export function selfTest({ tier = 'full' } = {}) { // the Dogfood Regression Gate as "scheduled BY YOUR PATHS", and nothing said // its `if:` skips pull_request and merge_group — so the PR never ran the two // dogfood pins that read that file, and their first run was `main`'s push - // battery, red. Pinned LIVE on ci.yml for both dogfood jobs (they carry the - // same `if:`), against a NEGATIVE control on a job that does run on the PR, - // and with an in-place ABLATION of the carried flag on every row, so the - // statement cannot pass as a string typed per job. If ci.yml moves either job + // battery, red. Pinned LIVE on ci.yml, against a NEGATIVE control on a job + // that does run on the PR, and with an in-place ABLATION of the carried flag + // on every row, so the statement cannot pass as a string typed per job. Since + // #30461 the two dogfood jobs carry DIFFERENT clauses: `Dogfood Verify CLI` + // keeps the full-battery one, and the `Dogfood Regression Gate` shards carry + // the queue-and-full one (pinned below this loop). If ci.yml moves either job // back onto the PR, these go red: re-point them to whichever path-scheduled // job still carries the clause — ⛔ do not delete them. const ledgerCard = 'packages/runtime/src/route-ledger.ts'; @@ -12281,7 +12311,7 @@ export function selfTest({ tier = 'full' } = {}) { const lines = jobFilteredStepLines([row], { covering: 1, named: 1, steps: n, accounted: 0, unaccounted: n, conditionalSteps: 0 }); return lines.find((l) => l.startsWith(` - [${row.workflow} · ${row.job}]`)) ?? ''; }; - for (const prefix of ['Dogfood Regression Gate', 'Dogfood Verify CLI']) { + for (const prefix of ['Dogfood Verify CLI']) { const row = ledgerRow(prefix); t( `LIVE: a route-ledger.ts change schedules '${prefix}', and its row carries the full-battery fact read off its own if:`, @@ -12295,7 +12325,8 @@ export function selfTest({ tier = 'full' } = {}) { t("...and so does its row in the plain rendering, on the row's own line", plainRowLine(row).includes(NOT_ON_PR)); // ABLATION, in place: the same row with the carried fact stripped. The // statement must vanish from both lines — rendered from the flag, or not at all. - const stripped = row ? Object.fromEntries(Object.entries(row).filter(([k]) => k !== 'fullBatteryOnly')) : null; + // Both event facts go: a full-battery row carries notOnPullRequest too (#30461). + const stripped = row ? Object.fromEntries(Object.entries(row).filter(([k]) => k !== 'fullBatteryOnly' && k !== 'notOnPullRequest')) : null; t( '...ABLATED — the carried fact stripped from the row — the statement disappears from BOTH lines', Boolean(stripped) @@ -12305,11 +12336,38 @@ export function selfTest({ tier = 'full' } = {}) { && !plainRowLine(stripped).includes(NOT_ON_PR), ); } + // #30461: the shards' row says the PR skips them and the queue runs them first + // — and never the full-battery sentence, which would now be false. + const NOT_ON_THIS_PR = 'does NOT run on this PR: its `if:` excludes the pull_request event'; + const QUEUE_FIRST = 'its first run on this change is the merge-queue build'; + const shardRow = ledgerRow('Dogfood Regression Gate'); + t( + "LIVE: a route-ledger.ts change schedules 'Dogfood Regression Gate', and its row carries the queue-and-full facts read off its own if:", + shardRow?.notOnPullRequest === true && shardRow?.fullBatteryOnly === false, + ); + t( + '...its --commands line SAYS the PR skips it and the queue build runs it first, ahead of the step list, and NOT the full-battery sentence', + Boolean(shardRow) && jobFilteredNotMeasuredLine(shardRow).indexOf(NOT_ON_THIS_PR) > -1 + && jobFilteredNotMeasuredLine(shardRow).includes(QUEUE_FIRST) + && jobFilteredNotMeasuredLine(shardRow).indexOf(NOT_ON_THIS_PR) < jobFilteredNotMeasuredLine(shardRow).indexOf(' step(s):') + && !jobFilteredNotMeasuredLine(shardRow).includes(NOT_ON_PR), + ); + t("...and so does its row in the plain rendering, on the row's own line", plainRowLine(shardRow).includes(NOT_ON_THIS_PR) && !plainRowLine(shardRow).includes(NOT_ON_PR)); + const shardStripped = shardRow ? Object.fromEntries(Object.entries(shardRow).filter(([k]) => k !== 'notOnPullRequest')) : null; + t( + '...ABLATED — notOnPullRequest stripped from the row — the statement disappears from BOTH lines', + Boolean(shardStripped) + && fullBatteryOnlyClause(shardStripped) === '' + && !jobFilteredNotMeasuredLine(shardStripped).includes(NOT_ON_THIS_PR) + && plainRowLine(shardStripped) !== '' + && !plainRowLine(shardStripped).includes(NOT_ON_THIS_PR), + ); const prJobRow = ledgerRow('Build Core'); t( 'LIVE NEGATIVE CONTROL: Build Core, scheduled by the same path and run on the PR, carries the fact as FALSE and no statement', - prJobRow?.fullBatteryOnly === false + prJobRow?.fullBatteryOnly === false && prJobRow?.notOnPullRequest === false && !jobFilteredNotMeasuredLine(prJobRow).includes(NOT_ON_PR) + && !jobFilteredNotMeasuredLine(prJobRow).includes(NOT_ON_THIS_PR) && plainRowLine(prJobRow) !== '' && !plainRowLine(prJobRow).includes(NOT_ON_PR), ); From c029b25517693ba7f98d5ae19efd72aece0c5573 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 19:14:34 +0000 Subject: [PATCH 2/3] docs(agents): keep the Two speeds line inside the 120-byte line budget Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2ebbZkr2QAEG1x7UC8 --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9a614e0398c..856259d9a8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -510,8 +510,8 @@ Even inside your own worktree, operate defensively: `Build & E2E UI` and `Live E2E UI`; a check outside those ten is advisory ([why that matters](docs/agents/multi-agent-discipline.md#advisory-checks-ride-through)). A required context is matched by check-run name, so a rename detaches its gate silently — treat those ten names as contract. - **Two speeds** — PR runs carry the fast set, queue runs add the dogfood shards; `main`'s scheduled and push - runs carry the full battery ([detail](docs/agents/multi-agent-discipline.md#two-speeds-which-runs-carry-which-battery)). + **Two speeds** — PR runs carry the fast set, queue runs add the dogfood shards; `main`'s scheduled and push runs + carry the full battery ([detail](docs/agents/multi-agent-discipline.md#two-speeds-which-runs-carry-which-battery)). **Main-red:** a full-battery red is its lane's first work — triage files it under the main-red convention, the lane's next PR fixes it, and ⛔ no lane PR arms the queue meanwhile. From 263527d3d538f2ef4d39cb94ecfd57f49c4d4857 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 21:45:44 +0000 Subject: [PATCH 3/3] docs(ci): the queue-build dogfood red is advisory, and the stale wall-time figure is the measured median Contract-review should-fixes on the dogfood merge_group change: THE TWO SPEEDS no longer says a dogfood red shows "before the merge" (the context is outside the required set, so the queue may merge first); the check-required-contexts tombstone names the follow-up ruling's two-step path back into the required set; the discipline doc's "10 minutes of wall per queue entry" becomes the measured ~20 minute median. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2ebbZkr2QAEG1x7UC8 --- .github/workflows/ci.yml | 4 +++- docs/agents/multi-agent-discipline.md | 8 ++++---- scripts/check-required-contexts.mjs | 9 ++++++--- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60df042f413..f6cd1c8e9d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,7 +122,9 @@ concurrency: # github.event_name != 'pull_request' # # in its `if:`, so a dogfood red shows on the queue build of the entry that -# carries it, before the merge rather than on `main`'s push run after it. +# carries it rather than first on `main`'s push run after it. It is advisory +# there: the context is outside the required set (below), so the queue may +# merge the entry before or despite the shards. # `main`'s 4-hourly `schedule` run, its `push` runs and an on-demand # `workflow_dispatch` carry the FULL BATTERY: all of that plus the three jobs # below that spell diff --git a/docs/agents/multi-agent-discipline.md b/docs/agents/multi-agent-discipline.md index b48eeab38df..ba77aab524b 100644 --- a/docs/agents/multi-agent-discipline.md +++ b/docs/agents/multi-agent-discipline.md @@ -130,11 +130,11 @@ A check outside those ten is advisory and rides through, and an advisory red ← [`AGENTS.md` › Multi-agent working discipline](../../AGENTS.md#multi-agent-working-discipline) -**Two speeds** (ruling record 6120820653): PR and queue runs carry the fast set, 10 minutes of - wall per queue entry; `main`'s scheduled (4-hourly) and push runs carry the full battery — dogfood, e2e, - `Temporal Conformance (live PG + MySQL)` — read by the publish lane before +**Two speeds** (ruling record 6120820653): PR and queue runs carry the fast set, about 20 minutes + of wall per queue entry (median, 2026-11-05); `main`'s scheduled (4-hourly) and push runs + carry the full battery — dogfood, e2e, `Temporal Conformance (live PG + MySQL)` — read by the publish lane before any approval. Queue runs also carry the three `Dogfood Regression Gate` shards (ruling record - 6663883327), so a dogfood red shows on the queue build before the merge; `Dogfood Verify CLI`, + 6663883327), so a dogfood red shows on the queue build, advisory only; `Dogfood Verify CLI`, Temporal Conformance and `UI Published Artifacts` stay full-battery only. The `Dogfood Regression Gate` context is outside the required set, so the queue does not hold an entry on it. diff --git a/scripts/check-required-contexts.mjs b/scripts/check-required-contexts.mjs index e63dbcff17f..43ced42092b 100644 --- a/scripts/check-required-contexts.mjs +++ b/scripts/check-required-contexts.mjs @@ -384,11 +384,14 @@ const UNATTRIBUTED_BATTERY = '(no battery open)'; * RETIRED_CONTEXT_NAMES row, for the reason the `Build Docs` / `Console Pin * Gate` tombstone above gives: neither name is dead — both jobs still publish * these check-runs on every full-battery run, and the instruction files name - * them correctly as full-battery jobs. + * where each runs. * Since #30461 (ruling record 6663883327, letter B) the three dogfood shards * also run on `merge_group`, so a queue build publishes `Dogfood Regression - * Gate` over their credentials; the context stays out of this registry and - * of the ruleset until a ruling puts it back, which that ruling did not do. + * Gate` over their credentials; `Temporal Conformance` stays full-battery + * only. The context stays out of this registry and of the ruleset for now: the + * follow-up ruling on #30461 (A, in two steps, 6669982756) puts it back only + * after a clean 24-hour reading of the queue builds, the row here landing + * first and the ruleset edit after it. */ export const REQUIRED_CONTEXTS = [ {