@@ -17,6 +17,7 @@ import (
1717 "github.com/entireio/cli/cmd/entire/cli/agent/types"
1818 cpkg "github.com/entireio/cli/cmd/entire/cli/checkpoint"
1919 "github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
20+ checkpointremote "github.com/entireio/cli/cmd/entire/cli/checkpoint/remote"
2021 "github.com/entireio/cli/cmd/entire/cli/interactive"
2122 "github.com/entireio/cli/cmd/entire/cli/logging"
2223 cliReview "github.com/entireio/cli/cmd/entire/cli/review"
@@ -286,26 +287,15 @@ func runAttach(ctx context.Context, w, errW io.Writer, sessionID string, agentNa
286287 reviewSkills = resolveReviewSkills (opts .ReviewSkillsOverride )
287288 }
288289
289- transcriptData , err := ag . ReadTranscript ( transcriptPath )
290+ transcriptData , storedTranscript , err := readAttachTranscript ( logCtx , ag , transcriptPath )
290291 if err != nil {
291- return fmt .Errorf ("failed to read transcript: %w" , err )
292- }
293-
294- // Normalize Gemini transcripts for storage.
295- storedTranscript := transcriptData
296- if ag .Type () == agent .AgentTypeGemini {
297- if normalized , normErr := geminicli .NormalizeTranscript (transcriptData ); normErr == nil {
298- storedTranscript = normalized
299- } else {
300- logging .Warn (logCtx , "failed to normalize Gemini transcript, storing raw" , "error" , normErr )
301- }
292+ return err
302293 }
303-
304294 meta := extractTranscriptMetadata (transcriptData )
305295 warnEmptyTranscriptMetadata (errW , ag .Name (), meta , opts )
296+ tokenUsage := agent .CalculateTokenUsage (logCtx , ag , transcriptData , 0 , "" )
306297
307- // Determine checkpoint ID: an explicit target uses the caller-supplied ID
308- // (new by construction, bound to opts.CommitSHA); otherwise reuse HEAD's
298+ // Explicit targets use the caller-supplied ID; otherwise reuse HEAD's
309299 // trailer if present or generate a fresh ID.
310300 var checkpointID id.CheckpointID
311301 var isExistingCheckpoint bool
@@ -343,6 +333,12 @@ func runAttach(ctx context.Context, w, errW io.Writer, sessionID string, agentNa
343333 }
344334 }
345335
336+ saveState := func () {
337+ if err := saveAttachSessionState (logCtx , repo , existingState , sessionID , ag .Type (), transcriptPath , checkpointID , meta , tokenUsage , opts , reviewSkills ); err != nil {
338+ logging .Warn (logCtx , "failed to save session state" , "error" , err )
339+ }
340+ }
341+
346342 if opts .explicitTarget () {
347343 freshRepo , alreadyAttached , expErr := prepareExplicitTarget (ctx , logCtx , w , repo , refs , checkpointID , sessionID )
348344 if freshRepo != repo {
@@ -357,6 +353,10 @@ func runAttach(ctx context.Context, w, errW io.Writer, sessionID string, agentNa
357353 return expErr
358354 }
359355 if alreadyAttached {
356+ // The checkpoint write may have succeeded on an earlier attempt while
357+ // its best-effort state save failed. Repair local state on every
358+ // idempotent retry before returning success.
359+ saveState ()
360360 return nil
361361 }
362362 }
@@ -366,8 +366,6 @@ func runAttach(ctx context.Context, w, errW io.Writer, sessionID string, agentNa
366366 return fmt .Errorf ("failed to get git author: %w" , err )
367367 }
368368
369- tokenUsage := agent .CalculateTokenUsage (logCtx , ag , transcriptData , 0 , "" )
370-
371369 _ , redactSpan := perf .Start (ctx , "redact_transcript" )
372370 redactedTranscript , redactErr := redact .JSONLBytes (storedTranscript )
373371 redactSpan .End ()
@@ -400,9 +398,7 @@ func runAttach(ctx context.Context, w, errW io.Writer, sessionID string, agentNa
400398 }
401399
402400 // Create or update session state.
403- if err := saveAttachSessionState (logCtx , repo , existingState , sessionID , ag .Type (), transcriptPath , checkpointID , meta , tokenUsage , opts , reviewSkills ); err != nil {
404- logging .Warn (logCtx , "failed to save session state" , "error" , err )
405- }
401+ saveState ()
406402
407403 fmt .Fprintf (w , "Attached session %s\n " , sessionID )
408404 printAttachFooter (w , meta , tokenUsage )
@@ -578,6 +574,24 @@ func ensureCheckpointAvailable(ctx, logCtx context.Context, repo *git.Repository
578574 return repo , missingCheckpointError (logCtx , checkpointID , primaryIsRefs )
579575}
580576
577+ // readAttachTranscript reads the session transcript, normalizing Gemini
578+ // transcripts for storage (raw is kept when normalization fails).
579+ func readAttachTranscript (logCtx context.Context , ag agent.Agent , transcriptPath string ) ([]byte , []byte , error ) {
580+ transcriptData , err := ag .ReadTranscript (transcriptPath )
581+ if err != nil {
582+ return nil , nil , fmt .Errorf ("failed to read transcript: %w" , err )
583+ }
584+ storedTranscript := transcriptData
585+ if ag .Type () == agent .AgentTypeGemini {
586+ if normalized , normErr := geminicli .NormalizeTranscript (transcriptData ); normErr == nil {
587+ storedTranscript = normalized
588+ } else {
589+ logging .Warn (logCtx , "failed to normalize Gemini transcript, storing raw" , "error" , normErr )
590+ }
591+ }
592+ return transcriptData , storedTranscript , nil
593+ }
594+
581595// linkExistingSessionCheckpoint handles a session whose state already records
582596// a checkpoint: reviews are refused (review-upgrade of an existing checkpoint
583597// is unsupported), otherwise the existing checkpoint is offered as a trailer
@@ -655,8 +669,8 @@ func prepareExplicitTarget(ctx, logCtx context.Context, w io.Writer, repo *git.R
655669// after the refresh is NOT an error — an explicit-target ID is normally brand
656670// new; the refresh exists so a retry from a fresh clone sees the earlier
657671// attempt instead of rebuilding the ID as an orphan that would clobber it on
658- // push. For git-refs, an explicitly missing remote source ref proves the ID is
659- // new and is safe to proceed; transport/auth/unknown failures still fail closed.
672+ // push. An explicitly missing remote source ref/branch proves the ID is new and
673+ // is safe to proceed; transport/auth/unknown failures still fail closed.
660674func ensureExplicitCheckpointFreshness (ctx context.Context , repo * git.Repository , refs cpkg.PersistentRefs , checkpointID id.CheckpointID ) (* git.Repository , bool , error ) {
661675 cfg , err := settings .LoadCheckpointsConfig (ctx )
662676 if err != nil {
@@ -672,6 +686,16 @@ func ensureExplicitCheckpointFreshness(ctx context.Context, repo *git.Repository
672686 return repo , true , nil
673687 }
674688
689+ if ! primaryIsRefs {
690+ remotePresent , probeErr := explicitTargetMetadataBranchPresent (ctx , refs )
691+ if probeErr != nil {
692+ return repo , false , fmt .Errorf ("failed to probe explicit checkpoint metadata branch before attach: %w" , probeErr )
693+ }
694+ if ! remotePresent {
695+ return repo , false , nil
696+ }
697+ }
698+
675699 freshRepo , fetchErr := refreshCheckpoint (ctx , checkpointID , primaryIsRefs )
676700 if fetchErr != nil {
677701 if primaryIsRefs && errors .Is (fetchErr , errCheckpointRefNotFound ) {
@@ -686,6 +710,21 @@ func ensureExplicitCheckpointFreshness(ctx context.Context, repo *git.Repository
686710 return freshRepo , present , nil
687711}
688712
713+ // explicitTargetMetadataBranchPresent distinguishes a genuinely unpublished v1
714+ // branch from an inconclusive fetch failure. `git ls-remote` succeeds with empty
715+ // output when the branch is absent, while transport/auth failures return errors.
716+ func explicitTargetMetadataBranchPresent (ctx context.Context , refs cpkg.PersistentRefs ) (bool , error ) {
717+ fetchTarget , err := checkpointremote .FetchURL (ctx )
718+ if err != nil {
719+ return false , fmt .Errorf ("resolve checkpoint fetch target: %w" , err )
720+ }
721+ output , err := checkpointremote .LsRemoteInDir (ctx , "" , fetchTarget , refs .Primary .String ())
722+ if err != nil {
723+ return false , fmt .Errorf ("list metadata branch on %s: %w" , checkpointremote .RedactURL (fetchTarget ), err )
724+ }
725+ return strings .TrimSpace (string (output )) != "" , nil
726+ }
727+
689728// refreshCheckpoint fetches the checkpoint referenced by HEAD from the remote and
690729// returns a freshly-opened repo so go-git sees the newly-fetched refs/packfiles.
691730// The fetch is backend-aware: git-refs fetches just this checkpoint's ref, while
0 commit comments