Skip to content

Commit ffec094

Browse files
authored
ci: the margin table was wrong twice — re-measure the pool, and size the job cap from maxima (#138)
* ci: the margin table in #131 was wrong in every row, and the job cap had already fired #131 replaced a "~2x headroom" claim with a measured table. The table was itself wrong -- every row, each in the safe-looking direction -- and the job cap it left in place was already negative. MEASURED over all 101 CI runs created 2026-08-01 (`gh api --paginate`), timing each leg's `Tests (pytest)` STEP and filtering on the STEP's own conclusion: leg claimed true (n) old cap true margin ubuntu-latest 12:27 12:31 (n=57) 19:00 1.518x windows-2022 18:39 21:34 (n=52) 26:00 1.206x windows-2025 24:35 25:51 (n=49) 26:00 1.006x Nine seconds, not 85. Two mechanical causes, both cheap to repeat: * THE POOL WAS A PAGE, NOT A QUESTION. It came from `gh run list --limit 20` -- a default-sized listing reported as though the sample had been chosen. 20 of 101. * FILTERING ON *JOB* CONCLUSION DROPS THE TIGHTEST STEPS BY CONSTRUCTION. A step near step_timeout is the one most likely to push its job into job_timeout, so the job is cancelled while the step concluded success. Five such rows exist that day and they include the maximum. Only the windows-2025 row was ever a maximum; ubuntu's and windows-2022's figures were that same run's other two legs. AND THE TWO-GATED-STEPS HAZARD IS NOT LATENT -- IT FIRED. Both `Tests (pytest)` and `Web console tests (pytest)` carry `step_timeout`, so a job can hold 2x step_timeout of gated work that step_timeout cannot bound. Run 30724385719 (main @ 8f01cef): Tests (pytest) 25:51 SUCCESS (9s under the 26:00 cap) Web console tests (pytest) CANCELLED JOB 30:13 CANCELLED <- job_timeout 30 fired A green first step, then an unattributed job-level kill during the second -- precisely what ci.yml:218's nesting note exists to prevent, by the path it does not consider. It cannot happen when a step is KILLED (that ends the job and skips what follows), only when the first step PASSES near its budget. Sizing job_timeout to hold both gated steps plus setup, rather than step_timeout plus a constant: leg step + web(max) + overhead old job new job ubuntu 19:00 + 1:58 + 0:41 = 21:39 22:00 +21s 26:00 +4:21 (1.20x) W22/W25 36:00 + 3:27 + 0:41 = 40:08 40:00 -8s 46:00 +5:52 (1.15x) All three legs sat inside a minute of their job cap and Windows was already negative: the +4 convention was carried through two cap changes without anyone summing what it had to cover. The 36:00 step decision is unchanged and remains correct -- 1.393x over the true 25:51 maximum, still comfortably above the 5:26 observed spread. Only its justification moves. Found by the ADR 0158 verification pass (eight agents re-deriving every claimed number against the API; 50 claims checked, 6 refuted), reported by the intersession-communication-hooks session, and re-derived here before acting. BACKLOG #344 still restates the superseded figures; that edit is blocked on three live sessions holding docs/BACKLOG.md and follows separately. * docs: handoff for ci-margin-correction Written at the owner's stop-work instruction (usage cap). Chat does not survive; a claim that lives only in a transcript reaches nobody. Carries: #138's state and its SQL Server blocker (NOT called a flake -- unproven), the corrected margin table, four retractions of my own findings with their corrected forms, and seven traps stated as fact-plus-measurement. The load-bearing line: the cap raise in #131 is correct and unchanged; its justification was wrong in every row, and #138 fixes it. * ci: re-measure the margin table a third time, and size the job cap from maxima The correction in this PR fixed #131's maxima but repeated #131's own failure mode in a new place: it reported a pool and an n that its stated pool cannot produce. Two independent re-derivations agree the day holds 70 ci.yml runs, not 101, and per-leg n = 42 / 39 / 36, not 57 / 52 / 49. The three MAXIMA (12:31 / 21:34 / 25:51) re-derive to the second and are unchanged, so the 36:00 step / 46:00 job decision stands on the same numbers it always did. What actually changed: * POOL AND n. 70 runs created 2026-08-01 UTC, cross-checked four ways; n = 42 / 39 / 36. A table whose own point is "state your pool and your n" has to carry an n the next reader can recompute. * THE POOL IS RIGHT-CENSORED, and nothing said so. Every run in it predates #131 (28d186b, 2026-08-02T00:35:28Z), so each observation survived a 26:00 cap. 25:51 is the largest step that FIT, not the largest the suite wants, and a multiple of a censored maximum under-provisions by construction. This is why 1.06x read as survivable right up to the moment #119 died. * THE JOB-CAP ADDENDS WERE TYPICAL VALUES, NOT MAXIMA. 0:41 is the median setup; the web-console figures 1:58 / 3:27 are each only third-highest on their leg. Measured maxima are 2:00 / 2:33 / 3:33 (web) and 1:20 / 1:09 / 1:04 (setup). Re-summed, ubuntu was -0:20 and Windows -0:37 against the old caps: BOTH were already negative, not just Windows. * THE NESTING INVARIANT IS NOT RESTORED, and now says so. It holds for `Tests (pytest)` on every leg and for `Web console tests (pytest)` on none: reaching that step already spends setup plus `Tests`, so its own cap can never fire first. A hang there still surfaces as an unattributed job kill. Stating otherwise would rest a compensating control on a false premise. * The "5:26 spread ... identical code" claim is withdrawn; neither endpoint re-derives. The true in-pool spread is 9:55, which is wider and supports the decision more strongly than the figure it replaces. Also corrects three pre-existing claims in this file that match no configuration -- the same defect class this note is about: - the pytest step's cap described as `timeout-minutes: 13` (it is matrix.step_timeout, 19 or 36) - "the 15-min job cap" (it is matrix.job_timeout, 26 or 46) - the mechanical margin guard cited as BACKLOG #341, which does not exist on main; it is #344 item 1 Drops HANDOFF-ci-margin-correction.md: session ephemera, and no HANDOFF file has ever been tracked at the repo root on main. * ci: mark the job-cap exhibit as mechanism, not verdict Run 30724385719 ran under the retired 26/30 pair and would have passed under #131's 40:00. It demonstrates that two steps sharing one step_timeout lets the job cap fire behind a green step; it is not itself evidence that 40:00 is too tight. That case rests on the arithmetic, which is arithmetic -- no job has been observed hitting 40:00. * backlog: correct #344's own figures, and file the job cap as instance 3 #344 is the item about bounds stated independently of the work they bound. Its instance 1 still carried the retracted measurement -- 24:35 over "11 runs", 1.06x, raised to "1.46x" -- every figure of which was superseded, and the edit had been outstanding since it was filed. Instance 1 now reads 25:51 / 1.006x / 1.393x, and carries its pool (70 ci.yml runs created 2026-08-01 UTC; per-leg n = 42 / 39 / 36) so the next reader can recompute it rather than trust it. Adds two things the re-measurement turned up: * INSTANCE 3 -- the CI job cap. Two steps in that job carry step_timeout, so job_timeout must cover their sum, and the +4 convention that sized it was never summed against what it had to hold. Recomputed from measured maxima, ubuntu was -0:20 and windows-2025 -0:37: both already underwater. It presents as a GREEN first step followed by an unattributed job-level kill, which is a signature instance 1's own proposed margin check would not catch, because the step it measures passed. * THE CENSORING TRAP. A max-passing figure is a LOWER BOUND: the pool is censored by whatever cap was in force when it was collected, so the runs that would have exceeded it were killed and are missing from exactly the tail being measured. This is why 1.006x looked survivable until #119 died. Proposal 5 records the structural fix instance 3 does not make: give the web-console step its own cap, because until then ci.yml's nesting invariant is unenforceable for the second gated step on every leg. Banner moves from "not started" to PARTIAL -- instances 1 and 3 are fixed, instance 2 is not. One banner, still OPEN. Also records, once, that instance 1's figures have now been published wrong twice; the maxima survived both passes and the pools did not. * ci: fix what the verification pass found in my own correction Nine agents re-derived this block; two re-derived the table from scratch under instructions to refute it. They confirmed every figure in the step table and refuted six things written around it. Fixing my own text, since the whole point of this change is not to ship a third unchecked table. * 26:07 EXISTS, and I said it did not. It is in this very pool -- run 30717229521 attempt 1, sha 8c407fb, step conclusion FAILURE, killed at the 26:00 cap -- hidden because the jobs endpoint defaults to `filter=latest`, which returns only the passing attempt-2 re-run. Same filter hid seven same-commit pairs, so the "no identical-code spread is computable" claim was also wrong. `?filter=all` shows both. * THE SPREAD RULE IS NOT MET, and I claimed it was. The first day after the raise produced 26:23 TWICE, both concluding SUCCESS -- uncensored evidence that the population exceeds the old 26:00 cap. Against 26:23 the headroom is 9:37 and the spread 10:27, so "headroom must exceed observed spread" FAILS at 36:00; it would need ~37:00. 36:00 is kept and the reason is now stated plainly -- this cap catches a deadlock, not slowness, and 1.365x over the worst observed run is ample for that -- rather than the rule being asserted as satisfied. Re-derive if a windows-2025 step is ever seen above 28:00. * WINDOWS setup(max) IS 1:20, NOT 1:04. 1:04 came from restricting to rows where BOTH gated steps succeeded, which drops the exhibit run printed 20 lines above (its web-console step was cancelled) -- the same censoring mistake as filtering the step table by job conclusion, made again. W25 is 40:53, so the old cap was -0:53, not -0:37. windows-2022 now gets its own row with its own addends instead of an unrecheckable 39:42. * "the +4 convention" was Windows-only. ubuntu went 15/13 -> 22/19, so +2 then +3, never +4. * "2 x step_timeout ... which job_timeout must cover" asserted a requirement the shipped caps do not meet (38 > 26, 72 > 46). Now says what they are actually sized against. * The nesting invariant at the top of the block asserted a guarantee it does not provide for the SECOND gated step on any leg. Amended there, where a reader meets it, not only in a caveat 100 lines below. Also: pytest_timeout / fault_timeout were quoted as flat 60s / 90s in three places; they are matrix values (60/120 and 90/150) passed explicitly on the command line, so each figure was false on two of three legs. And records that `test` is the only one of this file's ten jobs with any cap at all -- the other nine run on GitHub's 6h default. * backlog: #344 instance 3 carried the superseded -0:37, and the censoring now has evidence The windows-2025 job-cap shortfall is -0:53, not -0:37: the 1:04 setup addend it was computed from excluded the exhibit run itself, because that run's web-console step was cancelled. Same censoring mistake, one layer down. Instance 1 now cites the uncensored observation rather than only arguing the maximum must be a lower bound: the day after the raise, windows-2025 produced 26:23 twice, both passing -- runs the old 26:00 cap would have killed. Records that the jobs endpoint hides a killed attempt behind its passing re-run unless asked for ?filter=all, which is why nobody had seen them. * ci: #119 merged — stop saying it "died" Reported by the announce-hook session, verified here: gh pr view 119 gives MERGED, mergedAt 2026-08-02T01:45:00Z. What the 26:00 cap killed was a RUN, not the PR. "#119 died" reads as never-landed, and that reading had already propagated -- docs/WORKTREES.md asserted #119 "never merged (it died on an unrelated CI timeout)", which that session corrected against the API. This block was one of the places the phrasing came from, so it is fixed at the source rather than only downstream. * ci: 36:00/26:23 is 1.364x, not 1.365x Caught by running an exact-arithmetic assertion over every figure in the block rather than re-reading it: 2160/1583 = 1.3644978, which rounds to 1.364. I had rounded it up by hand in both places. Trivial in size and not in kind -- this is a change whose entire subject is numbers published without being recomputed, so it does not get to ship one. Every ratio, sum and delta in the block is now covered by that assertion and all fifteen are exact. * backlog: #344's own proposal 3 was harmful, and instance 2 was mis-diagnosed Found by investigating a sqlserver failure on this very PR, which turned out to be instance 2 recurring on a different test. PROPOSAL 3 IS WITHDRAWN. It said a poll deadline over a virtual-clock system should follow that clock rather than loop.time(). Implementing that would have HUNG the suite: _wait_until waits on real store I/O, never on virtual time, and ManualClock.now advances only inside advance(), which nothing calls from the poll loop (tests/test_stage_dispatcher.py:182-204). A mc.now-based deadline is never reached, so a bounded `assert False` becomes an unbounded hang stopped only by pytest_timeout or the job cap -- manufacturing the exact signature instances 1 and 3 are about. A virtual clock can only bound work it drives. INSTANCE 2 IS RE-DIAGNOSED, and the original reading -- "the 8.0s bound is too small" -- is refuted by the recurrence's own timings. The failing test took 8.185s, so _wait_until burned its full 8.000s and setup+teardown cost 0.185s: the store was FAST when it failed. In the same process against the same container the sibling retry_forever[sqlserver] drove seven identical fault cycles in 0.364s, and the [sqlite] variant of the failing test passed in 0.144s. A cycle costs ~30-45ms against an 8000ms bound. The lane is not slow to transition; it never transitions. Raising the number would not fix it and would bury it -- which is precisely the mislabelling this item's own Why warns about. The leading mechanism is recorded as evidenced-but-unconfirmed rather than asserted: a sanctioned EMPTY claim drops the lane to IDLE, and these tests deliberately disable the sweep that recovers it in production. What is settled is the negative: not latency, and not a bigger number. PROPOSAL 1 now carries the evidence for preferring a computed gate to a written instruction. Seven claims were retracted across this triage cluster and none was caught by re-reading; every one fell to a mechanism that could return "no". The one an author caught themselves was caught by running exact arithmetic over all fifteen ratios in the block. As a method, re-reading is 0-for-7 here. * backlog: #344 instance 2 inverts the item's own remedy, and needs observability first A second, larger pass measured what instance 2 actually costs, and the numbers change what should be done about it. RATE AND MARGIN. The two affected tests fail 2 times in ~479 observations (~0.4%), zero on postgres (119) and zero across 1,105 sqlite executions. Over a sample of green sqlserver jobs the failing test passes in min 0.185s / median 0.196s / max 0.204s -- the 8.0s bound is ~39x its worst passing run, ~14x over a wider 21-day pool. Both failures sit ~7x beyond the whole passing distribution: a gap, not a tail. SO THIS INSTANCE INVERTS THE ITEM'S OWN GENERIC REMEDY. "Size the bound against the work" would derive ~1-2s here -- TIGHTER than the 8.0s already in place. There is no larger number to justify, and raising it would only convert a 0.4% visible failure into a 0.4% invisible 30-60s pause. An item about bounds that have drifted too LOOSE has to be able to say when the answer is not a bigger number, and this is that case. MECHANISM IS EXPLICITLY UNRESOLVED. Two independent passes disagreed -- one proposes a sanctioned EMPTY claim dropping the lane to a terminal IDLE (these tests disable the sweep that recovers it in production), the other returned NOT PROVEN and is right that the evidence cannot separate that from a genuine stall. Recorded as unresolved rather than picking the more satisfying story. NEW PROPOSAL 6: make the expiry diagnostic before tuning it. `assert await _wait_until(...)` prints `assert False` and nothing else -- no phase, no park deadline, no streak, no task state -- which is why this was read as latency for a day. That is the prerequisite for judging any other proposal here, and unlike them it cannot itself be wrong about the cause. NOT changed: the note that a killed attempt hides behind its passing re-run unless you pass ?filter=all. A reviewing pass claimed filter=all does not return prior attempts; checked directly against both cited runs and it does (attempts [1,2] on each), so the existing text stands. * backlog: #344 proposal 6 — the instrument that settles instance 2 already ships Both adversarial passes converged on the same verdict (raising the bound would mask, not fix) but neither proposed the cheap discriminator, and it turns out not to need building. StageDispatcher.empty_claims (stage_dispatcher.py:1230) already returns (total, wake_fanout, idle_poll) and is fed by _record_empty, whose ONLY call site is the EMPTY branch of _claim_and_dispatch (:686). Under these tests' topology -- lane_provider=set(), sweep_interval=3600, one seeded row -- a clean run must read (0,0,0). So at the moment of failure `empty_claims[0] > 0` proves a spurious EMPTY dropped the lane to a terminal IDLE (T12 sets phase=IDLE and arms no timer), and `== 0` proves the claim never returned at all. One assertion separates the two hypotheses this item currently records as unresolved. A second signature costs nothing and is already in the captured log: a healthy run emits FOUR `re-pending head with backoff` records (1001.000 / 1003.500 / 1008.000 / 1016.500, the ManualClock base plus the infra backoff ladder); the failing run emitted ONE. The lane never took a second fault. Verified against the source before citing it -- accessor, call site and the T12 branch all read directly, not taken from the analysis. * backlog: two of my own #344 claims were wrong — postgres power, and a signature that does not generalise Both reported by the session that settled instance 2 on a live SQL Server, and both are defects in text I had already committed. POSTGRES'S ZERO EXONERATES NOTHING. I wrote "zero on postgres (119 observations)" alongside sqlite's zero, which reads as two backends clearing the mechanism. It is not: at ~0.4% a 119-observation sample expects ~0.5 events, so zero is the expected outcome whether or not Postgres is affected -- and Postgres claims via FOR UPDATE SKIP LOCKED, the SAME head-of-line skip. Only SQLite's 0 in 1,105 is structural, because its global lock totally orders producers and claimers. Citing an underpowered sample as evidence of absence is the same error as the censored maximum three paragraphs above it. THE LOG SIGNATURE HOLDS FOR ONE TEST, NOT BOTH. I wrote that "a healthy run" emits four `re-pending head with backoff` records against the failing run's one. True of test_adr0070_1_* only. test_adr0070_9_* takes the content path, which uses mark_failed and never emits that line at all, so zero there is EXPECTED and is not evidence of a second mechanism. Scoped, with the reason, and pointed at the counter rather than the log. Proposal 6 itself is vindicated: empty_claims settled it in one assertion, read (1,0,1) forced deterministically against a live SQL Server. The mechanism write- up belongs to that session; I have corrected only my own two claims and left instance 2's resolution to them.
1 parent 41a8c49 commit ffec094

2 files changed

Lines changed: 168 additions & 34 deletions

File tree

.github/workflows/ci.yml

Lines changed: 150 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,11 @@ jobs:
5454
# runner label set; the per-repo matrix is built in `changes`.
5555
runs-on: ${{ matrix.hosted }}
5656
# Wall-clock backstop: bound each leg so a hung test fails in minutes, not the 6h default. Three
57-
# nested watchdogs catch a hang at increasing scope (#55): the pytest-timeout per-test cap (60s,
58-
# pyproject addopts) + the faulthandler belt fire FIRST and name the stuck frame; the pytest STEP
59-
# has its own `timeout-minutes: 13` so a process-level deadlock below pytest fails the step fast;
60-
# this job cap is the outermost belt if even that is somehow out-raced.
57+
# nested watchdogs catch a hang at increasing scope (#55): the pytest-timeout per-test cap (matrix
58+
# `pytest_timeout`, 60s ubuntu / 120s Windows -- the step passes `--timeout=`, which overrides the
59+
# pyproject addopts value) + the faulthandler belt fire FIRST and name the stuck frame; the pytest STEP
60+
# has its own `timeout-minutes` (matrix `step_timeout`) so a process-level deadlock below pytest
61+
# fails the step fast; this job cap is the outermost belt if even that is somehow out-raced.
6162
timeout-minutes: ${{ matrix.job_timeout }}
6263
strategy:
6364
fail-fast: false
@@ -200,16 +201,18 @@ jobs:
200201
if: (needs.changes.outputs.code == 'true' || github.event_name == 'push' || github.event_name == 'workflow_dispatch') && runner.os == 'Linux'
201202
run: mypy --platform win32 messagefoundry
202203

203-
# Step-level watchdog UNDER the 15-min job cap (#55): the windows-2022 leg intermittently
204+
# Step-level watchdog UNDER the job cap (#55): the windows-2022 leg intermittently
204205
# wedges ~25% in (a Windows ProactorEventLoop listener-teardown / socket wait that the shared
205206
# session event loop can't get past), emits no output for ~12 min, then the JOB cap CANCELS it
206207
# — a red ✗ with no stack and no named test. Two belts make that fail FAST and NAMED instead:
207208
# * `--timeout-method=thread` (the ONLY method on Windows — SIGALRM is POSIX-only) dumps ALL
208-
# thread stacks at the per-test cap (60s, set in pyproject addopts), naming the stuck frame.
209-
# * PYTHONFAULTHANDLER=1 + `-o faulthandler_timeout=90` is the belt that fires even when the
209+
# thread stacks at the per-test cap (matrix `pytest_timeout`, 120s on these Windows legs),
210+
# naming the stuck frame.
211+
# * PYTHONFAULTHANDLER=1 + `-o faulthandler_timeout=` (matrix `fault_timeout`) is the belt that fires even when the
210212
# thread-timer CANNOT interrupt a main-thread C-level wait (a Proactor overlapped op /
211213
# blocking accept / subprocess.wait): pytest's faulthandler plugin arms a separate watchdog
212-
# thread that dumps the NATIVE stack of every thread at 90s (> the 60s pytest-timeout, so the
214+
# thread that dumps the NATIVE stack of every thread at `fault_timeout` (150s here, 90s on
215+
# ubuntu -- always set ABOVE that leg's `pytest_timeout`, so the
213216
# per-test dump is attributed first; this is the last-resort stack when the thread method is
214217
# itself out-raced). NB the plugin exposes this as the `faulthandler_timeout` ini key, not a
215218
# `--faulthandler-*` CLI flag, so it is passed with `-o`; it only DUMPS (never kills), safe.
@@ -218,6 +221,13 @@ jobs:
218221
# the silent cap). Keep the gap: the step must expire BEFORE the job, or the failure surfaces as
219222
# an uninformative job-level kill with no step attribution.
220223
#
224+
# THAT GUARANTEE COVERS THE FIRST GATED STEP ONLY, and saying otherwise would be a control resting
225+
# on a false premise. `Web console tests (pytest)` carries the SAME `step_timeout` but runs after
226+
# `Tests (pytest)`, so reaching it has already spent setup + `Tests`; its own cap cannot fire first
227+
# at any job_timeout worth setting (it would take ~39 min on ubuntu, ~73 min on Windows). A hang
228+
# THERE is still an unattributed job-level kill. Measured under `Tests (pytest)` below; the
229+
# structural fix is BACKLOG #344 proposal 5.
230+
#
221231
# It only works while the cap stays clear of a HEALTHY run. On 2026-07-31 the ubuntu leg finished
222232
# green in 775s against a 780s cap -- 5s of margin -- and the step was killed anyway, reported as
223233
# a failure on a run whose last line was "9598 passed, 855 skipped". A watchdog that cannot
@@ -230,19 +240,62 @@ jobs:
230240
# code under test: #74 landed tests/test_worktree_prune_merged.py (1,506 lines) and windows-2025
231241
# went 19:35 -> 26:07 on the same branch. The claim was already false when it was written.
232242
#
233-
# Measured over the 11 PASSING windows-2025 runs on 2026-08-01, timing the `Tests (pytest)` STEP,
234-
# which is what step_timeout gates. NOT the job: the job is ~3 min longer and capped separately,
235-
# and two sessions misread job durations as step durations while triaging this (c53f752b's JOB ran
236-
# 28:41 and PASSED, because job cap 30 vs step cap 26).
243+
# Measured by timing the `Tests (pytest)` STEP, which is what step_timeout gates. NOT the job: the
244+
# job runs several minutes longer and is capped separately, and at least three sessions misread job
245+
# durations as step durations while triaging this (c53f752b's JOB ran 28:41 and PASSED, because
246+
# job cap 30 vs step cap 26).
247+
#
248+
# POOL: every run of THIS workflow created on 2026-08-01 UTC -- 70 runs. Enumerated with
249+
# `gh api --paginate` over a deliberately WIDER window and filtered locally on `created_at`, then
250+
# cross-checked against the narrow `?created=` query (same 70 ids, symmetric difference 0). Rows
251+
# are each leg's `Tests (pytest)` step, kept when THAT STEP concluded success. n is per leg.
252+
#
253+
# leg max passing step n old cap old margin
254+
# ubuntu-latest 12:31 42 19:00 1.518x
255+
# windows-2022 21:34 39 26:00 1.206x
256+
# windows-2025 25:51 36 26:00 1.006x <- NINE SECONDS
257+
#
258+
# THESE MAXIMA ARE LOWER BOUNDS, BECAUSE THE POOL IS RIGHT-CENSORED. Every run in it predates #131
259+
# (28d186b5, landed 2026-08-02T00:35:28Z), so Windows ran under step 26:00 / job 30:00 and ubuntu
260+
# under 19:00 / 22:00. A run that wanted longer than its cap was KILLED at the cap and then --
261+
# correctly, by the filter above -- dropped for not concluding success. So 25:51 is the largest
262+
# step that FIT in 26:00, not the largest the suite wants. Sizing a cap as a multiple of a
263+
# censored maximum under-provisions by construction, and that is exactly how "1.06x" read as
264+
# survivable right up until #119's leg was killed at 26:07. (#119 itself merged, 2026-08-02T01:45Z
265+
# -- what the cap killed was a run, not the PR. Saying a PR "died" here reads as never-landed and
266+
# had already propagated into docs/WORKTREES.md as exactly that claim.)
267+
#
268+
# THE CENSORED VALUES ARE VISIBLE IF YOU ASK FOR THEM, AND THEY MATTER. The jobs endpoint defaults
269+
# to `filter=latest`, which hides a killed attempt behind its passing re-run; `?filter=all` shows
270+
# both. #119's 26:07 is in this very pool that way (run 30717229521 attempt 1, `8c407fb5`, step
271+
# conclusion FAILURE) beside its 22:25 attempt-2 success -- a 3:42 spread on identical code, and
272+
# the first day AFTER the raise produced 26:23 TWICE, both concluding SUCCESS (runs 30728793103
273+
# and 30731407003). So the population really does exceed the old 26:00 cap: the largest windows-2025
274+
# `Tests (pytest)` execution observed to date is 26:23, not 25:51.
237275
#
238-
# leg max passing step old cap old margin
239-
# ubuntu-latest 12:27 19:00 1.53x
240-
# windows-2022 18:39 26:00 1.39x
241-
# windows-2025 24:35 26:00 1.06x
276+
# THIS TABLE HAS BEEN WRONG TWICE. #131 published 12:27 / 18:39 / 24:35 over "11 passing runs";
277+
# the first correction published the maxima above but over "101 runs" with n = 57 / 52 / 49. The
278+
# maxima re-derive exactly; BOTH pool sizes were unreproducible. Three mechanisms produced that,
279+
# all cheap to repeat:
242280
#
243-
# windows-2025 had already PASSED at 24:35 -- 85 seconds of margin -- before #119 died. The "2x"
244-
# figure matched no leg. State the MEASURED value and its DATE, never a round multiple: a bare
245-
# multiple gives the next reader no way to tell when it has rotted, which is how this one survived.
281+
# * THE POOL WAS A PAGE, NOT A QUESTION. #131's came from `gh run list --limit 20` -- a default
282+
# page size, reported as though it had been chosen.
283+
# * FILTERING ON *JOB* CONCLUSION DROPS THE TIGHTEST STEPS BY CONSTRUCTION. A step that nearly
284+
# exhausts step_timeout is the most likely to push its job into job_timeout, so the job is
285+
# cancelled while the step itself concluded success. Re-running this pool with a job-conclusion
286+
# filter reproduces #131's 24:35 exactly; the step-conclusion filter gives 25:51. Measure the
287+
# step; filter on the step. Two such rows exist on 2026-08-01 and one of them is the maximum.
288+
# * A COUNT NOBODY RE-DERIVED. Neither "101 runs" nor n = 57 / 52 / 49 is reproducible under any
289+
# pool definition tried. A table whose own point is "state your pool and your n" has to carry
290+
# an n the next reader can recompute -- that column is how they would tell it had rotted.
291+
#
292+
# Only the windows-2025 row was ever a maximum; #131's ubuntu 12:27 was that same run's ubuntu leg,
293+
# quoted as if it were that leg's worst case. Its 18:39 was not even that: that run's windows-2022
294+
# step is 18:29, and 18:39 belongs to a different run entirely.
295+
#
296+
# State the MEASURED value, its POOL, its n and its DATE -- never a bare multiple. A multiple gives
297+
# the next reader no way to tell when it has rotted; a multiple without its pool cannot even be
298+
# rechecked.
246299
#
247300
# PROOF IT WAS THE CAP, NOT THE BRANCH. #119's windows-2025 leg was RE-RUN on the SAME commit
248301
# against the SAME 26:00 cap: attempt 1 was killed at the cap, attempt 2 concluded SUCCESS. Same
@@ -251,19 +304,88 @@ jobs:
251304
# see" is not a diagnosis here. A green re-run at 26:00 does not mean the suite fits; it means
252305
# that runner was fast enough that time.
253306
#
254-
# 36:00 is 1.46x over that 24:35 maximum -- the margin ubuntu already runs with. Both Windows legs
255-
# take the same number: windows-2022 is the faster of the two, so sizing on windows-2025 only
256-
# leaves it more room, and one value is one thing to re-derive. job_timeout moves 30 -> 40 to keep
257-
# the nesting invariant above: the step must still expire strictly BEFORE the job.
307+
# 36:00 is 1.393x over the 25:51 that fit under the old cap, and 1.364x over 26:23, the largest
308+
# windows-2025 execution actually observed. Use the second number: a ratio against a censored
309+
# maximum flatters itself.
310+
#
311+
# BE HONEST ABOUT THE SPREAD RULE -- 36:00 DOES NOT MEET IT. An earlier revision argued "headroom
312+
# must exceed observed spread". Against real values that rule FAILS here: headroom is 36:00 - 26:23
313+
# = 9:37, and the windows-2025 spread is 26:23 - 15:56 = 10:27. It would need roughly 37:00 to hold.
314+
# 36:00 is kept anyway, and the reason is stated rather than dressed up: this cap exists to catch a
315+
# whole-process DEADLOCK, not slowness (see the paragraph below), so 1.364x over the worst observed
316+
# run is ample for its actual job, and #131 already set this value. What the spread rule is good
317+
# for is telling you the margin is thinner than the ratio suggests. RE-DERIVE IF a windows-2025
318+
# `Tests (pytest)` step is ever seen above 28:00 -- that is the trigger, not a calendar reminder.
319+
#
320+
# A ratio against one run says nothing about a distribution, and #119's leg was killed by the
321+
# distribution, not by its own duration. Both Windows legs take the same number: windows-2022 is the faster, so
322+
# sizing on windows-2025 only leaves it more room, and one value is one thing to re-derive.
323+
#
324+
# THE JOB CAP IS NOT A ROUNDING-UP OF THE STEP CAP, AND IT HAS FIRED. Two steps in this job carry
325+
# `step_timeout` -- `Tests (pytest)` and `Web console tests (pytest)` -- so the job can contain
326+
# 2 x step_timeout of gated work that step_timeout cannot bound. Note the caps here do NOT cover
327+
# that worst case and are not sized to (2 x 19 = 38 > 26 on ubuntu, 2 x 36 = 72 > 46 on Windows);
328+
# they are sized against the OBSERVED sum, which is a weaker guarantee. See the closing paragraph.
329+
# Observed for real on run 30724385719 (main @ 8f01cef8, 2026-08-01):
330+
#
331+
# Tests (pytest) 00:01:21 -> 00:27:12 25:51 SUCCESS (9s under the 26:00 cap)
332+
# Web console tests (pytest) 00:27:12 -> 00:30:14 CANCELLED
333+
# JOB 00:00:06 -> 00:30:19 30:13 CANCELLED <- job_timeout 30 fired
334+
#
335+
# A GREEN first step, then an unattributed job-level kill during the second -- exactly the failure
336+
# the nesting note above exists to prevent, arriving by the path that note does not consider. It
337+
# does NOT happen when a step is killed (that ends the job and skips what follows, so those
338+
# durations never sum); it happens when the first step PASSES near its budget.
339+
#
340+
# READ THAT EXHIBIT FOR ITS MECHANISM, NOT AS A VERDICT ON THE CAP IT REPLACED: it ran under the
341+
# retired 26/30 pair and would have passed under #131's 40:00. What condemns 40:00 is the
342+
# arithmetic below -- which is arithmetic, not an observed kill; no job has yet hit 40:00.
343+
#
344+
# job_timeout is therefore sized against BOTH gated steps plus setup, rather than as step_timeout
345+
# plus a constant. Each addend below is that addend's MEASURED MAXIMUM over the same pool, taken
346+
# over rows where `Tests` concluded success -- NOT over rows where both steps did, which silently
347+
# drops the very run that motivates this whole paragraph (its web-console step was cancelled) and
348+
# is the same censoring mistake as filtering the step table by job conclusion. Two earlier
349+
# revisions used a 0:41 setup (the MEDIAN), web-console values that were each only third-highest
350+
# on their leg, and a 1:04 Windows setup that the exhibit above contradicts on its face:
351+
#
352+
# leg step_timeout + web-console(max) + setup(max) old job new job
353+
# ubuntu 19:00 + 2:00 + 1:20 = 22:20 22:00 -> -0:20 26:00 -> +3:40 (1.16x)
354+
# W22 36:00 + 2:33 + 1:09 = 39:42 40:00 -> +0:18 46:00 -> +6:18 (1.16x)
355+
# W25 36:00 + 3:33 + 1:20 = 40:53 40:00 -> -0:53 46:00 -> +5:07 (1.13x)
356+
#
357+
# ubuntu and windows-2025 were ALREADY NEGATIVE -- ubuntu too, which an earlier revision had the
358+
# wrong way round; windows-2022 was the one row genuinely in the black. Windows takes one value,
359+
# sized on windows-2025 as the worse of the pair. The `+4` habit that produced 40 was Windows-only
360+
# and was carried through two cap changes unchecked (30/26 then 40/36); ubuntu has never been +4 --
361+
# it went 15/13 to 22/19, so +2 then +3. Either way the number was derived from the OTHER BOUND
362+
# rather than from the work, which is the defect, not the particular constant.
363+
#
364+
# WHAT THIS DOES NOT FIX. The nesting invariant at the top of this note holds for `Tests (pytest)`
365+
# on every leg and for `Web console tests (pytest)` on NONE of them: reaching that step already
366+
# spends setup plus `Tests`, so its own cap can never fire first. Guaranteeing it would need
367+
# job_timeout above setup + 2 x step_timeout -- about 39:20 on ubuntu and 73:20 on Windows at the
368+
# measured setup maxima above, i.e. far beyond anything worth setting. A hang in the web-console
369+
# step therefore still surfaces as an unattributed job-level kill, which is the very failure the
370+
# invariant is there to prevent. The structural fix is to stop
371+
# the two steps sharing one budget (a 3:33 suite has no business holding 36:00) and is filed as
372+
# BACKLOG #344; this sizing only makes the job cap cover the sum. Re-derive it if either suite's
373+
# duration moves.
374+
#
375+
# AND KNOW WHAT IS NOT CAPPED AT ALL. `test` is the ONLY one of this file's ten jobs carrying any
376+
# `timeout-minutes`; the other nine -- `changes`, `ide`, `sqlserver-store`, `postgres-store`,
377+
# `load-test`, `load-test-sqlserver`, `windows-service-smoke`, `docker-smoke`, `ci-gate` -- run on
378+
# GitHub's 6-HOUR default. That is recorded here so the omission is known rather than assumed
379+
# deliberate; sizing them is out of scope for this change and belongs with BACKLOG #344.
258380
#
259381
# This cap is NOT what catches a hung test; pytest_timeout (120s) is, per test. The step cap only
260382
# catches a whole-process deadlock both in-process watchdogs miss, which is why it can sit well
261383
# above a healthy run. Sizing it tight buys no detection and costs false failures on green suites,
262384
# twice now.
263385
#
264386
# The remaining margin is a SHARED budget across every PR that lands and nothing accounts for it:
265-
# three PRs each adding a minute of Windows time reproduce #119's death, individually blameless.
266-
# A mechanical guard for that is BACKLOG #341; the underlying slowness is #320.
387+
# three PRs each adding a minute of Windows time reproduce #119's kill, individually blameless.
388+
# A mechanical guard for that is BACKLOG #344 item 1; the underlying slowness is #320.
267389
- name: Tests (pytest)
268390
if: needs.changes.outputs.code == 'true' || github.event_name == 'push' || github.event_name == 'workflow_dispatch'
269391
timeout-minutes: ${{ matrix.step_timeout }}
@@ -421,9 +543,9 @@ jobs:
421543
# $GITHUB_REPOSITORY is a built-in runner env var, read here as plain shell (NOT a workflow-
422544
# expression interpolation into the run body), so it is zizmor-safe and cannot be misparsed as
423545
# an Actions expression the way a literal double-brace token in a run: block would be.
424-
U='{"os":"ubuntu-latest","python-version":"3.14","hosted":["ubuntu-latest"],"job_timeout":22,"step_timeout":19,"pytest_timeout":60,"fault_timeout":90}'
425-
W22='{"os":"windows-2022","python-version":"3.14","hosted":["windows-2022"],"job_timeout":40,"step_timeout":36,"pytest_timeout":120,"fault_timeout":150}'
426-
W25='{"os":"windows-2025","python-version":"3.14","hosted":["windows-2025"],"job_timeout":40,"step_timeout":36,"pytest_timeout":120,"fault_timeout":150}'
546+
U='{"os":"ubuntu-latest","python-version":"3.14","hosted":["ubuntu-latest"],"job_timeout":26,"step_timeout":19,"pytest_timeout":60,"fault_timeout":90}'
547+
W22='{"os":"windows-2022","python-version":"3.14","hosted":["windows-2022"],"job_timeout":46,"step_timeout":36,"pytest_timeout":120,"fault_timeout":150}'
548+
W25='{"os":"windows-2025","python-version":"3.14","hosted":["windows-2025"],"job_timeout":46,"step_timeout":36,"pytest_timeout":120,"fault_timeout":150}'
427549
if [ "${GITHUB_REPOSITORY:-}" = "MEFORORG/MessageFoundry" ]; then
428550
echo "matrix={\"include\":[$U,$W22,$W25]}" >> "$GITHUB_OUTPUT"
429551
else

0 commit comments

Comments
 (0)