Skip to content

Commit 2b4b736

Browse files
peyton-altclaude
andcommitted
fix(checkpoint): harden refs backfill probe per review; rescope to store layer
Review findings from the PR #1824 multi-agent pass: - Tests: pin the zero-cost claim (backfill with a locally-present ref never fetches), verify the fetched-then-backfilled data reads back for all three request types, and assert the backfill commit parents on the fetched tip rather than orphaning over the checkpoint's history. - Comments rephrased order-agnostically: the write-fallthrough they cited is PR #1811's unmerged behavior; they now state the absence-vs-failure contract instead of a phantom mechanism. refBase's doc names its other deliberate callers (migrate.go local import; attach's externally-guarded writeSession). - FetchCheckpointRef's stale 'caller treats a fetch failure as not found' comment inverted the actual contract this PR depends on; it now documents failure-surfaces-never-absence, including the remote-lacks-ref caveat. - ref-checkpoint-backend.md: the write shape and on-demand-fetch sections now describe the create/backfill probe split. Scope note (review CRITICAL): no production write path currently wires a RefFetcher, so this PR is store-layer semantics + plumbing; the wiring is a follow-up gated on (1) FetchCheckpointRef distinguishing remote-lacks-ref (absence) from transport failure — today wiring would break the pre-migration-hex fallthrough — and (2) a hook-appropriate fetch timeout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f30a34c commit 2b4b736

4 files changed

Lines changed: 110 additions & 33 deletions

File tree

