Add per-engine spawn_stagger_s to serialize shared-state engine cold starts#27
Add per-engine spawn_stagger_s to serialize shared-state engine cold starts#27mlava wants to merge 2 commits into
Conversation
…starts Engines whose instances share local state fail their cold start when several workers launch in the same instant. OpenCode is the motivating case: every process opens one SQLite state DB (~/.local/share/opencode/opencode.db), so a 4-wide spawn wave has exactly one winner — the rest die in ~2s with "database is locked" (or a generic "Unexpected server error") before spending a single token, and ringer's simultaneous retries reproduce the collision. Observed in the field 2026-07-12: two waves, one survivor each; the two twice-dead tasks passed first-try when rerun serially. engines.<name>.spawn_stagger_s sets a minimum gap in seconds between consecutive spawns of that engine. A SpawnGate on the runner reserves spawn slots per engine (claim order, no barrier), applies to every attempt so retries stagger too, and logs a line to the worker log when a spawn actually waited. Default 0 preserves spawn-all-at-once for every existing config. Validated three ways: unit tests on the gate's slot math (injectable clock/sleep), an end-to-end mock-engine run asserting real spawn spacing from worker logs, and a live 3-wide opencode probe (stagger 2s): spawns at +0s/+2.0s/+4.0s, 3/3 first-try passes, zero lock errors — the same shape that lost 3 of 4 workers to the collision the same morning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
This is a solid feature — the shared-SQLite cold-start collision is real (we've hit it), the zero-by-default design is exactly right, and the test coverage (including the end-to-end subprocess case) is the standard we want. We'd like to merge it after four changes:
Happy to review a revision quickly — and heads-up that your #35/#36 touch adjacent code, so whichever lands second will want a small rebase. Thanks for the steady stream of well-scoped PRs; this is exactly the kind of contribution that's easy to say yes to. |
- duration_ms now excludes gate-queue time; the wait is recorded on the eval row as spawn_wait_ms (JSONL only) — queue time is orchestration overhead, not model speed - wait_turn() moved after command build + steering prep, immediately before the subprocess spawn, so the configured gap is the effective gap - spawn_stagger_s rejects inf/nan in load_engines, with tests beside the existing rejection cases - config.sample.toml notes that staggered workers still hold max_parallel slots while waiting, so a wide staggered batch can starve other engines Per review on PR NateBJones-Projects#27. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All four landed in ea105cc.
The e2e now also asserts the row evidence: exactly one row with On the rebase heads-up: noted — happy to rebase whichever of this and the #33/#35/#36 unification lands second. |
|
Heads-up on the The visible
Fix shape, if you want it: a |
…(opencode db-lock fix)
Problem
Engines whose instances share local state fail their cold start when several workers spawn in the same instant. OpenCode is the motivating case: every process opens one SQLite state DB (
~/.local/share/opencode/opencode.db), so a 4-wide spawn wave has exactly one winner — the rest die in ~2s withdatabase is locked(or a generic "Unexpected server error") before spending a single token. Ringer then retries all losers simultaneously, which reproduces the collision: one more winner, and the remaining tasks burn both attempts on harness failures that pollute the model scoreboard with FAIL rows the model never earned.Observed in the field (2026-07-12, four-model bakeoff): two spawn waves, exactly one survivor each; the two twice-dead tasks passed first-try when rerun serially.
Change
engines.<name>.spawn_stagger_s(float seconds, default0) sets a minimum gap between consecutive spawns of that engine:SpawnGateon the runner reserves spawn slots per engine in claim order — no barrier, workers of other engines are unaffected, and concurrent waiters sleep toward their own slots.[ringer.py] spawn stagger: waited N.Nsto the worker log whenever a spawn actually waited, so post-mortems can see the gate at work.0= spawn-all-at-once, zero behavior change for every existing config.Validation
tests/test_spawn_stagger.py): config parsing (defaults, float/int, rejects negative/string/bool) and the gate's slot math via injectable clock/sleep — three simultaneous claims spawn at t+0/+2/+4, engines don't block each other, no wait once the gap has already elapsed.ringer.py runover three mock-engine tasks atmax_parallel 3withspawn_stagger_s = 0.6, asserting spawn spacing from theattempt 1 startedtimestamps in the worker logs and exactly two stagger log lines.max_parallel 3with a 2s stagger — spawns at +0s/+2.002s/+4.001s, 3/3 first-try passes, zero lock errors. Same shape that lost 3 of 4 workers to the collision the same morning.Full suite: 190 tests, failure set identical to the pre-change baseline.
🤖 Generated with Claude Code