Skip to content

fix(ci): bound every job and step in this repo's workflows, and keep the bound enforced - #184

Merged
github-actions[bot] merged 2 commits into
mainfrom
feat/workflow-step-timeouts-162
Oct 25, 2026
Merged

github-actions[bot] merged 2 commits into
mainfrom
feat/workflow-step-timeouts-162

Conversation

@aitrawog

@aitrawog aitrawog commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Every step and every job in this repo's own deploy workflow now carries a deadline, and a dependency-free control keeps it that way. Head 21b923dc2b7b8bc91f3829566739aa121037a6ca — 3 files changed, +970 -0.

The defect, in one sentence: the deploy workflow carried no timeout-minutes on any of its 16 steps and no total deadline on its one job. A step with no bound cannot fail — it can only hang, until the runner's own six-hour limit, and the failure it produces names the JOB, never the step. That is exactly the remedy opencharly/docs#162 names: "a total deadline and a named step boundary, so a stall becomes a time-bounded failure that says where it died."

What this change is. Three files, one idea:

  • .github/scripts/check_step_timeouts.py (new, 718 lines) — the control. It reads this repo's workflow files with a dependency-free indentation reader (the repo carries no YAML parser: package.json holds astro, starlight, sharp and wrangler) and asserts that every job has a total deadline and every step has a step deadline shorter than its job's — a step bound at or above its job's is dead code, because the job's deadline fires first and the step never gets to name itself. It ships with a --self-test mode, and both are wired into deploy.yml as its first three steps: the check before the expensive work, then two proofs that the check can fail.
  • .github/scripts/test_check_step_timeouts.py (new, 178 lines) — the mutation harness. It removes each of the checker's five mechanisms in turn and asserts that the self-test then turns RED on the fixture that covers exactly that mechanism; a mutant that still passes is a mechanism the self-test does not reach, and the harness fails on it. Each mutation is an asserted single-string replacement — the anchor must occur exactly once in the committed source, so a moved anchor fails loudly instead of masquerading as a passing mutation.
  • .github/workflows/deploy.yml — the deploy job's total deadline (60 min) and a deadline on each of its 19 steps.

The scope decision, and why the copy is not edited here. One workflow file in this repo is not this repo's to fix: .github/workflows/tag-on-merge.yml is a distributed copy of opencharly/.github's canonical template (.github/workflows/tag-on-merge-dispatcher.yml), which that repo's bootstrap installs into every repo and whose reconcile script binds the copies back to it. A deadline added to the copy would duplicate the template's rule (R2) and would be erased by the org's own reconcile pass — a bound that the org's tooling can silently revert is not a bound. The copy is therefore restored to its pre-change content, and the checker REPORTS it — naming the owner and both tracking issues — instead of asserting it, because a skip that says nothing is a gap nobody can see. A path named explicitly on the command line is still asserted, so the copy's three unbounded surfaces — two jobs and one step — stay one command away (pasted in How tested below).

The bounds are sized from the measured runs, not chosen. The measurement window is FIXED and dated, because a sliding window moves under the numbers written next to it: over 2026-09-22..2026-10-24 (56 successful runs) the job's median is 10.62 min and its longest is 15.15 min, on 2026-09-22 — and that 15.15 min is also the longest of all 583 successful runs in the retained history, back to 2026-08-03. The job's 60-minute total is 4.0x that maximum; the Regenerate step's 30-minute bound is 2.8x the 10.82 min that step has ever taken across the same 56 runs. Real work does not trip either bound, and both fire long before the runner's own six-hour limit.

The interlock with the org validator. The deploy check is one of the checks the org validator's arm job waits for, and that job has been bounded since 2026-10-23 (opencharly/.github#236): the arm job at 75 minutes, its head-settle step at 70. This job's total is 60 — under the validator's wait — so a stall in this workflow now fails, and says WHICH step, before the validator gives up waiting for the head to settle. The deadlock's other half is therefore already landed; this PR is the docs-repo half of it.

What this PR does NOT claim. It does not claim to have reproduced the over-an-hour stall the issue describes. The runs that issue names have since completed, and each of them finished well inside an hour:

$ for id in 38860148526 38929233516 38929233966; do gh api repos/opencharly/docs/actions/runs/$id --jq ...; done
run 646 [docs] attempt=2 head=9cefc2654df8 failure  2026-10-16T06:15:06Z -> 2026-10-16T06:29:00Z
run 651 [docs] attempt=1 head=8d3005c7a974 failure  2026-10-16T16:05:57Z -> 2026-10-16T16:14:03Z
run 51 [charly/pr-validator] attempt=2 head=8d3005c7a974 failure  2026-10-16T16:05:57Z -> 2026-10-16T16:14:19Z

So the stall's own record is two checks in flight at once, each of which concluded inside 14 minutes — and across all 56 successful runs in the fixed window the Regenerate step never exceeded 10.82 minutes. What the issue measured is the shape the bound has to answer, not a single step that never returned. The claim this PR makes is the missing CONTROL, not a reproduction of the stall: every one of those steps ran with no deadline, so the next occurrence of that shape has nothing to fail against until this lands.

And one honest limit of the mechanism, stated rather than papered over. A job's timeout-minutes starts when the job STARTS RUNNING; it does not bound the time the job spends queued for a runner. The retained history shows that shape on the validator's own side — its run 48 was created at 06:57:53Z and its job did not start until 08:18:53Z, 81 minutes later:

$ gh api repos/opencharly/docs/actions/runs/38864231962 --jq ...
created 2026-10-16T06:57:53Z  head 15541bc9ed5a  conclusion failure

$ gh api repos/opencharly/docs/actions/runs/38864231962/jobs --jq ...
job validate / validate created 2026-10-16T08:18:51Z started 2026-10-16T08:18:53Z completed 2026-10-16T08:20:59Z conclusion failure

So the bound this PR adds is a bound on the WORK, and the queue wait that produced the 81-minute stall is a different thing — owned by the runner, not by this workflow, and already bounded on the validator's side by the 70-minute head-settle step.

How tested

Every block below is a verbatim slice of the run that gated the committed tree, 21b923dc2b7b8bc91f3829566739aa121037a6ca, in this repository's worktree.

The control, on the committed tree — exit 0, and the org-distributed copy is reported rather than asserted:

check-step-timeouts: OK (1 workflow file(s) asserted; every job and every step carries a deadline)
note: .github/workflows/tag-on-merge.yml: not asserted here - a distributed copy of opencharly/.github's tag-on-merge-dispatcher.yml; its deadline belongs in that template (opencharly/.github#256), and the copy-to-template binding is opencharly/.github#192
rc=0

The checker's own self-test — exit 0:

check-step-timeouts self-test: OK (unbounded steps, jobs and traps are all caught, and the org-distributed copy is reported)
rc=0

The mutation harness: five mechanisms removed, one at a time, and the self-test turns red on each — exit 0:

ok  mutA-nomark — the block-scalar marking removed, and the self-test turned red on it
ok  mutB-crossjob — the cross-job step bound removed, and the self-test turned red on it
ok  mutC-jobsend — the jobs-mapping end bound removed, and the self-test turned red on it
ok  mutE-deadbound — the dead-step-bound rule removed, and the self-test turned red on it
ok  mutF-orgdistributed — the org-distributed exclusion removed, and the self-test turned red on it
test_check_step_timeouts: OK (5 mutants; every one of them turns the self-test red)
rc=0

The coverage, which fails without the change. The same checker, run against origin/main's deploy.yml — 17 findings (1 job, 16 steps), exit 1:

$ python3 .github/scripts/check_step_timeouts.py .github/workflows/deploy.yml   # origin/main's deploy.yml, in a tree that mirrors this repo's layout
check-step-timeouts: FAILED
- .github/workflows/deploy.yml: job 'deploy' the job has no total deadline
- .github/workflows/deploy.yml: step 1 ('uses: actions/checkout@v7') in job 'deploy' the step has no deadline
- .github/workflows/deploy.yml: step 2 ('name: Test the corpus-currency report') in job 'deploy' the step has no deadline
- .github/workflows/deploy.yml: step 3 ('name: Report the corpus pin's currency') in job 'deploy' the step has no deadline
- .github/workflows/deploy.yml: step 4 ('name: Fetch the pinned charly source (CI-time temp checkout, recursive for box/*)') in job 'deploy' the step has no deadline
- .github/workflows/deploy.yml: step 5 ('uses: actions/setup-node@v7') in job 'deploy' the step has no deadline
- .github/workflows/deploy.yml: step 6 ('name: Fetch the pinned docs generator') in job 'deploy' the step has no deadline
- .github/workflows/deploy.yml: step 7 ('uses: actions/setup-go@v7') in job 'deploy' the step has no deadline
- .github/workflows/deploy.yml: step 8 ('name: Build the standalone plugin-docs CLI binary') in job 'deploy' the step has no deadline
- .github/workflows/deploy.yml: step 9 ('name: Test the generated-content drift gate') in job 'deploy' the step has no deadline
- .github/workflows/deploy.yml: step 10 ('name: Write the corpus wiring candy from the pins file') in job 'deploy' the step has no deadline
- .github/workflows/deploy.yml: step 11 ('name: Regenerate the generated content from the pinned charly') in job 'deploy' the step has no deadline
- .github/workflows/deploy.yml: step 12 ('name: Drift gate — the committed content must match the pinned charly') in job 'deploy' the step has no deadline
- .github/workflows/deploy.yml: step 13 ('name: Install dependencies') in job 'deploy' the step has no deadline
- .github/workflows/deploy.yml: step 14 ('name: Build') in job 'deploy' the step has no deadline
- .github/workflows/deploy.yml: step 15 ('name: Verify the build produced a site') in job 'deploy' the step has no deadline
- .github/workflows/deploy.yml: step 16 ('name: Deploy to Cloudflare Pages') in job 'deploy' the step has no deadline
rc=1

The org-distributed copy, asserted EXPLICITLY rather than skipped — its three unbounded surfaces (two jobs and one step), exit 1:

check-step-timeouts: FAILED
- .github/workflows/tag-on-merge.yml: job 'wait-for-merge' the job has no total deadline
- .github/workflows/tag-on-merge.yml: step 1 ('name: Wait for triggering PR to merge (bounded readiness probe)') in job 'wait-for-merge' the step has no deadline
- .github/workflows/tag-on-merge.yml: job 'tag' the job has no total deadline
rc=1

An independent reading of the same files. The checker parses YAML with its own indentation reader; PyYAML is a second, independent reading of the same bytes, and the two must agree on the IDENTITY of every finding (kind, job, step), not merely on the count. Over 15 workflow files — this repo's two, and the opencharly/.github repo's own 13, fetched fresh over HTTPS by the first line of the block — 0 mismatches, exit 0. This cross-check is an author-side verification and NOT a committed CI step: this repository carries no YAML dependency, and adding PyYAML to the workflow to prove a dependency-free checker would put the dependency back in. The tool that produced the block is author-side and is not committed to this repository, so its full source is pasted here — the reader can save it and re-run the command, and the file set is re-gatherable by the first line:

"""Cross-check the checker's FINDINGS against PyYAML's own reading of the same files.

The checker reads YAML with its own indentation reader (the repo carries no YAML
parser); PyYAML is an INDEPENDENT reading of the same bytes. The two must agree
on the IDENTITY of every finding (kind, job, step), not merely on the count.
"""
import importlib.util, pathlib, sys
import yaml

import os
spec = importlib.util.spec_from_file_location("chk", os.environ.get("CHK", ".github/scripts/check_step_timeouts.py"))
chk = importlib.util.module_from_spec(spec); spec.loader.exec_module(chk)

def yaml_ids(text):
    doc = yaml.safe_load(text); out = []
    for jname, job in (doc.get("jobs") or {}).items():
        if not isinstance(job, dict): continue
        jm = job.get("timeout-minutes")
        if not isinstance(jm, int): out.append(("job", jname, None))
        for i, s in enumerate(job.get("steps") or [], 1):
            if not isinstance(s, dict): continue
            sm = s.get("timeout-minutes")
            if not isinstance(sm, int): out.append(("step", jname, i)); continue
            if isinstance(jm, int) and sm >= jm: out.append(("dead", jname, i))
    return out

def mine_ids(text):
    out = []
    for m in chk.check_text(text, "f"):
        if "its deadline" in m and "is dead" in m: out.append(("dead", m))
        elif ": step " in m: out.append(("step", m))
        elif ": job " in m: out.append(("job", m))
    return out

def ident(kind, msg):
    parts = msg.split("'")
    job = msg.split("job '")[1].split("'")[0]
    if kind == "job": return ("job", job, None)
    step = int(msg.split("step ")[1].split(" ")[0])
    return (kind, job, step)

files = [pathlib.Path(p) for p in sys.argv[1:]]
mismatches = 0
for p in files:
    text = p.read_text()
    mine = sorted(ident(k, m) for k, m in mine_ids(text))
    theirs = sorted(yaml_ids(text))
    if mine != theirs:
        mismatches += 1
        print(f"MISMATCH {p}")
        print(f"   mine: {mine}")
        print(f"   yaml: {theirs}")
print(f"files compared: {len(files)}, mismatches: {mismatches}")
sys.exit(1 if mismatches else 0)
$ cd /tmp/pin904/xwf && gh api repos/opencharly/.github/contents/.github/workflows --jq ... | xargs -n1 curl -sS -O   # the org repo's own 13 workflow files, fetched fresh
$ python3 /tmp/pin904/crosscheck.py .github/workflows/deploy.yml .github/workflows/tag-on-merge.yml /tmp/pin904/xwf/*.yml
files compared: 15, mismatches: 0
rc=0

The cross-check's negative control. With the checker's cross-job step bound removed (the mutB mutant, emitted from the committed harness), the same cross-check finds a mismatch — in the org's own pr-validator.yml, whose validate job's twelfth step the mutant reads as unbounded and PyYAML does not — and exits 1, so a clean cross-check means something. The paths are scratch paths: the harness writes its mutants into a temporary tree that MIRRORS this repo's layout, because the checker derives its repository root from its own path, and a mutant written anywhere else would compute a different root and turn the checks red for the wrong reason:

$ python3 .github/scripts/test_check_step_timeouts.py --emit mutB-crossjob /tmp/pin904/negctl/.github/scripts/check_step_timeouts.py
emitted /tmp/pin904/negctl/.github/scripts/check_step_timeouts.py — the cross-job step bound removed
rc=0

$ CHK=/tmp/pin904/negctl/.github/scripts/check_step_timeouts.py python3 /tmp/pin904/crosscheck.py .github/workflows/deploy.yml .github/workflows/tag-on-merge.yml /tmp/pin904/xwf/*.yml
MISMATCH /tmp/pin904/xwf/pr-validator.yml
   mine: [('step', 'arm', 2), ('step', 'lint', 1), ('step', 'lint', 2), ('step', 'lint', 3), ('step', 'lint', 4), ('step', 'validate', 1), ('step', 'validate', 2), ('step', 'validate', 4), ('step', 'validate', 5), ('step', 'validate', 6), ('step', 'validate', 7), ('step', 'validate', 8), ('step', 'validate', 9), ('step', 'validate', 10), ('step', 'validate', 11)]
   yaml: [('step', 'arm', 2), ('step', 'lint', 1), ('step', 'lint', 2), ('step', 'lint', 3), ('step', 'lint', 4), ('step', 'validate', 1), ('step', 'validate', 2), ('step', 'validate', 4), ('step', 'validate', 5), ('step', 'validate', 6), ('step', 'validate', 7), ('step', 'validate', 8), ('step', 'validate', 9), ('step', 'validate', 10), ('step', 'validate', 11), ('step', 'validate', 12)]
files compared: 15, mismatches: 1
rc=1

This repo's own existing tests, unchanged by this PR — exit 0:

  PASS  the pin is at the corpus's merged main
  PASS  the pin is at the corpus's merged main — the report exits 0 (a report must never fail the build)
  PASS  the pin is at the corpus's merged main — the report does not call a squash-advance a divergence
  PASS  the pin is one commit behind
  PASS  the pin is one commit behind — the report exits 0 (a report must never fail the build)
  PASS  the pin is one commit behind — the report does not call a squash-advance a divergence
  PASS  the pin is off the corpus's main line
  PASS  the pin is off the corpus's main line — the report exits 0 (a report must never fail the build)
  PASS  the pin is off the corpus's main line — the report does not call a squash-advance a divergence
  PASS  the corpus is not materialized
  PASS  the corpus is not materialized — the report exits 0 (a report must never fail the build)
  PASS  the superproject records no corpus pin
  PASS  the superproject records no corpus pin — the report exits 0 (a report must never fail the build)
  PASS  the superproject records no corpus pin
  PASS  the superproject records no corpus pin — the report exits 0 (a report must never fail the build)
  PASS  the corpus's remote main cannot be fetched
  PASS  the corpus's remote main cannot be fetched — the report exits 0 (a report must never fail the build)
  PASS  the corpus's history is shallow and cannot be deepened
  PASS  the corpus's history is shallow and cannot be deepened — the report exits 0 (a report must never fail the build)
  PASS  the corpus is a depth-1 checkout at a pin that is behind
  PASS  the corpus is a depth-1 checkout at a pin that is behind — the report exits 0 (a report must never fail the build)
  PASS  the corpus is a depth-1 checkout at a pin that is behind — the report does not call a squash-advance a divergence
test_check_corpus_currency: OK (every state is reported, and none of them fails)
rc=0
test_clean_content (__main__.DriftGateTest.test_clean_content) ... ok
test_deleted_content (__main__.DriftGateTest.test_deleted_content) ... ok
test_modified_content (__main__.DriftGateTest.test_modified_content) ... ok
test_staged_content (__main__.DriftGateTest.test_staged_content) ... ok
test_unrelated_change (__main__.DriftGateTest.test_unrelated_change) ... ok
test_untracked_route_without_tracked_diff (__main__.DriftGateTest.test_untracked_route_without_tracked_diff) ... ok

----------------------------------------------------------------------
Ran 6 tests in 0.040s

OK
rc=0

The artifact check: no stray files, clean tree, and the org-distributed copy byte-identical to origin/main again.

$ git diff --stat origin/main -- .github/workflows/tag-on-merge.yml   # the org-distributed copy, against origin/main
(no output above = the copy is byte-identical to origin/main again)

$ find .github/scripts -name "__pycache__" -o -name "*.pyc"
(no output above = no stray artifacts)

$ git status --porcelain
(no output above = the tree is clean)

Every count this body states, read by an INDEPENDENT YAML reader from the committed file (the checker has its own indentation reader; PyYAML is a second reading of the same bytes):

$ python3 -c "yaml.safe_load(open('.github/workflows/deploy.yml'))"   # an independent reader of the COMMITTED deploy.yml
jobs: 1
  job 'deploy': job timeout-minutes=60, steps=19, steps carrying a deadline=19
tag-on-merge.yml (the org-distributed copy — reported, not asserted):
  job 'wait-for-merge': job timeout-minutes=None, steps=1, uses=None
  job 'tag': job timeout-minutes=None, steps=0, uses='opencharly/.github/.github/workflows/tag-on-merge.yml@main'

The measurements the bounds are sized from — the deploy job's total over a FIXED window, the same over the WHOLE retained history, the Regenerate step over the same 56 runs, and this repo's own 141 successful runs of the caller that invokes the org's reusable workflow (whose tag and wait-for-merge jobs are the ones the TEMPLATE must bound). The fixed window ends on 2026-10-24, so the runs that land after it cannot move the numbers written next to them; the whole-history figure (583 runs, first successful run 2026-08-03) includes them, and its maximum is the same run:

$ gh api --paginate repos/opencharly/docs/actions/workflows/deploy.yml/runs?per_page=100 --jq ... | awk (the FIXED window 2026-09-22..2026-10-24)
runs: 56 min: 5.98 median: 10.62 max: 15.15

$ the same measurement over the WHOLE retained history (the first and last successful run dates are printed too)
runs: 583 min: 0.65 median: 0.92 max: 15.15
first successful run: 2026-08-03   last successful run: 2026-10-25

$ the Regenerate step, over the same 56 runs
runs: 56 min: 3.33 median: 7.10 max: 10.82

$ gh api --paginate repos/opencharly/docs/actions/workflows/tag-on-merge.yml/runs?per_page=100 --jq ...   # THIS repo's own caller of the org's reusable workflow
successful runs: 141 min: 0.10 median: 0.33 max: 5.55

$ its tag / tag-and-changelog job, over the SAME successful runs
tag jobs: 141 min: 0.05 median: 0.13 max: 1.88

$ its wait-for-merge job, over the same successful runs
wait-for-merge jobs: 124 min: 0.03 median: 0.07 max: 5.23
MEASURE_EXIT=0

The org-wide exposure, re-measured at this head with this PR's own committed checker. The 8 repos of the filed issue, their 27 workflow files fetched fresh over HTTPS: the same 174 findings — 139 unbounded steps, 35 unbounded jobs — and still no dead bound (a step bound at or above its job's) anywhere in them:

$ for r in .github charly marketplace plugin-task spec layer-charly-internals opencharly plugin-check; do mkdir -p /tmp/pin904/org8/$r; gh api "repos/opencharly/$r/contents/.github/workflows" --jq '.[] | select(.name|endswith(".yml")) | .download_url' | xargs -n1 -I{} curl -sS -o /tmp/pin904/org8/$r/$(basename {}) {}; done   # the 8 repos' workflow files, fetched fresh over HTTPS
files fetched: 27

$ python3 .github/scripts/check_step_timeouts.py $(find /tmp/pin904/org8 -name "*.yml")   # the org-wide exposure, measured with THIS PR's committed checker
findings: 174   steps with no deadline: 139   jobs with no total deadline: 35   dead bounds: 0
rc=1

The org's own read-only reconcile tool, run over the org, which is what makes the copy's owner the right place for the bound — one template edit reaches every copy (418 repos, 415 of them drifted). It was run from a reconstruction of opencharly/.github's scripts/ and its canonical template. Both files are byte-identical to the org repo's own: the template's blob sha is 375cb1edcff9 (the org repo's own contents sha for that path) and the script's sha256 is b9ce1b918c8bf1d1c3a96996bb0477ce79ace540d5f0872056579157e39e1055, the sha256 of scripts/reconcile-tag-on-merge-callers.sh fetched from the org repo's main. So the reconstruction is the same file the tool reads in place, and the run below is the org's own tool over the org. Its report block, verbatim:

$ bash scripts/reconcile-tag-on-merge-callers.sh verify   # the org repo's own script, run from a reconstruction whose canonical template blob is byte-identical to the org repo's (375cb1edcff9) and whose script sha256 is b9ce1b91... (the org repo's own)
reconcile-tag-on-merge-callers: mode=verify
  canonical template : .github/workflows/tag-on-merge-dispatcher.yml  (blob 375cb1edcff9)
  canonical pin      : actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3
  repos examined     : 418
  repos allow-list   : <all discovered>

  COLUMN 1 — pin (the T3 / Node-20 question)
    pin-mutable (still unpinned)  : 408
    pin-pinned  (immutable)       : 10
    pin-no-uses (no such step)    : 0
    caller-absent                 : 0
  COLUMN 2 — prose (the template-revision question)
    prose-canonical               : 3
    prose-drifted                 : 415
  BOTH (mutable pin AND drifted prose) : 408
  NEITHER (pinned pin AND canonical prose) : 3

The same run continued — the 56 repos discover_repos could not see (archived, fork, or a non-main default), then the per-repo pin-mutable and prose-drifted lists, and finally its closing note:

  (read-only run — nothing written. --apply brings every DIFFERING caller to canonical;
   caller-absent and could-not-see are reported only, by design.)
rc=0

The change itself, and the commit the evidence belongs to:

$ git diff --shortstat origin/main...HEAD
 3 files changed, 970 insertions(+)

$ git rev-parse HEAD
21b923dc2b7b8bc91f3829566739aa121037a6ca

Rulebook compliance

  • R0 — the skills the dispatcher selects for this change were loaded before the change's first edit: /charly-internals:git-workflow (every git and gh action here), /charly-internals:strict-policy and /charly-internals:root-cause-analyzer (the review round's findings and the RCA in the round's PR comment), /charly-tools:docs-site and /charly-internals:skills. The harness cannot resolve /charly-build:docs ("Unknown skill"), so it was read by path from the corpus instead — marketplace/build/skills/docs/SKILL.md — which is the fallback the rulebook states for a harness that cannot resolve the /family:skill form. It was read during this round, before this round's first edit, and it confirmed the scope of the change: the docs repo's deploy.yml is the sole owner of generation, build and publish, and no content or generated page is touched here.
  • R1 — the review round's finding (a miscount in this PR's own first body) received a fresh RCA before the fix; the RCA, the mechanism and the MISSED CONTROL are in the round's PR comment. The round's second finding — that the org-distributed copy must not be hand-edited — is a blocking finding of this change and is fixed here, not filed. The three non-blocking findings this change surfaces (the canonical template's own three unbounded surfaces, the copy's prose drift, and the linter arm that would have caught the miscount) are filed under ## Filed issues.
  • R2 — one implementation of the rule: the deadline check lives in .github/scripts/check_step_timeouts.py and is not re-implemented in the workflow, in the copy, or in a second checker; the org-distributed copy is deliberately NOT given its own copy of the rule, which is the duplication R2 forbids.
  • R3 — no workaround: no sleep, no retry, no suppression, no fallback branch. The bound is a real deadline, and the control fails the build when one is missing.
  • R4 — the product was fixed before the prose: the rule lives in the workflow and in the control that enforces it; the comment that explains it says what the bound is for, and the copy's owner is named where the copy lives.
  • R5 — the legacy path is deleted in the same cutover: the deadline added to the copy in the first commit of this PR is gone, and the copy is byte-identical to origin/main again — proved by the claim-keyed diff pasted in the artifact check above (git diff --stat origin/main -- .github/workflows/tag-on-merge.yml prints nothing). No shim, alias or stale deadline survives in the tree.
  • R6 — git status was stated before every mutation; the branch is feat/workflow-step-timeouts-162 in a session worktree at .worktrees/docs162/docs; no force-push, no bypass, no push to main, and no file outside this repo touched.
  • R7 — the coverage fails without the change: the same checker on origin/main's deploy.yml reports 17 findings and exits 1 (pasted above), and the mutation harness proves the self-test can fail. The changed paths were executed live against the real workflow files, with the output and the exit codes retained above.
  • R8 — no mocked service: the check reads real workflow files and the measurements come from the real run history through the public API; nothing here pretends to be a service it is not.
  • R9 — N/A — this change touches no Go source and no binary, so there is no worktree-local build to stamp.
  • R10 — the gate for this change class is this repository's own workflow plus the org-wide charly/pr-validator; this repo carries no per-repo candy gate and no disposable: true target. The part of that workflow this change adds — the deadline check, its self-test and the mutation harness — was run on the committed tree above, with its output and exit codes retained, as were this repo's two existing test suites. The workflow's other half (the pinned charly fetch, the regeneration, the site build, the deploy) is not reproducible outside CI: it clones and builds the pinned toolchain, and it is proven by this PR's own deploy check, which is PENDING at this head.

The change is exactly these three files, and nothing else:

$ git diff --name-only origin/main...HEAD
.github/scripts/check_step_timeouts.py
.github/scripts/test_check_step_timeouts.py
.github/workflows/deploy.yml

Change classification

  • Change class: CI configuration and a CI control script. No product runtime, no schema, no generated artifact, no pin.
  • Attribution tier: analysed on a live system — the changed paths (the checker, the self-test, the mutation harness) ran live against this repository's real workflow files with their output and exit codes retained above, and the numbers the bounds are sized from come from this repository's real run history. The tier is not fully tested and validated because the workflow's own live CI run at this head is the PR's pending deploy check, not a result this body can paste. The round-1 head's deploy check ran the whole workflow green in CI, but it did not include the mutation-harness step this round added, so it is evidence about the round-1 head and not about this one.
  • R10 gate for this class: the repository's own deploy workflow and the org-wide charly/pr-validator. Of the workflow, the part this change adds — the deadline check, its self-test and the mutation harness — ran on the committed tree with its output and exit codes retained above, as did this repo's two existing test suites; the workflow's other half (the pinned charly fetch, the regeneration, the site build, the deploy) is not reproducible outside CI, and it is proven by this PR's own deploy check, which is PENDING at this head. The org-wide charly/pr-validator is the gate that is also PENDING at this head.

Filed issues

  • Every workflow step in the org carries a deadline - 139 unbounded steps and 35 unbounded jobs across 27 files in 8 repos .github#256 — the canonical template's own unbounded surfaces: the wait-for-merge job and its one step, and the tag job, carry no deadline. The bound belongs in the template, and the template is read by 418 repos (415 of them drifted from it, per the org's own reconcile report above). This repo's own 141 successful runs of the caller are the measurement pasted above: the whole run's maximum is 5.55 min, the tag / tag-and-changelog job's maximum is 1.88 min, and the wait-for-merge job's maximum is 5.23 min over the 124 runs that have one — so a bound in the template can be sized from data rather than guessed. That is this repo's population, not the org's; the org-wide population is that issue's own sweep to gather. The issue's per-repo table reproduces exactly at this head: 174 findings across 27 files in those 8 repos, 139 unbounded steps and 35 unbounded jobs, and no dead bound anywhere (pasted above).
  • R5: the per-repo tag-on-merge callers still run actions/github-script@v7 (Node 20, mutable) — #188's removal stopped at this repo .github#192 — this repo's copy of the caller is drifted from the canonical template, which that issue's own reconcile report classifies as a measurement rather than noise: the copy differs for a second reason now (it carries no deadline of its own, and the round-1 commit of this PR showed what happens when a deadline is added to a copy that the reconcile pass owns).
  • pr_body_lint does not check a fenced list declaration against its own enumeration (a "10 entries" fence can list 2) marketplace#518 — the linter arm that would have caught this PR's own round-1 miscount. The body linter polices a fence's SHAPE (ellipsis, placeholder) and never its ARITHMETIC: it has no rule comparing a count a body states against the count the body itself enumerates. The round-1 body said "2 jobs and 16 steps in deploy.yml" and pasted the output that shows one job — the body's own evidence disproved its sentence, and only the live validator caught it. That issue's proposed rule targets a count declared INSIDE a fence; the variant measured here declares the count in PROSE and enumerates it in a fence, so the same root cause ("a count and its enumeration are two representations of one fact") needs a second arm on the same rule. The evidence is added to that issue rather than filed as a new one, so the rule has one home.

Agent: docs-step-deadlines

Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (analysed on a live system)

…the bound enforced (#162)

The deploy workflow carried no `timeout-minutes` on any of its 16 steps and no
total deadline on its job; the tag-on-merge dispatcher carried none on its 2
jobs or its 1 step. A step with no bound cannot fail - it can only hang, until
the runner's own six-hour limit, and the failure it produces names the JOB,
never the step. That is the control this issue's remedy names: "a total
deadline and a named step boundary, so a stall becomes a time-bounded failure
that says where it died".

Every job and every step now carries a deadline, sized from the measured runs
(55 successful runs of this workflow, 2026-09-22..2026-10-24: 6.0-13.9 min
total, median 10.6; the Regenerate step, which is the whole cost of the job,
3.3-10.8 min, median 7.1). A dependency-free checker in .github/scripts/
asserts the rule on this repo's workflows and runs in CI, and its --self-test
proves it rejects an unbounded step - including a `timeout-minutes:` written
inside a `run:` body, which is text to the shell and satisfies nothing.

The over-an-hour stall the issue quotes is NOT reproduced here: the runs it
names have since completed, each inside 14 minutes, and the deploy's
Regenerate step never exceeded 10.8 minutes across all 55 successful runs.
What the issue measured was two runs in flight at once. This change is the
missing CONTROL, not a reproduction of the stall - and it is the control that
makes the next occurrence a named, bounded failure.

The deadlock's other half was already bounded in opencharly/.github since
2026-10-23 (the validator's `arm` job, 75 min, and its head-settle step,
70 min), so the two interlock: this job's total is 60 minutes and the
validator's wait is 70, so a stall here fails - and says which step - before
the validator gives up waiting for the head to settle.

Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (analysed on a live system)
@github-actions

Copy link
Copy Markdown

validator INCONCLUSIVE — no review verdict was produced (not a BLOCK; no code finding)

The gate could not obtain a review verdict on this run, so it produced no code finding. This is not a BLOCK and the PR is not being rejected — but the required check stays RED on purpose: unreviewed code must never merge.

  • class: the engine's own terminal line names the class (this workflow keeps no narrative for this marker, so it DEFERS to the line): inconclusive: the review produced no Verdict line — this is NOT a review verdict (the required check stays RED)
  • Verdict class: eval (counts toward AI_REVIEW_AUTO_CLOSE_AFTER)
  • review exit code: 1
  • in-job retries: none — the gate REPORTS the class instead of papering over it (R3)

Root cause (measured in this run). The engine classified the run ITSELF, and its terminal line is quoted below. For a marker this workflow has no bespoke narrative for, the gate does not re-derive a cause from a signature: the engine's own line IS the diagnosis, and the instruction that line carries (every engine class names the knob to change) is the remedy. A signature heuristic must never overwrite what the engine stated.

Measured in this run (the engine's own lines):

request — reasoning_effort="low" max_tokens=393216 attempt_timeout=15m0s idle=3m0s sampling=(temperature=1 top_p=0.95 frequency_penalty=0.5 presence_penalty=1) context_bytes=(system=2857 user=56064) files=3 comments=0
inconclusive: the review produced no Verdict line — this is NOT a review verdict (the required check stays RED)

Durable remedy.

  1. act on the instruction in the engine's quoted line above — it names the knob and the direction;
  2. if the marker is one the gate does not recognise, this workflow's decision table is missing a row: report the marker so the table learns it (a new engine class is a contract change, not a wording change).

Thread policy. A thread gets up to AI_REVIEW_AUTO_CLOSE_AFTER counting verdicts (BLOCK + engine/eval INCONCLUSIVE) before the auto-close step closes it. A clear INFRA failure does NOT count, so this thread stays open for a fresh attempt.

A run that produced no verdict keeps this required check RED: unreviewed code must never merge.

Full diagnostics: this run's evidence artifact + job log (not embedded here — embedding a runaway log would feed it into the next review and deepen the collapse).

@aitrawog

Copy link
Copy Markdown
Contributor Author

STATUS — the round's findings are fixed at head 4ec38ee…, the scope narrowed to the workflows this repo owns, and the RCA (mechanism, blast radius, missed control, root fix) is below.

Agent: docs-step-deadlines

Head: 4ec38ee090d7f1f3becbcbaf004f22bc608eace7 (was c199564f206976c989ebf1408e2835bab44f00e6). 3 files changed, +970 -0. The body is rewritten for this head and the linter reports 0 findings on it.

What the validator actually did on the pushed head

The only validator event on c199564f was INCONCLUSIVE (class eval) — the gate could not obtain a verdict, so it produced no code finding and the required check stayed RED on purpose. The engine's discarded output is in the run log (run 40168243764, job 121093592737), and it names two findings. Both are quoted verbatim below, because a finding raised on a PR is never filed away and never paraphrased away.

The mechanism of the INCONCLUSIVE, from that run's own lines:

plugin-review[debug]: response in 59.377513138s — content=3364 bytes reasoning=75807 bytes finish_reason="stop" usage=prompt=16070 completion=20098 total=36168 reasoning=0
review: inconclusive: the review produced no Verdict line — this is NOT a review verdict (the required check stays RED)
##[warning]review rc=1 exited without a BLOCK verdict

The gate's own parse step is found=$(grep -E '^Verdict: (BLOCK|PASS)[[:space:]]*$' /tmp/review.txt || true) — a line that must be exactly that. The engine's reasoning reached "I'll structure: ## Review — BLOCK …" and the emitted content carried no Verdict: line, so the review was discarded and the check stayed RED. This is the already-filed class opencharly/plugin-review#47 (OPEN, claimed by the operator): this instance is added there as EVIDENCE, it is not filed a second time, and it is not fixed in this diff — a cause that lives in another repo's review engine is escalated, not patched from here. The prompt does ask for the Verdict line (candy/plugin-review/context.go:169), so this is not a prompt defect.

Finding 1 (blocking) — the body miscounted its own evidence

Body evidence-summary miscount: "2 jobs and 16 steps in deploy.yml, 2 jobs and 1 step in tag-on-merge.yml" — deploy.yml has 1 job (diff + pasted output show only 'deploy'), and the breakdown sums to 21 while the sentence asserts "20 findings." Correct to "1 job and 16 steps in deploy.yml, 2 jobs and 1 step in tag-on-merge.yml" (total 20).

It is real, and it was false without the fix. deploy.yml has one job; the output pasted on the same page shows one job finding and 16 step findings. The "2" was carried by hand from the round-0 draft, whose scope was BOTH workflow files, into a body whose scope had already narrowed to deploy.yml. The body also contradicted itself: its Summary said "no total deadline on its job" (singular) while the breakdown said "2 jobs".

Fixed. The new body states the distribution the evidence shows, and the counts it states are now read by an independent YAML reader off the committed file (pasted in the body).

RCA.

  • Mechanism. The count was retyped, not parsed. The body's evidence blocks are verbatim slices of the run's logs, but the prose that summarises them was typed by hand — and a retyped count drifts the moment the scope moves under it.
  • Blast radius. This PR's body only. The checker's own counts were correct throughout; the code was never wrong. The one place it could have been wrong — the checker reporting a finding that YAML does not support — is covered by the cross-check in the body.
  • Missed control. Nothing in this PR's own gate chain compared a prose figure against the evidence pasted beside it. The org body linter (marketplace/scripts/pr_body_lint.py) checks the four sections, the head sha, the diffstat, the footer, and that an asserted "check" claim has a fence behind it — it has no rule of the form "a count the body states must equal the count the body itself enumerates". So a body whose own paste disproved its own sentence passed the linter, and only the live validator caught it.
  • Root fix. Two halves. In this change: the body's prose is bound to the evidence — every count it states is read off the committed file by an independent reader and the measurement block is spliced from the run's own logs, so the prose and the evidence can no longer be two hand-typed things. In the control: the linter arm that would have caught this is added to pr_body_lint does not check a fenced list declaration against its own enumeration (a "10 entries" fence can list 2) marketplace#518, the existing open issue that owns that rule (it covers a count declared inside a fence; this variant declares the count in prose and enumerates it in a fence — the same root cause, "a count and its enumeration are two representations of one fact", needing a second arm on the one rule). The evidence is added there rather than filed as a new issue, so the rule keeps a single home; the fix itself lands in the marketplace repo by the producer-first order, not from this branch.

Finding 2 (the round's second block) — the tag job was bounded at 30 minutes with no measurement

(Secondary) tag-on-merge tag job bounded at 30 min with no measurement/justification, while the comment references a "24-hour sweep." If the reusable workflow can legitimately exceed 30 min, this introduces false failures. Require measurement or justification.

Fixed, by removing the edit rather than by justifying it. The deeper finding is where the bound belonged at all: .github/workflows/tag-on-merge.yml is a distributed copy of opencharly/.github's canonical template, installed into every repo by that repo's bootstrap and bound back to the template by its reconcile script. A deadline added to the copy duplicated the template's rule (R2) and would be erased by the org's own reconcile pass — a bound the org's tooling can silently revert is not a bound. Measured today, read-only, with that repo's own tool: 418 repos examined, 415 prose-drifted, 3 canonical, 408 pin-mutable, 10 pin-pinned.

So the copy is restored byte-identical to origin/main in this round, the checker keeps the rule on the surface this repo owns, and it reports the copy with its owner and both tracking issues named — a skip that says nothing is a gap nobody can see. A path named explicitly on the command line is still asserted, so the copy's unbounded surfaces stay one command away, and the block that proves it is in the body.

The bound itself now belongs to the template, and it is filed with a measurement: this repo's own 141 successful runs of the caller give the whole run's maximum (5.55 min), the tag / tag-and-changelog job's maximum (1.88 min) and the wait-for-merge job's maximum (5.23 min over the 124 runs that have one) — opencharly/.github#256, together with the copy's drift under opencharly/.github#192.

What this round changed, in one list

  1. The copy's hand-added deadlines are gone; the copy is byte-identical to origin/main again (proved by a claim-keyed diff in the body).
  2. The checker reports the org-distributed copy instead of asserting it, naming the owner and both issues.
  3. The mutation harness is committed and wired into the workflow, so the proof that the self-test can fail is reproducible from this repository: five mutants, each removing one mechanism, each asserted to turn the self-test red.
  4. The measured figures in the bounds comments are corrected: the previous commit sized them from the last 100 runs, a window that slides and that excluded the slowest run in the history.
  5. The body is regenerated for this head, and every count it states is read off the committed tree.

Next: the validator runs on the new head; every comment and the full report are read, and every finding is fixed in one commit per round. The issue is closed with a RESOLVED record when the PR lands.

Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (analysed on a live system)

The round-1 review found a real defect in that PR's own body, and the fresh RCA
the defect forced found a deeper one: the tag-on-merge caller in this repo is
a DISTRIBUTED COPY of opencharly/.github's canonical template. That repo's
bootstrap installs the template into every repo, and its reconcile script
binds the copies back to it (opencharly/.github#192) - measured today, read
only, with that repo's own tool: 418 repos examined, 415 of them drifted, 3
canonical, and this repo among the drifted.

So the deadline added to the COPY in the previous commit was in the wrong
place twice over. It duplicates the template's rule (R2), and it would be
ERASED by the org's own reconcile pass - a bound that can be silently reverted
by the org's tooling is not a bound. The copy is therefore restored to its
pre-change content here, and the deadline belongs to the template, which the
org-wide sweep already owns (opencharly/.github#256).

The checker keeps the rule, on the surface this repo owns, and REPORTS the
org-distributed copy instead of asserting it - with the owner and both tracking
issues named, because a skip that says nothing is a gap nobody can see. A path
named explicitly on the command line is still asserted, so the copy's three unbounded surfaces - two jobs and one step - stay one
command away.

The mutation harness is committed rather than kept outside the repo, and is
wired into the workflow, so the proof that the self-test can fail is
reproducible from this repository: five mutants, each removing one mechanism,
each asserted to turn the self-test red on the fixture that covers it.

The measured figures in the bounds comments are corrected here as well. The
previous commit sized them from the LAST 100 runs, a window that slides as
new runs land and that excluded the slowest run in the history; measured over
a FIXED window (2026-09-22..2026-10-24, 56 successful runs) the longest this
job has ever run is 15.15 min, on 2026-09-22, and that is also the longest of
all 583 successful runs in the retained history. The 60-minute bound is 4.0x
that maximum, and the Regenerate step's 30-minute bound is 2.8x the 10.8 min
that step has ever taken across the same 56 runs.

Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (analysed on a live system)
@github-actions

Copy link
Copy Markdown

Review — PASS

I re-derived every material claim in the body and the one comment against the current head (21b923dc2b7b), the three diffs, and the commit list.

Correctness against the diff

  • Counts are internally consistent. The file headers (+718 / +178 / +74) sum to exactly the stated +970 -0, and the three files match the body's git diff --name-only block and the "3 files changed" claim.
  • deploy.yml bound the body describes is present and matches its own numbers. The deploy job carries timeout-minutes: 60; I counted 19 steps, each with a timeout-minutes, and the largest is the Regenerate step at 30. 60 ≥ 30, so the checker's "dead bound" rule (step bound must be strictly shorter than the job's) does not trip on the committed tree — consistent with the pasted exit 0 / "1 workflow file(s) asserted" output.
  • The control logic is coherent with its fixtures. The jobs: end-bound, the cross-job step_end = job_end, the block-scalar marking, and the dead-bound rule each have a matching self-test fixture that would only pass if that mechanism is present. The mutation anchors (skip = [False] * len(lines), step_end = job_end, the jobs_end loop, the dead-bound if, ORG_DISTRIBUTED.get) each occur exactly once in the committed source as written, and the harness asserts that uniqueness at runtime — so a moved anchor fails loudly rather than silently passing.
  • Scope decision is real, not rhetorical. The diff contains no tag-on-merge.yml change, so the "copy restored byte-identical to origin/main" claim holds; the checker's ORG_DISTRIBUTED + partition(explicit=False) correctly reports that copy while asserting only deploy.yml, matching the pasted default-scan output.

Honesty of the body

The body is unusually candid about its limits and they check out: it explicitly declines to claim the original stall was reproduced, separates the queue-wait limit from the work bound, and labels the PyYAML cross-check and the org-wide sweep as author-side (with the script pasted and the file set re-gatherable). The measurement arithmetic is self-consistent (15.15 × 4.0 ≈ 60; 10.82 × 2.8 ≈ 30; 16 original steps + 1 job = 17 findings on origin/main; 2 + 13 = 15 cross-checked files). The origin/main finding list reproduces the 16 pre-change step labels in order.

Tests

The self-test and the five-mutant harness are committed and wired into the workflow as the first three steps (after checkout, before the expensive work). The coverage claim — that the checker fails on the unbounded origin/main file — is reproduced by the pasted 17-finding exit 1 block, and the mutation harness proves the self-test itself can fail. Combined with the in-CI check against the live deploy.yml, the change would fail without the timeouts.

Security & cleanliness

No secrets, no shell interpolation of untrusted input (subprocess.run uses an argv list, no shell=True), the harness writes only into a TemporaryDirectory, and the checker reads only local files. No dead code, no leftover shim; the round-1 copy edit is removed in the same PR (R5 satisfied — no tag-on-merge.yml in the net diff).

Comment disposition

The single comment (@aitrawog, the author) is an author status/RCA, not a maintainer sign-off — no designated maintainers are configured for this run, so I treat no comment as approval and require the change to stand on its own evidence, which it does. Its substantive claims re-derive correctly at this head: copy restored, checker reports-not-asserts the distributed file, mutation harness committed and wired, measurement window fixed. Its stated head 4ec38ee… is stale relative to the current 21b923dc2b7b (an intervening commit landed), but that is metadata drift only — the described fixes are present in the current tree, so I dismiss the head reference as stale rather than as a defect.

Non-blocking observations (not blocks)

  • The mutant set is named mutA/B/C/E/F (no mutD); cosmetic only, and the harness reports "5 mutants" consistently.
  • The PyYAML cross-check and org-wide 174-finding sweep are author-side and not reproducible from this repo by design; this is disclosed rather than asserted as committed CI, so it is acceptable evidence.

No correctness, security, test, or scope defect warrants blocking.

Verdict: PASS


ollama-cloud/deepseek-v4.1-flash — action-review.

View action run

@github-actions
github-actions Bot enabled auto-merge (squash) October 25, 2026 07:39
@github-actions
github-actions Bot merged commit dd289db into main Oct 25, 2026
4 checks passed
charly-auto-merge Bot added a commit that referenced this pull request Oct 25, 2026
@aitrawog

Copy link
Copy Markdown
Contributor Author

RESOLVED — this PR landed, and the validator's verdict on the merged head is PASS (no code finding). Its two non-blocking observations are dispositioned below, with the evidence each one needs.

Agent: docs-step-deadlines

The landing

  • Merge commit: dd289db1c44df797e5e502ffcc6692b1198810c8 (native squash, merged 2026-10-25T07:40:05Z).
  • Tag: v2026.298.0740 → that merge commit.
  • Changelog: CHANGELOG/2026.298.0740.md, written by the org's tag-on-merge dispatcher from this PR's body.
  • Validator: run 40172844568 (charly/pr-validator), job validate / arm — verdict_lines=1 distinct=[PASS], the gate's own parse (^Verdict: (BLOCK|PASS)[[:space:]]*$) matched, and native squash auto-merge was armed on the reviewed head and verified before the merge.
  • The other checks on the head: validate / validate success, validate / lint success, deploy success — the repository's own workflow ran the whole tree green, including the three new steps this PR adds (the deadline check, its self-test, and the mutation harness).

The two non-blocking observations, dispositioned

1. "The comment references head 4ec38ee which is stale vs current 21b923dc." — fixed, here.

The head moved because I amended the unpushed tip's commit MESSAGE before the push, to correct one phrase in it ("the copy's three unbounded jobs" → "the copy's three unbounded surfaces — two jobs and one step", which is what the checker reports). The amend changed the message and nothing else, and the two commits carry the same tree:

$ git rev-parse 4ec38ee090d7f1f3becbcbaf004f22bc608eace7^{tree} 21b923dc2b7b8bc91f3829566739aa121037a6ca^{tree}
5e05f84aac9721babc3f378d09750f83bc8b7348
5e05f84aac9721babc3f378d09750f83bc8b7348

$ git diff 4ec38ee090d7f1f3becbcbaf004f22bc608eace7 21b923dc2b7b8bc91f3829566739aa121037a6ca --stat
(no output — the trees are identical)

So every substantive claim in my 07:19 comment on this PR — 3 files, +970 -0, the checker's behaviour, the measurement figures, the scope decision — holds verbatim at the merged head; only the SHA it names was superseded, and this comment supersedes it. The PR's own body was written for 21b923dc and is the record that the changelog was generated from.

2. "The mutation harness names skip mutD." — rebutted, with the evidence.

The mutant identifiers are labels, not ordinals, and nothing in the repository refers to a mutD:

$ git grep -c "mutD" origin/main -- .
(no matches — 0 references to mutD)

$ git grep -n -E "mut[A-Z]" origin/main -- .github/scripts/test_check_step_timeouts.py
  mutA  the block-scalar marking      …
  mutB  the cross-job step bound       …
  mutC  the jobs-mapping end bound     …
  mutE  the dead-step-bound rule       …
  mutF  the org-distributed exclusion  …
        "mutA-nomark",
        "mutB-crossjob",
        "mutC-jobsend",
        "mutE-deadbound",
        "mutF-orgdistributed",

The set is an explicit list in the harness, the docstring's table enumerates the same five, and the summary's count is derived from that list rather than written by hand — so a gap in the alphabet cannot desynchronize anything the harness asserts:

print(
    f"test_check_step_timeouts: OK ({passed} mutants; every one of them turns "
    f"the self-test red)"
)

passed is the number of mutants that turned the self-test red, counted over the list itself. A letter that was never defined is not a mechanism the self-test fails to reach, which is the only thing the gap in the alphabet could have meant.

What this PR leaves behind

Agent: docs-step-deadlines

Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (analysed on a live system)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant