fix(runner): fast-fail spawn-thrash when no task completes#657
Conversation
PROBLEM
The experiment runner is a long-lived controller that polls Marcus
every ~30 seconds and spawns ephemeral, one-task agents to fill the
"desired agent count" Marcus reports. When a task gets marked BLOCKED
(for example because a worktree merge to main failed with a real
content conflict that auto-rebase cannot resolve), every downstream
task that depends on it stays unclaimed forever. Marcus still reports
desired > in_flight, so the runner keeps spawning agents that
immediately get "no task ready" and exit. Each wasted agent costs
roughly $0.50–$1.00 in tokens.
The existing safety net is a stall watchdog that fires only after 20
minutes of zero change to (completed, in_progress, blocked). That tuple
keeps flickering during spawn-thrash (in_progress goes up by one when
the agent claims, back down when it exits), so the watchdog stays
quiet. Real recent incidents:
- test71 (verify-snake-8): 49 agents spawned over ~20 min
- test73 (verify-snake-10): 38 agents spawned before manual kill
CHANGE
Add a new SpawnThrashDetector alongside StallWatchdog in
spawn_controller.py. Each poll the runner feeds it two numbers:
- completed: cumulative tasks completed this run
- to_spawn: number of agents spawned this poll
If completed is unchanged AND to_spawn > 0 for `thrash_polls` polls
in a row, the detector reports thrash and the runner tears the
session down ("spawn_thrash" exit reason). Any completion resets the
counter. A poll with to_spawn == 0 holds the counter without
incrementing it (a slow task is not thrash, but the thrash may resume
once the runner spawns again).
Default threshold is 5 polls — at the 30s poll cadence that is a 2.5
minute fast-fail. Configurable via `spawn_thrash_polls` in
config.yaml; 0 disables. Worst case waste under the new threshold:
~5 agents per incident instead of the 30–50 we have been seeing.
WHY A SEPARATE DETECTOR
StallWatchdog answers "did the whole run wedge?" — that signal must
stay slow because a long-running task can legitimately leave the
tuple flat for minutes. SpawnThrashDetector answers a sharper
question — "is the runner actively burning money on doomed agents?"
— which can be detected quickly without false-positives on slow
tasks.
TESTS
Seven new unit tests in tests/unit/experiments/test_spawn_controller.py
cover: fires after N idle-spawn polls, completion resets, to_spawn=0
holds the counter, thrash_polls=0 disables, first-poll baseline never
fires, post-fire re-arm, and a realistic test73 trace. All 21
spawn_controller tests pass; all 90 spawn_agents tests pass.
This unblocks issue #206 (file-level baton arbitration), which the
user wants gated on spawn-thrash being fixed first so that a
coordination bug there cannot cost tens of dollars per run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Claude finished @lwgray's task —— View job Code Review Complete ✅Overall Assessment: This is a well-designed and implemented solution to an expensive failure mode. The code quality is high with excellent documentation, comprehensive testing, and clean separation of concerns. 🟢 StrengthsArchitecture & Design
Implementation Quality
Test Coverage
🟡 Minor ConsiderationsPerformance
Configuration
Error Handling
🟢 Security & Safety
📋 Code Quality Details
|
|
Codex Review: Didn't find any major issues. Nice work! ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
What is this system, briefly
Marcus is a coordination platform for multi-agent software builds. The
experiment runner (the long-running script in
dev-tools/experiments/runners/spawn_agents.py) is the program thatactually drives a Marcus run: it polls Marcus every ~30 seconds and
spawns short-lived "ephemeral" agents — each does exactly one task
and exits — to fill the agent count Marcus asks for. This PR fixes
a failure mode in the runner that can quietly burn tens of dollars
per run.
Why (the user-visible problem)
When a task gets marked BLOCKED in Marcus — for example because an
agent's worktree merge to
mainfailed with a real content conflictthat the auto-rebase recovery added in PR #656 cannot resolve — every
downstream task that depends on it stays unclaimed forever. Marcus
still reports
desired_agent_count > in_flight_tasks, so the runnerkeeps spawning agents that immediately receive "no task ready" and
exit. Each wasted agent costs roughly $0.50–$1.00 in tokens.
The existing safety net is a stall watchdog that fires only after
20 minutes of zero change to the
(completed, in_progress, blocked)task-count tuple. Under spawn-thrash, that tuple keeps flickering
(
in_progressgoes up by one when the agent claims, back down whenit exits) — so the watchdog never trips. Real recent incidents:
If this happened on a user's API key in a hosted Marcus deployment,
one bad project could rack up real money before anyone noticed.
What changed
New:
SpawnThrashDetector(indev-tools/experiments/runners/spawn_controller.py)A second watchdog that runs alongside
StallWatchdog. Each poll therunner reports two numbers:
completed: cumulative tasks completed this runto_spawn: number of agents the runner is spawning this poll(the output of
compute_spawn_count)The detector counts consecutive polls where
to_spawn > 0ANDcompleteddid not increase. When the count reachesthrash_polls(default 5), the detector reports thrash and the runner tears the
session down with the new
spawn_thrashexit reason.Behavior details:
to_spawn == 0) holds thecounter where it is — a slow task is not thrash, but the thrash may
resume once the runner spawns again, and we should not have to
start counting from scratch.
to compare
completedagainst), so the detector never fires onthe very first poll.
thrash_polls = 0disables the detector entirely.Wired into the runner main loop (
spawn_agents.py)The detector is constructed alongside the stall watchdog and consulted
every poll, immediately after the existing stall-watchdog check.
Config
A new top-level config key
spawn_thrash_polls(default5) isread in
ExperimentSpawner.__init__. At the default 30-second pollcadence this is a 2.5-minute fast-fail. Worst-case waste under
the new threshold: ~5 agents per incident instead of the 30–50 we
have been seeing — a 6–10x improvement.
Why a separate detector (vs. tightening StallWatchdog)
StallWatchdoganswers "has the whole run stopped making any kind ofprogress?" — that has to be a slow signal because a long-running task
can legitimately leave the tuple flat for many minutes. Speeding it
up would cause false positives on slow tasks.
SpawnThrashDetectoranswers a sharper question — "is the runneractively burning money on doomed agents?" — and can be detected
quickly without that risk because the signal includes the spawning
behavior itself, not just whether the counts moved.
Worked numerical example
At default
spawn_thrash_polls = 5andcontrol_poll_seconds = 30:desired_agent_count;for a typical 10-agent run, ≤ 5 spawns (one or two per poll)
Where to look in the code first
dev-tools/experiments/runners/spawn_controller.pySpawnThrashDetectorclass (pure decision logic, no I/O)dev-tools/experiments/runners/spawn_agents.pyspawn_thrash_pollsconfig keytests/unit/experiments/test_spawn_controller.pyTestSpawnThrashDetectorGlossary
completedflat whileto_spawn > 0.mainfailed). Downstream tasks that depend on it stay unclaimed.(completed, in_progress, blocked)stops changing. Does NOT catch spawn-thrash because the tuple keeps flickering.to_spawncompute_spawn_count(desired, in_flight, unclaimed)— how many agents the runner will start this poll.How to verify it works
TestSpawnThrashDetector.python -m mypy dev-tools/experiments/runners/spawn_controller.py \ dev-tools/experiments/runners/spawn_agents.pySuccess: no issues found in 2 source files.reliably produces a real content merge conflict. The runner should
emit
SPAWN-THRASH: spawned agents for 5 polls with no task completingand tear down after ~2.5 minutes instead of waiting20 minutes.
Test plan
SpawnThrashDetectorfires after N idle-spawn pollsto_spawn == 0holds the counter without incrementingthrash_polls == 0disables the detectorcompletedplateau at 4)spawn_agentsunit tests still passspawn_thrashteardown in ~2.5 minRelated
feat(#651): auto-recover BLOCKED-by-merge-conflict tasks via rebase(Option A — fixes the 80% stale-base case; real content conflicts still produce BLOCKED tasks that this detector now catches in 2.5 min)fix(#651): mark task BLOCKED when worktree merge to main fails(root cause of the BLOCKED-task scenarios that surface spawn-thrash)🤖 Generated with Claude Code