cmd/entire/cli/checkpoint/refs_store.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,14 @@ func (s *gitRefsStore) Write(ctx context.Context, req WriteRequest) error {
7474
// LOCAL-ONLY lookup. A missing ref yields (ZeroHash, nil) so the next write
7575
// becomes an orphan commit — correct for creates, whose ref never exists yet
7676
// (locally or remotely); probing the remote would add a doomed round-trip to
77-
// every condensation and break offline writes. Backfills, which target an
78-
// existing checkpoint, use refBaseForBackfill instead.
77+
// every condensation and, with a fetcher configured, fail offline writes.
78+
// Backfills, which target an existing checkpoint, use refBaseForBackfill
79+
// instead. Migration (migrate.go) also uses refBase deliberately: it imports
80+
// from the LOCAL v1 branch and must never probe the remote, even though its
81+
// target ref may already exist. One writeSession caller does target an
82+
// existing checkpoint — attach, which adds a session to it — but attach
83+
// pre-fetches and verifies the ref's presence itself (refreshCheckpoint)
84+
// before writing, so the local-only probe is safe there too.
7985
func (s *gitRefsStore) refBase(cid id.CheckpointID) (plumbing.Hash, *object.Tree, error) {
8086
refName, err := RefName(cid)
8187
if err != nil {
@@ -97,13 +103,15 @@ func (s *gitRefsStore) refBase(cid id.CheckpointID) (plumbing.Hash, *object.Tree
97103
// first fetched once from the remote (resolveRefMaybeFetch) when a fetcher is
98104
// configured: a backfill targets an EXISTING checkpoint that may have been
99105
// written or migrated on another machine, and declaring it absent without
100-
// looking remotely diverges from the read path — the write would land on a
101-
// fallback backend (or fail) while reads, which DO fetch, serve the refs
102-
// copy, leaving the backfill permanently invisible. A ref absent even after
103-
// the fetch yields (ZeroHash, nil), which the backfill helpers report as
104-
// ErrCheckpointNotFound; a fetch FAILURE is returned as-is — transient
105-
// unavailability must not read as absence, or the kind-routing store's
106-
// fallthrough would fork the write onto a stale copy.
106+
// looking remotely diverges from the read path — the backfill would be
107+
// handled as targeting a nonexistent checkpoint while reads, which DO fetch,
108+
// serve the refs copy, leaving the backfilled data permanently invisible.
109+
// A ref absent even after the fetch yields (ZeroHash, nil), which the
110+
// backfill helpers report as ErrCheckpointNotFound — the signal that the
111+
// checkpoint does not exist in this backend. A fetch FAILURE is returned
112+
// as-is: transient unavailability must never masquerade as absence, because
113+
// a caller or routing layer acting on a false "absent" would misdirect the
114+
// backfill (e.g. onto a stale copy in another backend) instead of retrying.
107115
func (s *gitRefsStore) refBaseForBackfill(ctx context.Context, cid id.CheckpointID) (plumbing.Hash, *object.Tree, error) {
108116
ref, err := s.resolveRefMaybeFetch(ctx, cid)
109117
if errors.Is(err, plumbing.ErrReferenceNotFound) {

cmd/entire/cli/checkpoint/refs_store_test.go

Lines changed: 83 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -121,30 +121,60 @@ func TestGitRefsStore_OnDemandRefFetch_FailurePropagates(t *testing.T) {
121121
// TestGitRefsStore_BackfillFetchesMissingRef: a backfill targets an EXISTING
122122
// checkpoint, which may have been written or migrated on another machine — so
123123
// like reads, backfills must on-demand fetch a ref that is missing locally
124-
// before declaring the checkpoint absent. Otherwise the write lands on a
125-
// fallback store (or errors) while reads — which DO fetch — serve the refs
126-
// copy, and the backfill is permanently invisible.
124+
// before declaring the checkpoint absent. Otherwise the backfill is handled
125+
// as targeting a nonexistent checkpoint while reads — which DO fetch — serve
126+
// the refs copy, and the backfilled data is permanently invisible.
127127
func TestGitRefsStore_BackfillFetchesMissingRef(t *testing.T) {
128128
t.Parallel()
129129
ctx := context.Background()
130130

131-
backfills := map[string]func(cid id.CheckpointID) WriteRequest{
132-
"summary": func(cid id.CheckpointID) WriteRequest {
133-
return SessionSummary{CheckpointID: cid, Summary: &Summary{Intent: "fetched intent"}}
131+
backfills := map[string]struct {
132+
makeReq func(cid id.CheckpointID) WriteRequest
133+
verify func(t *testing.T, store *gitRefsStore, cid id.CheckpointID)
134+
}{
135+
"summary": {
136+
makeReq: func(cid id.CheckpointID) WriteRequest {
137+
return SessionSummary{CheckpointID: cid, Summary: &Summary{Intent: "fetched intent"}}
138+
},
139+
verify: func(t *testing.T, store *gitRefsStore, cid id.CheckpointID) {
140+
t.Helper()
141+
meta, err := store.ReadSessionMetadata(context.Background(), cid, 0)
142+
require.NoError(t, err)
143+
require.NotNil(t, meta.Summary)
144+
assert.Equal(t, "fetched intent", meta.Summary.Intent)
145+
},
134146
},
135-
"transcript": func(cid id.CheckpointID) WriteRequest {
136-
return SessionTranscript{
137-
CheckpointID: cid,
138-
SessionID: "sess-1",
139-
Transcript: redact.AlreadyRedacted([]byte("finalized")),
140-
}
147+
"transcript": {
148+
makeReq: func(cid id.CheckpointID) WriteRequest {
149+
return SessionTranscript{
150+
CheckpointID: cid,
151+
SessionID: "sess-1",
152+
Transcript: redact.AlreadyRedacted([]byte("finalized")),
153+
}
154+
},
155+
verify: func(t *testing.T, store *gitRefsStore, cid id.CheckpointID) {
156+
t.Helper()
157+
content, err := store.ReadSessionContent(context.Background(), cid, 0)
158+
require.NoError(t, err)
159+
assert.Equal(t, []byte("finalized"), content.Transcript)
160+
},
141161
},
142-
"attribution": func(cid id.CheckpointID) WriteRequest {
143-
return CheckpointAttribution{CheckpointID: cid, Attribution: &Attribution{AgentLines: 3}}
162+
"attribution": {
163+
makeReq: func(cid id.CheckpointID) WriteRequest {
164+
return CheckpointAttribution{CheckpointID: cid, Attribution: &Attribution{AgentLines: 3}}
165+
},
166+
verify: func(t *testing.T, store *gitRefsStore, cid id.CheckpointID) {
167+
t.Helper()
168+
summary, err := store.Read(context.Background(), cid)
169+
require.NoError(t, err)
170+
require.NotNil(t, summary)
171+
require.NotNil(t, summary.CombinedAttribution)
172+
assert.Equal(t, 3, summary.CombinedAttribution.AgentLines)
173+
},
144174
},
145175
}
146176

147-
for name, makeReq := range backfills {
177+
for name, tc := range backfills {
148178
t.Run(name, func(t *testing.T) {
149179
t.Parallel()
150180
store := newRefsStore(t)
@@ -162,17 +192,50 @@ func TestGitRefsStore_BackfillFetchesMissingRef(t *testing.T) {
162192
return store.repo.Storer.SetReference(plumbing.NewHashReference(rn, commitHash))
163193
})
164194

165-
require.NoError(t, store.Write(ctx, makeReq(cid)),
195+
require.NoError(t, store.Write(ctx, tc.makeReq(cid)),
166196
"a backfill must fetch the missing ref instead of declaring the checkpoint absent")
167197
assert.Equal(t, 1, fetched, "fetcher invoked once for the missing ref")
198+
199+
// The write must have landed on the fetched history, not orphaned
200+
// over it: the new tip's parent is the pre-removal commit.
201+
newRef, err := store.repo.Reference(mustRefName(t, cid), true)
202+
require.NoError(t, err)
203+
newTip, err := store.repo.CommitObject(newRef.Hash())
204+
require.NoError(t, err)
205+
require.Len(t, newTip.ParentHashes, 1)
206+
assert.Equal(t, commitHash, newTip.ParentHashes[0],
207+
"the backfill commit must parent on the fetched tip")
208+
209+
tc.verify(t, store, cid)
168210
})
169211
}
170212
}
171213

214+
// TestGitRefsStore_BackfillLocalRefNeverFetches pins the zero-cost claim: a
215+
// backfill whose ref exists locally must not touch the remote — otherwise
216+
// every summary/attribution/transcript backfill pays a network round-trip and
217+
// offline finalization breaks.
218+
func TestGitRefsStore_BackfillLocalRefNeverFetches(t *testing.T) {
219+
t.Parallel()
220+
store := newRefsStore(t)
221+
cid := id.MustCheckpointID("a1b2c3d4e5f6")
222+
refsWrite(t, store, cid, "sess-1", "transcript")
223+
store.SetRefFetcher(func(_ context.Context, _ plumbing.ReferenceName) error {
224+
t.Error("a backfill with a locally-present ref must not fetch")
225+
return nil
226+
})
227+
228+
require.NoError(t, store.Write(context.Background(), SessionSummary{
229+
CheckpointID: cid,
230+
Summary: &Summary{Intent: "local"},
231+
}))
232+
}
233+
172234
// TestGitRefsStore_BackfillFetchFailureAborts: a failed fetch is a transient
173235
// availability problem, not evidence of absence. The backfill must surface it
174-
// as a real error — NOT ErrCheckpointNotFound, which the kind-routing store
175-
// treats as "try the next backend" and would fork the write onto a stale copy.
236+
// as a real error — NOT ErrCheckpointNotFound, which callers treat as "the
237+
// checkpoint does not exist in this backend", a signal a routing layer may
238+
// act on to select a different backend (forking the write onto a stale copy).
176239
func TestGitRefsStore_BackfillFetchFailureAborts(t *testing.T) {
177240
t.Parallel()
178241
store := newRefsStore(t)
@@ -186,13 +249,13 @@ func TestGitRefsStore_BackfillFetchFailureAborts(t *testing.T) {
186249
})
187250
require.ErrorIs(t, err, assert.AnError, "the fetch failure must surface")
188251
require.NotErrorIs(t, err, ErrCheckpointNotFound,
189-
"a fetch failure must not read as absence — the router would fall through and fork the write")
252+
"a fetch failure must not read as absence")
190253
}
191254

192255
// TestGitRefsStore_BackfillAbsentAfterFetchIsNotFound pins the genuine-absence
193256
// contract: a fetch that succeeds but restores no ref means the checkpoint
194257
// really does not exist in this backend, and the backfill reports the
195-
// not-found sentinel (which the router may legitimately fall through on).
258+
// not-found sentinel (which a routing layer may legitimately act on).
196259
func TestGitRefsStore_BackfillAbsentAfterFetchIsNotFound(t *testing.T) {
197260
t.Parallel()
198261
store := newRefsStore(t)

cmd/entire/cli/git_operations.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,13 @@ func resolveCheckpointFetchTarget(ctx context.Context) string {
476476
// FetchCheckpointRef fetches a single per-checkpoint ref (refs/entire/checkpoints/
477477
// <shard>/<id>) from the checkpoint remote into the local ref of the same name,
478478
// so the git-refs store can resolve a checkpoint written on another machine.
479-
// Best-effort: the caller treats a fetch failure as "checkpoint not found".
479+
//
480+
// Contract: a fetch failure is surfaced as a real error, never mapped to
481+
// "checkpoint not found" — callers (resolveRefMaybeFetch, for both reads and
482+
// backfill writes) deliberately keep transient unavailability distinguishable
483+
// from absence. Note this includes a ref the remote does not have: git fetch
484+
// of an explicit refspec fails outright, so "absent on the remote" currently
485+
// surfaces as a fetch error, not as absence.
480486
func FetchCheckpointRef(ctx context.Context, ref plumbing.ReferenceName) error {
481487
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
482488
defer cancel()

docs/architecture/ref-checkpoint-backend.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ The git-refs store (`gitRefsStore`, `checkpoint/refs_store.go`) shares the check
6969

7070
Every persistent write (`WriteSession`, and the `Backfill*` operations for transcript / summary / attribution) follows the same shape:
7171

72-
1. **Resolve the ref's current tip** (`refBase`). A missing ref → `(ZeroHash, nil)`, so the first write to a checkpoint becomes an **orphan commit**. A real lookup failure (IO/corruption) is surfaced, never silently treated as "new checkpoint".
72+
1. **Resolve the ref's current tip.** Creates (`WriteSession`) use the local-only `refBase`: a missing ref → `(ZeroHash, nil)`, so the first write to a checkpoint becomes an **orphan commit**. The `Backfill*` operations use `refBaseForBackfill`, which first on-demand fetches a locally-missing ref (when a ref fetcher is configured — the checkpoint may have been written or migrated on another machine); a ref still absent after the fetch surfaces as `ErrCheckpointNotFound` rather than orphaning, and a fetch failure surfaces as a real error, never as absence. A real lookup failure (IO/corruption) is surfaced, never silently treated as "new checkpoint".
7373
2. **Build the updated checkpoint subtree** from the existing tree plus the new content (shared `treeWriter` logic).
7474
3. **Create a commit** with the current tip as parent (orphan on first write, parented thereafter), so each checkpoint accretes its **own per-checkpoint history**.
7575
4. **Point the ref at the new commit** (`setRef`) and **enqueue it for push**.
@@ -111,9 +111,9 @@ All checkpoint-ref pushes are **fast-forward-only — never a force push.** Ther
111111

112112
When a push *is* rejected as non-fast-forward — genuine divergence, e.g. the same checkpoint was written on two machines — recovery **fetches the remote ref and replays the local-only commits on top** (`fetchAndRebaseRefCommon`), then retries. After the replay the local ref is a fast-forward over the remote, so the retry is *still* non-force and the remote commit is preserved as an ancestor rather than overwritten. A genuine cherry-pick conflict (both sides rewrote the same file, e.g. root `metadata.json`) leaves the ref queued — degrading to the safe state, never forcing.
113113

114-
### On-demand fetch for reads
114+
### On-demand fetch (reads and backfill writes)
115115

116-
A checkpoint written on another machine has no local ref. When a read misses locally and a **ref fetcher** is configured, `resolveRefMaybeFetch` fetches that one ref from the remote and retries once. It carefully distinguishes:
116+
A checkpoint written on another machine has no local ref. When a read — or a backfill write's base resolution (`refBaseForBackfill`) — misses locally and a **ref fetcher** is configured, `resolveRefMaybeFetch` fetches that one ref from the remote and retries once. It carefully distinguishes:
117117

118118
- **genuinely absent** (remote has no such checkpoint) → maps to `ErrCheckpointNotFound`;
119119
- **a real failure** (IO, network, context cancellation) → returned as-is, never swallowed as "not found".

0 commit comments

Comments
 (0)