77 "log/slog"
88 "os"
99 "strconv"
10+ "sync"
1011 "time"
1112
1213 "github.com/go-git/go-git/v6"
@@ -48,6 +49,17 @@ type gitRefsStore struct {
4849 blobFetcher BlobFetchFunc
4950 refFetcher RefFetchFunc
5051 remoteRefLister RemoteRefListFunc
52+
53+ // fetchFailureMu guards fetchFailure: the first transport-level ref-fetch
54+ // failure, memoized for the store's lifetime so a loop over N missing refs
55+ // (e.g. a stop hook finalizing every checkpoint of a turn) pays a dead —
56+ // or too-slow-for-the-budget — network once instead of N times. Genuine
57+ // remote absence is per-ref and
58+ // is never memoized. The memo never clears — safe because every
59+ // fetcher-wired store today is opened per command/hook invocation; a
60+ // long-lived fetcher-wired store would need an expiry before reusing this.
61+ fetchFailureMu sync.Mutex
62+ fetchFailure error
5163}
5264
5365// newGitRefsStore constructs the per-checkpoint-ref store for a repository.
@@ -116,8 +128,18 @@ func (s *gitRefsStore) Write(ctx context.Context, req WriteRequest) error {
116128}
117129
118130// refBase resolves a checkpoint ref's current tip commit (the parent for the
119- // next write) and subtree object (the checkpoint's current contents). A missing
120- // ref yields (ZeroHash, nil) so the next write becomes an orphan commit.
131+ // next write) and subtree object (the checkpoint's current contents) with a
132+ // LOCAL-ONLY lookup. A missing ref yields (ZeroHash, nil) so the next write
133+ // becomes an orphan commit — correct for creates, whose ref never exists yet
134+ // (locally or remotely); probing the remote would add a doomed round-trip to
135+ // every condensation and, with a fetcher configured, fail offline writes.
136+ // Backfills, which target an existing checkpoint, use refBaseForBackfill
137+ // instead. Migration (migrate.go) also uses refBase deliberately: it imports
138+ // from the LOCAL v1 branch and must never probe the remote, even though its
139+ // target ref may already exist. One writeSession caller does target an
140+ // existing checkpoint — attach, which adds a session to it — but attach
141+ // pre-fetches and verifies the ref's presence itself (refreshCheckpoint)
142+ // before writing, so the local-only probe is safe there too.
121143func (s * gitRefsStore ) refBase (cid id.CheckpointID ) (plumbing.Hash , * object.Tree , error ) {
122144 refName , err := RefName (cid )
123145 if err != nil {
@@ -132,6 +154,35 @@ func (s *gitRefsStore) refBase(cid id.CheckpointID) (plumbing.Hash, *object.Tree
132154 // rather than silently starting a fresh orphan history over the ref.
133155 return plumbing .ZeroHash , nil , fmt .Errorf ("resolve checkpoint ref %s: %w" , refName , err )
134156 }
157+ return s .refTip (cid , ref )
158+ }
159+
160+ // refBaseForBackfill resolves like refBase, but a ref missing locally is
161+ // first fetched once from the remote (resolveRefMaybeFetch) when a fetcher is
162+ // configured: a backfill targets an EXISTING checkpoint that may have been
163+ // written or migrated on another machine, and declaring it absent without
164+ // looking remotely diverges from the read path — the backfill would be
165+ // handled as targeting a nonexistent checkpoint while reads, which DO fetch,
166+ // serve the refs copy, leaving the backfilled data permanently invisible.
167+ // A ref absent even after the fetch yields (ZeroHash, nil), which the
168+ // backfill helpers report as ErrCheckpointNotFound — the signal that the
169+ // checkpoint does not exist in this backend. A fetch FAILURE is returned
170+ // as-is: transient unavailability must never masquerade as absence, because
171+ // a caller or routing layer acting on a false "absent" would misdirect the
172+ // backfill (e.g. onto a stale copy in another backend) instead of retrying.
173+ func (s * gitRefsStore ) refBaseForBackfill (ctx context.Context , cid id.CheckpointID ) (plumbing.Hash , * object.Tree , error ) {
174+ ref , err := s .resolveRefMaybeFetch (ctx , cid )
175+ if errors .Is (err , plumbing .ErrReferenceNotFound ) {
176+ return plumbing .ZeroHash , nil , nil // genuinely absent → backfill reports not-found
177+ }
178+ if err != nil {
179+ return plumbing .ZeroHash , nil , err
180+ }
181+ return s .refTip (cid , ref )
182+ }
183+
184+ // refTip reads the commit and tree at a resolved checkpoint ref.
185+ func (s * gitRefsStore ) refTip (cid id.CheckpointID , ref * plumbing.Reference ) (plumbing.Hash , * object.Tree , error ) {
135186 commit , err := s .repo .CommitObject (ref .Hash ())
136187 if err != nil {
137188 return plumbing .ZeroHash , nil , fmt .Errorf ("read checkpoint commit %s: %w" , ref .Hash (), err )
@@ -207,11 +258,14 @@ func (s *gitRefsStore) writeSession(ctx context.Context, opts WriteOptions) erro
207258}
208259
209260func (s * gitRefsStore ) backfillTranscript (ctx context.Context , opts UpdateOptions ) error {
261+ if err := ctx .Err (); err != nil {
262+ return err //nolint:wrapcheck // Propagating context cancellation
263+ }
210264 if opts .CheckpointID .IsEmpty () {
211265 return errors .New ("invalid update options: checkpoint ID is required" )
212266 }
213267
214- parentHash , existing , err := s .refBase ( opts .CheckpointID )
268+ parentHash , existing , err := s .refBaseForBackfill ( ctx , opts .CheckpointID )
215269 if err != nil {
216270 return err
217271 }
@@ -238,7 +292,7 @@ func (s *gitRefsStore) backfillSummary(ctx context.Context, checkpointID id.Chec
238292 return err //nolint:wrapcheck // Propagating context cancellation
239293 }
240294
241- parentHash , existing , err := s .refBase ( checkpointID )
295+ parentHash , existing , err := s .refBaseForBackfill ( ctx , checkpointID )
242296 if err != nil {
243297 return err
244298 }
@@ -262,7 +316,7 @@ func (s *gitRefsStore) backfillAttribution(ctx context.Context, checkpointID id.
262316 return err //nolint:wrapcheck // Propagating context cancellation
263317 }
264318
265- parentHash , existing , err := s .refBase ( checkpointID )
319+ parentHash , existing , err := s .refBaseForBackfill ( ctx , checkpointID )
266320 if err != nil {
267321 return err
268322 }
@@ -330,7 +384,33 @@ func (s *gitRefsStore) resolveRefMaybeFetch(ctx context.Context, cid id.Checkpoi
330384 if s .refFetcher == nil {
331385 return nil , err //nolint:wrapcheck // genuinely absent; caller maps ErrReferenceNotFound to ErrCheckpointNotFound
332386 }
387+ s .fetchFailureMu .Lock ()
388+ priorFailure := s .fetchFailure
389+ s .fetchFailureMu .Unlock ()
390+ if priorFailure != nil {
391+ // Note the cause may name a DIFFERENT ref — it is the first failure
392+ // of this operation, remembered so the outage is paid once.
393+ return nil , fmt .Errorf ("fetch checkpoint ref %s: skipped, an earlier checkpoint-ref fetch already failed in this operation: %w" , refName , priorFailure )
394+ }
333395 if fetchErr := s .refFetcher (ctx , refName ); fetchErr != nil {
396+ if errors .Is (fetchErr , plumbing .ErrReferenceNotFound ) {
397+ // The fetcher probed the remote and it genuinely lacks this ref
398+ // (remote.FetchCheckpointRef's absence signal) — absence, not a
399+ // failure, and per-ref, so it is not memoized.
400+ logging .Debug (ctx , "git-refs: remote has no such checkpoint ref" ,
401+ slog .String ("ref" , refName .String ()))
402+ return nil , plumbing .ErrReferenceNotFound
403+ }
404+ // Memoize only network verdicts: a cancellation originating from the
405+ // CALLER's context says nothing about the remote and must not poison
406+ // later fetches on this store.
407+ if ctx .Err () == nil {
408+ s .fetchFailureMu .Lock ()
409+ if s .fetchFailure == nil {
410+ s .fetchFailure = fetchErr
411+ }
412+ s .fetchFailureMu .Unlock ()
413+ }
334414 logging .Debug (ctx , "git-refs: on-demand checkpoint ref fetch failed" ,
335415 slog .String ("ref" , refName .String ()), slog .String ("error" , fetchErr .Error ()))
336416 return nil , fmt .Errorf ("fetch checkpoint ref %s: %w" , refName , fetchErr )
0 commit comments