From 8b6ff36c1749d8777dbfe3c967588987554ed9e3 Mon Sep 17 00:00:00 2001 From: Amir Khakshour Date: Thu, 29 Oct 2026 13:14:15 +0100 Subject: [PATCH 1/3] ci: run each python test shard on every runner core The unit shards ran serially and took 23-39 min of pytest each (36-39 min on unit-a), against a 50 min timeout raised two days ago to stop passing runs being cancelled. Setup is seconds; it is all pytest. Measured before changing anything: the whole tests/unit tier on 8 pytest-xdist workers passes (14,000 passed, 58 skipped, 0 failed, 0 errors) in 13.6 min, and the rest tier in 48 s instead of 123 s. The time is spread thin -- the slowest file is ~4% of the total and the top 30 files ~31% -- so parallelism is what moves it, not trimming single tests. - install pytest-xdist and run every shard with `-n auto` - lower the timeout to 30: a shard that needs more is a hang or a regression worth seeing Claude-Session: https://claude.ai/code/session_01E9VqwxxzV8Ad926sce4CgW --- .github/workflows/ci.yml | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af9650e4..101a5407 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,18 +58,21 @@ jobs: python-tests: name: python tests (${{ matrix.python-version }} / ${{ matrix.shard }}) runs-on: ubuntu-latest - # The unit shards run 22-33 min and drift with every added test file (the - # odd/even split reshuffles); 35 cancelled a passing 3.12/unit-a at 35.4 - # min on 2026-10-28 (a re-run took 28.6). Headroom, not a ceiling to aim at. - timeout-minutes: 50 + # Each shard runs its tests on every runner core (pytest-xdist, below). + # Serially the unit shards took 23-39 min and 50 was the timeout; a shard + # that now needs more than 30 is a hang or a regression worth seeing. + timeout-minutes: 30 strategy: fail-fast: false matrix: python-version: ["3.11", "3.12"] - # Sharding: the suite is ~2,090 tests dominated by bash<->python - # parity subprocess costs (~14-17 min as one job). Split the big - # tests/unit tier odd/even by file (interleave balances the slow - # clusters) and run everything else as a third shard. + # Sharding: tests/unit is ~14,000 tests whose time is spread thin — + # subprocess and git work in hundreds of files, the slowest file ~4% + # of the total — so parallelism, not trimming single tests, is what + # moves it. Split it odd/even by file and run everything else as a + # third shard; within a shard, xdist uses all cores (`-n auto`). The + # whole unit tier passes on 8 workers (14,000 passed, 0 failed, + # 2026-10-29), so tests do not share state across processes. shard: [unit-a, unit-b, rest] steps: - uses: actions/checkout@v7 @@ -90,7 +93,7 @@ jobs: # tests/unit/test_acp_agent_py.py imports at module scope; without # the extra the whole shard dies at collection, not on an assertion. pip install -e ".[yaml,acp]" - pip install pytest fastapi uvicorn httpx2 + pip install pytest "pytest-xdist>=3.5" fastapi uvicorn httpx2 # The web smoke suite points the FastAPI route handlers at a real # state.db; init bootstraps the full schema so those tests run @@ -100,15 +103,15 @@ jobs: - name: Run pytest (unit shard A) if: matrix.shard == 'unit-a' - run: ls tests/unit/*.py | awk 'NR % 2 == 1' | xargs python -m pytest -q -p no:cacheprovider + run: ls tests/unit/*.py | awk 'NR % 2 == 1' | xargs python -m pytest -q -p no:cacheprovider -n auto - name: Run pytest (unit shard B) if: matrix.shard == 'unit-b' - run: ls tests/unit/*.py | awk 'NR % 2 == 0' | xargs python -m pytest -q -p no:cacheprovider + run: ls tests/unit/*.py | awk 'NR % 2 == 0' | xargs python -m pytest -q -p no:cacheprovider -n auto - name: Run pytest (root + non-unit layers) if: matrix.shard == 'rest' - run: python -m pytest tests/ -q -p no:cacheprovider --ignore=tests/unit + run: python -m pytest tests/ -q -p no:cacheprovider -n auto --ignore=tests/unit # The optional `runtime` extra pins `verifiers>=0.2.0,<0.5`, and that ceiling moves # by dependabot PR (it went <0.3 -> <0.5 on 2026-10-27 with no check on the API). From 7299340b886945102f3a39c99f629fdeaefbede8 Mon Sep 17 00:00:00 2001 From: Amir Khakshour Date: Thu, 29 Oct 2026 13:32:23 +0100 Subject: [PATCH 2/3] ci: collect the nested unit tests no shard ever ran `ls tests/unit/*.py` matches top-level files only, and the rest shard passes --ignore=tests/unit, so tests/unit/acp/ (2 files, 28 tests) ran in no CI job. `find tests/unit -name 'test_*.py' | sort` reaches them; every top-level file already matches test_*.py, so nothing drops out. All 28 pass. The 804 files still split one-to-one across the shards. Also record the evidence for running the rest shard under xdist: 734 passed on 8 workers; its one local failure (test_no_populated_table_is_dark, which audits ./.mini-ork/state.db) reproduces serially and passes in CI. Found by pre-push review 175. Claude-Session: https://claude.ai/code/session_01E9VqwxxzV8Ad926sce4CgW --- .github/workflows/ci.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 101a5407..67a01d00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,9 +70,12 @@ jobs: # subprocess and git work in hundreds of files, the slowest file ~4% # of the total — so parallelism, not trimming single tests, is what # moves it. Split it odd/even by file and run everything else as a - # third shard; within a shard, xdist uses all cores (`-n auto`). The - # whole unit tier passes on 8 workers (14,000 passed, 0 failed, - # 2026-10-29), so tests do not share state across processes. + # third shard; within a shard, xdist uses all cores (`-n auto`). Both + # tiers pass on 8 workers (unit: 14,000 passed, 0 failed; rest: 734 + # passed, its one local failure reproduces serially; 2026-10-29), so + # tests do not share state across processes. + # `find`, not `ls tests/unit/*.py`: the glob never reached + # tests/unit/acp/, so its 28 tests ran in no shard at all. shard: [unit-a, unit-b, rest] steps: - uses: actions/checkout@v7 @@ -103,11 +106,11 @@ jobs: - name: Run pytest (unit shard A) if: matrix.shard == 'unit-a' - run: ls tests/unit/*.py | awk 'NR % 2 == 1' | xargs python -m pytest -q -p no:cacheprovider -n auto + run: find tests/unit -name 'test_*.py' | sort | awk 'NR % 2 == 1' | xargs python -m pytest -q -p no:cacheprovider -n auto - name: Run pytest (unit shard B) if: matrix.shard == 'unit-b' - run: ls tests/unit/*.py | awk 'NR % 2 == 0' | xargs python -m pytest -q -p no:cacheprovider -n auto + run: find tests/unit -name 'test_*.py' | sort | awk 'NR % 2 == 0' | xargs python -m pytest -q -p no:cacheprovider -n auto - name: Run pytest (root + non-unit layers) if: matrix.shard == 'rest' From 3a8479d5e00adbff69a06cb1fff4056b202d21ba Mon Sep 17 00:00:00 2001 From: Amir Khakshour Date: Thu, 29 Oct 2026 13:33:39 +0100 Subject: [PATCH 3/3] fix(pre-push): stop the hook's children writing to the pushing repo git exports GIT_DIR to hooks (always, in a linked worktree), and every layer of this hook inherits os.environ, including the review lenses, which are agents that may run the test suite. A test that runs `git init` / `add -A` / `commit` in a temp dir then operates on the pushing repository instead: `git init` re-initialises it, `add -A` from the temp dir replaces its index with the fixture, and `git config` writes to the shared .git/config. That happened on 2026-10-29 during one push of wt/ci-speed: 5 fixture commits landed on the branch (the tree went from 3,574 files to 8), and `[user] name = t, email = t@t` was written to the primary's .git/config, so every session committing in this repo authored as `t` until it was removed. One other session's commit on local main picked it up. Drop the repo-binding variables at entry, after the chdir to the worktree root, so git rediscovers the same repository from cwd. The list comes from `git rev-parse --local-env-vars`, which is what git clears for a submodule process. Reproduced in a scratch repo with a linked worktree: without the fix the hook's grandchild commits onto the branch and sets user.name; with it, neither happens. Claude-Session: https://claude.ai/code/session_01E9VqwxxzV8Ad926sce4CgW --- .githooks/pre-push | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.githooks/pre-push b/.githooks/pre-push index c146493a..e7f0f49b 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -54,6 +54,26 @@ def _git(*args: str) -> subprocess.CompletedProcess: ) +def _drop_repo_env() -> None: + """Remove the variables that bind a git process to THIS repository. + + git exports GIT_DIR to hooks (in a linked worktree it is always set), and + every layer below inherits os.environ. The review lenses are agents that + may run the test suite; a test that does `git init` / `add -A` / `commit` + in a temp dir then writes to the pushing repo instead. On 2026-10-29 one + push left 5 fixture commits on the branch (the tree reduced to 8 files) and + `[user] name = t` in the shared .git/config. The hook has already chdir'd + to the worktree root, so git rediscovers the same repo without them. + `--local-env-vars` is git's own list: what it clears for a submodule. + """ + names = _git("rev-parse", "--local-env-vars").stdout.split() or [ + "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", + "GIT_OBJECT_DIRECTORY", "GIT_PREFIX", + ] + for name in names: + os.environ.pop(name, None) + + def _source_local_env(repo_root: Path) -> None: """Parse simple KEY=VALUE lines from $MINI_ORK_HOME/config/local.sh. @@ -176,6 +196,8 @@ def main() -> int: except OSError: return 0 + _drop_repo_env() + # Per-developer local env preferences (non-secret). _source_local_env(REPO_ROOT)