Session ids: allocate the test store prefix instead of hashing it, and panic on a duplicate - #280
Merged
Broccolito merged 1 commit intoSep 12, 2026
Conversation
…nd panic on a duplicate id #273's `test (ubuntu-latest)` (run 34670441778, attempt 1) hung and was cancelled at the 40-minute timeout: no FAILED, no panic, four tests in the `biorouter` lib binary reported "running for over 60 seconds", ~4000 passing results discarded. The same binary finishes in 85 s elsewhere. WHAT THE FOUR STALLED TESTS SAY. Three carry `serial_test::serial(workspace_services)` and the fourth waits on `subagent_tool`'s `QUEUE_DEPTH_TESTS`, which `unverified_steering_stays_recoverable_through_a_successful_run` holds while it also holds the `workspace_services` key. So exactly ONE test was wedged and three were queued behind it -- the same shape as the 2026-08 hang, whose commit records "one of those two was merely queued behind the other". THE MITIGATION WAS NOT LOST, which changes what this commit can claim. `acae89ea` (2026-08-27) is an ancestor of #273's parent 38270a5 and its `#[cfg(test)]` per-store `id_prefix` is still in the tree. `clear_for_tests()`, which project memory records as the fix, was written and REVERTED -- it clears a global that concurrently-running tests read. So the collision the brief describes is closed for the lib suite, and this change does NOT claim to have found #273's mechanism. It fixes two holes in that mitigation, one measured as unsound and one measured as live. HOLE 1, FIXED HERE: THE PREFIX WAS PROBABILISTIC. `id_prefix` was `DefaultHasher(session_dir)` truncated to `u32`, so two stores whose paths collide in 32 bits both mint `<prefix>_1` -- silently, with the symptom arriving later as a turn that never returns. Measured: a brute force over TempDir-shaped paths found a colliding pair after 11,213 candidates, and 200,000 distinct paths yield only 199,995 prefixes -- 5 real collisions, against 4.66 expected for an ideal 32-bit hash, so it behaves as the birthday bound predicts. At the 752 stores one lib-test run actually allocates (instrumented; an earlier reading of 114,690 was this change's own 200k test contaminating the count) that is ~1 in 15,000 runs. Unsound, but too rare to be what hung #273 -- said plainly rather than inflated into a cause. It is now ALLOCATED from a counter, which cannot collide at all. HOLE 2, PROVEN LIVE AND DELIBERATELY LEFT OPEN. `#[cfg(test)]` compiles only for this crate's own unit tests. Every integration binary in the workspace -- and every test in biorouter-server, biorouter-mcp and biorouter-cli -- links this crate built WITHOUT it, prefixes ids with the date, and mints `<date>_1` from every store. Measured by running the new duplicate guard outside `cfg(test)`: `tests/agent.rs` fails 4 tests and `tests/conversation_writeback_stress.rs` fails 8, each reporting `session id 20260912_1 was minted twice in one process` between two named TempDir stores. CI runs those binaries. Both ways to close it are a maintainer's call, not a bug fix's, and the second was written and measured before being removed: - give later stores a non-date prefix in non-test builds -- changes user-visible ids on a real production path, since `biorouter-acp`'s server constructs its own manager; - floor the numeric part process-wide (one statement, production provably unchanged at one store per process) -- but it breaks every fixture that replays id reuse, because `a_rewrite_basis_cannot_cross_a_wipe_that_recycled_the_session_id` asserts the wipe hands *the same id* back. Seven such fixtures across four modules, found by running them. WHAT KEEPS THIS FROM BEING LOST AGAIN. `MINTED_IDS` records every id minted and the store that minted it, and panics naming both stores. The failure mode is the point: this bug does not fail where it is caused, so a panic at the mint is the difference between five minutes and a week. A re-mint by the SAME store is a quiet return, which is what makes the id-reuse seam keep working. Two defects in the guard's own first draft, both measured and both fixed: it panicked while holding its mutex, so one genuine detection became seven failures with six meaningless `PoisonError`s; and a clearing hook written for the reuse seam turned out to be dead code, deleted once the test passed without it. WHAT THIS DELETES, because a fix at the source should remove the papers over it rather than become the sixth. `subagent_tool`'s `reserve_child_session_ids` spacer bands (40/80/120/160, four call sites) and `workspace_extension`'s `seeded_target` band counter, whose k-th caller created 15 + k sessions. Measured on one lib-test run: 4264 -> 1167 `create_session` transactions, and that run 48.66 s -> 32.41 s. The two `serial_test` keys (`subagent_session_bus`, `agent_manager_pin`) are KEPT and their doc comments corrected. Both were added for the collision and both tell the next author to join them; that reason is gone, but they still buy ordering, and a scheduling change whose failure mode is a 40-minute CI hang needs more than a handful of local runs behind it. NOT REPRODUCED LOCALLY. The suite is green at 1, 2, 3, 4, 8 and 16 threads before and after. What is reproduced deterministically is each defect, by a test that fails before this change and passes after.
Broccolito
force-pushed
the
fix/subagent-handles-global-collides-across-tests
branch
from
September 12, 2026 09:21
d7846d9 to
7e38c66
Compare
Broccolito
deleted the
fix/subagent-handles-global-collides-across-tests
branch
September 12, 2026 09:46
Broccolito
added a commit
that referenced
this pull request
Sep 12, 2026
Textually clean — zero conflicts. The "ctor conflict" the previous pass was resolving had already been settled in this branch's own earlier merges of #282 (ccdd9b2) and #280 (055cb08); what was left was an unpushed branch, not an unfinished resolution. GitHub reported DIRTY because the remote tip (24a6d65) was 23 commits behind the local branch. The one thing worth writing down is that #286 and this branch are the SAME rule at two layers, not two patches, and the difference between them is not a matter of taste: * A subject that resolves `BIOROUTER_PATH_ROOT` **live** and needs it held still takes `env_lock` and installs the RECORDED sandbox root (`pin_sandbox_path_root`) — the two call sites this branch corrects. * A subject that resolves it live where the live value IS the assertion takes `env_lock` with an EMPTY set — #286's `the_session_database_is_not _the_developers`. Pinning there would assert the value it had just written, which #286 shipped once and corrected. * A subject that is already FROZEN takes no lock at all and compares against the recorded root. `Config::global()` is a `OnceCell<Config>` and `SHARED_STORE_ROOT` a `LazyLock<PathBuf>` forced by the ctor (#282), so neither re-reads the variable and there is nothing for a lock to serialise — pinning them would be #286's vacuity in this crate. All three say the same thing: after main starts, the live variable answers "whichever of ~30 relocating tests holds it right now", so the only stable answer is one recorded before any test ran. `only_the_resolver_and_the_sandbox_read_the_path_root_variable` enforces that mechanically for `crates/biorouter/src`. Nothing in this branch was made redundant by #280, #282 or #286, so nothing was deleted. #280's five removed collision defences stay removed: verified that `seeded_target` carries no band counter and `reserve_child_session_ids` survives only in the doc comment recording its deletion.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What I was asked to confirm, and what I actually found
The brief said #273's 40-minute
test (ubuntu-latest)hang was colliding testsession ids meeting the process-global
subagent_handle::HANDLES, that amitigation (
clear_for_tests()) had been lost, and that I should find out why.Two of those three are wrong, and saying so is most of the value here.
acae89ea(2026-08-27) madeid_prefixper-store under
#[cfg(test)], and it is an ancestor of Four serve / apps serve / CLI lifecycle defects: an ignored browser token, an orphaned daemon, a stale cancellation warning, and an ungated scheduled run #273's own parent38270a50— so it was in effect when Four serve / apps serve / CLI lifecycle defects: an ignored browser token, an orphaned daemon, a stale cancellation warning, and an ungated scheduled run #273 hung.clear_for_tests()neverappears in any commit (
git log --all -S): its own commit message recordsthat it was written and reverted, because it clears a global that
concurrently-running tests read.
not reproduce the hang: the suite is green at 1, 2, 3, 4, 8 and 16 threads on
this machine, before and after.
What I did establish about #273, from the cancelled attempt's log
(
gh api .../actions/jobs/103490679332/logs):And I found two holes in the surviving mitigation — one measured unsound,
one measured live. This PR fixes the first and documents the second with a
reproduction rather than half-closing it.
Hole 1, fixed: the prefix was probabilistic
id_prefixwasDefaultHasher(session_dir)truncated tou32. Two storeswhose paths collide in 32 bits both mint
<prefix>_1— silently, with thesymptom arriving later as a turn that never returns.
TempDir-shaped pathsSo it is unsound, but too rare to be what hung #273 — stated plainly rather
than inflated into a cause. (An earlier reading of 114,690 stores was this PR's
own 200k test contaminating the count; corrected by re-measuring with the new
tests skipped.)
It is now allocated from a counter, which cannot collide at all.
Hole 2, proven live, deliberately left open
#[cfg(test)]compiles only for this crate's own unit tests. Every integrationbinary in the workspace — and every test in
biorouter-server,biorouter-mcp,biorouter-cli— links this crate built without it, prefixes ids with thedate, and mints
<date>_1from every store. CI runs those binaries.Measured by building the new duplicate guard outside
cfg(test):Both ways to close it are a maintainer's call, and the second was written,
measured and removed:
ids on a real production path (
biorouter-acp/src/server.rs:346constructs itsown manager);
CLAIM_NEXT_SESSION_N(
MAX(db_max+1, high_water+1, ?2)), production provably identical at one storeper process. But it breaks every fixture that replays id reuse, because
a_rewrite_basis_cannot_cross_a_wipe_that_recycled_the_session_idassertssecond == first— the wipe must hand the same id back. Seven such fixturesacross four modules, found by running them, not by reading them.
Filed as a follow-up with the full reproduction.
What keeps it from being lost again
MINTED_IDSrecords every minted id and the store that minted it, and panicsnaming both stores. The failure mode is the point: this bug does not fail where
it is caused, so a panic at the mint is the difference between five minutes and a
week of bisection. A re-mint by the same store is a quiet return, which is
what lets the documented id-reuse seam keep working.
Two defects in the guard's own first draft, both measured and fixed: it panicked
while holding its mutex, so one genuine detection became seven failures with six
meaningless
PoisonErrors; and a clearing hook written for the reuse seam turnedout to be dead code, deleted once the test passed without it.
What this deletes
A fix at the source should remove the papers over it rather than become the
sixth. The tree carried five hand-rolled defences against one collision.
Gone:
subagent_tool'sreserve_child_session_idsspacer bands (40/80/120/160,four call sites) and
workspace_extension'sseeded_targetband counter, whosek-th caller created 15 + k sessions.
Measured on one lib-test run, isolating just this removal:
create_sessioncallsThe two
serial_testkeys (subagent_session_bus,agent_manager_pin) arekept, with their now-false doc comments corrected — both were added for the
collision and both instruct the next author to join them. They still buy
ordering, and a scheduling change whose failure mode is a 40-minute CI hang needs
more than a handful of local runs behind it.
Tests that fail before and pass after
Each mechanism was reverted in isolation and re-run:
two_stores_the_old_hashed_prefix_confused_now_get_different_prefixesFAILED (both38a7a4d7),the_store_prefix_is_exact_at_a_scale_where_a_32_bit_hash_is_notFAILED (199,995 ≠ 200,000)the_duplicate_guard_panics_and_names_both_storesFAILEDThe hash-collision fixture is deterministic, not probabilistic: it asserts the
pair still collides under the old scheme first, so if
DefaultHashereverchanges the test fails loudly telling you to re-brute-force a pair, rather than
passing vacuously.
Repeated-run evidence (post-rebase onto
main)BIOROUTER_DISABLE_KEYRING=true cargo test -p biorouter --lib, run fromcrates/biorouter. A 34-minute stall could not hide in any of these.Baseline on the pre-change tree, for comparison: green at threads 4/1/2/3 in
162 s / 240 s / 100 s / 108 s — i.e. the hang does not reproduce here either way.
cargo fmt --checkclean.cargo clippy -p biorouter --all-targetsclean.⚠
./scripts/clippy-lint.shfails onbiorouter-server/src/routes/reply.rs:1863(
result_large_erronauthorize_steer, from #260). Pre-existing onmain—git diff origin/mainfor that file is empty — and CI's clippy step isinformational (
cargo clippy --workspace --all-targets --locked, no-D warnings), so it is not gating. A chip already exists for it.Production behaviour
Unchanged, and structurally so: every line added is
#[cfg(test)]. The#[cfg(not(test))]id_prefix(today's date) andCLAIM_NEXT_SESSION_Narebyte-identical to
main.one_store_still_numbers_from_one_without_gapspinsthat one store still numbers 1..n with no gaps.
🤖 Generated with Claude Code