Skip to content

LOU-K5: restore checkpointing for the remainder of a run after resumeAfterApproval() - #13

Merged
LinuxDevil merged 1 commit into
mainfrom
loushy/lou-k5
Sep 28, 2026
Merged

LinuxDevil merged 1 commit into
mainfrom
loushy/lou-k5

Conversation

@LinuxDevil

Copy link
Copy Markdown
Owner

Summary

resumeAfterApproval() (src/execution/resume.ts) correctly deletes the stale pre-pause checkpoint stored under snapshot.sessionId before continuing the run - that checkpoint predates the pause (checkpoints are only saved after a tool result, and a pause happens before the deferred tool runs), so it would silently clobber the just-reconstructed messages/steps if it were ever loaded.

But the follow-up AgentExecutor.execute() call that actually continues the run then forced sessionId: undefined, checkpointStore: undefined as a "belt-and-suspenders" guard against re-triggering rehydration. That guard was overbroad: forcing both to undefined doesn't just prevent rehydration for this one call, it also disables AgentExecutor's normal per-tool-result checkpointStore.save() calls for the entire rest of the resumed run. If the process crashed three tool-calls after a human approved a paused tool call, all progress since the approval was lost, because durability had been silently turned off for the remainder of that run - not just for the resume step itself.

Fix

resumeAfterApproval() now threads the real sessionId (snapshot.sessionId) and checkpointStore (its own dedicated parameter) through to the follow-up AgentExecutor.execute() call instead of forcing them to undefined. ResumeExecuteOptions keeps both fields omitted (unchanged) - they're derived internally, not accepted from executeOptions, so a caller can't override them.

Safety argument (why rehydration still can't fire)

AgentExecutor's rehydration branch in runAgentLoop() is:

let checkpoint: Checkpoint | null = null;
if (sessionId && checkpointStore) {
  checkpoint = await checkpointStore.load(sessionId);
}

It's keyed strictly on checkpointStore.load(sessionId) returning non-null - there's no interaction with initialSteps or any other option. resumeAfterApproval() deletes whatever is under snapshot.sessionId before making the execute() call, and there's no intervening await back into caller code between that delete and execute()'s load(). So by the time the rehydration check runs, there is nothing left to find, and execution falls into the normal "build from scratch" path using the reconstructed messages/skipSystemPromptInjection/initialSteps - exactly as it did before this fix, just with checkpointing now live for the rest of the run.

Accepted risk: a genuine race exists only if some other process/caller writes a new checkpoint under the exact same sessionId in the narrow window between the delete and the resumed execute() call's load(). This is documented in code rather than solved with cross-process locking, consistent with how this file already treats sessionId as identifying a single logical run (e.g. ApprovalStore.resolve() is delete-on-read with no additional lock either).

Changes

  • src/execution/resume.ts: pass sessionId/checkpointStore through instead of forcing undefined; rewrote the doc comments explaining the old vs. new behavior and why it's still safe.
  • src/execution/resume.test.ts: added tests proving (a) a new checkpoint is written for a tool-call step taken after a successful resume when a checkpointStore is supplied, and (b) resuming without a checkpointStore remains fully backward compatible. Existing stale-checkpoint-never-rehydrated coverage still passes unchanged.

Test plan

  • npm run typecheck - clean
  • npm run lint - no new errors/warnings introduced (pre-existing unrelated errors/warnings in other files, confirmed via targeted eslint run on only the touched files)
  • npx vitest run src/execution/resume.test.ts src/execution/checkpoint.test.ts src/execution/AgentExecutor.test.ts - 44/44 passing

🤖 Generated with Claude Code

resumeAfterApproval() correctly deleted the stale pre-pause checkpoint
under snapshot.sessionId, but then forced sessionId/checkpointStore to
undefined on the follow-up AgentExecutor.execute() call as a
belt-and-suspenders guard against re-rehydrating that stale state. The
guard was overbroad: it also silently disabled checkpointing for the
entire remainder of the resumed run, so a crash a few tool-calls after
a human approval would lose all progress made since the approval.

Now the real sessionId/checkpointStore are threaded through instead.
This stays safe because AgentExecutor's rehydration branch only fires
when checkpointStore.load(sessionId) finds something, and the delete
above guarantees it won't - so the resumed run always falls into the
normal "build from scratch" path using the reconstructed
messages/initialSteps, and AgentExecutor's per-tool-result
checkpoint.save() calls resume writing durable progress from there on.

Adds tests proving: a new checkpoint is written for a tool-call step
taken after a successful resume, the stale pre-pause checkpoint is
still never rehydrated, and resuming without a checkpointStore remains
fully backward compatible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@LinuxDevil
LinuxDevil merged commit 114e377 into main Sep 28, 2026
1 check failed
@LinuxDevil
LinuxDevil deleted the loushy/lou-k5 branch September 28, 2026 23:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant