Version: bmad-loop 0.8.1 (uv tool, git install) · macOS 15 (Darwin 24.6.0) · git worktree isolation, adapter.name = "claude"
Component: verify.py:26 / verify.py:78-82 (_git, GIT_TIMEOUT_S) reached from engine.py:834 (_rollback_or_pause)
Summary
verify._git runs every git call with a hard timeout=GIT_TIMEOUT_S (120s, a module constant — neither policy- nor env-configurable). subprocess.TimeoutExpired is not caught anywhere on the way up, so one slow git call takes down the entire run.
We hit it on the failure-recovery path, which is what makes it worth reporting: the dev session had already timed out, _dev_phase called _rollback_or_pause to handle that gracefully, and the recovery itself killed the run.
File "bmad_loop/engine.py", line 1511, in _dev_phase
self._rollback_or_pause(task)
File "bmad_loop/engine.py", line 834, in _rollback_or_pause
if task.baseline_commit and not verify.attempt_dirty(
File "bmad_loop/verify.py", line 181, in attempt_dirty
rc, _ = _git(repo, "diff", "--quiet", baseline, "--", ".", *_exclude_specs(exclude))
File "bmad_loop/verify.py", line 78, in _git
proc = subprocess.run(
subprocess.TimeoutExpired: Command '['git', '-C', '<worktree>', 'diff', '--quiet',
'<baseline>', '--', '.', ':(exclude).bmad-loop/policy.toml']' timed out after 120 seconds
Net effect: 0 done, 0 deferred, 0 escalated and a crashed: true state.json. The story's work was intact in the worktree the whole time — only the orchestrator died.
Why the git call was slow
The host was under heavy memory pressure (the story had been standing up Docker/Postgres containers). On an unloaded machine the same git diff --quiet against the same worktree completes in ~20s. So 120s is a sane normal-case bound; the defect is that exceeding it is run-fatal rather than a handled outcome.
What I expected
attempt_dirty answers a yes/no housekeeping question ("is the tree dirty vs baseline?"). A timeout there should degrade to the conservative answer (assume dirty → keep the worktree, pause the story) and be logged — not propagate past _run_isolated and end the run. This is the same class as #139: housekeeping that should be non-fatal is run-fatal.
Proposed fix
- Catch
subprocess.TimeoutExpired in verify._git and re-raise as GitError — callers already handle that type — or catch it in attempt_dirty and take the conservative branch.
- Make the bound configurable (
limits.git_timeout_s, or BMAD_LOOP_GIT_TIMEOUT_S alongside the existing BMAD_LOOP_* env seams). 120s is a fine default, but a loaded box or a large worktree can legitimately exceed it.
Note
I've since deleted the run directory, so I can't attach artifacts — the traceback above is verbatim from crash.txt.
Version: bmad-loop 0.8.1 (uv tool, git install) · macOS 15 (Darwin 24.6.0) · git worktree isolation,
adapter.name = "claude"Component:
verify.py:26/verify.py:78-82(_git,GIT_TIMEOUT_S) reached fromengine.py:834(_rollback_or_pause)Summary
verify._gitruns every git call with a hardtimeout=GIT_TIMEOUT_S(120s, a module constant — neither policy- nor env-configurable).subprocess.TimeoutExpiredis not caught anywhere on the way up, so one slow git call takes down the entire run.We hit it on the failure-recovery path, which is what makes it worth reporting: the dev session had already timed out,
_dev_phasecalled_rollback_or_pauseto handle that gracefully, and the recovery itself killed the run.Net effect:
0 done, 0 deferred, 0 escalatedand acrashed: truestate.json. The story's work was intact in the worktree the whole time — only the orchestrator died.Why the git call was slow
The host was under heavy memory pressure (the story had been standing up Docker/Postgres containers). On an unloaded machine the same
git diff --quietagainst the same worktree completes in ~20s. So 120s is a sane normal-case bound; the defect is that exceeding it is run-fatal rather than a handled outcome.What I expected
attempt_dirtyanswers a yes/no housekeeping question ("is the tree dirty vs baseline?"). A timeout there should degrade to the conservative answer (assume dirty → keep the worktree, pause the story) and be logged — not propagate past_run_isolatedand end the run. This is the same class as #139: housekeeping that should be non-fatal is run-fatal.Proposed fix
subprocess.TimeoutExpiredinverify._gitand re-raise asGitError— callers already handle that type — or catch it inattempt_dirtyand take the conservative branch.limits.git_timeout_s, orBMAD_LOOP_GIT_TIMEOUT_Salongside the existingBMAD_LOOP_*env seams). 120s is a fine default, but a loaded box or a large worktree can legitimately exceed it.Note
I've since deleted the run directory, so I can't attach artifacts — the traceback above is verbatim from
crash.txt.