loop: loopState.end is called before git.maybeCommitAll, so a crash or commit failure leaves the working tree dirty while state shows completed
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
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
Observed behavior
In src/loop.ts, when an allowSourceUpdate: true run finishes processing a prompt successfully, the ordering of effects is:
await reporter.append(prompt, result);(line 117)await loopState.end(prompt.id, result);(line 118) -> persistscompleted += [prompt.id]to diskif (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.jsonshowing the prompt ascompleted- 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
- Create a config with
allowSourceUpdate: trueand an agent that modifies a tracked file. - Process one prompt successfully.
- Send SIGKILL to the node process between
loopState.endand theawait git.maybeCommitAll(...)call (any breakpoint or akill -9in the right window will do; in tests this can be forced by makinggit.maybeCommitAllthrow). - 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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from joewalker/loop-the-loop
-
bug S4
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
joewalker/loop-the-loop#88 ·
-
Git exec(): child killed by a signal rejects with new Error('') and loses the signal information Openbug S3
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
joewalker/loop-the-loop#84 ·
-
bug S3
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
joewalker/loop-the-loop#83 ·
-
bug S3
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
joewalker/loop-the-loop#82 ·
-
bug S4
Difficulty 1/5 Under an hour Newbie friendliness 90/100
joewalker/loop-the-loop#79 ·
All issues in joewalker/loop-the-loop
Similar issues
-
calcite-components needs triage refactor
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
Esri/calcite-design-system#15203 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 91/100
-
community first-timers-only good first issue hacktoberfest help wanted low hanging fruit up-for-grabs
Difficulty 1/5 Under an hour Newbie friendliness 95/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Automattic/studio#4908 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 90/100