Skip to content

clean classifies runs once then mutates, so a concurrent resume can lose its worktree #533

Description

@pbean

Raised by the codex review gate on PR #532 and split out rather than folded in: the hazard
predates that PR, and closing it is a concurrency-model change, not a guard fix.

The race

cmd_clean classifies runs once, then mutates:

for run_dir in runs.list_run_dirs(project):
    ...
    elif runs.reclaimable(run_dir):        # sampled here
        reclaimable.append(run_dir)
...
for run_dir in reclaimable:                # mutated here, never re-classified
    ...reconcile_orphan_worktrees / trim_run_dir / archive_run / delete_run...

reclaimable(run_dir) is not engine_alive(run_dir) plus a terminal state. A stopped run
satisfies it and is also resumable, so bmad-loop resume <id> may start an engine and an agent
session in that run's worktree at any point after the sample. clean then force-removes the
worktree (reconcile_orphan_worktrees) and deletes worktrees/ (trim_run_dir) beneath a live
agent.

Nothing serializes the two. There is no lock, and resume does not consult a clean in progress.

What PR #532 does and does not change

#532 adds a live-session guard at the top of the reclaim loop and at the delete_run /
archive_run chokepoint. That narrows the window — a session already live when clean
reaches the run now protects it — but it does not close it, because both reads are samples too.
Its own scope note says so. Specifically, after #532:

  • a session appearing between the loop-top guard and the removal is refused at the chokepoint,
    and clean records the run and continues (agent session appeared mid-clean — not removed) —
    but the worktree reconcile and trim above it have already run;
  • a run inside the retention window reaches trim_run_dir with only the loop-top sample, so
    a session appearing after it loses worktrees/ with no second check at all.

Both are the same one-sample-then-mutate shape, and both exist identically on main at
814f0a2 — where the loop has no session check whatsoever.

Why it is not just "add another check"

Another sample immediately before each mutation shrinks the window again without closing it;
the operation is not atomic with respect to session creation. Closing it needs one of:

  • a per-run lock that resume / run start and clean both take (and that survives a crash
    holding it, which is why this is not a one-liner);
  • or making the destructive steps refuse on a fresh read taken inside the same operation that
    removes — i.e. moving the guard into the mutation rather than before it;
  • or accepting the race explicitly and documenting clean as unsafe to run concurrently with
    resume, which is at least honest and is roughly today's contract, undocumented.

The third is cheap and may be the right answer; it is a decision, not a patch, which is why this
was split out of #532 instead of being folded into it.

Reproduction shape

Not yet reproduced live — the window is small and needs a timing wedge. A deterministic version
is available at the seam: make runs.reclaimable (or live_session_may_be_ours) answer
differently on successive calls, as test_cmd_clean_survives_a_session_appearing_mid_clean does
in #532 for the narrower chokepoint case.

Refs: #532, #526, #419

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:engineOrchestrator engine and run lifecyclebugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions