loop: loopState.end is called before git.maybeCommitAll, so a crash or commit failure leaves the working tree dirty while state shows completed

Open Beginner friendly
#41 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
78/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Quiet
Tech stack
git, typescript
Domain
cli, devtools

Research direction

Start in src/loop.ts at loopImpl and inspect the existing ordering around reporter.append, git.maybeCommitAll, and loopState.end. Run the relevant tests in src/test/loop.test.ts, including the report-failure case near line 332. Done means commit failures leave the prompt outstanding and a crash before state persistence leaves a clean, recoverable tree.

Written by the indexing model from the issue text.

Description

bug S3

Observed behavior

In src/loop.ts, when an allowSourceUpdate: true run finishes processing a prompt successfully, the ordering of effects is:

  1. await reporter.append(prompt, result); (line 117)
  2. await loopState.end(prompt.id, result); (line 118) -> persists completed += [prompt.id] to disk
  3. if (git) { await git.maybeCommitAll(message); } (line 124-127) -> creates the commit

The state file is updated to mark the prompt as completed before the commit is created. If the process is killed (Ctrl-C, OOM, etc.) between step 2 and step 3, or if git.maybeCommitAll throws, we are left with:

  • *-loop-state.json showing the prompt as completed
  • A dirty working tree containing the agent's uncommitted changes

On the next invocation, the very first thing loopImpl does (line 90-94) is:

const git = allowSourceUpdate ? new Git(process.cwd()) : undefined;
if (git && !(await git.isClean())) {
  throw new Error(
    'Working directory is not clean. Commit or stash changes before starting.',
  );
}

The loop refuses to start at all, even though the partial work has already been recorded as completed and would be silently skipped if we forced past the cleanliness check. The user has to manually inspect the diff, decide whether to keep or discard it, and reconcile it against the state file. There is no built-in recovery path.

Expected behavior

Either the state update and the git commit should be ordered so that any crash between them is recoverable (e.g. commit first, then loopState.end; if the commit succeeds but state.end then fails, on restart the prompt is re-processed against an already-committed-clean tree, which is the safe direction), or the cleanliness mismatch should be detected on restart and surfaced as something more actionable than "Working directory is not clean".

The current order is also asymmetric with the (clearly intentional) reporter -> state ordering that the test on src/__test__/loop.test.ts:332 ('should keep the prompt outstanding if writing the report fails') relies on: there, reporter failures must precede state updates so the prompt stays outstanding. The same reasoning argues the commit (which is a strictly more important side effect than the report append) should also precede loopState.end.

Minimal reproduction

  1. Create a config with allowSourceUpdate: true and an agent that modifies a tracked file.
  2. Process one prompt successfully.
  3. Send SIGKILL to the node process between loopState.end and the await git.maybeCommitAll(...) call (any breakpoint or a kill -9 in the right window will do; in tests this can be forced by making git.maybeCommitAll throw).
  4. Re-run the loop.

Observed: the second run throws Working directory is not clean. Commit or stash changes before starting. even though the first run's state file already shows the prompt as completed.

Expected: the second run either successfully resumes (re-running the prompt against a clean tree because the partial work was discarded, or skipping because the commit had already happened) or surfaces a clearer "previous run left work uncommitted for prompt X" message.

Suggested fix

Swap the order in loopImpl so the commit happens before loopState.end:

await reporter.append(prompt, result);

if (result.status === 'success' && git) {
  logger.info(`Committing changes for ${prompt.id}`);
  await git.maybeCommitAll(message);
}

await loopState.end(prompt.id, result);

With this order, a crash between commit and state.end leaves the prompt outstanding but the tree clean, which is recoverable: the next run simply re-processes the prompt. A failing git.maybeCommitAll also propagates without first marking the prompt complete, so the user can fix the underlying git problem and re-run.

Dominant language
TypeScript
Stars
2
Forks
1
PR merge metrics
No merged PRs in 30d

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from joewalker/loop-the-loop

All issues in joewalker/loop-the-loop

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.