Skip to content

Uncaught GIT_TIMEOUT in verify.attempt_dirty crashes the whole run from the rollback/pause path #156

Description

@ferrandmathis-afk

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

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions