Skip to content

ci(smart-ci): add the nightly coordinator decision module (CI-10 slice 1) - #2587

Merged
Chris0Jeky merged 4 commits into
mainfrom
issue-2334/ci10-nightly-coordinator
Sep 6, 2026
Merged

ci(smart-ci): add the nightly coordinator decision module (CI-10 slice 1)#2587
Chris0Jeky merged 4 commits into
mainfrom
issue-2334/ci10-nightly-coordinator

Conversation

@Chris0Jeky

@Chris0Jeky Chris0Jeky commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Summary

CI-10 slice 1 (CI10-1-coordinator) from the acceleration-bundle issue file
docs/analysis/2026-08-30-acceleration-bundle/issues/2334-ci-10-nightly-coordinator-and-release-qualification.md.

Two new files, nothing else touched:

  • scripts/ci/smart-ci/nightly-coordinator.mjs - a pure decision function plus a thin CLI, in the
    same shape as plan.mjs and recall-report.mjs.
  • scripts/ci/smart-ci/nightly-coordinator.test.mjs - 24 node --test cases covering the issue's
    head-start test plan plus the workflow dependency graph.

This slice changes no workflow behaviour. Nothing schedules, skips or gates a job here. Wiring the
verdict into ci-nightly.yml and nightly-quality.yml is CI10-2, the weekly sweep is CI10-3, and
the release-trigger collapse is CI10-4. Consistent with the issue's rollout note, the coordinator is
meant to land computing and logging its verdict while the existing nightlies still run
unconditionally; rollback is deleting two files, since nothing consumes them yet.

The function answers one question: what changed on main since the last complete deep
qualification, and which deep suites would produce new evidence tonight. Every input is explicit.
There is no network access, no git invocation and no clock read anywhere in the module, including
the CLI: the workflow will supply the SHAs, the diff, the receipt and nowUtc as arguments and
files. generatedAtUtc echoes the nowUtc input rather than falling back to Date.now(), so a
receipt is reproducible.

Path-to-group matching reuses matchGroups() from scripts/ci/smart-ci/lib/plan.mjs. No second
glob matcher was written, and the escalation reason ids unmapped-path and control-path-change
deliberately repeat the planner's vocabulary.

Design

Inputs: the parsed policy document and its digest, the last deep-qualified receipt
({ headSha, treeSha, completedAtUtc, complete }) or null, the current main headSha and
treeSha, the changed-file list between the last qualified SHA and current main or null when that
diff is unavailable, nowUtc as an ISO-8601 string, an optional weekly slot as a UTC weekday
number, and an optional forceFull flag for the later workflow_dispatch input.

Verdict When Selected suites
no-change the current tree SHA equals the last qualified tree SHA (identical-tree-sha), or the diff maps only to path groups with no deep suite (no-deep-suite-groups) none
affected the diff maps cleanly to mapped path groups (affected-groups) the union of those groups' deep suites, closed under the workflow needs: edges
weekly-full the configured weekly UTC slot matches today (weekly-slot) all twelve
full-sweep any fail-closed reason below, or an explicit --force-full all twelve

Precedence is full-sweep > weekly-full > no-change (identical tree) > affected.

Fail-closed reasons, each a stable id carried in the receipt, matching docs/ci/SMART_CI.md
invariant 2 (unknown change equals full escalation):

