test: stop the voice budget test racing the CI scheduler - #378
Conversation
VoiceToolSafetyTest > "approval spends the tool's budget rather than adding to it" has failed twice in four days, macOS only, never on Ubuntu or Windows: 47b61bc 2026-08-24 failure (before any of the latency work) cacaba5 success ae37876 2026-08-27 failure ea28e08 success It is a genuine flake, not a regression from either merge, and it fails at the assertFailsWith line - no exception at all rather than the wrong one. Cause. The test sleeps delay(250) inside the approver, against an approval slice of min(APPROVAL_MS, 400) = 400 ms. That leaves ~150 ms of headroom to cover two withContext(Dispatchers.IO) hops and whatever the runner's scheduler is doing. On a loaded macOS box the headroom runs out, so withTimeoutOrNull around the approval returns null, confirmationRefusal answers with the refusal payload, the tool call never runs, nothing throws, and assertFailsWith fails. The test was asserting on the scheduler, not on the executor. Fix. CompositeVoiceToolExecutor already takes an injectable clock, added with the comment "Injected so a slow approval can be exercised without one" - the test just never used it. The approver now returns immediately while REPORTING that it spent 250 ms, which is exactly the input the property is about. The fixture gains a nowMs parameter to pass it through. Why this is now deterministic in the direction that matters: the approval reports 250 ms of a 400 ms budget, so the call runs under withTimeoutOrNull(150) while sleeping 250 ms. delay can overshoot but never finish early, so the timeout always wins. The remaining sleep is 250-vs-150, where overshoot only strengthens the assertion, rather than 250-vs-400 where overshoot inverted the outcome. The test keeps its full discriminating power: reintroducing the bug it exists for (hand the call a fresh budget instead of the remainder) makes it fail, verified by mutating `remainingMs = budgetMs - (nowMs() - startedAtMs)` to `= budgetMs`. Swept the rest of the voice tests for the same shape. The other delays are all delay(60_000) against small budgets, where overshoot can only make the timeout more certain. No other instance of a sleep sized close to the budget it races. Deliberately not verified by local load testing: the flake is macOS-CI-specific and a green loop on a developer machine is what made this look fine for months. CI is the only venue that can confirm it.
ReviewTest-only change, and the diagnosis holds up against
And the mutation you describe ( Three things worth considering, one of them substantive. 1.
|
The flake
VoiceToolSafetyTest > approval spends the tool's budget rather than adding to it, macOS only, never Ubuntu or Windows:47b61bc4(Aug 24, before the latency work)cacaba54ae378765(#376 merge)ea28e085(#377 merge, current master)Pre-existing and unrelated to either merge - it predates both. It fails at the
assertFailsWithline, meaning no exception at all, not the wrong one.Cause
The test sleeps
delay(250)inside the approver, against an approval slice ofmin(APPROVAL_MS, 400) = 400 ms. That leaves ~150 ms of headroom to cover twowithContext(Dispatchers.IO)hops plus whatever the runner is doing.When the headroom runs out on a loaded box,
withTimeoutOrNullaround the approval returns null,confirmationRefusalanswers with the refusal payload, the tool call never runs, nothing throws, andassertFailsWithfails. The test was asserting on the scheduler rather than on the executor.Fix
CompositeVoiceToolExecutoralready takes an injectable clock - added with the comment "Injected so a slow approval can be exercised without one". The test simply never used it.The approver now returns immediately while reporting that it spent 250 ms, which is exactly the input the property is about. The fixture gains a
nowMsparameter to pass it through.Deterministic in the direction that matters: the approval reports 250 ms of a 400 ms budget, so the call runs under
withTimeoutOrNull(150)while sleeping 250 ms.delaycan overshoot but never finish early, so the timeout always wins. The remaining sleep is 250-vs-150, where overshoot only strengthens the assertion - rather than 250-vs-400, where overshoot inverted the outcome.Still catches the bug it exists for
Mutating
remainingMs = budgetMs - (nowMs() - startedAtMs)to= budgetMs(a fresh budget for the call - the exact bug the test was written for) makes it fail. Verified.Swept for the same shape
The other delays in these tests are all
delay(60_000)against small budgets, where overshoot can only make the timeout more certain. No other instance of a sleep sized close to the budget it races.What I did not do
I did not try to prove this with local load testing. The flake is macOS-CI-specific, and a green loop on a developer machine is exactly what let it look fine while it was failing in CI. This PR's own CI run is the evidence, and re-running it a few times is the honest way to build confidence.
Test-only change: 2 files, +32/-2.