Skip to content

Commit 5f292e3

Browse files
authored
Merge pull request #1351 from entireio/feat/checkpoints-v1.1-rewind-clean
checkpoints v1.1: topology coverage for picker and cleanup
2 parents 9ca949f + 0eb285c commit 5f292e3

14 files changed

Lines changed: 294 additions & 866 deletions

CLAUDE.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,6 @@ The `Strategy` interface provides:
419419
- `SaveTaskStep()` - Save subagent task step checkpoint
420420
- `GetRewindPoints()` / `Rewind()` - List and restore to checkpoints
421421
- `GetSessionLog()` / `GetSessionInfo()` - Retrieve session data
422-
- `ListSessions()` / `GetSession()` - Session discovery
423422

424423
#### How It Works
425424

cmd/entire/cli/explain.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -822,7 +822,7 @@ func runPreFetch(ctx context.Context, ft *checkpoint.FetchingTree, cpID id.Check
822822
}
823823

824824
func loadV1MetadataRootTree(repo *git.Repository) (*object.Tree, error) {
825-
if tree, err := strategy.GetMetadataBranchTree(repo); err == nil {
825+
if tree, err := strategy.GetMetadataRefTree(repo, plumbing.NewBranchReferenceName(paths.MetadataBranchName)); err == nil {
826826
return tree, nil
827827
}
828828
tree, err := strategy.GetRemoteMetadataBranchTree(repo)

cmd/entire/cli/explain_test.go

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1618,17 +1618,6 @@ func TestFormatSessionInfo_WithSourceRef(t *testing.T) {
16181618
}
16191619
}
16201620

1621-
// TestManualCommitStrategyCallable verifies that the strategy's methods are callable
1622-
func TestManualCommitStrategyCallable(t *testing.T) {
1623-
s := strategy.NewManualCommitStrategy()
1624-
1625-
// GetAdditionalSessions should exist and be callable
1626-
_, err := s.GetAdditionalSessions(context.Background())
1627-
if err != nil {
1628-
t.Logf("GetAdditionalSessions returned error: %v", err)
1629-
}
1630-
}
1631-
16321621
func TestFormatSessionInfo_CheckpointNumberingReversed(t *testing.T) {
16331622
now := time.Now()
16341623
session := &strategy.Session{

cmd/entire/cli/resume.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ func getMetadataTree(ctx context.Context) (*object.Tree, *git.Repository, error)
341341
freshRepo, freshErr := openRepository(ctx)
342342
if freshErr == nil {
343343
logRefHash(freshRepo, "checkpoint-remote")
344-
metadataTree, treeErr := strategy.GetMetadataBranchTree(freshRepo)
344+
metadataTree, treeErr := strategy.GetMetadataRefTree(freshRepo, plumbing.NewBranchReferenceName(paths.MetadataBranchName))
345345
if treeErr == nil {
346346
logging.Debug(logCtx, "metadata tree obtained via checkpoint remote fetch",
347347
slog.String("tree_hash", metadataTree.Hash.String()),
@@ -365,7 +365,7 @@ func getMetadataTree(ctx context.Context) (*object.Tree, *git.Repository, error)
365365
freshRepo, repoErr := openRepository(ctx)
366366
if repoErr == nil {
367367
logRefHash(freshRepo, "treeless-fetch")
368-
metadataTree, treeErr := strategy.GetMetadataBranchTree(freshRepo)
368+
metadataTree, treeErr := strategy.GetMetadataRefTree(freshRepo, plumbing.NewBranchReferenceName(paths.MetadataBranchName))
369369
if treeErr == nil {
370370
logging.Debug(logCtx, "metadata tree obtained via treeless fetch",
371371
slog.String("tree_hash", metadataTree.Hash.String()),
@@ -387,7 +387,7 @@ func getMetadataTree(ctx context.Context) (*object.Tree, *git.Repository, error)
387387
localRepo, repoErr := openRepository(ctx)
388388
if repoErr == nil {
389389
logRefHash(localRepo, "local")
390-
metadataTree, err := strategy.GetMetadataBranchTree(localRepo)
390+
metadataTree, err := strategy.GetMetadataRefTree(localRepo, plumbing.NewBranchReferenceName(paths.MetadataBranchName))
391391
if err == nil {
392392
logging.Debug(logCtx, "metadata tree obtained from local branch",
393393
slog.String("tree_hash", metadataTree.Hash.String()),
@@ -405,7 +405,7 @@ func getMetadataTree(ctx context.Context) (*object.Tree, *git.Repository, error)
405405
freshRepo, repoErr := openRepository(ctx)
406406
if repoErr == nil {
407407
logRefHash(freshRepo, "full-fetch")
408-
metadataTree, treeErr := strategy.GetMetadataBranchTree(freshRepo)
408+
metadataTree, treeErr := strategy.GetMetadataRefTree(freshRepo, plumbing.NewBranchReferenceName(paths.MetadataBranchName))
409409
if treeErr == nil {
410410
logging.Debug(logCtx, "metadata tree obtained via full fetch",
411411
slog.String("tree_hash", metadataTree.Hash.String()),

cmd/entire/cli/strategy/clean_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,21 @@ package strategy
22

33
import (
44
"context"
5+
"os"
56
"os/exec"
7+
"path/filepath"
68
"strings"
79
"testing"
810
"time"
911

12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
1015
"github.com/entireio/cli/cmd/entire/cli/checkpoint"
16+
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
1117
"github.com/entireio/cli/cmd/entire/cli/paths"
18+
"github.com/entireio/cli/cmd/entire/cli/testutil"
19+
"github.com/entireio/cli/redact"
1220

1321
"github.com/go-git/go-git/v6"
1422
"github.com/go-git/go-git/v6/plumbing"
@@ -465,3 +473,116 @@ func TestListOrphanedSessionStates_ShadowBranchMatching(t *testing.T) {
465473
}
466474
}
467475
}
476+
477+
// In v1.1 mode, a session whose checkpoint lives only on v1 must be flagged
478+
// orphaned because the topology read goes to the (unset) mirror.
479+
func TestListOrphanedSessionStates_V11ReadsViaTopology(t *testing.T) {
480+
dir := t.TempDir()
481+
testutil.InitRepo(t, dir)
482+
testutil.WriteFile(t, dir, "f.txt", "init")
483+
testutil.GitAdd(t, dir, "f.txt")
484+
testutil.GitCommit(t, dir, "init")
485+
486+
t.Chdir(dir)
487+
488+
repo, err := git.PlainOpen(dir)
489+
require.NoError(t, err)
490+
491+
const sessionID = "test-session-v11-orphan"
492+
cpID := id.MustCheckpointID("b2c3d4e5f6a1")
493+
require.NoError(t, checkpoint.NewGitStore(repo).WriteCommitted(t.Context(), checkpoint.WriteCommittedOptions{
494+
CheckpointID: cpID,
495+
SessionID: sessionID,
496+
Strategy: "manual-commit",
497+
Transcript: redact.AlreadyRedacted([]byte("transcript\n")),
498+
Prompts: []string{"prompt"},
499+
AuthorName: "Test",
500+
AuthorEmail: "test@test.com",
501+
}))
502+
503+
// BaseCommit is arbitrary; no shadow branch is created, so any value routes
504+
// through the same orphan path. StartedAt clears the grace window.
505+
state := &SessionState{
506+
SessionID: sessionID,
507+
BaseCommit: "0000000000000000000000000000000000000000",
508+
StartedAt: time.Now().Add(-(sessionGracePeriod + time.Minute)),
509+
StepCount: 1,
510+
}
511+
require.NoError(t, SaveSessionState(t.Context(), state))
512+
513+
settingsDir := filepath.Join(dir, ".entire")
514+
require.NoError(t, os.MkdirAll(settingsDir, 0o755))
515+
require.NoError(t, os.WriteFile(
516+
filepath.Join(settingsDir, paths.SettingsFileName),
517+
[]byte(`{"enabled": true, "strategy_options": {"checkpoints_version": "1.1"}}`),
518+
0o644,
519+
))
520+
521+
orphans, err := ListOrphanedSessionStates(t.Context())
522+
require.NoError(t, err)
523+
524+
var flagged bool
525+
for _, item := range orphans {
526+
if item.ID == sessionID {
527+
flagged = true
528+
break
529+
}
530+
}
531+
assert.True(t, flagged, "session must be flagged orphaned: mirror is unset, so topology read returns no checkpoints")
532+
}
533+
534+
// Archived sessions of a multi-session condensed checkpoint must not be
535+
// flagged as orphaned: their IDs appear in cp.SessionIDs even though
536+
// cp.SessionID is the most-recent session.
537+
func TestListOrphanedSessionStates_MultiSessionArchivedNotOrphaned(t *testing.T) {
538+
dir := t.TempDir()
539+
testutil.InitRepo(t, dir)
540+
testutil.WriteFile(t, dir, "f.txt", "init")
541+
testutil.GitAdd(t, dir, "f.txt")
542+
testutil.GitCommit(t, dir, "init")
543+
544+
t.Chdir(dir)
545+
546+
repo, err := git.PlainOpen(dir)
547+
require.NoError(t, err)
548+
549+
cpID := id.MustCheckpointID("c3d4e5f6a1b2")
550+
const archivedSessionID = "archived-session"
551+
const latestSessionID = "latest-session"
552+
553+
// Two sequential writes with the same checkpoint ID produce a multi-session
554+
// checkpoint: the second write archives the first session under <sharded>/0
555+
// and lists both IDs in SessionIDs.
556+
store := checkpoint.NewGitStore(repo)
557+
for _, sid := range []string{archivedSessionID, latestSessionID} {
558+
require.NoError(t, store.WriteCommitted(t.Context(), checkpoint.WriteCommittedOptions{
559+
CheckpointID: cpID,
560+
SessionID: sid,
561+
Strategy: "manual-commit",
562+
Transcript: redact.AlreadyRedacted([]byte("transcript\n")),
563+
Prompts: []string{"prompt-" + sid},
564+
AuthorName: "Test",
565+
AuthorEmail: "test@test.com",
566+
}))
567+
}
568+
569+
staleStart := time.Now().Add(-(sessionGracePeriod + time.Minute))
570+
for _, sid := range []string{archivedSessionID, latestSessionID} {
571+
require.NoError(t, SaveSessionState(t.Context(), &SessionState{
572+
SessionID: sid,
573+
BaseCommit: "0000000000000000000000000000000000000000",
574+
StartedAt: staleStart,
575+
StepCount: 1,
576+
}))
577+
}
578+
579+
orphans, err := ListOrphanedSessionStates(t.Context())
580+
require.NoError(t, err)
581+
582+
for _, item := range orphans {
583+
assert.NotEqual(t, archivedSessionID, item.ID,
584+
"archived session in multi-session checkpoint must not be flagged orphaned")
585+
assert.NotEqual(t, latestSessionID, item.ID,
586+
"latest session in multi-session checkpoint must not be flagged orphaned")
587+
}
588+
}

cmd/entire/cli/strategy/cleanup.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ func DeleteShadowBranches(ctx context.Context, branches []string) (deleted []str
142142

143143
// ListOrphanedSessionStates returns session state files that are orphaned.
144144
// A session state is orphaned if:
145-
// - No checkpoints on entire/checkpoints/v1 reference this session ID
145+
// - No checkpoints on the configured committed read ref reference this session ID
146146
// - No shadow branch exists for the session's base commit
147147
//
148148
// This is strategy-agnostic as session states are shared by all strategies.
@@ -168,14 +168,20 @@ func ListOrphanedSessionStates(ctx context.Context) ([]CleanupItem, error) {
168168
return []CleanupItem{}, nil
169169
}
170170

171-
// Get all checkpoints to find which sessions have checkpoints
172-
cpStore := checkpoint.NewGitStore(repo)
171+
// Get all committed checkpoints from the configured read ref to find which sessions have checkpoints
172+
cpStore := checkpoint.NewCommittedReadStore(ctx, repo)
173173

174174
sessionsWithCheckpoints := make(map[string]bool)
175175
checkpoints, listErr := cpStore.ListCommitted(ctx)
176176
if listErr == nil {
177177
for _, cp := range checkpoints {
178+
// cp.SessionID is the most-recent session in a multi-session checkpoint;
179+
// cp.SessionIDs lists every session that contributed. Track all of them so
180+
// archived sessions of condensed checkpoints aren't flagged as orphaned.
178181
sessionsWithCheckpoints[cp.SessionID] = true
182+
for _, sid := range cp.SessionIDs {
183+
sessionsWithCheckpoints[sid] = true
184+
}
179185
}
180186
}
181187

@@ -196,7 +202,7 @@ func ListOrphanedSessionStates(ctx context.Context) ([]CleanupItem, error) {
196202
continue
197203
}
198204

199-
// Check if session has checkpoints on entire/checkpoints/v1
205+
// Check if session has checkpoints in committed checkpoint storage
200206
hasCheckpoints := sessionsWithCheckpoints[state.SessionID]
201207

202208
// Check if shadow branch exists for this session's base commit and worktree

cmd/entire/cli/strategy/common.go

Lines changed: 21 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -760,22 +760,19 @@ func decodeCheckpointInfo(
760760
return &metadata, nil
761761
}
762762

763-
// GetMetadataBranchTree returns the tree object for the entire/checkpoints/v1 branch.
764-
func GetMetadataBranchTree(repo *git.Repository) (*object.Tree, error) {
765-
refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
766-
ref, err := repo.Reference(refName, true)
763+
// GetMetadataRefTree returns the tree object at the given committed-metadata ref.
764+
func GetMetadataRefTree(repo *git.Repository, ref plumbing.ReferenceName) (*object.Tree, error) {
765+
resolvedRef, err := repo.Reference(ref, true)
767766
if err != nil {
768-
return nil, fmt.Errorf("failed to get metadata branch reference: %w", err)
767+
return nil, fmt.Errorf("read ref %s: %w", ref, err)
769768
}
770-
771-
commit, err := repo.CommitObject(ref.Hash())
769+
commit, err := repo.CommitObject(resolvedRef.Hash())
772770
if err != nil {
773-
return nil, fmt.Errorf("failed to get metadata branch commit: %w", err)
771+
return nil, fmt.Errorf("read commit at %s: %w", ref, err)
774772
}
775-
776773
tree, err := commit.Tree()
777774
if err != nil {
778-
return nil, fmt.Errorf("failed to get metadata branch tree: %w", err)
775+
return nil, fmt.Errorf("read tree at %s: %w", ref, err)
779776
}
780777
return tree, nil
781778
}
@@ -942,28 +939,32 @@ func ReadLatestSessionPromptFromCommittedTree(tree *object.Tree, cpID id.Checkpo
942939

943940
// ReadAllSessionPromptsFromTree reads the first prompt for all sessions in a multi-session checkpoint.
944941
// Returns a slice of prompts parallel to sessionIDs (oldest to newest).
945-
// For single-session checkpoints, returns a slice with just the root prompt.
942+
// For single-session checkpoints, returns a slice with just the session prompt.
946943
func ReadAllSessionPromptsFromTree(tree *object.Tree, checkpointPath string, sessionCount int, sessionIDs []string) []string {
947944
if sessionCount <= 1 || len(sessionIDs) <= 1 {
948-
// Single session - just return the root prompt
949-
prompt := ReadSessionPromptFromTree(tree, checkpointPath)
945+
prompt := ReadSessionPromptFromTree(tree, checkpointPath+"/0")
946+
if prompt == "" {
947+
prompt = ReadSessionPromptFromTree(tree, checkpointPath)
948+
}
950949
if prompt != "" {
951950
return []string{prompt}
952951
}
953952
return nil
954953
}
955954

956-
// Multi-session: read prompts from archived folders (0/, 1/, etc.) and root
957955
prompts := make([]string, len(sessionIDs))
958956

959-
// Read archived session prompts (folders 0, 1, ... N-2)
960-
for i := range sessionCount - 1 {
961-
archivedPath := fmt.Sprintf("%s/%d", checkpointPath, i)
962-
prompts[i] = ReadSessionPromptFromTree(tree, archivedPath)
957+
sessionLimit := min(sessionCount, len(prompts))
958+
for i := range sessionLimit {
959+
sessionPath := fmt.Sprintf("%s/%d", checkpointPath, i)
960+
prompts[i] = ReadSessionPromptFromTree(tree, sessionPath)
963961
}
964962

965-
// Read the most recent session prompt (at root level)
966-
prompts[len(prompts)-1] = ReadSessionPromptFromTree(tree, checkpointPath)
963+
// Older committed metadata stored the latest prompt at the checkpoint root.
964+
latestIndex := sessionLimit - 1
965+
if latestIndex >= 0 && prompts[latestIndex] == "" {
966+
prompts[latestIndex] = ReadSessionPromptFromTree(tree, checkpointPath)
967+
}
967968

968969
return prompts
969970
}
@@ -1549,73 +1550,6 @@ func collectUntrackedFiles(ctx context.Context) ([]string, error) {
15491550
//
15501551
// See push_common.go and session_test.go for usage examples.
15511552

1552-
// getSessionDescriptionFromTree reads the first line of prompt.txt from a git tree.
1553-
// This is the tree-based equivalent of getSessionDescription (which reads from filesystem).
1554-
//
1555-
// If metadataDir is provided, looks for files at metadataDir/prompt.txt.
1556-
// If metadataDir is empty, first tries the root of the tree (for when the tree is already
1557-
// the session directory), then falls back to
1558-
// searching for .entire/metadata/*/prompt.txt (for full worktree trees).
1559-
func getSessionDescriptionFromTree(tree *object.Tree, metadataDir string) string {
1560-
// Helper to read first line from a file in tree
1561-
readFirstLine := func(path string) string {
1562-
file, err := tree.File(path)
1563-
if err != nil {
1564-
return ""
1565-
}
1566-
content, err := file.Contents()
1567-
if err != nil {
1568-
return ""
1569-
}
1570-
lines := strings.SplitN(content, "\n", 2)
1571-
if len(lines) > 0 && lines[0] != "" {
1572-
return strings.TrimSpace(lines[0])
1573-
}
1574-
return ""
1575-
}
1576-
1577-
// If metadataDir is provided, look there directly
1578-
if metadataDir != "" {
1579-
if desc := readFirstLine(metadataDir + "/" + paths.PromptFileName); desc != "" {
1580-
return desc
1581-
}
1582-
return NoDescription
1583-
}
1584-
1585-
// No metadataDir provided - first try looking at the root of the tree
1586-
// (used when the tree is already the session directory)
1587-
if desc := readFirstLine(paths.PromptFileName); desc != "" {
1588-
return desc
1589-
}
1590-
1591-
// Fall back to searching for .entire/metadata/*/prompt.txt
1592-
// (used when the tree is the full worktree)
1593-
var desc string
1594-
//nolint:errcheck // We ignore errors here as we're just searching for a description
1595-
_ = tree.Files().ForEach(func(f *object.File) error {
1596-
if desc != "" {
1597-
return nil // Already found description
1598-
}
1599-
name := f.Name
1600-
if strings.Contains(name, ".entire/metadata/") && strings.HasSuffix(name, "/"+paths.PromptFileName) {
1601-
content, err := f.Contents()
1602-
if err != nil {
1603-
return nil //nolint:nilerr // Skip files we can't read, continue searching
1604-
}
1605-
lines := strings.SplitN(content, "\n", 2)
1606-
if len(lines) > 0 && lines[0] != "" {
1607-
desc = strings.TrimSpace(lines[0])
1608-
}
1609-
}
1610-
return nil
1611-
})
1612-
1613-
if desc != "" {
1614-
return desc
1615-
}
1616-
return NoDescription
1617-
}
1618-
16191553
// GetGitAuthorFromRepo retrieves the git user.name and user.email,
16201554
// checking both the repository-local config and the global ~/.gitconfig.
16211555
// Delegates to checkpoint.GetGitAuthorFromRepo — this wrapper exists so

0 commit comments

Comments
 (0)