test(checkpoint): failing repro for concurrent checkpoint-write lost update (#1917) - #1920
Draft
Soph wants to merge 1 commit into
Draft
test(checkpoint): failing repro for concurrent checkpoint-write lost update (#1917)#1920Soph wants to merge 1 commit into
Soph wants to merge 1 commit into
Conversation
…oint writes
Two agents working in one worktree whose uncondensed work lands in a single
commit end up sharing ONE checkpoint: post-commit condenses both sessions into
it and records it in each session's TurnCheckpointIDs. When both turns end,
both Stop hooks run finalizeAllTurnCheckpoints over that same checkpoint.
Nothing serializes them. Each Stop hook holds only its own per-session flock
(stateLockPath is keyed by session ID), and both persistent stores advance the
checkpoint ref with an unconditional Storer.SetReference — no compare-and-swap
against the tip they read. Both hooks read the same tip, build sibling commits,
and the last ref write silently makes the other's finalize unreachable. Both
hooks exit 0.
The test drives real hook subprocesses and runs the identical scenario twice:
sequentially (control) and concurrently. Only the interleaving differs.
git-branch/concurrent FAIL 5/5 git-branch/sequential PASS 5/5
git-refs/concurrent FAIL 5/5 git-refs/sequential PASS 5/5
The concurrent arms are RED on purpose: this commit is the reproduction, not
the fix. The ephemeral shadow-branch store already solved this shape with
withShadowBranchFlock + casUpdateShadowBranchRef + retry-from-a-fresh-parent
(checkpoint/shadow_ref.go); the persistent stores never got the same treatment.
Refs #1917
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01KZDYZZFNPR7QKPJ3WH2MBCN7
Contributor
There was a problem hiding this comment.
Pull request overview
This PR adds a new integration test that deterministically reproduces #1917’s “lost update” race by running two real Stop-hook subprocesses that concurrently finalize the same checkpoint (across both git-branch and git-refs backends). It’s intended as a concrete reproducer to guide a future CAS-based fix in the persistent checkpoint stores.
Changes:
- Add
TestConcurrent_TwoStopHooks_SameCheckpoint, covering sequential (control) vs concurrent Stop-hook finalization. - Introduce helpers to (a) construct Stop-hook subprocesses without running them and (b) read checkpoint blobs in a backend-aware way.
Comment on lines
+81
to
+84
| t.Run("concurrent", func(t *testing.T) { | ||
| t.Parallel() | ||
| runTwoStopHooksScenario(t, backend, true) | ||
| }) |
| if err != nil { | ||
| env.T.Fatalf("marshal stop input: %v", err) | ||
| } | ||
| cmd := exec.CommandContext(context.Background(), getTestBinary(), "hooks", agentClaudeCode, "stop") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft — this PR is a reproduction, not a fix. The concurrent subtests are
red on purpose, so CI will fail. It exists to make #1917 concrete and to pin
down which scenario is actually reachable.
What it reproduces
Two agents working in one worktree, whose uncondensed work lands in a single
commit, end up sharing one checkpoint: post-commit condenses both
sessions into it and records it in each session's
TurnCheckpointIDs. Whenboth turns end, both Stop hooks run
finalizeAllTurnCheckpointsover that samecheckpoint.
Nothing serializes them:
stateLockPathiskeyed by session ID, and these are legitimately different sessions;
Storer.SetReference(checkpoint/refs_store.go:206for git-refs,checkpoint/store.go:91for git-branch) with no compare-and-swap against thetip they read.
Both hooks read the same tip, build sibling commits, and the last ref write
makes the other's finalize unreachable. Both hooks exit 0; nothing is logged.
Results
Real hook subprocesses,
ForEachBackend× sequential/concurrent. The two armsrun the identical scenario — only whether the Stop hooks start together differs.
git-branchgit-refsCaveats, stated plainly
the test forces with a barrier. Real turn ends are independent, so the field
rate is roughly finalize duration ÷ spread between the two turn ends. The
test pads the transcript (400 filler lines) to widen the finalize window;
real transcripts are larger, so the real window is wider than the tested one,
not narrower.
a hand-authored interleave, and the loss is silent.
SetReferencewrites no reflog), so it is gc-able garbage. The v1 reconcilepath can't recover it either:
ReconcileDisconnectedMetadataRefwalks backfrom the local ref tip (
strategy/metadata_reconcile.go:170). Per-checkpointdirectories guarantee cherry-picks apply cleanly; they don't guarantee the
commit still exists to cherry-pick. Reconcile handles divergence between two
reachable tips — this destroys reachability inside one clone before any
reconcile can observe it.
Note on #1917 as filed
The reporter's scenario — a summary backfill racing an attribution backfill on
one checkpoint — is essentially unreachable in practice: those writers are
sequenced by the session lifecycle, and the only unscheduled one is
user-invoked
entire checkpoint explain --generate, which runs after the fact.The defect is real; the reachable path is this one.
Their proposed regression test also asserts the buggy behavior (it passes by
observing the loss). This one asserts the correct behavior, so it goes green
when fixed.
Fix direction (not in this PR)
The ephemeral shadow-branch store already solved this exact shape:
withShadowBranchFlock+casUpdateShadowBranchRef+ retry-from-a-fresh-parentwith jitter backoff and dangling-object cleanup (
checkpoint/shadow_ref.go,driven from
checkpoint/ephemeral.go:112). The persistent stores never got thesame treatment. Two things worth stating for whoever picks it up:
already built — re-pointing reintroduces the lost update;
CheckAndSetReference— see the rationale atshadow_ref.go:65: it doesn't interoperate with native git's.lockfiles,and these refs are touched by concurrent hook processes and the user's own git.
Tightening lock scoping alone cannot fix this instance, since the two writers
are legitimately different sessions touching one checkpoint.
Refs #1917
🤖 Generated with Claude Code
Note
Low Risk
Test-only change; concurrent subtests are intentionally red and document expected failure until a store-level CAS fix lands.
Overview
Adds integration coverage for #1917: two sessions sharing one post-commit checkpoint, each running a real Claude Stop hook subprocess when their turn ends.
The scenario builds a single commit that condenses both agents, then appends distinct post-commit transcript markers and runs Stop hooks sequentially (control) or concurrently (barrier-started). Helpers
stopHookCmdandcheckpointBlobsupport parallel subprocesses and bothgit-branch/git-refsbackends viaForEachBackend.Assertions require both markers in stored checkpoint transcripts after both hooks exit 0. The concurrent subtests are expected to fail today (silent lost update); sequential should pass. This is a reproduction PR, not a fix.
Reviewed by Cursor Bugbot for commit af59539. Configure here.