Repository navigation
fix(test): make the naive-writer mutation control collide deterministically - #4996
Conversation
…ically The control in tests/agy-start-cli.test.py ran 8 naive writers on one shared `.tmp` name and asserted that at least one raised. With a one-field payload the write window is microseconds, so a fast runner serialises the eight calls, nobody collides, and the control fails although the production atomic seed() is correct (2/20 locally under CPU load; twice in CI on #4976). Hold every writer's staged file behind a threading.Barrier(8) between its write and its os.replace: the first rename wins, the other seven target a name that no longer exists, so the control now asserts exactly 7 FileNotFoundError on every run instead of hoping for a race. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
john-the-dev
left a comment
There was a problem hiding this comment.
Approving at exact head d7964fac7662613b13796cb453ee7fdcc97f11ca. This is test-only, one file.
Why it's deterministic now:
staged.wait()sits between the shared.tmpwrite andos.replace, so all 8 writers have staged before any of them renames.- The first
os.replaceconsumes the single sharedp + ".tmp", and the other 7 find it gone. That gives exactlywriters - 1FileNotFoundErroron POSIX, every run. - No ordering assumption is left for disk speed to break.
What I ran, from a detached worktree at the head:
python3 tests/agy-start-cli.test.pypassed 10/10 (26 tests, OK).- Mutant with
staged.wait()removed: 10/10 runs fail. The exact-count assertion depends on the barrier, so the control can't pass vacuously.
Failure mode: if a writer dies before the barrier, the other writers raise BrokenBarrierError after at most the 30 s timeout. The count/type assertion then fails and prints repr of the failures, so a broken run fails loudly rather than hanging. Returning the exception object (not its repr) is what makes the assertIsInstance check possible.
Non-blocking: this relies on POSIX os.replace semantics for a missing source. On a Windows runner, concurrent opens of one .tmp could raise PermissionError instead. The Windows lane is skipped today, so this is only a note if that ever changes.
CI at review time: 15 pass, 8 pending, 1 skipping. This approval doesn't cover the pending lanes.
keweichen
left a comment
There was a problem hiding this comment.
Requesting changes on d7964fac7662613b13796cb453ee7fdcc97f11ca.
Blocker: force the schedule through the production-path test, not only its synthetic stand-in
The barrier makes the local naive_seed deterministic, but it no longer proves the contract stated immediately above it: “the naive shared-tmp-name writer this replaced must make the assertion above actually fail.” The production-path test at tests/agy-start-cli.test.py:113-137 still calls self.seed.seed(path) under ordinary executor scheduling. The new barrier exists only inside the separate local writer at :139-177. Therefore a future production regression from unique mkstemp names back to one fixed .tmp can serialize and pass the real assertion while this synthetic control independently passes.
I verified the distinction with an exact-head source mutation that kept the production writer's mode handling but replaced unique staging with one fixed .tmp: the real production-path test failed 16/20 but passed 4/20, while the new synthetic control passed 20/20 regardless. So the committed control does not make the sibling test's source-mutation discrimination deterministic; the PR body's three mutated runs happened to take the colliding schedule.
Please put the forced interleaving on the actual self.seed.seed path (for example, wrap/patch its os.replace with an 8-party barrier in the production concurrency test), then show the same committed harness passes with unique sources and deterministically fails with a fixed shared source. Reshape or remove the separate control so it exercises that same path rather than asserting a stronger relationship than it proves.
Evidence and scope
- Confirmed the bug on parent/current main
1be758ba5: the old focused control failed on my run 131 withfailures == []; the two cited #4976 CI attempts failed identically and passed on rerun at the same SHAs. - The new local barrier logic itself is sound: exactly one rename succeeds and seven raise
FileNotFoundError; the focused test passed 250/250 here and an independent run passed 1,000/1,000. The full file collected all 26 tests and passed. git diff --checkandreview-checks.shpass; the diff is test-only, so there is no live-path or user-facing blast radius.- Current CI's changed
agy-start-cli.test.pypassed. The redbrowser-persistentandslack-proactive-claim-requires-ownersuites are untouched and are not this finding, but the required aggregate gate still needs a green rerun before merge.
Non-blocking bookkeeping: #4928 predates #4977 and reports this exact flake with the same barrier suggestion. Please link/close that duplicate too so the original report is not left open after this PR lands.
Verdict: changes requested; not merge-ready until the committed test proves the production-path mutation deterministically and required CI is green on the resulting head.
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 90%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changesNo lines with coverage information in this diff. |
|
CI at |
…e naive control The previous commit put the barrier only inside the synthetic naive writer, so the production-path concurrency test still ran seed() under free executor scheduling: a regression from unique mkstemp staging back to one fixed `.tmp` could serialise and pass it (4/20 on review) while the control passed regardless. Both tests now share one harness that patches the module's os.replace with a wrapper holding every writer at an 8-party barrier before it renames, so all writers stage before any rename on every run. The two tests differ only in the writer: seed() must raise nothing and leave intact JSON with no stray staging file; the naive fixed-name writer must fail 7 of 8 with FileNotFoundError. With seed() mutated to a fixed staging name the production test fails 20/20 under load and the control still passes; with the real seed() both pass 20/20. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@cla-assistant check |
|
@keweichen — addressed at What changed: both tests now go through one harness, Evidence (20× each, whole file, under
Also added |
john-the-dev
left a comment
There was a problem hiding this comment.
Re-approving at exact head 34caa0de9b11e3b79e7cae394130d8ca2a9facb7. The delta from my d7964fac7 approval is one commit in tests/agy-start-cli.test.py (+44/−41).
What changed: both concurrency tests now go through _run_writers_staged_then_renamed. It patches the module's os.replace to wait on an 8-way Barrier before renaming. The production test and the mutation control therefore face the identical forced interleaving and differ only in the writer. The production test also now asserts that no staging files are left behind.
What I ran, from a detached worktree at head:
- The test file: 10/10 pass.
- Mutant:
src/agent/agy/onboarding_seed.py:69, withtempfile.mkstemp(...)replaced by a fixedpath + '.tmp'opened viaos.open. Result: 5/5 runs fail, ontest_concurrent_writers_do_not_collide_on_shared_staging_name. So the production test, not just the control, now catches the shared-staging-name bug deterministically. - My first mutation attempt used a regex that silently missed the line, and that run showed 0/5 failing. I re-applied it by line and confirmed with
git diff --stat(1 line changed) before trusting the 5/5.
Hazard: if seed() ever calls os.replace more or fewer than once per writer, the Barrier breaks after its 30 s timeout. The test then fails rather than hangs, which is the right failure mode.
keweichen
left a comment
There was a problem hiding this comment.
Re-reviewed exact head 34caa0de9b11e3b79e7cae394130d8ca2a9facb7: APPROVE. This replaces my stale request-changes review on d7964fac7.
The prior mutation-discrimination blocker is resolved. Both the real self.seed.seed path and the naive fixed-.tmp control now use the same helper and the same patched-os.replace eight-way barrier; only the writer differs.
Evidence:
python3 tests/agy-start-cli.test.py -v— 26/26 passed.- Unmutated production test — 250/250 passed; naive control — 250/250 passed.
- With production mutated from unique
mkstempstaging topath + ".tmp", the production-path test failed 250/250 (exactly sevenFileNotFoundErrors per representative failure), while the naive control stayed green 250/250. The full mutant suite had exactly the intended one failure. - After reverting the mutation, the production test passed 100/100 and the checkout was clean.
git diff --check,scripts/review-checks.sh, current CI, and CLA are green.
No blocking findings remain. The change is test-only; the barrier's failure mode is bounded by its 30-second timeout.
What changed, and why
tests/agy-start-cli.test.pyhas a production-path concurrency test for the atomicseed()writer and a mutation control for it (8 naive writers through one shared<path>.tmp). Both relied on the executor happening to interleave the eight writers' stage-then-rename steps. That race is hoped for, not guaranteed: with a small payload the write window is microseconds, a fast runner serialises the calls, nobody collides, and the control fails (failures == []) although the production writer is correct. It failed twice on #4976 (runs 36808730430, 36816482701) on a PR that touches no agy file, and passed on rerun; #4928 reports the same flake.Root cause: a property of the form "this race must happen" was asserted; a CI lane cannot promise it. The interleaving is now forced by construction, on the real writer and on the control alike.
Fix (test-only, no
src/change): one shared harness,_run_writers_staged_then_renamed(writer, path), patches the module'sos.replace(unittest.mock.patch.object(self.seed.os, "replace", …)— the same seamtest_write_failure_cleans_up_staging_file_and_reraisesalready uses) with a wrapper that waits on an 8-partythreading.Barrierbefore renaming. Every writer therefore stages before any writer renames, on every run. The two tests differ only in the writer passed in:test_concurrent_writers_do_not_collide_on_shared_staging_nameruns the realself.seed.seed: asserts no exception, the 500 000-charunrelatedBigFieldintact, all three fieldstrue, and no stray staging file left in the directory.test_concurrency_test_would_catch_a_naive_non_atomic_writerruns_naive_seed(the fixed shared.tmpwriterseed()replaced) through the same harness: asserts exactly 7 of 8 fail, each aFileNotFoundError(only the first rename can win).Why all eight real writers reach the barrier: no rename happens until all eight are waiting, and each writer reads before it stages, so all eight read the unseeded file, compute
changed=True, and callos.replace. The barrier has a 30 s timeout so a broken schedule surfaces asBrokenBarrierError, not a hang.Files to look at first
tests/agy-start-cli.test.py:_run_writers_staged_then_renamed,_naive_seed, and the two tests that call them (lines ~113-185).What user behavior or bug does this prove
No user-visible behavior; it removes a flake that fails unrelated PRs in
python standalone tests, and makes the production-path test deterministically catch a regression from uniquemkstempstaging back to a fixed shared name.Feature/documentation consistency
N/A — test-only change; no user-visible feature or capability changes.
Before/after evidence
Whole file run 20× under CPU load (
yes > /dev/null &×4) on a 10-core macOS host.Before — parent commit
1be758ba5(origin/main):One of those failures (run 3):
After — HEAD
34caa0de9:With the live worker's
SUTANDO_*environment scrubbed (bash -c "env -u SUTANDO_… python3 tests/agy-start-cli.test.py; echo exit=$?"):The committed harness catches a fixed-staging-name production writer deterministically
Source mutation at HEAD in
src/agent/agy/onboarding_seed.py, keeping the writer's mode handling and replacing only the unique staging:Same 20× loop under load against the mutated source:
The production-path test fails 20/20 and nothing else does; the control passes 20/20 because it tests the naive writer, not
seed(). Mutation then reverted (git checkout -- src/agent/agy/onboarding_seed.py; the PR diff contains nosrc/line) and the file passes:Tests run
Live path: N/A — test file only.
Closes #4977
Closes #4928
🤖 Generated with Claude Code