Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions cli/checkpoint/v2_committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,26 @@ import (
// determined from the /main ref and passed to the /full/current write to
// keep both refs consistent.
func (s *V2GitStore) WriteCommitted(ctx context.Context, opts WriteCommittedOptions) error {
_, err := s.WriteCommittedWithSessionIndex(ctx, opts)
StorerMu.Lock()
defer StorerMu.Unlock()
// writeCommittedWithSessionIndexLocked (not the public wrapper) because we
// already hold StorerMu — re-entering the non-reentrant mutex would deadlock.
_, err := s.writeCommittedWithSessionIndexLocked(ctx, opts)
return err
}

// WriteCommittedWithSessionIndex writes a committed checkpoint and returns the
// v2 session index used for the write. The index may point at an existing
// session when the checkpoint already contains the same session ID.
func (s *V2GitStore) WriteCommittedWithSessionIndex(ctx context.Context, opts WriteCommittedOptions) (int, error) {
StorerMu.Lock()
defer StorerMu.Unlock()
return s.writeCommittedWithSessionIndexLocked(ctx, opts)
}

// writeCommittedWithSessionIndexLocked is the unlocked implementation shared by
// WriteCommitted and WriteCommittedWithSessionIndex. Callers MUST hold StorerMu.
func (s *V2GitStore) writeCommittedWithSessionIndexLocked(ctx context.Context, opts WriteCommittedOptions) (int, error) {
// Validate upfront before any writes to avoid partial ref updates
if err := validateWriteOpts(opts); err != nil {
return 0, err
Expand Down Expand Up @@ -68,6 +80,9 @@ func (s *V2GitStore) WriteCommittedWithSessionIndex(ctx context.Context, opts Wr
//
// Returns ErrCheckpointNotFound if the checkpoint doesn't exist on /main.
func (s *V2GitStore) UpdateCommitted(ctx context.Context, opts UpdateCommittedOptions) error {
StorerMu.Lock()
defer StorerMu.Unlock()

if opts.CheckpointID.IsEmpty() {
return errors.New("invalid update options: checkpoint ID is required")
}
Expand Down Expand Up @@ -98,6 +113,8 @@ type fullSessionArtifacts struct {
// HasFullSessionArtifacts reports whether the raw transcript and content hash
// for a checkpoint session exist in any local v2 /full/* ref.
func (s *V2GitStore) HasFullSessionArtifacts(checkpointID id.CheckpointID, sessionIndex int) (bool, error) {
StorerMu.Lock()
defer StorerMu.Unlock()
artifacts, err := s.findFullSessionArtifacts(checkpointID, sessionIndex)
if err != nil {
return false, err
Expand Down Expand Up @@ -138,7 +155,9 @@ func (s *V2GitStore) findFullSessionArtifacts(checkpointID id.CheckpointID, sess
func (s *V2GitStore) fullRefSearchOrder() ([]plumbing.ReferenceName, error) {
refNames := []plumbing.ReferenceName{plumbing.ReferenceName(paths.V2FullCurrentRefName)}

archived, err := s.ListArchivedGenerations()
// listArchivedGenerationsLocked (not the public wrapper) — this private
// helper is only reached from other public methods that already hold StorerMu.
archived, err := s.listArchivedGenerationsLocked()
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -705,7 +724,9 @@ func (s *V2GitStore) writeCommittedFullTranscript(ctx context.Context, opts Writ
}

func (s *V2GitStore) rotateCurrentIfNeeded(ctx context.Context, treeHash plumbing.Hash) {
checkpointCount, countErr := s.CountCheckpointsInTree(treeHash)
// countCheckpointsInTreeLocked (not the public wrapper) — we already hold
// StorerMu via the writing public method that invoked writeCommittedFullTranscript.
checkpointCount, countErr := s.countCheckpointsInTreeLocked(treeHash)
if countErr != nil {
logging.Warn(
ctx, "failed to count checkpoints for rotation check",
Expand All @@ -716,7 +737,9 @@ func (s *V2GitStore) rotateCurrentIfNeeded(ctx context.Context, treeHash plumbin
if checkpointCount < s.maxCheckpoints() {
return
}
if rotErr := s.rotateGeneration(ctx); rotErr != nil {
// rotateGenerationLocked (not the public wrapper) — we already hold StorerMu
// via the writing public method that invoked writeCommittedFullTranscript.
if rotErr := s.rotateGenerationLocked(ctx); rotErr != nil {
logging.Warn(
ctx, "generation rotation failed",
slog.String("error", rotErr.Error()),
Expand Down Expand Up @@ -802,6 +825,9 @@ func validateWriteOpts(opts WriteCommittedOptions) error {
// UpdateSummary persists an AI-generated summary into the latest session's
// metadata on the v2 /main ref. Mirrors GitStore.UpdateSummary for v1.
func (s *V2GitStore) UpdateSummary(ctx context.Context, checkpointID id.CheckpointID, summary *Summary) error {
StorerMu.Lock()
defer StorerMu.Unlock()

if err := ctx.Err(); err != nil {
return err //nolint:wrapcheck // Propagating context cancellation
}
Expand Down Expand Up @@ -875,6 +901,9 @@ func (s *V2GitStore) UpdateSummary(ctx context.Context, checkpointID id.Checkpoi
// Older CLI versions wrote these before the rename to raw_transcript.
// Returns nil if /full/current doesn't exist or no v1 files were found.
func (s *V2GitStore) CleanupV1TranscriptFiles(ctx context.Context, checkpointID id.CheckpointID, sessionCount int) error {
StorerMu.Lock()
defer StorerMu.Unlock()

refName := plumbing.ReferenceName(paths.V2FullCurrentRefName)
parentHash, rootTreeHash, err := s.GetRefState(refName)
if err != nil {
Expand Down
50 changes: 46 additions & 4 deletions cli/checkpoint/v2_generation.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ type GenerationMetadata struct {
// ReadGeneration reads generation.json from the given tree hash.
// Returns a zero-value GenerationMetadata if the file doesn't exist (new/empty generation).
func (s *V2GitStore) ReadGeneration(treeHash plumbing.Hash) (GenerationMetadata, error) {
StorerMu.Lock()
defer StorerMu.Unlock()
return s.readGenerationLocked(treeHash)
}

// readGenerationLocked is the unlocked implementation of ReadGeneration.
// Callers MUST hold StorerMu.
func (s *V2GitStore) readGenerationLocked(treeHash plumbing.Hash) (GenerationMetadata, error) {
if treeHash == plumbing.ZeroHash {
return GenerationMetadata{}, nil
}
Expand Down Expand Up @@ -79,11 +87,13 @@ func (s *V2GitStore) ReadGeneration(treeHash plumbing.Hash) (GenerationMetadata,

// ReadGenerationFromRef reads generation.json from the tree pointed to by the given ref.
func (s *V2GitStore) ReadGenerationFromRef(refName plumbing.ReferenceName) (GenerationMetadata, error) {
StorerMu.Lock()
defer StorerMu.Unlock()
_, treeHash, err := s.GetRefState(refName)
if err != nil {
return GenerationMetadata{}, fmt.Errorf("failed to get ref state: %w", err)
}
return s.ReadGeneration(treeHash)
return s.readGenerationLocked(treeHash)
}

// marshalGenerationBlob marshals gen as generation.json and stores it as a git blob.
Expand Down Expand Up @@ -120,6 +130,14 @@ func (s *V2GitStore) writeGeneration(gen GenerationMetadata, entries map[string]
// The tree structure is <id[:2]>/<id[2:]>/ — we count second-level directories
// across all shard prefixes. Returns 0 for an empty tree.
func (s *V2GitStore) CountCheckpointsInTree(treeHash plumbing.Hash) (int, error) {
StorerMu.Lock()
defer StorerMu.Unlock()
return s.countCheckpointsInTreeLocked(treeHash)
}

// countCheckpointsInTreeLocked is the unlocked implementation of
// CountCheckpointsInTree. Callers MUST hold StorerMu.
func (s *V2GitStore) countCheckpointsInTreeLocked(treeHash plumbing.Hash) (int, error) {
if treeHash == plumbing.ZeroHash {
return 0, nil
}
Expand Down Expand Up @@ -396,6 +414,14 @@ var GenerationRefPattern = regexp.MustCompile(`^\d{13}$`)
// listArchivedGenerations returns the names of all archived generation refs
// (everything under V2FullRefPrefix matching the expected numeric format), sorted ascending.
func (s *V2GitStore) ListArchivedGenerations() ([]string, error) {
StorerMu.Lock()
defer StorerMu.Unlock()
return s.listArchivedGenerationsLocked()
}

// listArchivedGenerationsLocked is the unlocked implementation of
// ListArchivedGenerations. Callers MUST hold StorerMu.
func (s *V2GitStore) listArchivedGenerationsLocked() ([]string, error) {
refs, err := s.repo.References()
if err != nil {
return nil, fmt.Errorf("failed to list references: %w", err)
Expand Down Expand Up @@ -425,7 +451,15 @@ func (s *V2GitStore) ListArchivedGenerations() ([]string, error) {
// NextGenerationNumber returns the next sequential generation number for archiving.
// Scans existing archived refs and returns max+1. Returns 1 if no archives exist.
func (s *V2GitStore) NextGenerationNumber() (int, error) {
archived, err := s.ListArchivedGenerations()
StorerMu.Lock()
defer StorerMu.Unlock()
return s.nextGenerationNumberLocked()
}

// nextGenerationNumberLocked is the unlocked implementation of
// NextGenerationNumber. Callers MUST hold StorerMu.
func (s *V2GitStore) nextGenerationNumberLocked() (int, error) {
archived, err := s.listArchivedGenerationsLocked()
if err != nil {
return 0, err
}
Expand All @@ -451,6 +485,14 @@ func (s *V2GitStore) NextGenerationNumber() (int, error) {
// 2. Reset: create a fresh orphan commit with an empty tree + seed generation.json,
// point /full/current at it.
func (s *V2GitStore) rotateGeneration(ctx context.Context) error {
StorerMu.Lock()
defer StorerMu.Unlock()
return s.rotateGenerationLocked(ctx)
}

// rotateGenerationLocked is the unlocked implementation of rotateGeneration.
// Callers MUST hold StorerMu.
func (s *V2GitStore) rotateGenerationLocked(ctx context.Context) error {
refName := plumbing.ReferenceName(paths.V2FullCurrentRefName)

// Guard against concurrent rotation: re-read /full/current and check if
Expand All @@ -459,7 +501,7 @@ func (s *V2GitStore) rotateGeneration(ctx context.Context) error {
if err != nil {
return fmt.Errorf("rotation: failed to read /full/current: %w", err)
}
checkpointCount, err := s.CountCheckpointsInTree(currentTreeHash)
checkpointCount, err := s.countCheckpointsInTreeLocked(currentTreeHash)
if err != nil {
return fmt.Errorf("rotation: failed to count checkpoints: %w", err)
}
Expand All @@ -472,7 +514,7 @@ func (s *V2GitStore) rotateGeneration(ctx context.Context) error {
return fmt.Errorf("rotation: failed to read /full/current ref: %w", err)
}

archiveNumber, err := s.NextGenerationNumber()
archiveNumber, err := s.nextGenerationNumberLocked()
if err != nil {
return fmt.Errorf("rotation: failed to determine next generation number: %w", err)
}
Expand Down
46 changes: 40 additions & 6 deletions cli/checkpoint/v2_read.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ import (
// ReadCommitted reads the checkpoint summary from the v2 /main ref.
// Returns nil, nil if the checkpoint doesn't exist (same contract as GitStore.ReadCommitted).
func (s *V2GitStore) ReadCommitted(ctx context.Context, checkpointID id.CheckpointID) (*CheckpointSummary, error) {
StorerMu.Lock()
defer StorerMu.Unlock()
return s.readCommittedLocked(ctx, checkpointID)
}

// readCommittedLocked is the unlocked implementation of ReadCommitted.
// Callers MUST hold StorerMu.
func (s *V2GitStore) readCommittedLocked(ctx context.Context, checkpointID id.CheckpointID) (*CheckpointSummary, error) {
if err := ctx.Err(); err != nil {
return nil, err //nolint:wrapcheck // Propagating context cancellation
}
Expand Down Expand Up @@ -66,6 +74,9 @@ func (s *V2GitStore) ReadCommitted(ctx context.Context, checkpointID id.Checkpoi
// ListCommitted lists all committed checkpoints from the v2 /main ref.
// Scans sharded paths: <id[:2]>/<id[2:]>/ directories containing metadata.json.
func (s *V2GitStore) ListCommitted(ctx context.Context) ([]CommittedInfo, error) {
StorerMu.Lock()
defer StorerMu.Unlock()

if err := ctx.Err(); err != nil {
return nil, err //nolint:wrapcheck // Propagating context cancellation
}
Expand Down Expand Up @@ -140,6 +151,9 @@ func (s *V2GitStore) ListCommitted(ctx context.Context) ([]CommittedInfo, error)
// ReadSessionCompactTranscript reads transcript.jsonl for a session from the v2
// /main ref. Returns ErrNoTranscript when compact transcript is missing.
func (s *V2GitStore) ReadSessionCompactTranscript(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) ([]byte, error) {
StorerMu.Lock()
defer StorerMu.Unlock()

if err := ctx.Err(); err != nil {
return nil, err //nolint:wrapcheck // Propagating context cancellation
}
Expand Down Expand Up @@ -186,6 +200,9 @@ func (s *V2GitStore) ReadSessionCompactTranscript(ctx context.Context, checkpoin
// ReadSessionMetadata reads only the metadata.json for a specific session within a v2 checkpoint.
// Returns ErrCheckpointNotFound if the checkpoint or session doesn't exist on /main.
func (s *V2GitStore) ReadSessionMetadata(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*CommittedMetadata, error) {
StorerMu.Lock()
defer StorerMu.Unlock()

if err := ctx.Err(); err != nil {
return nil, err //nolint:wrapcheck // Propagating context cancellation
}
Expand Down Expand Up @@ -234,6 +251,9 @@ func (s *V2GitStore) ReadSessionMetadata(ctx context.Context, checkpointID id.Ch
// (transcript.jsonl) on /main can substitute for display.
// Returns ErrCheckpointNotFound if the checkpoint or session doesn't exist on /main.
func (s *V2GitStore) ReadSessionMetadataAndPrompts(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*SessionContent, error) {
StorerMu.Lock()
defer StorerMu.Unlock()

if err := ctx.Err(); err != nil {
return nil, err //nolint:wrapcheck // Propagating context cancellation
}
Expand Down Expand Up @@ -294,6 +314,14 @@ func (s *V2GitStore) ReadSessionMetadataAndPrompts(ctx context.Context, checkpoi
// Returns ErrNoTranscript if the session exists but no raw transcript is available.
// Returns ErrCheckpointNotFound if the checkpoint or session doesn't exist on /main.
func (s *V2GitStore) ReadSessionContent(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*SessionContent, error) {
StorerMu.Lock()
defer StorerMu.Unlock()
return s.readSessionContentLocked(ctx, checkpointID, sessionIndex)
}

// readSessionContentLocked is the unlocked implementation of ReadSessionContent.
// Callers MUST hold StorerMu.
func (s *V2GitStore) readSessionContentLocked(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*SessionContent, error) {
if err := ctx.Err(); err != nil {
return nil, err //nolint:wrapcheck // Propagating context cancellation
}
Expand Down Expand Up @@ -365,7 +393,7 @@ func (s *V2GitStore) readTranscriptFromFullRefs(ctx context.Context, checkpointI
return transcript, nil
}

archived, err := s.ListArchivedGenerations()
archived, err := s.listArchivedGenerationsLocked()
if err != nil {
return nil, err
}
Expand All @@ -387,7 +415,7 @@ func (s *V2GitStore) readTranscriptFromFullRefs(ctx context.Context, checkpointI
}

// Search newly fetched refs only
newArchived, err := s.ListArchivedGenerations()
newArchived, err := s.listArchivedGenerationsLocked()
if err != nil {
return nil, nil //nolint:nilerr // Best-effort: fetch-on-demand failure shouldn't block resume
}
Expand Down Expand Up @@ -551,7 +579,10 @@ func readTranscriptFromObjectTree(tree *object.Tree, agentType types.AgentType)
// non-wrapped error (containing the session ID and checkpoint ID for context)
// if no session in the checkpoint matches sessionID.
func (s *V2GitStore) ReadSessionContentByID(ctx context.Context, checkpointID id.CheckpointID, sessionID string) (*SessionContent, error) {
summary, err := s.ReadCommitted(ctx, checkpointID)
StorerMu.Lock()
defer StorerMu.Unlock()

summary, err := s.readCommittedLocked(ctx, checkpointID)
if err != nil {
return nil, err
}
Expand All @@ -560,7 +591,7 @@ func (s *V2GitStore) ReadSessionContentByID(ctx context.Context, checkpointID id
}

for i := range summary.Sessions {
content, readErr := s.ReadSessionContent(ctx, checkpointID, i)
content, readErr := s.readSessionContentLocked(ctx, checkpointID, i)
if readErr != nil {
continue
}
Expand All @@ -575,7 +606,10 @@ func (s *V2GitStore) ReadSessionContentByID(ctx context.Context, checkpointID id
// GetSessionLog reads the latest session's raw transcript and session ID from v2 refs.
// Convenience wrapper matching the GitStore.GetSessionLog signature.
func (s *V2GitStore) GetSessionLog(ctx context.Context, cpID id.CheckpointID) ([]byte, string, error) {
summary, err := s.ReadCommitted(ctx, cpID)
StorerMu.Lock()
defer StorerMu.Unlock()

summary, err := s.readCommittedLocked(ctx, cpID)
if err != nil {
return nil, "", err
}
Expand All @@ -587,7 +621,7 @@ func (s *V2GitStore) GetSessionLog(ctx context.Context, cpID id.CheckpointID) ([
}

latestIndex := len(summary.Sessions) - 1
content, err := s.ReadSessionContent(ctx, cpID, latestIndex)
content, err := s.readSessionContentLocked(ctx, cpID, latestIndex)
if err != nil {
return nil, "", err
}
Expand Down
20 changes: 19 additions & 1 deletion cli/fork_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import (
"github.com/GrayCodeAI/trace/cli/checkpoint/id"
"github.com/GrayCodeAI/trace/cli/checkpoint/remote"
"github.com/GrayCodeAI/trace/cli/logging"
"github.com/GrayCodeAI/trace/cli/oplog"
"github.com/GrayCodeAI/trace/cli/paths"
"github.com/GrayCodeAI/trace/cli/session"
"github.com/GrayCodeAI/trace/cli/settings"
"github.com/GrayCodeAI/trace/cli/strategy"
"github.com/GrayCodeAI/trace/cli/trailers"
"github.com/GrayCodeAI/trace/cli/versioninfo"
"github.com/google/uuid"
Expand Down Expand Up @@ -212,12 +214,28 @@ func forkSession(
// gives the fork an independent starting point without copying a worktree.
commitHash, branchName := forkCodeCommit(ctx, repo, cpID, meta, newSessionID)
if !commitHash.IsZero() {
ref := plumbing.NewHashReference(plumbing.NewBranchReferenceName(branchName), commitHash)
refName := plumbing.NewBranchReferenceName(branchName)
ref := plumbing.NewHashReference(refName, commitHash)
// Serialize against concurrent V2GitStore storer access (and any
// other StorerMu-guarded writer) — go-git's storer is not
// concurrency-safe. fork/undo already cooperate via the same mutex.
checkpoint.StorerMu.Lock()
if err := repo.Storer.SetReference(ref); err != nil {
checkpoint.StorerMu.Unlock()
return forkResult{}, fmt.Errorf("failed to create fork branch %s: %w", branchName, err)
}
result.ForkBranch = branchName
result.BaseCommit = commitHash.String()

// New branch, so before-hash is the zero hash — 'trace undo' deletes
// the branch rather than trying to point it "back" anywhere.
logErr := strategy.RecordOplogEntry(
ctx, repo, oplog.OpFork, refName.String(), plumbing.ZeroHash, commitHash, cpID.String(),
)
checkpoint.StorerMu.Unlock()
if logErr != nil {
logging.Warn(ctx, "failed to record oplog entry for fork", "error", logErr.Error())
}
}

if err := writeForkSessionState(ctx, newSessionID, result.BaseCommit, meta); err != nil {
Expand Down
Loading