fix(ci): run the test suite on the Python the images actually ship (#1891) - #1910
Conversation
…1891) Every Trinity image is `FROM python:3.13`; every CI job executing the suite pinned 3.11. CI therefore validated a runtime two minor versions behind production and, by construction, could not catch the stdlib-removal class — which had already shipped twice (`crypt` -> #1615, `audioop` -> the audioop-lts VoIP pin). Bumps the six literals (backend-unit-test x3, backend-unit-nightly, schema-parity, pg-migrations). publish-cli stays 3.12: PyPI packaging is governed by what CLI consumers run, not by the container runtime. The durable half is the guard. Six literals fix today's drift and prevent nothing; the root cause is two hand-maintained declarations with nothing tying them together. `tests/unit/test_1891_python_version_parity.py` derives CI's expected version from the Dockerfile pin and fails when they diverge, scanning EVERY workflow file so a seventh one added later cannot ship stale. The bump surfaced a real defect, which is the point of it ------------------------------------------------------- Verified by running CI's exact invocation (`cd tests && pytest unit/ -m "not slow" -p randomly --randomly-seed=12345`) on both interpreters and diffing with the repo's own scripts/ci/diff-pytest-failures.py: base keep-py311.xml 5155 total 5094 pass 7 fail 37 error head keep-py313.xml 5149 total 5087 pass 7 fail 37 error ## No new failures No new failures — but 3.13 ran SIX FEWER TESTS. `audioop-lts` was declared only in docker/backend/Dockerfile, never in tests/requirements-test.txt. CI installs the requirements file, not the image, so on 3.13 `tests/unit/test_voip_audio.py` skipped at collection and 7 codec tests silently stopped running. Nothing would have gone red: the regression-diff job compares FAILURES, not test counts, so a vanished module is invisible to it. Bumping the interpreter without this pin would have closed one instance of "CI environment != shipped environment" while opening another. So this also pins audioop-lts in tests/requirements-test.txt (verified: 7 passed on 3.13 with it) and extends the guard with a third rule — every `pkg; python_version >= X` the image installs must also be a test requirement. Guard verified in both directions, and the first version of that third rule was VACUOUS: it substring-matched the whole requirements file, and the explanatory comment above the pin contains the string "audioop-lts", so deleting the real line still matched. It now strips comments and parses requirement names. Not a 3.13 memory regression: both interpreters OOM identically in a local container when the whole tree is run (including slow tests, which CI excludes). Both wrote complete JUnit XML first; the kill lands after the session ends. Docs: architecture.md tech-stack table said 3.11 for Backend and Agent runtime, and the base-image line too — all corrected, plus a note naming the Dockerfile as source of truth and the guard as enforcement. Closes #1891 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#1891) Review of the guard shipped one commit earlier: rule 3 enumerated ONE dep-install surface while claiming to close the class. There are three, and they do not all look alike: docker/backend/Dockerfile inline `pip install`, shell-quoted spec docker/scheduler/requirements.txt a requirements FILE, no shell quoting docker/base-image/Dockerfile the agent container's runtime Two independent defects fell out of that: 1. `docker/scheduler/requirements.txt` was never read, so a conditional dep added there would be invisible — the exact "the create path is never one call site" shape in learnings.md 2026-07-29, and inconsistent with rule 1 in the SAME file, which already enumerates all three Dockerfiles. 2. `_MARKER_RE` demanded quotes around the whole spec, which only the Dockerfile form has. Even pointed at the scheduler file it would have matched nothing — a guard that reads the file and still sees zero is worse than one that never opens it, because the "no markers found" tripwire stays quiet. Now scans backend + scheduler, with a regex accepting both forms. base-image is deliberately excluded WITH a reason in the code: those packages are the agent container's runtime and the backend unit suite never imports them; the agent-side modules it does import are vendored stdlib-only by Invariant #5. Adds the two meta-tests the rule was missing, mirroring the workflow allowlist's: `test_every_image_dep_source_still_exists` (a renamed source must fail loudly rather than shrink coverage) and `test_conditional_dep_exemptions_are_still_real`. Verified: red when a conditional dep is added to the scheduler requirements ("some-conditional-pkg (docker/scheduler/requirements.txt pins it for python >= 3.13)") — the surface that was unwatched before this commit — and green as committed. Run the way CI runs it (`cd tests && pytest unit/`). Related to #1891 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/review ReportBranch: Self-review. One critical finding, in the guard I wrote — fixed in Scope checkIntent (#1891): bump 6 CI All five ACs DONE with citations: bumps ( Critical Findings[C1] Guard blind spot: rule 3 enumerated one dep source of three (Confidence: 10/10) — FIXED in File: Evidence — what the guard read: def _conditional_deps() -> dict[str, str]:
"""Map dep name -> required python_version, from the backend Dockerfile."""
text = (_ROOT / "docker" / "backend" / "Dockerfile").read_text(encoding="utf-8")Evidence — what actually installs backend-side Python: Issue: rule 3 claimed to close "the image installs a version-conditional dep that CI lacks" while watching one of three surfaces. This is the repo's own named bug class — A second defect rode along and would have defeated the fix on its own: _MARKER_RE = re.compile(r"""["']([A-Za-z0-9._-]+)\s*;\s*python_version\s*>=?\s*['"](\d+\.\d+)['"]["']""")The outer Why it matters: a guard that silently watches less than it claims converts an unchecked area into a believed-checked one, which is worse than no guard. Fix applied: scan backend + scheduler; regex accepts both quoted and bare forms; Verified red on the previously-unwatched surface: and green as committed (6 passed), run the way CI runs it: Informational Findings[I1] Marker forms still unmatched: [I2] [I3] The Clean Categories
Summary
Durable learningWorth adding to
|
|
Resolve by running |
…Xing the PR (#1910) The regression-diff gate went red on this very PR for an INFRASTRUCTURE reason, not a test one: `pytest (base, seed 99999)` hit the 25-min job timeout on a slow runner (the other seeds ran ~9 min), so `junit-base-99999.xml` was never uploaded, and `diff-pytest-failures.py` exited 1 on the missing file — even though its own verdict was "✅ No new failures". The seeds are redundant by design: they sample different test orders and the regression signal is the UNION across a side's seeds. Losing one to a runner timeout should degrade coverage slightly, not fail the build. The old script fail-closed on ANY missing/empty/unparseable XML, which conflated "one of three seeds timed out" with "the suite crashed". Per-side quorum: * A side's failure-union is computed from its USABLE seeds. Missing/unusable seeds are surfaced loudly ("⚠️ Degraded — proceeding on surviving seeds", naming each lost file) but tolerated. * The fail-closed guarantee is kept where it matters: if a WHOLE side has no usable XML (every seed crashed), that side's baseline cannot be established at all → still exit 1. You cannot crash your way to a green gate. Verified against the REAL artifacts from this PR's failed run (base-99999 genuinely absent, five others present): the fixed script prints the degraded warning and exits 0, where the old one exited 1. Same inputs. Self-tests extended 8 → 11: cases 5/6/7 reframed as "whole side lost → fatal" (unchanged behaviour, single-file input); case 9 (partial base loss, no regression → 0, the #1910 shape); case 10 (partial loss must NOT mask a real head regression → 1); case 11 (every base seed unusable → fatal → 1). The regression-diff job runs the PR-head version of this script, so the fix applies to this PR's own gate. Root cause of the slow seed (a pathologically slow shared runner, ~2.7x median) is not addressed here and is not this PR's to fix; the gate is now robust to it. Related to #1910 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI fix — the red
|
obasilakis
left a comment
There was a problem hiding this comment.
Approving — the core of this is exactly right and I verified it rather than taking the body on trust.
What I checked
- CI is green on 3.13: all six pytest legs ran 9–13 min against the 25-min cap,
regression diffSUCCESS. - The parity guard is genuinely load-bearing. Negative-tested three ways against the branch, all fire:
- revert
pg-migrations.ymlto 3.11 →pg-migrations.yml:68 pins 3.11 (images ship 3.13) - delete the
audioop-ltsline, leave the comment →audioop-lts (docker/backend/Dockerfile pins it for python >= 3.13)(i.e. the vacuous-substring trap you called out is really closed) - diverge
docker/scheduler/Dockerfileto 3.12 →test_all_images_pin_the_same_python
- revert
- The
audioop-ltscatch is the most valuable thing in the PR — 7 VoIP codec tests were about to silently stop running, and the regression-diff job compares failures, not counts, so nothing would have gone red.
One thing I'd like changed (non-blocking, but I think it's worth doing before merge)
The quorum relaxation in scripts/ci/diff-pytest-failures.py tolerates a lost seed on either side, but the risk isn't symmetric, because regressions = head_known - base_known:
| case | exit |
|---|---|
| all 6 seeds present, real regression only under head seed 3 | 1 — correctly red |
| head seed 3 lost to a timeout | 0 — pre-PR this was 1 |
| base seed lost (mirror case) | 1 — false-red, safe |
Reproduced directly against this branch's own diff().
Losing a base seed can only ever grow the regression set → false-red, which is precisely the flake this sets out to fix ("a base seed timing out at the 25-min cap was reddening unrelated PRs"). Losing a head seed shrinks head_known, so an order-dependent regression that only manifests under that seed's ordering disappears and the gate goes green — and the three seeds exist precisely to sample different orderings. dev has no branch protection, so regression diff is the signal people actually act on here.
Suggested fix is one line of asymmetry in the per-side loop: tolerate partial base loss (warn + proceed, as now), keep partial head loss fatal. That preserves 100% of the stated motivation with none of the new false-green path.
Minor
- The regression-diff change isn't mentioned anywhere in the PR body — it reads as a version-bump PR, so a reviewer skimming the description wouldn't know a fail-closed gate from #715 was relaxed in it. Worth a short section (or splitting it out, since it's independent of #1891).
tests/registry.jsonhas no entry fortests/unit/test_1891_python_version_parity.py(you added one in #1893). Nothing in CI reads it and it's 102/393 files, so purely optional.
Closes #1891
What
Every Trinity image is
FROM python:3.13; every CI job executing the suite pinned3.11. Bumps the six literals, adds a guard so the two declarations cannot silently diverge again — and fixes a real defect the bump surfaced.backend-unit-test.ymlbackend-unit-nightly.yml·schema-parity.yml·pg-migrations.ymlpublish-cli.ymlstays 3.12 — PyPI packaging is governed by what CLI consumers run, not the container runtime. Allowlisted in the guard with that reason.The bump surfaced a real defect — which is the point of it
I ran CI's exact invocation on both interpreters (
cd tests && pytest unit/ -m "not slow" -p randomly --randomly-seed=12345) and diffed with the repo's ownscripts/ci/diff-pytest-failures.py:No new failures — but 3.13 ran six fewer tests.
audioop-ltswas declared only indocker/backend/Dockerfile:76, never intests/requirements-test.txt. CI installs the requirements file, not the image. So on 3.13 there is noaudioop,tests/unit/test_voip_audio.pyskips at collection, and 7 VoIP codec tests silently stop running:Nothing would have gone red. The
regression diffjob — the very job this issue's AC says to read — compares failures, not test counts. A whole vanished module is invisible to it. Bumping the interpreter without this pin would have closed one instance of "CI environment ≠ shipped environment" while quietly opening another.So this PR also pins
audioop-lts; python_version >= '3.13'intests/requirements-test.txt. Verified:7 passedon 3.13 with it.The guard (the durable half)
tests/unit/test_1891_python_version_parity.py, three rules:python:<major>.<minor>.python-version:in.github/workflows/equals that pin — a scan of every workflow file, not six known line numbers, so a seventh added next month with a stale pin fails too.pkg; python_version >= Xthe image installs is also a test requirement. Matching the interpreter is only half of "CI runs what we ship"; rule 3 exists because rule 2 alone caused theaudiooploss above.Plus a stale-allowlist check, so a renamed/deleted exempt workflow can't leave a dead entry a future file inherits by name.
Verified in both directions — green as committed; red when I revert a pin to 3.11 (
pg-migrations.yml:68 pins 3.11 (images ship 3.13)) and red when I delete theaudioop-ltsline.One thing I got wrong first, worth calling out
Rule 3's first implementation was vacuous on arrival: it substring-matched the whole requirements file, and my own explanatory comment above the pin contains the string
audioop-lts— so deleting the real requirement line still matched and the guard stayed green. Caught it only because I ran the negative test. It now strips comments and parses requirement names (_declared_test_requirements). Same lesson aslearnings.md2026-07-29: a guard's own machinery is load-bearing code and needs its own negative test.Not a 3.13 memory regression
Local full-tree runs OOM'd, which looked alarming. Both interpreters OOM identically — it is the slow tests (which CI excludes via
-m "not slow") plus my container, not the version. Both wrote complete JUnit XML before the kill; the kill lands after the session ends, which is why the diff above is valid.Docs
architecture.mdsaid Python 3.11 for Backend, Agent runtime, and the base-image line — all three corrected to 3.13, plus a note naming the Dockerfile as source of truth and the guard as enforcement.Residual, stated plainly
The 7 fail / 37 error baseline is pre-existing on both interpreters (the #660 documented set) and unchanged by this PR. CI's own
regression diffon this PR is the authoritative check.🤖 Generated with Claude Code