Reason id Trigger
force-full-requested explicit dispatch escalation
last-receipt-missing no last deep-qualified receipt (also the expired-artifact case in the issue's edge list)
last-receipt-unreadable the receipt is not an object, or its SHAs or timestamp do not parse
last-receipt-incomplete complete !== true; a partially failed nightly never advances the marker
current-head-sha-invalid / current-tree-sha-invalid the supplied SHA is not 40 hex characters
now-unparseable nowUtc does not parse
weekly-slot-invalid a weekly slot was supplied but is not an integer 0 to 6, so a typo escalates instead of silently disabling the sweep
policy-invalid validatePolicy() from lib/plan.mjs reports errors, or the policy is missing
diff-unavailable the changed-file list is null, the force-moved-history case
unmapped-path a changed path matched no policy path group
control-path-change a changed path matched policy.controlPaths
group-not-in-suite-map a matched policy group has no entry in GROUP_DEEP_SUITES
coordinator-error any thrown error inside the decision

A no-change verdict is an explicit receipt, never a skip. It still lists all twelve suites under
skippedSuites with the reason no-change, because invariant 1 forbids a skipped job reporting as
success to branch protection.

Deep suite vocabulary is the twelve job ids that exist today: openapi-guardrail,
developer-portal, backend-solution, e2e-smoke, load-concurrency-harness,
performance-regression-gate, e2e-cross-browser, container-images, sast-scanning from
.github/workflows/ci-nightly.yml, and backend-coverage, frontend-coverage,
dependency-security-signals from .github/workflows/nightly-quality.yml. Verified against both
files; the module reads neither.

The group-to-suite table GROUP_DEEP_SUITES is an exported module constant with a comment saying
CI10-2 moves it into ci/policy.v1.json next to pathGroups so the policy digest covers it. It is
deliberately conservative: backend groups select backend-solution, backend-coverage,
load-concurrency-harness, performance-regression-gate and container-images; frontend groups
select frontend-coverage, e2e-smoke and e2e-cross-browser; containers-deploy and
backend-project-files pull in dependency-security-signals, sast-scanning and
container-images; docs, repo metadata, agent tooling, worktree helpers, governance scripts and
other scripts select nothing. Note that the dependency lockfiles and manifests named in the issue
(package-lock.json, Directory.Packages.props, frontend/taskdeck-web/package.json and the rest)
are already controlPaths in the policy, so they escalate to the full sweep before this table is
consulted; backend-project-files covers the .csproj and .sln manifests that are not control
paths.

A selection is not a free set of job ids: ci-nightly.yml gives e2e-smoke,
load-concurrency-harness, performance-regression-gate, e2e-cross-browser and
container-images a needs: backend-solution edge, and GitHub Actions skips a job whose needs:
dependency was skipped unless its if: uses always() or !cancelled(), which none of these do.
A receipt that selected a dependent without its dependency would therefore promise evidence that
never runs, which is the invariant 1 failure mode. Those edges are declared as the exported table
SUITE_PREREQUISITES (nightly-quality.yml declares no needs: at all, so its three suites carry
none), and closeUnderPrerequisites() closes every selection transitively inside buildReceipt(),
so the closure holds for every verdict and for any future selection path. Closure only adds, so it
can never shrink a full sweep. A group entry therefore names the suites whose evidence that surface
needs and does not have to repeat their dependencies. nightlyCoordinatorSuiteGraphErrors() checks
the table names only known suites and stays acyclic, and a test parses the needs: edges straight
out of both workflow files and asserts they equal the declared table, so a workflow that gains or
loses an edge fails the suite rather than silently drifting. CI10-2 owns keeping the two in step
when it wires the receipt into the workflows.

nightlyCoordinatorMappingErrors(policy) reconciles the table against the live policy in both
directions and a test enumerates it, so neither a new policy group with no mapping nor a dropped
policy group can pass unnoticed. At decision time a matched group with no mapping is a full-sweep
with group-not-in-suite-map, never an empty selection.

detectDuplicateQualification(treeSha, lastReceipt) is exported for CI-12 #2336 to consume as the
nightly half of its duplicate-qualification flag. It reports tree-sha-already-qualified when the
tree SHAs match, and distinguishes tree-sha-differs, no-last-receipt,
current-tree-sha-invalid, last-receipt-tree-sha-invalid and last-receipt-incomplete so the
consumer never has to guess why a match did not happen. An incomplete receipt is not a duplicate
because it qualified nothing.

The receipt is JSON with schemaVersion: 1 and kind: "nightly-plan", carrying both current SHAs
(or null with a reason), the last-qualified receipt or its unavailability reason, the sorted reason
ids, the duplicate flag and its reason, the weekly slot state, the diff availability and count, the
matched groups, the unmapped and control paths, the selected suites in canonical order, and the
skipped suites each with a reason. Per the task scope there is no ci/schemas entry in this slice:
the shape is specified in the module's JSDoc header, and CI10-2 adds the schema when it moves the
suite table into the policy. A markdown rendering for $GITHUB_STEP_SUMMARY is derived only from
the receipt, so it is deterministic too.

Measurement

Duplicate release qualification measured on 2026-09-04 from gh run list: ci-release.yml and
release-security.yml each ran twice for one tag only, v0.1.0 (push 03:13Z and release 03:22Z on
2026-08-19, both startup_failure for ci-release); v0.1.1, v0.1.2, v0.2.0 and v0.3.0-rc.1
each ran once on the push trigger, so the release-published trigger has not fired for a
workflow-created release since. CI Nightly has failed every scheduled night from 2026-08-26 to
2026-09-04 on exactly one job, E2E Cross-Browser Matrix / E2E (mobile-safari), issue #2180,
which the product lane now owns; Nightly Quality Signals succeeded on every one of those nights.

That measurement is the preflight the issue asks for before CI10-4 touches the release triggers. It
narrows the collapse: the double trigger is real in both workflow files, but it has produced a
measured duplicate for exactly one tag, so CI10-4 is a correctness fix against a latent trigger, not
a running cost. It also confirms the nightly failure the issue makes this lane's ownership is a
single job, not a broad nightly problem.

Docs to add after the pending SMART_CI.md changes land

docs/ci/SMART_CI.md is deliberately untouched here. Two other pending changes, PR #2506 and the
unpublished branch origin/issue-2326/smart-ci-recall-report (tip 3b291914a), already add rows to
its sections 10 and 11, and a third writer would collide. The rows below are formatted to match the
existing ones and should be added by whichever change lands last.

Section 10, Commands, after the Action pin inventory row:

| Decide tonight's nightly plan locally (pure, no git or network) | `node scripts/ci/smart-ci/nightly-coordinator.mjs --policy ci/policy.v1.json --head-sha <sha> --tree-sha <sha> [--last-receipt <file>] [--changed-files <one path per line>] --now <iso8601> [--weekly-slot <0-6>] [--force-full] --out artifacts/nightly-plan.json [--out-md artifacts/nightly-plan.md]` |

Section 11, File map, after the action-pins.mjs line:

scripts/ci/smart-ci/nightly-coordinator.mjs  nightly deep-suite selection and no-change receipt (CI-10)

The module's JSDoc header carries the full receipt shape and the CLI usage, so both lines are
derivable from the source if the wording needs to change.

Verification

Run from the worktree .worktrees/codex-2334-ci10-nightly-coordinator, on Node 24, at head
929331247:

  • node --test scripts/ci/smart-ci/nightly-coordinator.test.mjs - 24 tests, 24 pass, 0 fail.
  • node --test scripts/ci/smart-ci/*.test.mjs - 115 tests, 115 pass, 0 fail. The whole Smart CI
    suite is green, so the new module does not disturb the planner, gate, recall, estate, action-pin
    or merge-ref tests.
  • node scripts/check-docs-governance.mjs - "Docs governance check passed."
  • git diff --check - clean, exit 0.
  • Manual CLI smoke run with no last receipt: verdict full-sweep, reason last-receipt-missing, all
    twelve suites listed as run.

The test file covers exactly the head-start plan: no relevant change gives no-change naming both
SHAs; a backend-only change selects the backend suites and asserts e2e-cross-browser is absent;
the weekly slot forces weekly-full with an empty diff and again with an unchanged tree SHA;
missing, unreadable and incomplete last receipts each give full-sweep; an unreachable diff gives
full-sweep with diff-unavailable; duplicate qualification is detected including the
case-insensitive SHA form; an unmapped path and a control-path change each give full-sweep; two
calls with the same input produce byte-identical JSON and markdown, and input ordering does not move
a byte; the policy-group enumeration runs in both directions and proves a synthetic new group fails
closed; and the CLI test drives the real script through a temp directory, asserting the JSON file,
the markdown file, the appended step summary and stdout all agree.

Two cases were added in the fix round for the needs: closure. One parses the needs: edges out of
ci-nightly.yml and nightly-quality.yml and asserts they match SUITE_PREREQUISITES exactly,
that every one of the twelve deep suites is a real job in one of the two files, and that the parse
actually found the five documented edges rather than nothing. The other walks seven representative
diffs, including a docs-only no-change and a fail-closed full sweep, and asserts no receipt ever
selects a suite while listing one of its prerequisites as skipped. The guard was mutation-tested:
deleting the container-images edge from the table makes both cases fail, and restoring it makes
them pass, so they are not vacuous.

Not verified

  • Hosted-only qualification. This is the CI-control region (R4/T2, .claude/rules/ci-control.md):
    the proving check is the hosted run on this exact PR head, specifically the Smart CI / Planner
    Self-Test lane. Everything above is a local run and is additive only. The hosted result on this PR
    is the R4 proof.
  • No workflow behaviour changes in this slice, so there is nothing about nightly scheduling,
    skipping or gating to verify yet. The verdict is not consumed anywhere; CI10-2 is where a real
    nightly first reads it.
  • The verdict has never been checked against a real quiet night. The issue's rollout requires at
    least one week including one weekend of the coordinator logging its verdict beside the
    unconditional nightlies before it gates anything, and that observation has not started.
  • The measurement block was gathered by the coordinating session from gh run list and is cited as
    given; it was not re-run in this worktree.
  • The needs: skip semantics behind the closure are GitHub's documented rule, not something this
    slice observed at runtime: nothing here schedules a job, so no real nightly has demonstrated a
    dependent being skipped behind a skipped backend-solution. The dependency edges themselves were
    read directly from the two workflow files and are asserted by a test.
  • No backend, frontend or E2E checks were run. Nothing outside scripts/ci/smart-ci/ changed.

Risk notes

  • Two new files, no edits to any existing file. Nothing imports the module yet, so the blast radius
    of a defect in it is zero until CI10-2 wires it up.
  • The one real design risk is the group-to-suite table under-selecting: a group mapped to too few
    suites would let a real regression through on an affected night. It is mitigated by conservatism
    (groups select supersets), by every ambiguous or unrecognised case escalating to the full sweep,
    and by the enumeration test that fails when a policy group has no mapping. It is not eliminated:
    the table is a judgement about which suites can produce new evidence for a given surface, and
    CI10-2 should review it again when it moves into the policy where the digest covers it.
  • The table lives outside ci/policy.v1.json for now, so a policy change and a table change are two
    commits rather than one. The enumeration test is what keeps them in step until CI10-2 merges them.
  • matchGroups() is reused rather than reimplemented, so the coordinator inherits the planner's glob
    semantics exactly, including any future fix to them.
  • Under the maintainer's SC-10 review of the CI-control region, this PR is opened ready for review
    and is not merged by the authoring session.

Refs #2334
Refs #2324

CI-10 slice 1 (#2334): a pure decision over the last deep-qualified receipt, the
main head and tree SHAs, the diff since that receipt, the clock and the weekly UTC
slot. Verdicts are no-change, affected, weekly-full and full-sweep; every
fail-closed path selects the complete sweep with a stable reason id, and a quiet
night is an explicit receipt rather than a skipped workflow.

Path-to-group mapping reuses matchGroups() from lib/plan.mjs. The group-to-suite
table is a module constant that CI10-2 moves into ci/policy.v1.json. No workflow
behaviour changes in this slice.
The issue's head-start test plan (#2334): no-change with both SHAs, backend-only
selection without the browser matrix, the weekly slot overriding an empty diff,
missing / unreadable / incomplete last receipt, an unreachable diff, duplicate
qualification, an unmapped path, a changed control path, determinism, and CLI
argv parsing with file output through a temp directory.

Adds the enumeration test that asserts every ci/policy.v1.json path group has a
deep-suite mapping in both directions, so a new policy group cannot silently
select nothing.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

… edges

Five ci-nightly.yml jobs declare needs: backend-solution, so a receipt that selected e2e-smoke, e2e-cross-browser, container-images or the k6 suites without backend-solution promised evidence GitHub Actions would skip. Declare the needs edges as SUITE_PREREQUISITES, close every receipt selection under them, and enumerate the workflow edges in the tests so a new edge cannot drift away from the table.
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Fix round: one HIGH finding fixed, six non-blocking findings triaged

Head 929331247.

Fixed (HIGH): selection was not closed under the workflow needs: graph

An independent review found that selectedSuites could name a job the nightly workflow would skip.
ci-nightly.yml gives five jobs a needs: backend-solution edge (e2e-smoke,
load-concurrency-harness, performance-regression-gate, e2e-cross-browser, container-images),
and GitHub Actions skips a job whose needs: dependency was skipped unless its if: uses
always() or !cancelled(), which none of them do. Four selections violated that: frontend-src
and frontend-e2e (through FRONTEND_SUITES), containers-deploy (through DEPENDENCY_SUITES)
and load-and-evals. A frontend-only night would have promised e2e-smoke and e2e-cross-browser
as evidence that could not run, which is the SMART_CI invariant 1 failure mode.

The fix declares the edges as SUITE_PREREQUISITES and closes every selection transitively in
buildReceipt(), so the property holds for every verdict and any future selection path rather than
for the four entries that happened to be wrong today. Closure only adds suites, so it cannot shrink
a full sweep, and a group entry now names the evidence that surface needs without repeating its
dependencies.

Two tests were added. One parses the needs: edges straight out of ci-nightly.yml and
nightly-quality.yml and asserts they equal the declared table, that all twelve deep suites are
real jobs in those files, and that the parse found the five documented edges rather than nothing.
The other walks seven representative diffs and asserts no receipt selects a suite while listing one
of its prerequisites as skipped. Both were mutation-tested: deleting the container-images edge
makes them fail, restoring it makes them pass. The frontend expectation that asserted the omission
as correct was rewritten with a comment naming the edge.

node --test scripts/ci/smart-ci/*.test.mjs is 115 tests, 115 pass, 0 fail;
node scripts/check-docs-governance.mjs passes; git diff --check is clean.

Not fixed in this round

Per the review budget these are recorded rather than turned into a fix cascade. None of them is a
merge blocker in a slice that nothing consumes yet, and each names the slice that should own it.

  • MEDIUM, empty changed-file list with a differing tree SHA yields no-change instead of escalating.
    Real, and worth a diff-inconsistent guard, but the reachable path runs through how CI10-2 writes
    the diff file, so it belongs with that wiring.
  • MEDIUM, nowUtc accepted without a UTC designator, so a naive timestamp shifts the weekly weekday
    with the host timezone. Hosted runners are UTC and the workflow will pass a Z timestamp; the
    cheap fix is to require an explicit offset and it should land with CI10-3, which is the slice that
    makes the weekly slot load-bearing.
  • MEDIUM, frontend-src and frontend-e2e do not select container-images although the frontend
    image is built from that tree. A genuine coverage judgement about the table, not a defect in the
    decision logic, and the table moves into ci/policy.v1.json in CI10-2 where the digest covers it.
  • LOW, duplicateQualification can be true on a receipt the same run declares unreadable. Correct
    observation; the consumer is CI-12 and this should be settled when that flag is actually read.
  • LOW, markdownCell does not escape backticks the way renderPath in lib/plan.mjs does. The
    coordinator only renders paths already merged to main, so the effect is a garbled step summary.
  • LOW, the header says the CLI always exits 0 while an unknown flag and an unreadable changed-files
    path both throw. The non-zero exit is the safer behaviour; the sentence is what needs adjusting.
  • LOW, sast-scanning is selected only for auth-security and the dependency and container groups.
    The job runs advisory with enforce-findings: false, and it is the same table judgement as the
    container-images item above.

This is the CI-control region under SC-10: the PR is ready for review and is not merged by the
authoring session. The hosted run on this head is the R4 proof.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Review (agent half of the ADR-0066 gate; Codex credits exhausted, SC-9). Two fresh-context reviewer passes (read-only, Opus 5). This PR is CI-control (scripts/ci/**, T2): it stays parked for the maintainer's review under OUTSTANDING_TASKS SC-10 and is not merged by an agent.

Round 1 at f6a3ff6: verdict FIX_FIRST on one HIGH. selectedSuites was not closed under ci-nightly.yml's needs: backend-solution edges (e2e-smoke, load-concurrency-harness, performance-regression-gate, e2e-cross-browser, container-images all need it), so four groups (frontend-src, frontend-e2e, containers-deploy, load-and-evals) named suites that could not execute, and one test asserted that omission as correct. Fixed at 9293312: an exported SUITE_PREREQUISITES table plus closeUnderPrerequisites() applied inside buildReceipt() for every verdict, a drift guard that line-scans the needs: edges out of both nightly workflows and asserts they equal the table (exactly five edge-bearing jobs, all twelve suites real jobs), a closure test over seven representative diffs, and the rewritten frontend expectation. Mutation-proved: deleting one edge from the table reds two tests. Smart CI suite 115/115; module tests 24/24; docs governance green.

Round 2 (scoped to the fix diff) at 9293312: verdict SHIP. The closure is monotone (adds only), no suite can be both selected and skipped, the guard is non-vacuous and runs in the Planner Self-Test lane, and the module is imported by no workflow yet, so nothing it computes can select, skip or gate a job in this slice.

Confirmed by round 1: two new files only; determinism (no Date.now, Math.random, process.env or locale-dependent sorts; fixed key order; codepoint sorts); fail-closed on null/unreadable/incomplete receipt, null diff, unmapped path, control path, invalid policy, invalid SHAs, unparseable clock, invalid weekly slot, force-full and undefined input; all 23 policy path groups mapped with the enumeration checked both ways; the twelve suite ids match the real job ids.

Non-blocking findings, all recorded here and on #2334 for the slice that makes each reachable:

  1. MEDIUM, parked to CI10-2 (workflow wiring): an empty changed-file list with a tree SHA that differs from the last qualified tree yields no-change instead of escalating; the cross-check belongs with the code that writes the diff file.
  2. MEDIUM, parked to CI10-3 (weekly sweep): nowUtc without a UTC designator is parsed as local time, so the weekly weekday would be runner-timezone dependent; fix when the slot becomes load-bearing (accept only Z or an offset).
  3. MEDIUM, parked to CI10-2 (table moves into ci/policy.v1.json): frontend groups do not select container-images although the frontend image is built from frontend/taskdeck-web/.
  4. LOW, parked to CI-12 CI-12: CI receipts, weekly cost/critical-path/flake report and per-lane budget regression #2336: duplicateQualification can be true on a receipt the same run declares unreadable.
  5. LOW, parked to CI10-2: markdownCell escapes less than lib/plan.mjs renderPath; reuse it when the module is wired.
  6. LOW, parked to CI10-2: the header sentence "the CLI always exits 0" is contradicted by the unknown-flag and unreadable-changed-files paths; the non-zero exit is the safer behaviour, the sentence is the defect.
  7. LOW, parked to CI10-2: sast-scanning is selected only for auth-security and the dependency/container groups (advisory job today).
  8. LOW (round 2): a test comment says "three groups" over a loop of four.

Merge gate: maintainer review (SC-10) plus hosted Smart CI / Planner Self-Test and ci-required green at 9293312.

@Chris0Jeky
Chris0Jeky merged commit 08e4823 into main Sep 6, 2026
35 checks passed
@Chris0Jeky
Chris0Jeky deleted the issue-2334/ci10-nightly-coordinator branch September 6, 2026 13:26
@github-project-automation github-project-automation Bot moved this from Pending to Done in Taskdeck Execution Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant