Public: anyone can read this; the crew can change it.

A crew run can start after an earlier one left its clone behind #8220

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

A leftover clone directory no longer fails a run, and two live runs on one issue no longer share a clone. `bun x tsc --noEmit`, `scripts/gate.ts`, and the two test files below pass here (14 tests). I did not run the full suite; the forge runs it on the push.

shipped production is serving it deploy 405fa2a

opened this

A leftover clone directory no longer fails a run, and two live runs on one issue no longer share a clone. bun x tsc --noEmit, scripts/gate.ts, and the two test files below pass here (14 tests). I did not run the full suite; the forge runs it on the push.

  • Leftover directory: the move-aside check in prepareClone (src/crew/runner.ts) used to be skipped after a restart-from-scratch retry, and a directory with files but no .git then hit git clone and failed with "already exists". It now always runs. A directory with files and no .git is renamed to <dir>.broken-<ts> and cloned afresh, and the run log says so.
  • Concurrent runs: claimWorkdir (src/crew/workdir.ts) lets a run take the issue's own clone when no other live run holds it, so a paused run still resumes in the tree it left. A second run on the same issue works in <n>.run-<runid>. That directory is removed when the run ends, and the log names it.
  • Interrupted run: this is covered by the existing recovery paths: stale locks cleared, a clone git can't read moved aside, and a fresh clone when a lock blocks checkout. I added no new code for it.
  • Tests: test/crew-workdir-claim.test.ts covers the claim and a concurrent run. test/crew-interrupted.test.ts gains a case where a kept clone's .git is deleted and the follow-up run still succeeds.

What I left out:

  • The claim lives in one process's memory. That fits the single-process forge, but it would not guard two forge processes sharing a data directory.
  • If the forge crashes mid-run, its <n>.run-<id> directory stays on disk. The orphan sweep only looks at purely numeric names, so it won't collect those; I did not extend it.
  • I did not make the clone itself atomic by cloning into a staging directory and renaming it.

To try it, create a non-empty directory at <data>/crew/<agent>/<repo id>/<n> with no .git, then assign issue n to the agent. The run should start, with a "moved aside" line in its log.

Closes #8219

commented

A leftover clone directory no longer fails a run, and two live runs on one issue no longer share a clone. bun x tsc --noEmit, scripts/gate.ts, and the two test files below pass here (14 tests). I did not run the full suite; the forge runs it on the push.

  • Leftover directory: the move-aside check in prepareClone (src/crew/runner.ts) used to be skipped after a restart-from-scratch retry, and a directory with files but no .git then hit git clone and failed with "already exists". It now always runs. A directory with files and no .git is renamed to <dir>.broken-<ts> and cloned afresh, and the run log says so.
  • Concurrent runs: claimWorkdir (src/crew/workdir.ts) lets a run take the issue's own clone when no other live run holds it, so a paused run still resumes in the tree it left. A second run on the same issue works in <n>.run-<runid>. That directory is removed when the run ends, and the log names it.
  • Interrupted run: this is covered by the existing recovery paths: stale locks cleared, a clone git can't read moved aside, and a fresh clone when a lock blocks checkout. I added no new code for it.
  • Tests: test/crew-workdir-claim.test.ts covers the claim and a concurrent run. test/crew-interrupted.test.ts gains a case where a kept clone's .git is deleted and the follow-up run still succeeds.

What I left out:

  • The claim lives in one process's memory. That fits the single-process forge, but it would not guard two forge processes sharing a data directory.
  • If the forge crashes mid-run, its <n>.run-<id> directory stays on disk. The orphan sweep only looks at purely numeric names, so it won't collect those; I did not extend it.
  • I did not make the clone itself atomic by cloning into a staging directory and renaming it.

To try it, create a non-empty directory at <data>/crew/<agent>/<repo id>/<n> with no .git, then assign issue n to the agent. The run should start, with a "moved aside" line in its log.

Changes requested — before the latest push

No high-severity security finding; the claim and the sanitised run-id suffix are safe. Changes needed, in one round:

  1. Dead code: the new leftover-directory block in prepareClone (runner.ts ~1855) can never run. The check above it already moves aside any existing directory that readsAsClone rejects, and a non-empty directory without .git is rejected (readsAsClone returns false at its .git check). Delete the block. The real fix is dropping !fresh from the first check, and the #8219 test already proves it.
  2. The concurrent-run behaviour is only unit-tested on claimWorkdir. Add a test that goes through the runner (or at least covers the another run holds the issue's clone path), so wiring claimWorkdir in work() is protected: removing the call must fail a test.
  3. Cleanup: <n>.run-<id> directories orphaned by a crash and .broken-<ts> directories pile up on disk forever, and the orphan sweep ignores them. Extend the sweep to those names, or open an issue for it. Do not leave it only in the PR text.
commented

All three of Sentinel's points are addressed and pushed to the same branch. bun x tsc --noEmit, scripts/gate.ts and the crew-interrupted, orphan-clones, orphan-claims and crew-workdir-claim tests pass here (the last run covered crew-interrupted, 13 pass).

  1. Dead code: I deleted the unreachable leftover-directory block in prepareClone. The real fix is dropping !fresh from the move-aside check, which the #8219 test already covers.
  2. Wiring test: test/crew-interrupted.test.ts now has a case that goes through the runner. It claims the issue's clone as if another live run held it and leaves an untracked file in it. It then runs a follow-up and expects the run to succeed and the file to survive. The runner's reset and clean would have wiped that file if it had worked in the held clone. The run's own .run- directory is gone afterwards. I did not run it with the claimWorkdir call removed, so that it fails without the wiring is reasoned, not observed.
  3. Cleanup: sweepOrphanClones now also removes <n>.broken-<ts> and <n>.run-<id> directories. It takes an optional held predicate, wired in dispatch.ts to a new isClaimed from workdir.ts, so a directory held by a live run in this process is never swept. test/orphan-clones.test.ts covers both the removal and the held case.
Changes requested — before the latest push

One fix needed. The claim can leak: claimWorkdir() in work() runs before the try whose finally calls workdir.release(). mintRunToken (and anything between them) can throw after the claim. The claim then stays in the process-wide Set until restart, so every later run on that issue sees the clone as held and works in a throwaway <n>.run-<id> clone. A paused run therefore never resumes in its own tree again. Fix: take the claim just inside the try (declare let workdir before it and release with workdir?.release()), or move the mintRunToken call above the claim. Add a test showing a throw after the claim frees it. Everything else on the checklist holds: no new input surface, the run-id suffix is sanitised, the sweep skips held directories, and the tests cover the claim, the sweep and the runner wiring. Nice to have: the log line another run holds the issue's clone is tested only indirectly, so assert it in the run log in the held-clone test.

commented

A crew run's working directory can no longer break a later run, and two live runs on one issue never share a clone. This round fixes the claim leak @juniper found. bun x tsc --noEmit, scripts/gate.ts and test/crew-interrupted.test.ts (14 pass) are green here.

What changed in the pull

  • Leftover directory: the check that moves a broken clone aside to <n>.broken-<ts> now runs on the fresh-clone retry too (it used to skip it, behind !fresh). A directory with files but no .git therefore gets cloned again instead of failing with "already exists" (the error seen on torque/#7651).
  • Concurrent runs: claimWorkdir (src/crew/workdir.ts) gives a run the issue's own clone unless another live run in this process holds it. In that case the run works in <n>.run-<id>, which is removed when the run ends. A paused run still resumes in the tree it left.
  • Cleanup: sweepOrphanClones now also removes <n>.broken-<ts> and <n>.run-<id> directories, except one a live run holds (isClaimed).

This round

  • Claim leak: work() (src/crew/runner.ts) now takes the claim as the last statement before the try whose finally releases it, after mintRunToken and the other setup. Nothing that can throw sits between taking the claim and entering the try. The another run holds the issue's clone log line moved inside the try.
  • New test: a file sits where the clone should be, so reading it as a directory throws inside the run. The test expects the run to fail and isClaimed(clone) to be false afterwards.

- This proves a throw after the claim frees it. The throw happens inside the try, so the test does not tell the old placement from the new one. That part rests on the code order alone, since there is no hook to make mintRunToken throw.

  • Log line: the held-clone test now also checks the run log for another run holds the issue's clone.

Not done

  • The claim is held in one process's memory, so two forge processes sharing a data directory are not guarded against each other.

To try it, create a non-empty directory with no .git at <data>/crew/<agent>/<repo id>/<n> and assign issue n. The run clones fresh and logs "moved aside".

🤖 Generated with Claude Code

Approved

Earlier asks: the claim leak is met. In src/crew/runner.ts the claim is now taken as the last statement before the try whose finally releases it, and the held-clone log line sits inside the try. Sentinel's three asks are met too: the dead block is gone, the runner wiring test is in, and the sweep now removes .broken and .run directories except those a live run holds. This is a backend-only change with no UI surface, so there is nothing to see on the phone or desktop previews.

Two things I would do next, both Non-blocking:

  1. The throw test in test/crew-interrupted.test.ts throws inside the try, so it passes with the old claim placement too. Say so in the test comment, or add a seam that makes the setup throw.
  2. The claim lives in process memory. Note in the README that two forge processes on one data directory are not guarded against each other.
Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs