From 8166f82df459d146769d1443fdfdef5720ebbc4d Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 18 Jun 2026 18:31:03 -0400 Subject: [PATCH 01/30] feat: adopt sessions across worktrees Entire-Checkpoint: 7fb3d4d78c7f --- cmd/entire/cli/session_adopt.go | 260 +++++++++++++++++++++++++++ cmd/entire/cli/session_adopt_test.go | 218 ++++++++++++++++++++++ cmd/entire/cli/sessions.go | 3 + 3 files changed, 481 insertions(+) create mode 100644 cmd/entire/cli/session_adopt.go create mode 100644 cmd/entire/cli/session_adopt_test.go diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go new file mode 100644 index 0000000000..5f7fd2de2d --- /dev/null +++ b/cmd/entire/cli/session_adopt.go @@ -0,0 +1,260 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/versioninfo" + "github.com/spf13/cobra" +) + +type adoptOptions struct { + FromWorktree string + Force bool +} + +const adoptRecentWindow = 12 * time.Hour + +func newAdoptCmd() *cobra.Command { + var opts adoptOptions + + cmd := &cobra.Command{ + Use: "adopt [session-id]", + Short: "Adopt an active session from another worktree", + Long: `Adopt an active session from another worktree into the current repository. + +This is useful when an agent starts in one repository or worktree, then moves +and makes changes in another. Adoption copies the live session state into the +current repo and seeds it with the current repo's uncommitted file changes so +the next commit can be linked normally.`, + Example: ` entire session adopt 019ed5fe-ec49-7a72-89fd-f38e323f5448 --from ../cli + entire session adopt --from /path/to/source/worktree`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + sessionID := "" + if len(args) > 0 { + sessionID = args[0] + } + return runAdopt(cmd.Context(), cmd.OutOrStdout(), sessionID, opts) + }, + } + + cmd.Flags().StringVar(&opts.FromWorktree, "from", "", "source worktree that already tracks the session") + cmd.Flags().BoolVar(&opts.Force, "force", false, "replace an existing local state file for the same session") + cmd.Flags().BoolVar(&opts.Force, "yes", false, "replace an existing local state file for the same session") + + return cmd +} + +func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOptions) error { + if strings.TrimSpace(opts.FromWorktree) == "" { + return errors.New("source worktree is required; pass --from ") + } + + sourceStore, sourceWorktree, err := stateStoreForWorktree(ctx, opts.FromWorktree) + if err != nil { + return err + } + + sourceState, err := selectAdoptSourceSession(ctx, sourceStore, sourceWorktree, sessionID) + if err != nil { + return err + } + + adopted, filesTouched, err := buildAdoptedSessionState(ctx, sourceState) + if err != nil { + return err + } + + targetStore, err := session.NewStateStore(ctx) + if err != nil { + return fmt.Errorf("open current session store: %w", err) + } + existing, err := targetStore.Load(ctx, adopted.SessionID) + if err != nil { + return fmt.Errorf("load current session state: %w", err) + } + if existing != nil && !opts.Force { + return fmt.Errorf("session %s is already tracked in this repo; rerun with --force to replace it", adopted.SessionID) + } + if err := targetStore.Save(ctx, adopted); err != nil { + return fmt.Errorf("save adopted session state: %w", err) + } + + fmt.Fprintf(w, "Adopted session %s from %s\n", shortSessionID(adopted.SessionID), sourceWorktree) + if len(filesTouched) == 0 { + fmt.Fprintln(w, "No current file changes were detected, so the next commit may not link until hooks record changes.") + return nil + } + fmt.Fprintf(w, "Tracking %d file(s): %s\n", len(filesTouched), strings.Join(filesTouched, ", ")) + return nil +} + +func stateStoreForWorktree(ctx context.Context, worktreePath string) (*session.StateStore, string, error) { + absWorktree, err := filepath.Abs(worktreePath) + if err != nil { + return nil, "", fmt.Errorf("resolve source worktree: %w", err) + } + + cmd := exec.CommandContext(ctx, "git", "-C", absWorktree, "rev-parse", "--show-toplevel", "--git-common-dir") + output, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(output)) + if msg != "" { + return nil, "", fmt.Errorf("resolve source git directory: %s: %w", msg, err) + } + return nil, "", fmt.Errorf("resolve source git directory: %w", err) + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) < 2 { + return nil, "", fmt.Errorf("resolve source git directory: unexpected git output %q", strings.TrimSpace(string(output))) + } + sourceRoot := strings.TrimSpace(lines[0]) + commonDir := strings.TrimSpace(lines[1]) + if !filepath.IsAbs(commonDir) { + commonDir = filepath.Join(absWorktree, commonDir) + } + commonDir = filepath.Clean(commonDir) + + return session.NewStateStoreWithDir(filepath.Join(commonDir, session.SessionStateDirName)), sourceRoot, nil +} + +func selectAdoptSourceSession(ctx context.Context, store *session.StateStore, sourceWorktree, sessionID string) (*session.State, error) { + if sessionID != "" { + sourceState, err := store.Load(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("load source session state: %w", err) + } + if sourceState == nil { + return nil, fmt.Errorf("session %s was not found in %s", sessionID, sourceWorktree) + } + if sourceState.Phase == session.PhaseEnded || sourceState.FullyCondensed { + return nil, fmt.Errorf("session %s is ended or fully condensed and cannot be adopted", sessionID) + } + return sourceState, nil + } + + states, err := store.List(ctx) + if err != nil { + return nil, fmt.Errorf("list source sessions: %w", err) + } + candidates := make([]*session.State, 0, len(states)) + for _, state := range states { + if isRecentAdoptCandidate(state) { + candidates = append(candidates, state) + } + } + sort.Slice(candidates, func(i, j int) bool { + return sessionLastSeen(candidates[i]).After(sessionLastSeen(candidates[j])) + }) + + switch len(candidates) { + case 0: + return nil, fmt.Errorf("no recent active sessions found in %s", sourceWorktree) + case 1: + return candidates[0], nil + default: + ids := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + ids = append(ids, candidate.SessionID) + } + return nil, fmt.Errorf("multiple recent active sessions found in %s; pass one of: %s", + sourceWorktree, strings.Join(ids, ", ")) + } +} + +func isRecentAdoptCandidate(state *session.State) bool { + if state == nil || state.Phase == session.PhaseEnded || state.FullyCondensed { + return false + } + lastSeen := sessionLastSeen(state) + if lastSeen.IsZero() { + return false + } + return time.Since(lastSeen) <= adoptRecentWindow +} + +func sessionLastSeen(state *session.State) time.Time { + if state.LastInteractionTime != nil { + return *state.LastInteractionTime + } + return state.StartedAt +} + +func buildAdoptedSessionState(ctx context.Context, source *session.State) (*session.State, []string, error) { + repo, err := openRepository(ctx) + if err != nil { + return nil, nil, fmt.Errorf("open current repository: %w", err) + } + defer repo.Close() + + head, err := repo.Head() + if err != nil { + return nil, nil, fmt.Errorf("resolve current HEAD: %w", err) + } + + worktreeRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + return nil, nil, fmt.Errorf("resolve current worktree root: %w", err) + } + worktreeID, err := paths.GetWorktreeID(worktreeRoot) + if err != nil { + return nil, nil, fmt.Errorf("resolve current worktree ID: %w", err) + } + + branch, branchErr := GetCurrentBranch(ctx) + if branchErr != nil { + branch = "" + } + filesTouched, err := currentFilesTouched(ctx) + if err != nil { + return nil, nil, err + } + + now := time.Now() + adopted := *source + adopted.CLIVersion = versioninfo.Version + adopted.BaseCommit = head.Hash().String() + adopted.AttributionBaseCommit = head.Hash().String() + adopted.WorktreePath = worktreeRoot + adopted.WorktreeID = worktreeID + adopted.Branch = branch + adopted.LastInteractionTime = &now + adopted.FilesTouched = filesTouched + adopted.TurnCheckpointIDs = nil + adopted.LastCheckpointID = id.EmptyCheckpointID + adopted.LastCheckpointCommitHash = "" + adopted.FullyCondensed = false + adopted.DivergenceNoticeShown = false + adopted.UntrackedFilesAtStart = nil + adopted.PromptAttributions = nil + adopted.PendingPromptAttribution = nil + adopted.PromptWindowBase = 0 + adopted.PromptWindowResetPending = false + adopted.AttachedManually = true + + return &adopted, filesTouched, nil +} + +func currentFilesTouched(ctx context.Context) ([]string, error) { + changes, err := DetectFileChanges(ctx, nil) + if err != nil { + return nil, fmt.Errorf("detect current file changes: %w", err) + } + files := mergeUnique(nil, changes.Modified) + files = mergeUnique(files, changes.New) + files = mergeUnique(files, changes.Deleted) + sort.Strings(files) + return files, nil +} diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go new file mode 100644 index 0000000000..339b842cbc --- /dev/null +++ b/cmd/entire/cli/session_adopt_test.go @@ -0,0 +1,218 @@ +package cli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +func TestSessionAdopt_CopiesExternalSessionIntoCurrentWorktree(t *testing.T) { + sourceRepo := setupAdoptRepo(t) + targetRepo := setupAdoptRepo(t) + + sessionID := "test-adopt-session-001" + transcriptPath := filepath.Join(sourceRepo, ".claude", sessionID+".jsonl") + if err := os.MkdirAll(filepath.Dir(transcriptPath), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(transcriptPath, []byte(`{"type":"user","message":{"role":"user","content":"update target file"},"uuid":"u1"}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + lastInteraction := time.Now().Add(-1 * time.Minute) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + AttributionBaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + TranscriptPath: transcriptPath, + LastPrompt: "update target file", + FilesTouched: []string{"source-only.txt"}, + TurnCheckpointIDs: []string{"abc123def456"}, + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + t.Chdir(targetRepo) + + var out bytes.Buffer + err := runAdopt(context.Background(), &out, sessionID, adoptOptions{ + FromWorktree: sourceRepo, + Force: true, + }) + if err != nil { + t.Fatalf("runAdopt failed: %v", err) + } + + targetStore, err := session.NewStateStore(context.Background()) + if err != nil { + t.Fatal(err) + } + adopted, err := targetStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if adopted == nil { + t.Fatal("expected adopted session state in target repo") + } + if adopted.WorktreePath != targetRepo { + t.Fatalf("WorktreePath = %q, want %q", adopted.WorktreePath, targetRepo) + } + if adopted.BaseCommit != testutil.GetHeadHash(t, targetRepo) { + t.Fatalf("BaseCommit = %q, want target HEAD", adopted.BaseCommit) + } + if adopted.TranscriptPath != transcriptPath { + t.Fatalf("TranscriptPath = %q, want %q", adopted.TranscriptPath, transcriptPath) + } + if !adopted.AttachedManually { + t.Fatal("expected adopted session to be marked manual") + } + if len(adopted.FilesTouched) != 1 || adopted.FilesTouched[0] != "feature.txt" { + t.Fatalf("FilesTouched = %v, want [feature.txt]", adopted.FilesTouched) + } + if len(adopted.TurnCheckpointIDs) != 0 { + t.Fatalf("TurnCheckpointIDs = %v, want empty target-local checkpoint bookkeeping", adopted.TurnCheckpointIDs) + } + if !bytes.Contains(out.Bytes(), []byte("Adopted session")) { + t.Fatalf("output = %q, want adoption confirmation", out.String()) + } +} + +func TestSessionAdopt_EnablesPrepareCommitMsgTrailer(t *testing.T) { + sourceRepo := setupAdoptRepo(t) + targetRepo := setupAdoptRepo(t) + + sessionID := "test-adopt-trailer-001" + targetRelPath := "src/feature.go" + targetAbsPath := filepath.Join(targetRepo, targetRelPath) + + transcriptPath := filepath.Join(sourceRepo, ".claude", sessionID+".jsonl") + if err := os.MkdirAll(filepath.Dir(transcriptPath), 0o750); err != nil { + t.Fatal(err) + } + transcript := `{"type":"human","message":{"content":"write feature.go"}} +{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"file_path":"` + targetAbsPath + `","content":"package src\n"}}]}} +` + if err := os.WriteFile(transcriptPath, []byte(transcript), 0o600); err != nil { + t.Fatal(err) + } + stale := time.Now().Add(-3 * time.Minute) + if err := os.Chtimes(transcriptPath, stale, stale); err != nil { + t.Fatal(err) + } + + lastInteraction := time.Now().Add(-1 * time.Minute) + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + AttributionBaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + TranscriptPath: transcriptPath, + LastPrompt: "write feature.go", + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetRepo, targetRelPath, "package src\n") + testutil.GitAdd(t, targetRepo, targetRelPath) + t.Chdir(targetRepo) + + var out bytes.Buffer + err := runAdopt(context.Background(), &out, sessionID, adoptOptions{ + FromWorktree: sourceRepo, + Force: true, + }) + if err != nil { + t.Fatalf("runAdopt failed: %v", err) + } + + commitMsgFile := filepath.Join(targetRepo, "COMMIT_EDITMSG") + if err := os.WriteFile(commitMsgFile, []byte("add feature\n"), 0o600); err != nil { + t.Fatal(err) + } + + if err := strategy.NewManualCommitStrategy().PrepareCommitMsg(context.Background(), commitMsgFile, ""); err != nil { + t.Fatalf("PrepareCommitMsg failed: %v", err) + } + + content, err := os.ReadFile(commitMsgFile) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "Entire-Checkpoint:") { + t.Fatalf("commit message = %q, want Entire-Checkpoint trailer", string(content)) + } +} + +func TestSessionAdopt_FromSubdirectoryReadsSourceStore(t *testing.T) { + sourceRepo := setupAdoptRepo(t) + targetRepo := setupAdoptRepo(t) + + sourceSubdir := filepath.Join(sourceRepo, "nested", "dir") + if err := os.MkdirAll(sourceSubdir, 0o750); err != nil { + t.Fatal(err) + } + + sessionID := "test-adopt-from-subdir" + lastInteraction := time.Now().Add(-1 * time.Minute) + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + t.Chdir(targetRepo) + + var out bytes.Buffer + err := runAdopt(context.Background(), &out, sessionID, adoptOptions{ + FromWorktree: sourceSubdir, + Force: true, + }) + if err != nil { + t.Fatalf("runAdopt failed from source subdir: %v", err) + } +} + +func setupAdoptRepo(t *testing.T) string { + t.Helper() + + repoDir := t.TempDir() + testutil.InitRepo(t, repoDir) + testutil.WriteFile(t, repoDir, "init.txt", "init\n") + testutil.GitAdd(t, repoDir, "init.txt") + testutil.GitCommit(t, repoDir, "init") + enableEntire(t, repoDir) + realRepoDir, err := filepath.EvalSymlinks(repoDir) + if err != nil { + t.Fatal(err) + } + return realRepoDir +} diff --git a/cmd/entire/cli/sessions.go b/cmd/entire/cli/sessions.go index d60f05f3ff..73529d04d4 100644 --- a/cmd/entire/cli/sessions.go +++ b/cmd/entire/cli/sessions.go @@ -167,6 +167,7 @@ Commands: stop Stop one or more active sessions current Show the active session for the current worktree attach Attach an existing agent session + adopt Adopt an active session from another worktree resume Switch to a branch and resume its session Examples: @@ -176,6 +177,7 @@ Examples: entire session stop Interactive stop entire session current Active session for cwd entire session attach Attach an external session + entire session adopt --from ../repo Adopt a moved session entire session resume Resume from a branch`, PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { if _, err := paths.WorktreeRoot(cmd.Context()); err != nil { @@ -190,6 +192,7 @@ Examples: cmd.AddCommand(newStopCmd()) cmd.AddCommand(newSessionCurrentCmd()) cmd.AddCommand(newAttachCmd()) + cmd.AddCommand(newAdoptCmd()) cmd.AddCommand(newResumeCmd()) return cmd From 2bc10ef5d1d257cbf12b8aad85ffbdfcf55f7429 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 18 Jun 2026 18:44:45 -0400 Subject: [PATCH 02/30] fix: address session adopt review findings Entire-Checkpoint: b7f863b91041 --- cmd/entire/cli/session_adopt.go | 9 ++++++++- cmd/entire/cli/session_adopt_test.go | 8 ++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index 5f7fd2de2d..68f0fdd83c 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -97,6 +97,7 @@ func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOpti return nil } fmt.Fprintf(w, "Tracking %d file(s): %s\n", len(filesTouched), strings.Join(filesTouched, ", ")) + fmt.Fprintln(w, "Review tracked files before committing; adoption attributes current changes in this repo to the adopted session.") return nil } @@ -232,9 +233,15 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess adopted.Branch = branch adopted.LastInteractionTime = &now adopted.FilesTouched = filesTouched + + // Reset target-local checkpoint bookkeeping. Source checkpoint IDs can point + // at metadata in another repository or checkpoint branch; carrying them into + // this repo would let amend and turn-finalization paths operate on unrelated + // checkpoints. adopted.TurnCheckpointIDs = nil adopted.LastCheckpointID = id.EmptyCheckpointID adopted.LastCheckpointCommitHash = "" + adopted.FullyCondensed = false adopted.DivergenceNoticeShown = false adopted.UntrackedFilesAtStart = nil @@ -242,7 +249,7 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess adopted.PendingPromptAttribution = nil adopted.PromptWindowBase = 0 adopted.PromptWindowResetPending = false - adopted.AttachedManually = true + adopted.AttachedManually = false return &adopted, filesTouched, nil } diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index 339b842cbc..b56e7e38b1 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -43,6 +43,7 @@ func TestSessionAdopt_CopiesExternalSessionIntoCurrentWorktree(t *testing.T) { LastPrompt: "update target file", FilesTouched: []string{"source-only.txt"}, TurnCheckpointIDs: []string{"abc123def456"}, + AttachedManually: true, }); err != nil { t.Fatal(err) } @@ -79,8 +80,8 @@ func TestSessionAdopt_CopiesExternalSessionIntoCurrentWorktree(t *testing.T) { if adopted.TranscriptPath != transcriptPath { t.Fatalf("TranscriptPath = %q, want %q", adopted.TranscriptPath, transcriptPath) } - if !adopted.AttachedManually { - t.Fatal("expected adopted session to be marked manual") + if adopted.AttachedManually { + t.Fatal("adopted active sessions should not be marked manually attached") } if len(adopted.FilesTouched) != 1 || adopted.FilesTouched[0] != "feature.txt" { t.Fatalf("FilesTouched = %v, want [feature.txt]", adopted.FilesTouched) @@ -91,6 +92,9 @@ func TestSessionAdopt_CopiesExternalSessionIntoCurrentWorktree(t *testing.T) { if !bytes.Contains(out.Bytes(), []byte("Adopted session")) { t.Fatalf("output = %q, want adoption confirmation", out.String()) } + if !bytes.Contains(out.Bytes(), []byte("Review tracked files before committing")) { + t.Fatalf("output = %q, want tracked-file attribution warning", out.String()) + } } func TestSessionAdopt_EnablesPrepareCommitMsgTrailer(t *testing.T) { From efeca6ca988e03d438016dd9564e35b00c624ad2 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 18 Jun 2026 18:55:14 -0400 Subject: [PATCH 03/30] fix: clarify adopted transcript path Entire-Checkpoint: 0d316d428771 --- cmd/entire/cli/session_adopt.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index 68f0fdd83c..fc89dea7a4 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -225,7 +225,12 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess now := time.Now() adopted := *source + + // Keep the source live transcript path. In cross-repo adoption the transcript + // belongs to the continuing agent session, not the target repository; clearing + // or recomputing it from the target repo would drop live transcript capture. adopted.CLIVersion = versioninfo.Version + adopted.TranscriptPath = source.TranscriptPath adopted.BaseCommit = head.Hash().String() adopted.AttributionBaseCommit = head.Hash().String() adopted.WorktreePath = worktreeRoot From 899e59037658d1c6098e4ab1ffcbab912c369eb6 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 22 Jun 2026 13:17:24 -0400 Subject: [PATCH 04/30] fix: reset adopted checkpoint window Entire-Checkpoint: a946a346b0a7 --- cmd/entire/cli/session_adopt.go | 4 + cmd/entire/cli/session_adopt_test.go | 110 +++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index fc89dea7a4..65a5fc1ec8 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -243,6 +243,10 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess // at metadata in another repository or checkpoint branch; carrying them into // this repo would let amend and turn-finalization paths operate on unrelated // checkpoints. + adopted.StepCount = 0 + adopted.CheckpointTranscriptStart = 0 + adopted.CheckpointTranscriptSize = 0 + adopted.TranscriptIdentifierAtStart = "" adopted.TurnCheckpointIDs = nil adopted.LastCheckpointID = id.EmptyCheckpointID adopted.LastCheckpointCommitHash = "" diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index b56e7e38b1..a6e5ddac48 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/testutil" @@ -168,6 +169,115 @@ func TestSessionAdopt_EnablesPrepareCommitMsgTrailer(t *testing.T) { } } +func TestSessionAdopt_ResetsSourceCheckpointWindow(t *testing.T) { + sourceRepo := setupAdoptRepo(t) + targetRepo := setupAdoptRepo(t) + + sessionID := "test-adopt-reset-window" + targetRelPath := "src/feature.go" + targetAbsPath := filepath.Join(targetRepo, targetRelPath) + + transcriptPath := filepath.Join(sourceRepo, ".claude", sessionID+".jsonl") + if err := os.MkdirAll(filepath.Dir(transcriptPath), 0o750); err != nil { + t.Fatal(err) + } + transcript := `{"type":"human","message":{"content":"first source prompt"},"uuid":"source-user"} +{"type":"assistant","message":{"content":"source response"},"uuid":"source-assistant"} +{"type":"human","message":{"content":"write target feature"},"uuid":"target-user"} +{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"file_path":"` + targetAbsPath + `","content":"package src\n"}}]},"uuid":"target-assistant"} +` + if err := os.WriteFile(transcriptPath, []byte(transcript), 0o600); err != nil { + t.Fatal(err) + } + + lastInteraction := time.Now().Add(-1 * time.Minute) + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + AttributionBaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + TranscriptPath: transcriptPath, + LastPrompt: "write target feature", + StepCount: 4, + CheckpointTranscriptStart: 2, + CheckpointTranscriptSize: 1234, + CondensedTranscriptLines: 2, + TranscriptLinesAtStart: 2, + TranscriptIdentifierAtStart: "source-assistant", + TurnCheckpointIDs: []string{"abc123def456"}, + LastCheckpointID: id.MustCheckpointID("abc123def456"), + LastCheckpointCommitHash: "source-commit", + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetRepo, targetRelPath, "package src\n") + testutil.GitAdd(t, targetRepo, targetRelPath) + t.Chdir(targetRepo) + + var out bytes.Buffer + err := runAdopt(context.Background(), &out, sessionID, adoptOptions{ + FromWorktree: sourceRepo, + Force: true, + }) + if err != nil { + t.Fatalf("runAdopt failed: %v", err) + } + + targetStore, err := session.NewStateStore(context.Background()) + if err != nil { + t.Fatal(err) + } + adopted, err := targetStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if adopted == nil { + t.Fatal("expected adopted session state in target repo") + } + if adopted.StepCount != 0 { + t.Fatalf("StepCount = %d, want 0 for first target checkpoint", adopted.StepCount) + } + if adopted.CheckpointTranscriptStart != 0 { + t.Fatalf("CheckpointTranscriptStart = %d, want 0", adopted.CheckpointTranscriptStart) + } + if adopted.CheckpointTranscriptSize != 0 { + t.Fatalf("CheckpointTranscriptSize = %d, want 0", adopted.CheckpointTranscriptSize) + } + if adopted.TranscriptIdentifierAtStart != "" { + t.Fatalf("TranscriptIdentifierAtStart = %q, want empty", adopted.TranscriptIdentifierAtStart) + } + if len(adopted.TurnCheckpointIDs) != 0 { + t.Fatalf("TurnCheckpointIDs = %v, want empty", adopted.TurnCheckpointIDs) + } + if !adopted.LastCheckpointID.IsEmpty() { + t.Fatalf("LastCheckpointID = %s, want empty", adopted.LastCheckpointID.String()) + } + if adopted.LastCheckpointCommitHash != "" { + t.Fatalf("LastCheckpointCommitHash = %q, want empty", adopted.LastCheckpointCommitHash) + } + + commitMsgFile := filepath.Join(targetRepo, "COMMIT_EDITMSG") + if err := os.WriteFile(commitMsgFile, []byte("add target feature\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := strategy.NewManualCommitStrategy().PrepareCommitMsg(context.Background(), commitMsgFile, ""); err != nil { + t.Fatalf("PrepareCommitMsg failed: %v", err) + } + content, err := os.ReadFile(commitMsgFile) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "Entire-Checkpoint:") { + t.Fatalf("commit message = %q, want Entire-Checkpoint trailer", string(content)) + } +} + func TestSessionAdopt_FromSubdirectoryReadsSourceStore(t *testing.T) { sourceRepo := setupAdoptRepo(t) targetRepo := setupAdoptRepo(t) From 40b886442ff8fc48e9211e78140295ce81f68b95 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 22 Jun 2026 13:28:55 -0400 Subject: [PATCH 05/30] fix: guard adopt against shared session stores Entire-Checkpoint: c555c03f3864 --- cmd/entire/cli/session_adopt.go | 29 +++++++---- cmd/entire/cli/session_adopt_test.go | 75 ++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 11 deletions(-) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index 65a5fc1ec8..13d44dbc03 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -61,11 +61,19 @@ func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOpti return errors.New("source worktree is required; pass --from ") } - sourceStore, sourceWorktree, err := stateStoreForWorktree(ctx, opts.FromWorktree) + sourceStore, sourceWorktree, sourceCommonDir, err := stateStoreForWorktree(ctx, opts.FromWorktree) if err != nil { return err } + targetStore, _, targetCommonDir, err := stateStoreForWorktree(ctx, ".") + if err != nil { + return fmt.Errorf("open current session store: %w", err) + } + if sourceCommonDir == targetCommonDir { + return errors.New("source and target share the same git common dir; session adopt only moves sessions across independent git session stores") + } + sourceState, err := selectAdoptSourceSession(ctx, sourceStore, sourceWorktree, sessionID) if err != nil { return err @@ -76,10 +84,6 @@ func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOpti return err } - targetStore, err := session.NewStateStore(ctx) - if err != nil { - return fmt.Errorf("open current session store: %w", err) - } existing, err := targetStore.Load(ctx, adopted.SessionID) if err != nil { return fmt.Errorf("load current session state: %w", err) @@ -101,10 +105,10 @@ func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOpti return nil } -func stateStoreForWorktree(ctx context.Context, worktreePath string) (*session.StateStore, string, error) { +func stateStoreForWorktree(ctx context.Context, worktreePath string) (*session.StateStore, string, string, error) { absWorktree, err := filepath.Abs(worktreePath) if err != nil { - return nil, "", fmt.Errorf("resolve source worktree: %w", err) + return nil, "", "", fmt.Errorf("resolve source worktree: %w", err) } cmd := exec.CommandContext(ctx, "git", "-C", absWorktree, "rev-parse", "--show-toplevel", "--git-common-dir") @@ -112,14 +116,14 @@ func stateStoreForWorktree(ctx context.Context, worktreePath string) (*session.S if err != nil { msg := strings.TrimSpace(string(output)) if msg != "" { - return nil, "", fmt.Errorf("resolve source git directory: %s: %w", msg, err) + return nil, "", "", fmt.Errorf("resolve source git directory: %s: %w", msg, err) } - return nil, "", fmt.Errorf("resolve source git directory: %w", err) + return nil, "", "", fmt.Errorf("resolve source git directory: %w", err) } lines := strings.Split(strings.TrimSpace(string(output)), "\n") if len(lines) < 2 { - return nil, "", fmt.Errorf("resolve source git directory: unexpected git output %q", strings.TrimSpace(string(output))) + return nil, "", "", fmt.Errorf("resolve source git directory: unexpected git output %q", strings.TrimSpace(string(output))) } sourceRoot := strings.TrimSpace(lines[0]) commonDir := strings.TrimSpace(lines[1]) @@ -127,8 +131,11 @@ func stateStoreForWorktree(ctx context.Context, worktreePath string) (*session.S commonDir = filepath.Join(absWorktree, commonDir) } commonDir = filepath.Clean(commonDir) + if resolved, err := filepath.EvalSymlinks(commonDir); err == nil { + commonDir = resolved + } - return session.NewStateStoreWithDir(filepath.Join(commonDir, session.SessionStateDirName)), sourceRoot, nil + return session.NewStateStoreWithDir(filepath.Join(commonDir, session.SessionStateDirName)), sourceRoot, commonDir, nil } func selectAdoptSourceSession(ctx context.Context, store *session.StateStore, sourceWorktree, sessionID string) (*session.State, error) { diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index a6e5ddac48..e8da8da9ad 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -315,6 +316,69 @@ func TestSessionAdopt_FromSubdirectoryReadsSourceStore(t *testing.T) { } } +func TestSessionAdopt_RejectsSameGitCommonDir(t *testing.T) { + sourceRepo := setupAdoptRepo(t) + targetWorktree := filepath.Join(t.TempDir(), "target-worktree") + runAdoptGit(t, sourceRepo, "worktree", "add", targetWorktree, "-b", "target-worktree") + t.Cleanup(func() { + runAdoptGit(t, sourceRepo, "worktree", "remove", targetWorktree, "--force") + }) + + sessionID := "test-adopt-same-common-dir" + lastInteraction := time.Now().Add(-1 * time.Minute) + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + StepCount: 4, + CheckpointTranscriptStart: 2, + LastCheckpointID: id.MustCheckpointID("abc123def456"), + LastCheckpointCommitHash: "source-commit", + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetWorktree, "feature.txt", "agent change\n") + t.Chdir(targetWorktree) + + var out bytes.Buffer + err := runAdopt(context.Background(), &out, sessionID, adoptOptions{ + FromWorktree: sourceRepo, + Force: true, + }) + if err == nil { + t.Fatal("runAdopt succeeded, want same-common-dir refusal") + } + if !strings.Contains(err.Error(), "same git common dir") { + t.Fatalf("runAdopt error = %v, want same git common dir refusal", err) + } + + loaded, err := sourceStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if loaded == nil { + t.Fatal("expected source session state to remain") + } + if loaded.StepCount != 4 { + t.Fatalf("StepCount = %d, want source state preserved at 4", loaded.StepCount) + } + if loaded.CheckpointTranscriptStart != 2 { + t.Fatalf("CheckpointTranscriptStart = %d, want source state preserved at 2", loaded.CheckpointTranscriptStart) + } + if loaded.LastCheckpointID.String() != "abc123def456" { + t.Fatalf("LastCheckpointID = %s, want source checkpoint preserved", loaded.LastCheckpointID.String()) + } + if loaded.LastCheckpointCommitHash != "source-commit" { + t.Fatalf("LastCheckpointCommitHash = %q, want source commit preserved", loaded.LastCheckpointCommitHash) + } +} + func setupAdoptRepo(t *testing.T) string { t.Helper() @@ -330,3 +394,14 @@ func setupAdoptRepo(t *testing.T) string { } return realRepoDir } + +func runAdoptGit(t *testing.T, dir string, args ...string) { + t.Helper() + + cmd := exec.CommandContext(context.Background(), "git", args...) + cmd.Dir = dir + cmd.Env = testutil.GitIsolatedEnv() + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, output) + } +} From b422e6414fae546cd1c5b319e7374b7095f13b8b Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 22 Jun 2026 14:05:04 -0400 Subject: [PATCH 06/30] fix: anchor adopted prompt windows Entire-Checkpoint: 6fb8304acd8d --- cmd/entire/cli/session_adopt.go | 5 ++++- cmd/entire/cli/session_adopt_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index 13d44dbc03..cc3e571201 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -263,7 +263,10 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess adopted.UntrackedFilesAtStart = nil adopted.PromptAttributions = nil adopted.PendingPromptAttribution = nil - adopted.PromptWindowBase = 0 + // Preserve cumulative turn/context metrics for the continuing agent session, + // but start the target checkpoint prompt window at the current turn count so + // the first adopted checkpoint only counts target-side turns. + adopted.PromptWindowBase = adopted.SessionTurnCount adopted.PromptWindowResetPending = false adopted.AttachedManually = false diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index e8da8da9ad..650418f45e 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -205,6 +205,10 @@ func TestSessionAdopt_ResetsSourceCheckpointWindow(t *testing.T) { TranscriptPath: transcriptPath, LastPrompt: "write target feature", StepCount: 4, + SessionDurationMs: 120_000, + SessionTurnCount: 7, + ContextTokens: 42_000, + ContextWindowSize: 200_000, CheckpointTranscriptStart: 2, CheckpointTranscriptSize: 1234, CondensedTranscriptLines: 2, @@ -213,6 +217,8 @@ func TestSessionAdopt_ResetsSourceCheckpointWindow(t *testing.T) { TurnCheckpointIDs: []string{"abc123def456"}, LastCheckpointID: id.MustCheckpointID("abc123def456"), LastCheckpointCommitHash: "source-commit", + PromptWindowBase: 3, + PromptWindowResetPending: true, }); err != nil { t.Fatal(err) } @@ -253,6 +259,24 @@ func TestSessionAdopt_ResetsSourceCheckpointWindow(t *testing.T) { if adopted.TranscriptIdentifierAtStart != "" { t.Fatalf("TranscriptIdentifierAtStart = %q, want empty", adopted.TranscriptIdentifierAtStart) } + if adopted.SessionDurationMs != 120_000 { + t.Fatalf("SessionDurationMs = %d, want preserved source duration", adopted.SessionDurationMs) + } + if adopted.SessionTurnCount != 7 { + t.Fatalf("SessionTurnCount = %d, want preserved source turn count", adopted.SessionTurnCount) + } + if adopted.ContextTokens != 42_000 { + t.Fatalf("ContextTokens = %d, want preserved source context tokens", adopted.ContextTokens) + } + if adopted.ContextWindowSize != 200_000 { + t.Fatalf("ContextWindowSize = %d, want preserved source context window size", adopted.ContextWindowSize) + } + if adopted.PromptWindowBase != adopted.SessionTurnCount { + t.Fatalf("PromptWindowBase = %d, want current SessionTurnCount %d", adopted.PromptWindowBase, adopted.SessionTurnCount) + } + if adopted.PromptWindowResetPending { + t.Fatal("PromptWindowResetPending = true, want false for adopted target window") + } if len(adopted.TurnCheckpointIDs) != 0 { t.Fatalf("TurnCheckpointIDs = %v, want empty", adopted.TurnCheckpointIDs) } From 7beae84a81daa6b726a6e17be93c4b5c3cfa5f82 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 22 Jun 2026 14:12:49 -0400 Subject: [PATCH 07/30] fix: clear adopted legacy transcript offsets Entire-Checkpoint: db8b4bffc8b4 --- cmd/entire/cli/session/state.go | 11 +++++++-- cmd/entire/cli/session_adopt.go | 1 + cmd/entire/cli/session_adopt_test.go | 36 ++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index 4e1650e5c3..3360111f29 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -364,8 +364,7 @@ func (s *State) NormalizeAfterLoad(ctx context.Context) { // will see 0 for these fields and fall back to scoping from the transcript start. // This is acceptable since CLI upgrades are monotonic and the worst case is // redundant transcript content in a condensation, not data loss. - s.CondensedTranscriptLines = 0 - s.TranscriptLinesAtStart = 0 + s.ClearLegacyTranscriptOffsets() // Backfill AttributionBaseCommit for sessions created before this field existed. // Without this, a mid-turn commit would migrate BaseCommit and the fallback in @@ -382,6 +381,14 @@ func (s *State) NormalizeAfterLoad(ctx context.Context) { } } +// ClearLegacyTranscriptOffsets clears deprecated transcript offset fields so +// callers that intentionally reset CheckpointTranscriptStart do not re-persist +// stale legacy state. +func (s *State) ClearLegacyTranscriptOffsets() { + s.CondensedTranscriptLines = 0 + s.TranscriptLinesAtStart = 0 +} + // RealignAttributionBase sets AttributionBaseCommit to newBase and clears any // bookkeeping whose meaning depends on attribution being diverged from the // shadow-branch base. Call this every time a code path intentionally brings diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index cc3e571201..e48cebcd16 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -254,6 +254,7 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess adopted.CheckpointTranscriptStart = 0 adopted.CheckpointTranscriptSize = 0 adopted.TranscriptIdentifierAtStart = "" + adopted.ClearLegacyTranscriptOffsets() adopted.TurnCheckpointIDs = nil adopted.LastCheckpointID = id.EmptyCheckpointID adopted.LastCheckpointCommitHash = "" diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index 650418f45e..07f9574254 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "encoding/json" "os" "os/exec" "path/filepath" @@ -303,6 +304,41 @@ func TestSessionAdopt_ResetsSourceCheckpointWindow(t *testing.T) { } } +func TestSessionAdopt_ClearsLegacyTranscriptOffsets(t *testing.T) { + targetRepo := setupAdoptRepo(t) + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + t.Chdir(targetRepo) + + adopted, _, err := buildAdoptedSessionState(context.Background(), &session.State{ + SessionID: "test-adopt-legacy-offsets", + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + Phase: session.PhaseActive, + BaseCommit: "source-head", + WorktreePath: "/source/repo", + CheckpointTranscriptStart: 9, + CondensedTranscriptLines: 9, + TranscriptLinesAtStart: 9, + }) + if err != nil { + t.Fatalf("buildAdoptedSessionState failed: %v", err) + } + if adopted.CheckpointTranscriptStart != 0 { + t.Fatalf("CheckpointTranscriptStart = %d, want 0", adopted.CheckpointTranscriptStart) + } + + encoded, err := json.Marshal(adopted) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(encoded, []byte("condensed_transcript_lines")) { + t.Fatalf("adopted state JSON contains condensed_transcript_lines: %s", encoded) + } + if bytes.Contains(encoded, []byte("transcript_lines_at_start")) { + t.Fatalf("adopted state JSON contains transcript_lines_at_start: %s", encoded) + } +} + func TestSessionAdopt_FromSubdirectoryReadsSourceStore(t *testing.T) { sourceRepo := setupAdoptRepo(t) targetRepo := setupAdoptRepo(t) From 096be674f613589a48ec00ce783e0875af8c885e Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 22 Jun 2026 14:48:42 -0400 Subject: [PATCH 08/30] fix: harden session adopt review cases Entire-Checkpoint: 616df3aa9473 --- cmd/entire/cli/session_adopt.go | 20 +++- cmd/entire/cli/session_adopt_test.go | 162 +++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 4 deletions(-) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index e48cebcd16..1896c44c27 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "context" "errors" "fmt" @@ -112,9 +113,11 @@ func stateStoreForWorktree(ctx context.Context, worktreePath string) (*session.S } cmd := exec.CommandContext(ctx, "git", "-C", absWorktree, "rev-parse", "--show-toplevel", "--git-common-dir") - output, err := cmd.CombinedOutput() + var stderr bytes.Buffer + cmd.Stderr = &stderr + output, err := cmd.Output() if err != nil { - msg := strings.TrimSpace(string(output)) + msg := strings.TrimSpace(stderr.String()) if msg != "" { return nil, "", "", fmt.Errorf("resolve source git directory: %s: %w", msg, err) } @@ -147,7 +150,7 @@ func selectAdoptSourceSession(ctx context.Context, store *session.StateStore, so if sourceState == nil { return nil, fmt.Errorf("session %s was not found in %s", sessionID, sourceWorktree) } - if sourceState.Phase == session.PhaseEnded || sourceState.FullyCondensed { + if !isAdoptableSourceSession(sourceState) { return nil, fmt.Errorf("session %s is ended or fully condensed and cannot be adopted", sessionID) } return sourceState, nil @@ -183,7 +186,7 @@ func selectAdoptSourceSession(ctx context.Context, store *session.StateStore, so } func isRecentAdoptCandidate(state *session.State) bool { - if state == nil || state.Phase == session.PhaseEnded || state.FullyCondensed { + if !isAdoptableSourceSession(state) { return false } lastSeen := sessionLastSeen(state) @@ -193,6 +196,13 @@ func isRecentAdoptCandidate(state *session.State) bool { return time.Since(lastSeen) <= adoptRecentWindow } +func isAdoptableSourceSession(state *session.State) bool { + return state != nil && + state.Phase != session.PhaseEnded && + state.EndedAt == nil && + !state.FullyCondensed +} + func sessionLastSeen(state *session.State) time.Time { if state.LastInteractionTime != nil { return *state.LastInteractionTime @@ -244,6 +254,8 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess adopted.WorktreeID = worktreeID adopted.Branch = branch adopted.LastInteractionTime = &now + adopted.Phase = session.PhaseActive + adopted.EndedAt = nil adopted.FilesTouched = filesTouched // Reset target-local checkpoint bookkeeping. Source checkpoint IDs can point diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index 07f9574254..e7c021ffb2 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" "time" @@ -171,6 +172,134 @@ func TestSessionAdopt_EnablesPrepareCommitMsgTrailer(t *testing.T) { } } +func TestSessionAdopt_IdleSourceSurvivesPrepareCommitMsgTrailer(t *testing.T) { + sourceRepo := setupAdoptRepo(t) + targetRepo := setupAdoptRepo(t) + + sessionID := "test-adopt-idle-source" + targetRelPath := "src/idle.go" + targetAbsPath := filepath.Join(targetRepo, targetRelPath) + transcriptPath := filepath.Join(sourceRepo, ".claude", sessionID+".jsonl") + if err := os.MkdirAll(filepath.Dir(transcriptPath), 0o750); err != nil { + t.Fatal(err) + } + transcript := `{"type":"human","message":{"content":"write idle.go"}} +{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"file_path":"` + targetAbsPath + `","content":"package src\n"}}]}} +` + if err := os.WriteFile(transcriptPath, []byte(transcript), 0o600); err != nil { + t.Fatal(err) + } + + lastInteraction := time.Now().Add(-1 * time.Minute) + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseIdle, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + AttributionBaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + TranscriptPath: transcriptPath, + LastPrompt: "write idle.go", + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetRepo, targetRelPath, "package src\n") + testutil.GitAdd(t, targetRepo, targetRelPath) + t.Chdir(targetRepo) + + var out bytes.Buffer + err := runAdopt(context.Background(), &out, sessionID, adoptOptions{ + FromWorktree: sourceRepo, + Force: true, + }) + if err != nil { + t.Fatalf("runAdopt failed: %v", err) + } + + targetStore, err := session.NewStateStore(context.Background()) + if err != nil { + t.Fatal(err) + } + adopted, err := targetStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if adopted == nil { + t.Fatal("expected adopted session state") + } + if adopted.Phase != session.PhaseActive { + t.Fatalf("Phase = %q, want active so commit hooks do not sweep adopted state", adopted.Phase) + } + if adopted.EndedAt != nil { + t.Fatalf("EndedAt = %v, want nil", adopted.EndedAt) + } + + commitMsgFile := filepath.Join(targetRepo, "COMMIT_EDITMSG") + if err := os.WriteFile(commitMsgFile, []byte("add idle feature\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := strategy.NewManualCommitStrategy().PrepareCommitMsg(context.Background(), commitMsgFile, ""); err != nil { + t.Fatalf("PrepareCommitMsg failed: %v", err) + } + content, err := os.ReadFile(commitMsgFile) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "Entire-Checkpoint:") { + t.Fatalf("commit message = %q, want Entire-Checkpoint trailer", string(content)) + } +} + +func TestSessionAdopt_RejectsEndedAtSourceSession(t *testing.T) { + sourceRepo := setupAdoptRepo(t) + targetRepo := setupAdoptRepo(t) + + sessionID := "test-adopt-ended-at" + endedAt := time.Now().Add(-30 * time.Second) + lastInteraction := time.Now().Add(-1 * time.Minute) + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + EndedAt: &endedAt, + Phase: session.PhaseIdle, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + AttributionBaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + t.Chdir(targetRepo) + + var out bytes.Buffer + err := runAdopt(context.Background(), &out, sessionID, adoptOptions{ + FromWorktree: sourceRepo, + Force: true, + }) + if err == nil { + t.Fatal("runAdopt succeeded, want ended-session refusal") + } + if !strings.Contains(err.Error(), "ended or fully condensed") { + t.Fatalf("runAdopt error = %v, want ended-session refusal", err) + } + + _, err = selectAdoptSourceSession(context.Background(), sourceStore, sourceRepo, "") + if err == nil { + t.Fatal("selectAdoptSourceSession succeeded, want no recent active sessions") + } + if !strings.Contains(err.Error(), "no recent active sessions") { + t.Fatalf("selectAdoptSourceSession error = %v, want no recent active sessions", err) + } +} + func TestSessionAdopt_ResetsSourceCheckpointWindow(t *testing.T) { sourceRepo := setupAdoptRepo(t) targetRepo := setupAdoptRepo(t) @@ -376,6 +505,39 @@ func TestSessionAdopt_FromSubdirectoryReadsSourceStore(t *testing.T) { } } +func TestStateStoreForWorktreeIgnoresGitStderrOnSuccess(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell script fake git") + } + + fakeBin := t.TempDir() + fakeGit := filepath.Join(fakeBin, "git") + script := `#!/bin/sh +printf 'advice: noisy git warning\n' >&2 +printf '%s\n%s\n' "$FAKE_WORKTREE_ROOT" "$FAKE_GIT_COMMON_DIR" +` + if err := os.WriteFile(fakeGit, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + sourceRoot := filepath.Join(t.TempDir(), "source") + commonDir := filepath.Join(t.TempDir(), "common.git") + t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("FAKE_WORKTREE_ROOT", sourceRoot) + t.Setenv("FAKE_GIT_COMMON_DIR", commonDir) + + _, gotSourceRoot, gotCommonDir, err := stateStoreForWorktree(context.Background(), ".") + if err != nil { + t.Fatalf("stateStoreForWorktree failed: %v", err) + } + if gotSourceRoot != sourceRoot { + t.Fatalf("sourceRoot = %q, want %q", gotSourceRoot, sourceRoot) + } + if gotCommonDir != filepath.Clean(commonDir) { + t.Fatalf("commonDir = %q, want %q", gotCommonDir, filepath.Clean(commonDir)) + } +} + func TestSessionAdopt_RejectsSameGitCommonDir(t *testing.T) { sourceRepo := setupAdoptRepo(t) targetWorktree := filepath.Join(t.TempDir(), "target-worktree") From a089e14eda8602d06b30e152cadb0fe1aaa51649 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 22 Jun 2026 15:26:21 -0400 Subject: [PATCH 09/30] fix: validate adopted transcript ownership Entire-Checkpoint: ae0dfea989e0 --- cmd/entire/cli/checkpoint/open.go | 2 +- cmd/entire/cli/session_adopt.go | 21 +++++++ cmd/entire/cli/session_adopt_test.go | 71 ++++++++++++++++++++++-- cmd/entire/cli/strategy/manual_commit.go | 4 +- 4 files changed, 91 insertions(+), 7 deletions(-) diff --git a/cmd/entire/cli/checkpoint/open.go b/cmd/entire/cli/checkpoint/open.go index b9583adbff..b4c3ad73d0 100644 --- a/cmd/entire/cli/checkpoint/open.go +++ b/cmd/entire/cli/checkpoint/open.go @@ -55,7 +55,7 @@ func resolveOpenRefs(ctx context.Context, opts OpenOptions) CommittedRefs { } // Temporary returns the git-backed temporary shadow-branch store. -func (s *Stores) Temporary() TemporaryStore { return s.temporary } //nolint:ireturn // temporary store capability is the abstraction boundary +func (s *Stores) Temporary() TemporaryStore { return s.temporary } // Refs returns the resolved committed-ref topology. func (s *Stores) Refs() CommittedRefs { return s.refs } diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index 1896c44c27..527212e916 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" @@ -79,6 +80,9 @@ func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOpti if err != nil { return err } + if err := validateAdoptSourceTranscript(sourceState, sourceWorktree); err != nil { + return err + } adopted, filesTouched, err := buildAdoptedSessionState(ctx, sourceState) if err != nil { @@ -106,6 +110,23 @@ func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOpti return nil } +func validateAdoptSourceTranscript(source *session.State, sourceWorktree string) error { + if source == nil || strings.TrimSpace(source.TranscriptPath) == "" { + return nil + } + + owner, ok := agent.AgentForTranscriptPath(source.TranscriptPath, sourceWorktree) + if !ok { + return fmt.Errorf("unexpected transcript path for session %s: %s is not owned by a registered agent for %s", + source.SessionID, source.TranscriptPath, sourceWorktree) + } + if source.AgentType != "" && owner.Type() != source.AgentType { + return fmt.Errorf("unexpected transcript path for session %s: %s belongs to %s, but source state says %s", + source.SessionID, source.TranscriptPath, owner.Type(), source.AgentType) + } + return nil +} + func stateStoreForWorktree(ctx context.Context, worktreePath string) (*session.StateStore, string, string, error) { absWorktree, err := filepath.Abs(worktreePath) if err != nil { diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index e7c021ffb2..34fdac232d 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -24,7 +24,7 @@ func TestSessionAdopt_CopiesExternalSessionIntoCurrentWorktree(t *testing.T) { targetRepo := setupAdoptRepo(t) sessionID := "test-adopt-session-001" - transcriptPath := filepath.Join(sourceRepo, ".claude", sessionID+".jsonl") + transcriptPath := claudeAdoptTranscriptPath(t, sourceRepo, sessionID) if err := os.MkdirAll(filepath.Dir(transcriptPath), 0o750); err != nil { t.Fatal(err) } @@ -101,6 +101,61 @@ func TestSessionAdopt_CopiesExternalSessionIntoCurrentWorktree(t *testing.T) { } } +func TestSessionAdopt_RejectsUnexpectedSourceTranscriptPath(t *testing.T) { + sourceRepo := setupAdoptRepo(t) + targetRepo := setupAdoptRepo(t) + + sessionID := "test-adopt-reject-transcript" + transcriptPath := filepath.Join(t.TempDir(), sessionID+".jsonl") + if err := os.WriteFile(transcriptPath, []byte(`{"type":"user"}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + lastInteraction := time.Now().Add(-1 * time.Minute) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + AttributionBaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + TranscriptPath: transcriptPath, + LastPrompt: "update target file", + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + t.Chdir(targetRepo) + + var out bytes.Buffer + err := runAdopt(context.Background(), &out, sessionID, adoptOptions{ + FromWorktree: sourceRepo, + Force: true, + }) + if err == nil { + t.Fatal("runAdopt succeeded, want transcript-path refusal") + } + if !strings.Contains(err.Error(), "unexpected transcript path") { + t.Fatalf("runAdopt error = %v, want unexpected transcript path", err) + } + + targetStore, storeErr := session.NewStateStore(context.Background()) + if storeErr != nil { + t.Fatal(storeErr) + } + adopted, loadErr := targetStore.Load(context.Background(), sessionID) + if loadErr != nil { + t.Fatal(loadErr) + } + if adopted != nil { + t.Fatalf("target state was written despite transcript-path refusal: %#v", adopted) + } +} + func TestSessionAdopt_EnablesPrepareCommitMsgTrailer(t *testing.T) { sourceRepo := setupAdoptRepo(t) targetRepo := setupAdoptRepo(t) @@ -109,7 +164,7 @@ func TestSessionAdopt_EnablesPrepareCommitMsgTrailer(t *testing.T) { targetRelPath := "src/feature.go" targetAbsPath := filepath.Join(targetRepo, targetRelPath) - transcriptPath := filepath.Join(sourceRepo, ".claude", sessionID+".jsonl") + transcriptPath := claudeAdoptTranscriptPath(t, sourceRepo, sessionID) if err := os.MkdirAll(filepath.Dir(transcriptPath), 0o750); err != nil { t.Fatal(err) } @@ -179,7 +234,7 @@ func TestSessionAdopt_IdleSourceSurvivesPrepareCommitMsgTrailer(t *testing.T) { sessionID := "test-adopt-idle-source" targetRelPath := "src/idle.go" targetAbsPath := filepath.Join(targetRepo, targetRelPath) - transcriptPath := filepath.Join(sourceRepo, ".claude", sessionID+".jsonl") + transcriptPath := claudeAdoptTranscriptPath(t, sourceRepo, sessionID) if err := os.MkdirAll(filepath.Dir(transcriptPath), 0o750); err != nil { t.Fatal(err) } @@ -308,7 +363,7 @@ func TestSessionAdopt_ResetsSourceCheckpointWindow(t *testing.T) { targetRelPath := "src/feature.go" targetAbsPath := filepath.Join(targetRepo, targetRelPath) - transcriptPath := filepath.Join(sourceRepo, ".claude", sessionID+".jsonl") + transcriptPath := claudeAdoptTranscriptPath(t, sourceRepo, sessionID) if err := os.MkdirAll(filepath.Dir(transcriptPath), 0o750); err != nil { t.Fatal(err) } @@ -617,6 +672,14 @@ func setupAdoptRepo(t *testing.T) string { return realRepoDir } +func claudeAdoptTranscriptPath(t *testing.T, sourceRepo, sessionID string) string { + t.Helper() + + transcriptDir := filepath.Join(sourceRepo, ".claude", "projects", "adopt-test") + t.Setenv("ENTIRE_TEST_CLAUDE_PROJECT_DIR", transcriptDir) + return filepath.Join(transcriptDir, sessionID+".jsonl") +} + func runAdoptGit(t *testing.T, dir string, args ...string) { t.Helper() diff --git a/cmd/entire/cli/strategy/manual_commit.go b/cmd/entire/cli/strategy/manual_commit.go index 4f9a877011..1bc65e38c2 100644 --- a/cmd/entire/cli/strategy/manual_commit.go +++ b/cmd/entire/cli/strategy/manual_commit.go @@ -52,7 +52,7 @@ func (s *ManualCommitStrategy) getCheckpointStores(ctx context.Context, repo *gi // topology. Writes target refs.Primary; reads target refs.Read. The strategy's // blob fetcher is wired in so reads can fetch blobs on demand after a treeless // fetch. -func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git.Repository) (checkpoint.CommittedStore, error) { //nolint:ireturn // committed store capability is the abstraction boundary +func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git.Repository) (checkpoint.CommittedStore, error) { stores, err := s.getCheckpointStores(ctx, repo) if err != nil { return nil, err @@ -62,7 +62,7 @@ func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git // getTemporaryStore returns the git-backed shadow-branch store with the // strategy's blob fetcher wired in. -func (s *ManualCommitStrategy) getTemporaryStore(ctx context.Context, repo *git.Repository) (checkpoint.TemporaryStore, error) { //nolint:ireturn // temporary store capability is the abstraction boundary +func (s *ManualCommitStrategy) getTemporaryStore(ctx context.Context, repo *git.Repository) (checkpoint.TemporaryStore, error) { stores, err := s.getCheckpointStores(ctx, repo) if err != nil { return nil, err From f07173106fd29ba4b1f6320bd45f68c2ea56d602 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 22 Jun 2026 15:40:41 -0400 Subject: [PATCH 10/30] fix: restore store interface lint suppressions Entire-Checkpoint: b8bd53b01bba --- cmd/entire/cli/checkpoint/open.go | 2 ++ cmd/entire/cli/strategy/manual_commit.go | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/cmd/entire/cli/checkpoint/open.go b/cmd/entire/cli/checkpoint/open.go index b4c3ad73d0..1eed358d64 100644 --- a/cmd/entire/cli/checkpoint/open.go +++ b/cmd/entire/cli/checkpoint/open.go @@ -55,6 +55,8 @@ func resolveOpenRefs(ctx context.Context, opts OpenOptions) CommittedRefs { } // Temporary returns the git-backed temporary shadow-branch store. +// +//nolint:ireturn // temporary store capability is the abstraction boundary func (s *Stores) Temporary() TemporaryStore { return s.temporary } // Refs returns the resolved committed-ref topology. diff --git a/cmd/entire/cli/strategy/manual_commit.go b/cmd/entire/cli/strategy/manual_commit.go index 1bc65e38c2..a49610ef66 100644 --- a/cmd/entire/cli/strategy/manual_commit.go +++ b/cmd/entire/cli/strategy/manual_commit.go @@ -52,6 +52,8 @@ func (s *ManualCommitStrategy) getCheckpointStores(ctx context.Context, repo *gi // topology. Writes target refs.Primary; reads target refs.Read. The strategy's // blob fetcher is wired in so reads can fetch blobs on demand after a treeless // fetch. +// +//nolint:ireturn // committed store capability is the abstraction boundary func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git.Repository) (checkpoint.CommittedStore, error) { stores, err := s.getCheckpointStores(ctx, repo) if err != nil { @@ -62,6 +64,8 @@ func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git // getTemporaryStore returns the git-backed shadow-branch store with the // strategy's blob fetcher wired in. +// +//nolint:ireturn // temporary store capability is the abstraction boundary func (s *ManualCommitStrategy) getTemporaryStore(ctx context.Context, repo *git.Repository) (checkpoint.TemporaryStore, error) { stores, err := s.getCheckpointStores(ctx, repo) if err != nil { From db9ada812c38dc67e0750d4e2633662316ffb5bf Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 22 Jun 2026 17:46:53 -0400 Subject: [PATCH 11/30] fix: support shared-store session adoption Entire-Checkpoint: 48521e055655 --- cmd/entire/cli/checkpoint/open.go | 8 +- cmd/entire/cli/session_adopt.go | 71 ++++++++++- cmd/entire/cli/session_adopt_test.go | 151 ++++++++++++++++++++--- cmd/entire/cli/strategy/manual_commit.go | 8 +- 4 files changed, 203 insertions(+), 35 deletions(-) diff --git a/cmd/entire/cli/checkpoint/open.go b/cmd/entire/cli/checkpoint/open.go index 1eed358d64..63dfe3206c 100644 --- a/cmd/entire/cli/checkpoint/open.go +++ b/cmd/entire/cli/checkpoint/open.go @@ -24,9 +24,9 @@ type OpenOptions struct { // temporary capability and resolved committed-ref topology. type Stores struct { // Primary is the committed store that serves committed reads and writes. - Primary CommittedStore + Primary *GitStore - temporary TemporaryStore + temporary *GitStore refs CommittedRefs } @@ -55,9 +55,7 @@ func resolveOpenRefs(ctx context.Context, opts OpenOptions) CommittedRefs { } // Temporary returns the git-backed temporary shadow-branch store. -// -//nolint:ireturn // temporary store capability is the abstraction boundary -func (s *Stores) Temporary() TemporaryStore { return s.temporary } +func (s *Stores) Temporary() *GitStore { return s.temporary } // Refs returns the resolved committed-ref topology. func (s *Stores) Refs() CommittedRefs { return s.refs } diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index 527212e916..91b3b75fba 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -68,12 +68,13 @@ func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOpti return err } - targetStore, _, targetCommonDir, err := stateStoreForWorktree(ctx, ".") + targetStore, targetWorktree, targetCommonDir, err := stateStoreForWorktree(ctx, ".") if err != nil { return fmt.Errorf("open current session store: %w", err) } - if sourceCommonDir == targetCommonDir { - return errors.New("source and target share the same git common dir; session adopt only moves sessions across independent git session stores") + sameSessionStore := sourceCommonDir == targetCommonDir + if sameSessionStore && sameAdoptPath(sourceWorktree, targetWorktree) { + return errors.New("source and target are the same worktree; no session adoption is needed") } sourceState, err := selectAdoptSourceSession(ctx, sourceStore, sourceWorktree, sessionID) @@ -93,7 +94,7 @@ func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOpti if err != nil { return fmt.Errorf("load current session state: %w", err) } - if existing != nil && !opts.Force { + if existing != nil && !opts.Force && !canReplaceAdoptState(existing, sourceState, sameSessionStore) { return fmt.Errorf("session %s is already tracked in this repo; rerun with --force to replace it", adopted.SessionID) } if err := targetStore.Save(ctx, adopted); err != nil { @@ -110,6 +111,13 @@ func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOpti return nil } +func canReplaceAdoptState(existing, source *session.State, sameSessionStore bool) bool { + return sameSessionStore && + existing != nil && + source != nil && + existing.SessionID == source.SessionID +} + func validateAdoptSourceTranscript(source *session.State, sourceWorktree string) error { if source == nil || strings.TrimSpace(source.TranscriptPath) == "" { return nil @@ -163,6 +171,10 @@ func stateStoreForWorktree(ctx context.Context, worktreePath string) (*session.S } func selectAdoptSourceSession(ctx context.Context, store *session.StateStore, sourceWorktree, sessionID string) (*session.State, error) { + sourceWorktreeID, worktreeIDErr := paths.GetWorktreeID(sourceWorktree) + if worktreeIDErr != nil { + sourceWorktreeID = "" + } if sessionID != "" { sourceState, err := store.Load(ctx, sessionID) if err != nil { @@ -174,6 +186,10 @@ func selectAdoptSourceSession(ctx context.Context, store *session.StateStore, so if !isAdoptableSourceSession(sourceState) { return nil, fmt.Errorf("session %s is ended or fully condensed and cannot be adopted", sessionID) } + if !sessionBelongsToSourceWorktree(sourceState, sourceWorktree, sourceWorktreeID) { + return nil, fmt.Errorf("session %s belongs to %s, not %s", + sessionID, adoptSessionWorktreeLabel(sourceState), sourceWorktree) + } return sourceState, nil } @@ -183,7 +199,7 @@ func selectAdoptSourceSession(ctx context.Context, store *session.StateStore, so } candidates := make([]*session.State, 0, len(states)) for _, state := range states { - if isRecentAdoptCandidate(state) { + if isRecentAdoptCandidate(state) && sessionBelongsToSourceWorktree(state, sourceWorktree, sourceWorktreeID) { candidates = append(candidates, state) } } @@ -206,6 +222,32 @@ func selectAdoptSourceSession(ctx context.Context, store *session.StateStore, so } } +func sessionBelongsToSourceWorktree(state *session.State, sourceWorktree, sourceWorktreeID string) bool { + if state == nil { + return false + } + if state.WorktreeID != "" && sourceWorktreeID != "" { + return state.WorktreeID == sourceWorktreeID + } + if state.WorktreePath != "" { + return sameAdoptPath(state.WorktreePath, sourceWorktree) + } + return true +} + +func adoptSessionWorktreeLabel(state *session.State) string { + if state == nil { + return unknownPlaceholder + } + if state.WorktreePath != "" { + return state.WorktreePath + } + if state.WorktreeID != "" { + return state.WorktreeID + } + return unknownPlaceholder +} + func isRecentAdoptCandidate(state *session.State) bool { if !isAdoptableSourceSession(state) { return false @@ -307,6 +349,25 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess return &adopted, filesTouched, nil } +func sameAdoptPath(a, b string) bool { + return canonicalAdoptPath(a) == canonicalAdoptPath(b) +} + +func canonicalAdoptPath(path string) string { + if path == "" { + return "" + } + abs, err := filepath.Abs(path) + if err == nil { + path = abs + } + path = filepath.Clean(path) + if resolved, err := filepath.EvalSymlinks(path); err == nil { + path = resolved + } + return path +} + func currentFilesTouched(ctx context.Context) ([]string, error) { changes, err := DetectFileChanges(ctx, nil) if err != nil { diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index 34fdac232d..71b8012e49 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -14,6 +14,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/testutil" @@ -560,6 +561,85 @@ func TestSessionAdopt_FromSubdirectoryReadsSourceStore(t *testing.T) { } } +func TestSessionAdopt_FiltersSharedSourceStoreByFromWorktree(t *testing.T) { + sourceRepo := setupAdoptRepo(t) + siblingWorktree := filepath.Join(t.TempDir(), "sibling-worktree") + runAdoptGit(t, sourceRepo, "worktree", "add", siblingWorktree, "-b", "sibling-worktree") + resolvedSiblingWorktree, err := filepath.EvalSymlinks(siblingWorktree) + if err != nil { + t.Fatal(err) + } + siblingWorktree = resolvedSiblingWorktree + t.Cleanup(func() { + runAdoptGit(t, sourceRepo, "worktree", "remove", siblingWorktree, "--force") + }) + targetRepo := setupAdoptRepo(t) + + sourceWorktreeID, err := paths.GetWorktreeID(sourceRepo) + if err != nil { + t.Fatal(err) + } + siblingWorktreeID, err := paths.GetWorktreeID(siblingWorktree) + if err != nil { + t.Fatal(err) + } + + lastInteraction := time.Now().Add(-1 * time.Minute) + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: "source-worktree-session", + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + WorktreeID: sourceWorktreeID, + }); err != nil { + t.Fatal(err) + } + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: "sibling-worktree-session", + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, siblingWorktree), + WorktreePath: siblingWorktree, + WorktreeID: siblingWorktreeID, + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + t.Chdir(targetRepo) + + var out bytes.Buffer + err = runAdopt(context.Background(), &out, "", adoptOptions{ + FromWorktree: sourceRepo, + }) + if err != nil { + t.Fatalf("runAdopt failed: %v", err) + } + + targetStore, err := session.NewStateStore(context.Background()) + if err != nil { + t.Fatal(err) + } + adopted, err := targetStore.Load(context.Background(), "source-worktree-session") + if err != nil { + t.Fatal(err) + } + if adopted == nil { + t.Fatal("expected source worktree session to be adopted") + } + if wrong, err := targetStore.Load(context.Background(), "sibling-worktree-session"); err != nil { + t.Fatal(err) + } else if wrong != nil { + t.Fatalf("adopted sibling worktree session unexpectedly: %#v", wrong) + } +} + func TestStateStoreForWorktreeIgnoresGitStderrOnSuccess(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses a POSIX shell script fake git") @@ -593,15 +673,29 @@ printf '%s\n%s\n' "$FAKE_WORKTREE_ROOT" "$FAKE_GIT_COMMON_DIR" } } -func TestSessionAdopt_RejectsSameGitCommonDir(t *testing.T) { +func TestSessionAdopt_MovesSameStoreSessionIntoCurrentWorktree(t *testing.T) { sourceRepo := setupAdoptRepo(t) targetWorktree := filepath.Join(t.TempDir(), "target-worktree") runAdoptGit(t, sourceRepo, "worktree", "add", targetWorktree, "-b", "target-worktree") + resolvedTargetWorktree, err := filepath.EvalSymlinks(targetWorktree) + if err != nil { + t.Fatal(err) + } + targetWorktree = resolvedTargetWorktree t.Cleanup(func() { runAdoptGit(t, sourceRepo, "worktree", "remove", targetWorktree, "--force") }) - sessionID := "test-adopt-same-common-dir" + sourceWorktreeID, err := paths.GetWorktreeID(sourceRepo) + if err != nil { + t.Fatal(err) + } + targetWorktreeID, err := paths.GetWorktreeID(targetWorktree) + if err != nil { + t.Fatal(err) + } + + sessionID := "test-adopt-same-store" lastInteraction := time.Now().Add(-1 * time.Minute) sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) if err := sourceStore.Save(context.Background(), &session.State{ @@ -612,6 +706,7 @@ func TestSessionAdopt_RejectsSameGitCommonDir(t *testing.T) { Phase: session.PhaseActive, BaseCommit: testutil.GetHeadHash(t, sourceRepo), WorktreePath: sourceRepo, + WorktreeID: sourceWorktreeID, StepCount: 4, CheckpointTranscriptStart: 2, LastCheckpointID: id.MustCheckpointID("abc123def456"), @@ -621,38 +716,56 @@ func TestSessionAdopt_RejectsSameGitCommonDir(t *testing.T) { } testutil.WriteFile(t, targetWorktree, "feature.txt", "agent change\n") + testutil.GitAdd(t, targetWorktree, "feature.txt") t.Chdir(targetWorktree) var out bytes.Buffer - err := runAdopt(context.Background(), &out, sessionID, adoptOptions{ + err = runAdopt(context.Background(), &out, sessionID, adoptOptions{ FromWorktree: sourceRepo, - Force: true, }) - if err == nil { - t.Fatal("runAdopt succeeded, want same-common-dir refusal") - } - if !strings.Contains(err.Error(), "same git common dir") { - t.Fatalf("runAdopt error = %v, want same git common dir refusal", err) + if err != nil { + t.Fatalf("runAdopt failed: %v", err) } loaded, err := sourceStore.Load(context.Background(), sessionID) if err != nil { t.Fatal(err) } - if loaded == nil { - t.Fatal("expected source session state to remain") + if loaded.WorktreePath != targetWorktree { + t.Fatalf("WorktreePath = %q, want %q", loaded.WorktreePath, targetWorktree) + } + if loaded.WorktreeID != targetWorktreeID { + t.Fatalf("WorktreeID = %q, want %q", loaded.WorktreeID, targetWorktreeID) + } + if loaded.BaseCommit != testutil.GetHeadHash(t, targetWorktree) { + t.Fatalf("BaseCommit = %q, want target HEAD", loaded.BaseCommit) + } + if loaded.StepCount != 0 { + t.Fatalf("StepCount = %d, want reset target-local checkpoint state", loaded.StepCount) + } + if loaded.CheckpointTranscriptStart != 0 { + t.Fatalf("CheckpointTranscriptStart = %d, want reset target-local transcript window", loaded.CheckpointTranscriptStart) } - if loaded.StepCount != 4 { - t.Fatalf("StepCount = %d, want source state preserved at 4", loaded.StepCount) + if !loaded.LastCheckpointID.IsEmpty() { + t.Fatalf("LastCheckpointID = %s, want empty target-local checkpoint ID", loaded.LastCheckpointID.String()) } - if loaded.CheckpointTranscriptStart != 2 { - t.Fatalf("CheckpointTranscriptStart = %d, want source state preserved at 2", loaded.CheckpointTranscriptStart) + if loaded.LastCheckpointCommitHash != "" { + t.Fatalf("LastCheckpointCommitHash = %q, want empty target-local commit hash", loaded.LastCheckpointCommitHash) } - if loaded.LastCheckpointID.String() != "abc123def456" { - t.Fatalf("LastCheckpointID = %s, want source checkpoint preserved", loaded.LastCheckpointID.String()) + + commitMsgFile := filepath.Join(targetWorktree, "COMMIT_EDITMSG") + if err := os.WriteFile(commitMsgFile, []byte("add same-store feature\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := strategy.NewManualCommitStrategy().PrepareCommitMsg(context.Background(), commitMsgFile, ""); err != nil { + t.Fatalf("PrepareCommitMsg failed: %v", err) } - if loaded.LastCheckpointCommitHash != "source-commit" { - t.Fatalf("LastCheckpointCommitHash = %q, want source commit preserved", loaded.LastCheckpointCommitHash) + content, err := os.ReadFile(commitMsgFile) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "Entire-Checkpoint:") { + t.Fatalf("commit message = %q, want Entire-Checkpoint trailer", string(content)) } } diff --git a/cmd/entire/cli/strategy/manual_commit.go b/cmd/entire/cli/strategy/manual_commit.go index a49610ef66..bab42878bf 100644 --- a/cmd/entire/cli/strategy/manual_commit.go +++ b/cmd/entire/cli/strategy/manual_commit.go @@ -52,9 +52,7 @@ func (s *ManualCommitStrategy) getCheckpointStores(ctx context.Context, repo *gi // topology. Writes target refs.Primary; reads target refs.Read. The strategy's // blob fetcher is wired in so reads can fetch blobs on demand after a treeless // fetch. -// -//nolint:ireturn // committed store capability is the abstraction boundary -func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git.Repository) (checkpoint.CommittedStore, error) { +func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git.Repository) (*checkpoint.GitStore, error) { stores, err := s.getCheckpointStores(ctx, repo) if err != nil { return nil, err @@ -64,9 +62,7 @@ func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git // getTemporaryStore returns the git-backed shadow-branch store with the // strategy's blob fetcher wired in. -// -//nolint:ireturn // temporary store capability is the abstraction boundary -func (s *ManualCommitStrategy) getTemporaryStore(ctx context.Context, repo *git.Repository) (checkpoint.TemporaryStore, error) { +func (s *ManualCommitStrategy) getTemporaryStore(ctx context.Context, repo *git.Repository) (*checkpoint.GitStore, error) { stores, err := s.getCheckpointStores(ctx, repo) if err != nil { return nil, err From b492e450647720d689edfde0a767ca5afbe866cf Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 22 Jun 2026 21:11:05 -0400 Subject: [PATCH 12/30] Harden session adoption state replacement Entire-Checkpoint: 296b9c8f3d4c --- cmd/entire/cli/session_adopt.go | 83 ++++++++++++++++--- cmd/entire/cli/session_adopt_test.go | 116 ++++++++++++++++++++++++++- 2 files changed, 187 insertions(+), 12 deletions(-) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index 91b3b75fba..500b9bb748 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -6,8 +6,10 @@ import ( "errors" "fmt" "io" + "maps" "os/exec" "path/filepath" + "slices" "sort" "strings" "time" @@ -38,9 +40,13 @@ func newAdoptCmd() *cobra.Command { This is useful when an agent starts in one repository or worktree, then moves and makes changes in another. Adoption copies the live session state into the current repo and seeds it with the current repo's uncommitted file changes so -the next commit can be linked normally.`, +the next commit can be linked normally. + +When the source and target share a Git session store, adoption moves the same +session state file to the current worktree and requires --force or --yes.`, Example: ` entire session adopt 019ed5fe-ec49-7a72-89fd-f38e323f5448 --from ../cli - entire session adopt --from /path/to/source/worktree`, + entire session adopt --from /path/to/source/worktree + entire session adopt --from ../source-worktree --yes`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { sessionID := "" @@ -94,7 +100,7 @@ func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOpti if err != nil { return fmt.Errorf("load current session state: %w", err) } - if existing != nil && !opts.Force && !canReplaceAdoptState(existing, sourceState, sameSessionStore) { + if existing != nil && !opts.Force { return fmt.Errorf("session %s is already tracked in this repo; rerun with --force to replace it", adopted.SessionID) } if err := targetStore.Save(ctx, adopted); err != nil { @@ -111,13 +117,6 @@ func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOpti return nil } -func canReplaceAdoptState(existing, source *session.State, sameSessionStore bool) bool { - return sameSessionStore && - existing != nil && - source != nil && - existing.SessionID == source.SessionID -} - func validateAdoptSourceTranscript(source *session.State, sourceWorktree string) error { if source == nil || strings.TrimSpace(source.TranscriptPath) == "" { return nil @@ -304,7 +303,7 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess } now := time.Now() - adopted := *source + adopted := cloneAdoptSourceState(source) // Keep the source live transcript path. In cross-repo adoption the transcript // belongs to the continuing agent session, not the target repository; clearing @@ -349,6 +348,68 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess return &adopted, filesTouched, nil } +func cloneAdoptSourceState(source *session.State) session.State { + adopted := *source + adopted.EndedAt = cloneTimePtr(source.EndedAt) + adopted.LastInteractionTime = cloneTimePtr(source.LastInteractionTime) + adopted.ReviewSkills = slices.Clone(source.ReviewSkills) + adopted.TurnCheckpointIDs = slices.Clone(source.TurnCheckpointIDs) + adopted.UntrackedFilesAtStart = slices.Clone(source.UntrackedFilesAtStart) + adopted.FilesTouched = slices.Clone(source.FilesTouched) + adopted.TokenUsage = cloneTokenUsage(source.TokenUsage) + adopted.SkillEvents = cloneSkillEvents(source.SkillEvents) + adopted.PromptAttributions = clonePromptAttributions(source.PromptAttributions) + if source.PendingPromptAttribution != nil { + pending := clonePromptAttribution(*source.PendingPromptAttribution) + adopted.PendingPromptAttribution = &pending + } + return adopted +} + +func cloneTimePtr(t *time.Time) *time.Time { + if t == nil { + return nil + } + cloned := *t + return &cloned +} + +func cloneTokenUsage(usage *agent.TokenUsage) *agent.TokenUsage { + if usage == nil { + return nil + } + cloned := *usage + cloned.SubagentTokens = cloneTokenUsage(usage.SubagentTokens) + return &cloned +} + +func cloneSkillEvents(events []agent.SkillEvent) []agent.SkillEvent { + cloned := slices.Clone(events) + for i := range cloned { + if events[i].TranscriptAnchor != nil { + anchor := *events[i].TranscriptAnchor + anchor.EntryIDs = slices.Clone(events[i].TranscriptAnchor.EntryIDs) + cloned[i].TranscriptAnchor = &anchor + } + cloned[i].Native = maps.Clone(events[i].Native) + } + return cloned +} + +func clonePromptAttributions(attrs []session.PromptAttribution) []session.PromptAttribution { + cloned := slices.Clone(attrs) + for i := range cloned { + cloned[i] = clonePromptAttribution(attrs[i]) + } + return cloned +} + +func clonePromptAttribution(attr session.PromptAttribution) session.PromptAttribution { + attr.UserAddedPerFile = maps.Clone(attr.UserAddedPerFile) + attr.UserRemovedPerFile = maps.Clone(attr.UserRemovedPerFile) + return attr +} + func sameAdoptPath(a, b string) bool { return canonicalAdoptPath(a) == canonicalAdoptPath(b) } diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index 71b8012e49..24c7003b1c 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -524,6 +524,101 @@ func TestSessionAdopt_ClearsLegacyTranscriptOffsets(t *testing.T) { } } +func TestSessionAdopt_CloneSourceStateDoesNotShareMutableFields(t *testing.T) { + lastInteraction := time.Now().Add(-1 * time.Minute) + endedAt := time.Now() + source := &session.State{ + SessionID: "test-adopt-deep-copy", + StartedAt: time.Now().Add(-5 * time.Minute), + EndedAt: &endedAt, + LastInteractionTime: &lastInteraction, + ReviewSkills: []string{"/review"}, + TurnCheckpointIDs: []string{"source-checkpoint"}, + UntrackedFilesAtStart: []string{"untracked.txt"}, + FilesTouched: []string{"source.txt"}, + TokenUsage: &agent.TokenUsage{ + InputTokens: 1, + SubagentTokens: &agent.TokenUsage{ + OutputTokens: 2, + }, + }, + SkillEvents: []agent.SkillEvent{ + { + ID: "skill-event", + TranscriptAnchor: &agent.SkillEventTranscriptAnchor{ + EntryIDs: []string{"entry-1"}, + }, + Native: map[string]string{"tool": "skill"}, + }, + }, + PromptAttributions: []session.PromptAttribution{ + { + UserAddedPerFile: map[string]int{"source.txt": 1}, + UserRemovedPerFile: map[string]int{"source.txt": 2}, + }, + }, + PendingPromptAttribution: &session.PromptAttribution{ + UserAddedPerFile: map[string]int{"pending.txt": 3}, + UserRemovedPerFile: map[string]int{"pending.txt": 4}, + }, + } + + adopted := cloneAdoptSourceState(source) + *adopted.EndedAt = endedAt.Add(1 * time.Hour) + *adopted.LastInteractionTime = lastInteraction.Add(1 * time.Hour) + adopted.ReviewSkills[0] = "/changed" + adopted.TurnCheckpointIDs[0] = "changed-checkpoint" + adopted.UntrackedFilesAtStart[0] = "changed-untracked.txt" + adopted.FilesTouched[0] = "changed-source.txt" + adopted.TokenUsage.SubagentTokens.OutputTokens = 99 + adopted.SkillEvents[0].TranscriptAnchor.EntryIDs[0] = "changed-entry" + adopted.SkillEvents[0].Native["tool"] = "changed-skill" + adopted.PromptAttributions[0].UserAddedPerFile["source.txt"] = 99 + adopted.PromptAttributions[0].UserRemovedPerFile["source.txt"] = 99 + adopted.PendingPromptAttribution.UserAddedPerFile["pending.txt"] = 99 + adopted.PendingPromptAttribution.UserRemovedPerFile["pending.txt"] = 99 + + if !source.EndedAt.Equal(endedAt) { + t.Fatalf("source EndedAt was mutated: %v", source.EndedAt) + } + if !source.LastInteractionTime.Equal(lastInteraction) { + t.Fatalf("source LastInteractionTime was mutated: %v", source.LastInteractionTime) + } + if source.ReviewSkills[0] != "/review" { + t.Fatalf("source ReviewSkills = %v, want unchanged", source.ReviewSkills) + } + if source.TurnCheckpointIDs[0] != "source-checkpoint" { + t.Fatalf("source TurnCheckpointIDs = %v, want unchanged", source.TurnCheckpointIDs) + } + if source.UntrackedFilesAtStart[0] != "untracked.txt" { + t.Fatalf("source UntrackedFilesAtStart = %v, want unchanged", source.UntrackedFilesAtStart) + } + if source.FilesTouched[0] != "source.txt" { + t.Fatalf("source FilesTouched = %v, want unchanged", source.FilesTouched) + } + if source.TokenUsage.SubagentTokens.OutputTokens != 2 { + t.Fatalf("source TokenUsage.SubagentTokens.OutputTokens = %d, want unchanged", source.TokenUsage.SubagentTokens.OutputTokens) + } + if source.SkillEvents[0].TranscriptAnchor.EntryIDs[0] != "entry-1" { + t.Fatalf("source SkillEvents entry IDs = %v, want unchanged", source.SkillEvents[0].TranscriptAnchor.EntryIDs) + } + if source.SkillEvents[0].Native["tool"] != "skill" { + t.Fatalf("source SkillEvents native = %v, want unchanged", source.SkillEvents[0].Native) + } + if source.PromptAttributions[0].UserAddedPerFile["source.txt"] != 1 { + t.Fatalf("source PromptAttributions user added = %v, want unchanged", source.PromptAttributions[0].UserAddedPerFile) + } + if source.PromptAttributions[0].UserRemovedPerFile["source.txt"] != 2 { + t.Fatalf("source PromptAttributions user removed = %v, want unchanged", source.PromptAttributions[0].UserRemovedPerFile) + } + if source.PendingPromptAttribution.UserAddedPerFile["pending.txt"] != 3 { + t.Fatalf("source PendingPromptAttribution user added = %v, want unchanged", source.PendingPromptAttribution.UserAddedPerFile) + } + if source.PendingPromptAttribution.UserRemovedPerFile["pending.txt"] != 4 { + t.Fatalf("source PendingPromptAttribution user removed = %v, want unchanged", source.PendingPromptAttribution.UserRemovedPerFile) + } +} + func TestSessionAdopt_FromSubdirectoryReadsSourceStore(t *testing.T) { sourceRepo := setupAdoptRepo(t) targetRepo := setupAdoptRepo(t) @@ -723,11 +818,30 @@ func TestSessionAdopt_MovesSameStoreSessionIntoCurrentWorktree(t *testing.T) { err = runAdopt(context.Background(), &out, sessionID, adoptOptions{ FromWorktree: sourceRepo, }) + if err == nil { + t.Fatal("runAdopt succeeded without --force, want existing same-store state refusal") + } + if !strings.Contains(err.Error(), "already tracked in this repo") { + t.Fatalf("runAdopt error = %v, want existing-state refusal", err) + } + + loaded, err := sourceStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if loaded.WorktreePath != sourceRepo { + t.Fatalf("WorktreePath changed without --force: %q", loaded.WorktreePath) + } + + err = runAdopt(context.Background(), &out, sessionID, adoptOptions{ + FromWorktree: sourceRepo, + Force: true, + }) if err != nil { t.Fatalf("runAdopt failed: %v", err) } - loaded, err := sourceStore.Load(context.Background(), sessionID) + loaded, err = sourceStore.Load(context.Background(), sessionID) if err != nil { t.Fatal(err) } From d4e9717ea3e27366112acf449c7fd5fab7e41b33 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 22 Jun 2026 23:37:54 -0400 Subject: [PATCH 13/30] Restore checkpoint store abstraction boundary Entire-Checkpoint: d52beee4662f --- cmd/entire/cli/checkpoint/open.go | 8 +++++--- cmd/entire/cli/strategy/manual_commit.go | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cmd/entire/cli/checkpoint/open.go b/cmd/entire/cli/checkpoint/open.go index 63dfe3206c..31e4dbe908 100644 --- a/cmd/entire/cli/checkpoint/open.go +++ b/cmd/entire/cli/checkpoint/open.go @@ -24,9 +24,9 @@ type OpenOptions struct { // temporary capability and resolved committed-ref topology. type Stores struct { // Primary is the committed store that serves committed reads and writes. - Primary *GitStore + Primary CommittedStore - temporary *GitStore + temporary TemporaryStore refs CommittedRefs } @@ -55,7 +55,9 @@ func resolveOpenRefs(ctx context.Context, opts OpenOptions) CommittedRefs { } // Temporary returns the git-backed temporary shadow-branch store. -func (s *Stores) Temporary() *GitStore { return s.temporary } +func (s *Stores) Temporary() TemporaryStore { //nolint:ireturn // temporary store capability is the abstraction boundary + return s.temporary +} // Refs returns the resolved committed-ref topology. func (s *Stores) Refs() CommittedRefs { return s.refs } diff --git a/cmd/entire/cli/strategy/manual_commit.go b/cmd/entire/cli/strategy/manual_commit.go index bab42878bf..4f9a877011 100644 --- a/cmd/entire/cli/strategy/manual_commit.go +++ b/cmd/entire/cli/strategy/manual_commit.go @@ -52,7 +52,7 @@ func (s *ManualCommitStrategy) getCheckpointStores(ctx context.Context, repo *gi // topology. Writes target refs.Primary; reads target refs.Read. The strategy's // blob fetcher is wired in so reads can fetch blobs on demand after a treeless // fetch. -func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git.Repository) (*checkpoint.GitStore, error) { +func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git.Repository) (checkpoint.CommittedStore, error) { //nolint:ireturn // committed store capability is the abstraction boundary stores, err := s.getCheckpointStores(ctx, repo) if err != nil { return nil, err @@ -62,7 +62,7 @@ func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git // getTemporaryStore returns the git-backed shadow-branch store with the // strategy's blob fetcher wired in. -func (s *ManualCommitStrategy) getTemporaryStore(ctx context.Context, repo *git.Repository) (*checkpoint.GitStore, error) { +func (s *ManualCommitStrategy) getTemporaryStore(ctx context.Context, repo *git.Repository) (checkpoint.TemporaryStore, error) { //nolint:ireturn // temporary store capability is the abstraction boundary stores, err := s.getCheckpointStores(ctx, repo) if err != nil { return nil, err From 20429fc230ae025771a7f5fa4e33fe68148492d3 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 22 Jun 2026 23:49:12 -0400 Subject: [PATCH 14/30] Serialize same-store session adoption Entire-Checkpoint: 433a8df1c11a --- cmd/entire/cli/checkpoint/open.go | 4 +- cmd/entire/cli/session_adopt.go | 77 +++++++++++++++++--- cmd/entire/cli/session_adopt_test.go | 93 ++++++++++++++++++++++++ cmd/entire/cli/strategy/manual_commit.go | 8 +- 4 files changed, 169 insertions(+), 13 deletions(-) diff --git a/cmd/entire/cli/checkpoint/open.go b/cmd/entire/cli/checkpoint/open.go index 31e4dbe908..21f96a1f8d 100644 --- a/cmd/entire/cli/checkpoint/open.go +++ b/cmd/entire/cli/checkpoint/open.go @@ -55,7 +55,9 @@ func resolveOpenRefs(ctx context.Context, opts OpenOptions) CommittedRefs { } // Temporary returns the git-backed temporary shadow-branch store. -func (s *Stores) Temporary() TemporaryStore { //nolint:ireturn // temporary store capability is the abstraction boundary +// +//nolint:ireturn // temporary store capability is the abstraction boundary +func (s *Stores) Temporary() TemporaryStore { return s.temporary } diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index 500b9bb748..39ef1df45d 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -18,6 +18,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/versioninfo" "github.com/spf13/cobra" ) @@ -91,30 +92,86 @@ func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOpti return err } - adopted, filesTouched, err := buildAdoptedSessionState(ctx, sourceState) + var adopted *session.State + var filesTouched []string + if sameSessionStore { + adopted, filesTouched, err = adoptFromSameSessionStore(ctx, sourceWorktree, sourceState, opts) + } else { + adopted, filesTouched, err = adoptFromExternalSessionStore(ctx, targetStore, sourceState, opts) + } if err != nil { return err } + fmt.Fprintf(w, "Adopted session %s from %s\n", shortSessionID(adopted.SessionID), sourceWorktree) + if len(filesTouched) == 0 { + fmt.Fprintln(w, "No current file changes were detected, so the next commit may not link until hooks record changes.") + return nil + } + fmt.Fprintf(w, "Tracking %d file(s): %s\n", len(filesTouched), strings.Join(filesTouched, ", ")) + fmt.Fprintln(w, "Review tracked files before committing; adoption attributes current changes in this repo to the adopted session.") + return nil +} + +func adoptFromExternalSessionStore(ctx context.Context, targetStore *session.StateStore, sourceState *session.State, opts adoptOptions) (*session.State, []string, error) { + adopted, filesTouched, err := buildAdoptedSessionState(ctx, sourceState) + if err != nil { + return nil, nil, err + } existing, err := targetStore.Load(ctx, adopted.SessionID) if err != nil { - return fmt.Errorf("load current session state: %w", err) + return nil, nil, fmt.Errorf("load current session state: %w", err) } if existing != nil && !opts.Force { - return fmt.Errorf("session %s is already tracked in this repo; rerun with --force to replace it", adopted.SessionID) + return nil, nil, fmt.Errorf("session %s is already tracked in this repo; rerun with --force to replace it", adopted.SessionID) } if err := targetStore.Save(ctx, adopted); err != nil { - return fmt.Errorf("save adopted session state: %w", err) + return nil, nil, fmt.Errorf("save adopted session state: %w", err) } + return adopted, filesTouched, nil +} - fmt.Fprintf(w, "Adopted session %s from %s\n", shortSessionID(adopted.SessionID), sourceWorktree) - if len(filesTouched) == 0 { - fmt.Fprintln(w, "No current file changes were detected, so the next commit may not link until hooks record changes.") +func adoptFromSameSessionStore(ctx context.Context, sourceWorktree string, sourceState *session.State, opts adoptOptions) (*session.State, []string, error) { + if !opts.Force { + return nil, nil, fmt.Errorf("session %s is already tracked in this repo; rerun with --force to replace it", sourceState.SessionID) + } + + sourceWorktreeID, worktreeIDErr := paths.GetWorktreeID(sourceWorktree) + if worktreeIDErr != nil { + sourceWorktreeID = "" + } + + var adopted *session.State + var filesTouched []string + err := strategy.MutateSessionState(ctx, sourceState.SessionID, func(current *strategy.SessionState) error { + if !isAdoptableSourceSession(current) { + return fmt.Errorf("session %s is ended or fully condensed and cannot be adopted", sourceState.SessionID) + } + if !sessionBelongsToSourceWorktree(current, sourceWorktree, sourceWorktreeID) { + return fmt.Errorf("session %s belongs to %s, not %s", + sourceState.SessionID, adoptSessionWorktreeLabel(current), sourceWorktree) + } + if err := validateAdoptSourceTranscript(current, sourceWorktree); err != nil { + return err + } + + next, touched, err := buildAdoptedSessionState(ctx, current) + if err != nil { + return err + } + *current = *next + snapshot := cloneAdoptSourceState(next) + adopted = &snapshot + filesTouched = touched return nil + }) + if errors.Is(err, strategy.ErrStateNotFound) { + return nil, nil, fmt.Errorf("session %s was not found in %s", sourceState.SessionID, sourceWorktree) } - fmt.Fprintf(w, "Tracking %d file(s): %s\n", len(filesTouched), strings.Join(filesTouched, ", ")) - fmt.Fprintln(w, "Review tracked files before committing; adoption attributes current changes in this repo to the adopted session.") - return nil + if err != nil { + return nil, nil, fmt.Errorf("adopt same-store session state: %w", err) + } + return adopted, filesTouched, nil } func validateAdoptSourceTranscript(source *session.State, sourceWorktree string) error { diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index 24c7003b1c..9add6a5d01 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -768,6 +768,99 @@ printf '%s\n%s\n' "$FAKE_WORKTREE_ROOT" "$FAKE_GIT_COMMON_DIR" } } +func TestSessionAdopt_SameStoreReloadsSourceStateUnderLock(t *testing.T) { + sourceRepo := setupAdoptRepo(t) + targetWorktree := filepath.Join(t.TempDir(), "target-worktree") + runAdoptGit(t, sourceRepo, "worktree", "add", targetWorktree, "-b", "target-worktree") + resolvedTargetWorktree, err := filepath.EvalSymlinks(targetWorktree) + if err != nil { + t.Fatal(err) + } + targetWorktree = resolvedTargetWorktree + t.Cleanup(func() { + runAdoptGit(t, sourceRepo, "worktree", "remove", targetWorktree, "--force") + }) + + sourceWorktreeID, err := paths.GetWorktreeID(sourceRepo) + if err != nil { + t.Fatal(err) + } + targetWorktreeID, err := paths.GetWorktreeID(targetWorktree) + if err != nil { + t.Fatal(err) + } + + sessionID := "test-adopt-same-store-reload" + lastInteraction := time.Now().Add(-1 * time.Minute) + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + WorktreeID: sourceWorktreeID, + LastPrompt: "stale prompt", + SessionTurnCount: 1, + }); err != nil { + t.Fatal(err) + } + staleSelected, err := sourceStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + WorktreeID: sourceWorktreeID, + LastPrompt: "fresh hook prompt", + SessionTurnCount: 9, + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetWorktree, "feature.txt", "agent change\n") + testutil.GitAdd(t, targetWorktree, "feature.txt") + t.Chdir(targetWorktree) + + adopted, _, err := adoptFromSameSessionStore(context.Background(), sourceRepo, staleSelected, adoptOptions{ + Force: true, + }) + if err != nil { + t.Fatalf("adoptFromSameSessionStore failed: %v", err) + } + if adopted.LastPrompt != "fresh hook prompt" { + t.Fatalf("adopted LastPrompt = %q, want fresh hook prompt", adopted.LastPrompt) + } + if adopted.SessionTurnCount != 9 { + t.Fatalf("adopted SessionTurnCount = %d, want fresh source value", adopted.SessionTurnCount) + } + + loaded, err := sourceStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if loaded.WorktreePath != targetWorktree { + t.Fatalf("WorktreePath = %q, want %q", loaded.WorktreePath, targetWorktree) + } + if loaded.WorktreeID != targetWorktreeID { + t.Fatalf("WorktreeID = %q, want %q", loaded.WorktreeID, targetWorktreeID) + } + if loaded.LastPrompt != "fresh hook prompt" { + t.Fatalf("loaded LastPrompt = %q, want fresh hook prompt", loaded.LastPrompt) + } + if loaded.SessionTurnCount != 9 { + t.Fatalf("loaded SessionTurnCount = %d, want fresh source value", loaded.SessionTurnCount) + } +} + func TestSessionAdopt_MovesSameStoreSessionIntoCurrentWorktree(t *testing.T) { sourceRepo := setupAdoptRepo(t) targetWorktree := filepath.Join(t.TempDir(), "target-worktree") diff --git a/cmd/entire/cli/strategy/manual_commit.go b/cmd/entire/cli/strategy/manual_commit.go index 4f9a877011..a49610ef66 100644 --- a/cmd/entire/cli/strategy/manual_commit.go +++ b/cmd/entire/cli/strategy/manual_commit.go @@ -52,7 +52,9 @@ func (s *ManualCommitStrategy) getCheckpointStores(ctx context.Context, repo *gi // topology. Writes target refs.Primary; reads target refs.Read. The strategy's // blob fetcher is wired in so reads can fetch blobs on demand after a treeless // fetch. -func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git.Repository) (checkpoint.CommittedStore, error) { //nolint:ireturn // committed store capability is the abstraction boundary +// +//nolint:ireturn // committed store capability is the abstraction boundary +func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git.Repository) (checkpoint.CommittedStore, error) { stores, err := s.getCheckpointStores(ctx, repo) if err != nil { return nil, err @@ -62,7 +64,9 @@ func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git // getTemporaryStore returns the git-backed shadow-branch store with the // strategy's blob fetcher wired in. -func (s *ManualCommitStrategy) getTemporaryStore(ctx context.Context, repo *git.Repository) (checkpoint.TemporaryStore, error) { //nolint:ireturn // temporary store capability is the abstraction boundary +// +//nolint:ireturn // temporary store capability is the abstraction boundary +func (s *ManualCommitStrategy) getTemporaryStore(ctx context.Context, repo *git.Repository) (checkpoint.TemporaryStore, error) { stores, err := s.getCheckpointStores(ctx, repo) if err != nil { return nil, err From b7042edf819ca2caf57b955ba67f5bf24e987972 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 23 Jun 2026 19:19:28 -0400 Subject: [PATCH 15/30] chore(lint): align ireturn allow-list Entire-Checkpoint: 9ac13d9e6d9e --- .golangci.yaml | 4 ++++ cmd/entire/cli/activity_tui.go | 2 +- cmd/entire/cli/attach.go | 2 +- cmd/entire/cli/checkpoint/open.go | 6 +----- cmd/entire/cli/strategy/manual_commit.go | 4 ---- cmd/entire/cli/uiform/uiform.go | 2 -- 6 files changed, 7 insertions(+), 13 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index cff02e4982..a880c92447 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -114,6 +114,8 @@ linters: - empty - stdlib - charm.land/bubbletea/v2.Model + - charm.land/bubbletea/v2.Msg + - charm.land/huh/v2.Theme - grpc.DialOption - github.com/entireio/cli/cmd/entire/cli/summarize.Generator - github.com/entireio/cli/cmd/entire/cli/agent\..+ @@ -122,6 +124,8 @@ linters: - github.com/entireio/cli/cmd/entire/cli/review/types.AgentReviewer - github.com/entireio/cli/cmd/entire/cli/review.SynthesisProvider - github.com/entireio/cli/cmd/entire/cli/checkpoint.CommittedReader + - github.com/entireio/cli/cmd/entire/cli/checkpoint.CommittedStore + - github.com/entireio/cli/cmd/entire/cli/checkpoint.TemporaryStore - github.com/entireio/cli/cmd/entire/cli/strategy.Strategy - github.com/entireio/cli/internal/entireclient/tokenstore.store - github.com/go-git/go-git/v6/x/plugin.Signer diff --git a/cmd/entire/cli/activity_tui.go b/cmd/entire/cli/activity_tui.go index 862ff0f4d3..e5fa49984b 100644 --- a/cmd/entire/cli/activity_tui.go +++ b/cmd/entire/cli/activity_tui.go @@ -70,7 +70,7 @@ func runActivityTUI(ctx context.Context, client *api.Client) error { return nil } -func (m activityModel) fetchData() tea.Msg { //nolint:ireturn // bubbletea Cmd signature requires tea.Msg return +func (m activityModel) fetchData() tea.Msg { activity, commits, err := fetchActivityData(m.ctx, m.client) if err != nil { return activityErrMsg{err: err} diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index e63f57f513..985d3ee3f4 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -62,7 +62,7 @@ func (opts attachOptions) committedRefs(ctx context.Context) cpkg.CommittedRefs // openAttachStore opens the committed store for the resolved topology. refs is // passed explicitly so attach preserves PrimaryAsRead() pinning. -func openAttachStore(ctx context.Context, repo *git.Repository, refs cpkg.CommittedRefs) (cpkg.CommittedStore, error) { //nolint:ireturn // committed store capability preserves attach's read-ref override +func openAttachStore(ctx context.Context, repo *git.Repository, refs cpkg.CommittedRefs) (cpkg.CommittedStore, error) { stores, err := cpkg.Open(ctx, repo, cpkg.OpenOptions{Refs: &refs}) if err != nil { return nil, fmt.Errorf("open checkpoint store: %w", err) diff --git a/cmd/entire/cli/checkpoint/open.go b/cmd/entire/cli/checkpoint/open.go index 21f96a1f8d..b4c3ad73d0 100644 --- a/cmd/entire/cli/checkpoint/open.go +++ b/cmd/entire/cli/checkpoint/open.go @@ -55,11 +55,7 @@ func resolveOpenRefs(ctx context.Context, opts OpenOptions) CommittedRefs { } // Temporary returns the git-backed temporary shadow-branch store. -// -//nolint:ireturn // temporary store capability is the abstraction boundary -func (s *Stores) Temporary() TemporaryStore { - return s.temporary -} +func (s *Stores) Temporary() TemporaryStore { return s.temporary } // Refs returns the resolved committed-ref topology. func (s *Stores) Refs() CommittedRefs { return s.refs } diff --git a/cmd/entire/cli/strategy/manual_commit.go b/cmd/entire/cli/strategy/manual_commit.go index a49610ef66..1bc65e38c2 100644 --- a/cmd/entire/cli/strategy/manual_commit.go +++ b/cmd/entire/cli/strategy/manual_commit.go @@ -52,8 +52,6 @@ func (s *ManualCommitStrategy) getCheckpointStores(ctx context.Context, repo *gi // topology. Writes target refs.Primary; reads target refs.Read. The strategy's // blob fetcher is wired in so reads can fetch blobs on demand after a treeless // fetch. -// -//nolint:ireturn // committed store capability is the abstraction boundary func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git.Repository) (checkpoint.CommittedStore, error) { stores, err := s.getCheckpointStores(ctx, repo) if err != nil { @@ -64,8 +62,6 @@ func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git // getTemporaryStore returns the git-backed shadow-branch store with the // strategy's blob fetcher wired in. -// -//nolint:ireturn // temporary store capability is the abstraction boundary func (s *ManualCommitStrategy) getTemporaryStore(ctx context.Context, repo *git.Repository) (checkpoint.TemporaryStore, error) { stores, err := s.getCheckpointStores(ctx, repo) if err != nil { diff --git a/cmd/entire/cli/uiform/uiform.go b/cmd/entire/cli/uiform/uiform.go index 86738c0a4a..cd21272e2f 100644 --- a/cmd/entire/cli/uiform/uiform.go +++ b/cmd/entire/cli/uiform/uiform.go @@ -20,8 +20,6 @@ func IsAccessibleMode() bool { } // Theme returns Entire's standard huh theme. -// -//nolint:ireturn // huh.Theme is an interface in v2 func Theme() huh.Theme { return huh.ThemeFunc(huh.ThemeDracula) } From 654c16afeddac4566f5f1686b48ccb106e5b6992 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 23 Jun 2026 19:20:17 -0400 Subject: [PATCH 16/30] fix(session): lock external session adoption Entire-Checkpoint: 90af7f32f5e5 --- cmd/entire/cli/session_adopt.go | 134 +++++++++++++++++++-- cmd/entire/cli/session_adopt_test.go | 170 +++++++++++++++++++++++++++ 2 files changed, 291 insertions(+), 13 deletions(-) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index 39ef1df45d..b95f66fa9f 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "maps" + "os" "os/exec" "path/filepath" "slices" @@ -16,9 +17,11 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/internal/flock" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/entireio/cli/cmd/entire/cli/validation" "github.com/entireio/cli/cmd/entire/cli/versioninfo" "github.com/spf13/cobra" ) @@ -97,7 +100,16 @@ func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOpti if sameSessionStore { adopted, filesTouched, err = adoptFromSameSessionStore(ctx, sourceWorktree, sourceState, opts) } else { - adopted, filesTouched, err = adoptFromExternalSessionStore(ctx, targetStore, sourceState, opts) + adopted, filesTouched, err = adoptFromExternalSessionStore( + ctx, + sourceStore, + sourceWorktree, + sourceCommonDir, + targetStore, + targetCommonDir, + sourceState.SessionID, + opts, + ) } if err != nil { return err @@ -113,20 +125,62 @@ func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOpti return nil } -func adoptFromExternalSessionStore(ctx context.Context, targetStore *session.StateStore, sourceState *session.State, opts adoptOptions) (*session.State, []string, error) { - adopted, filesTouched, err := buildAdoptedSessionState(ctx, sourceState) - if err != nil { - return nil, nil, err +func adoptFromExternalSessionStore( + ctx context.Context, + sourceStore *session.StateStore, + sourceWorktree string, + sourceCommonDir string, + targetStore *session.StateStore, + targetCommonDir string, + sessionID string, + opts adoptOptions, +) (*session.State, []string, error) { + sourceWorktreeID, worktreeIDErr := paths.GetWorktreeID(sourceWorktree) + if worktreeIDErr != nil { + sourceWorktreeID = "" } - existing, err := targetStore.Load(ctx, adopted.SessionID) + + var adopted *session.State + var filesTouched []string + err := withAdoptSessionLocks(ctx, sessionID, []string{sourceCommonDir, targetCommonDir}, func() error { + sourceState, err := sourceStore.Load(ctx, sessionID) + if err != nil { + return fmt.Errorf("load source session state: %w", err) + } + if sourceState == nil { + return fmt.Errorf("session %s was not found in %s", sessionID, sourceWorktree) + } + if !isAdoptableSourceSession(sourceState) { + return fmt.Errorf("session %s is ended or fully condensed and cannot be adopted", sessionID) + } + if !sessionBelongsToSourceWorktree(sourceState, sourceWorktree, sourceWorktreeID) { + return fmt.Errorf("session %s belongs to %s, not %s", + sessionID, adoptSessionWorktreeLabel(sourceState), sourceWorktree) + } + if err := validateAdoptSourceTranscript(sourceState, sourceWorktree); err != nil { + return err + } + + next, touched, err := buildAdoptedSessionState(ctx, sourceState) + if err != nil { + return err + } + existing, err := targetStore.Load(ctx, next.SessionID) + if err != nil { + return fmt.Errorf("load current session state: %w", err) + } + if existing != nil && !opts.Force { + return fmt.Errorf("session %s is already tracked in this repo; rerun with --force to replace it", next.SessionID) + } + if err := targetStore.Save(ctx, next); err != nil { + return fmt.Errorf("save adopted session state: %w", err) + } + adopted = next + filesTouched = touched + return nil + }) if err != nil { - return nil, nil, fmt.Errorf("load current session state: %w", err) - } - if existing != nil && !opts.Force { - return nil, nil, fmt.Errorf("session %s is already tracked in this repo; rerun with --force to replace it", adopted.SessionID) - } - if err := targetStore.Save(ctx, adopted); err != nil { - return nil, nil, fmt.Errorf("save adopted session state: %w", err) + return nil, nil, err } return adopted, filesTouched, nil } @@ -191,6 +245,60 @@ func validateAdoptSourceTranscript(source *session.State, sourceWorktree string) return nil } +func withAdoptSessionLocks(ctx context.Context, sessionID string, commonDirs []string, fn func() error) error { + lockPaths := make([]string, 0, len(commonDirs)) + seen := make(map[string]struct{}, len(commonDirs)) + for _, commonDir := range commonDirs { + lockPath, err := adoptSessionLockPath(commonDir, sessionID) + if err != nil { + return err + } + if _, ok := seen[lockPath]; ok { + continue + } + seen[lockPath] = struct{}{} + lockPaths = append(lockPaths, lockPath) + } + sort.Strings(lockPaths) + + releases := make([]func(), 0, len(lockPaths)) + for _, lockPath := range lockPaths { + if err := ctx.Err(); err != nil { + releaseAdoptSessionLocks(releases) + return fmt.Errorf("adopt session lock canceled: %w", err) + } + release, err := flock.Acquire(lockPath) + if err != nil { + releaseAdoptSessionLocks(releases) + return fmt.Errorf("acquire session state lock: %w", err) + } + releases = append(releases, release) + } + defer releaseAdoptSessionLocks(releases) + + return fn() +} + +func releaseAdoptSessionLocks(releases []func()) { + for i := len(releases) - 1; i >= 0; i-- { + releases[i]() + } +} + +func adoptSessionLockPath(commonDir, sessionID string) (string, error) { + if strings.TrimSpace(commonDir) == "" { + return "", errors.New("resolve session state lock: empty git common dir") + } + if err := validation.ValidateSessionID(sessionID); err != nil { + return "", fmt.Errorf("invalid session ID: %w", err) + } + lockDir := filepath.Join(commonDir, "entire-session-locks") + if err := os.MkdirAll(lockDir, 0o750); err != nil { + return "", fmt.Errorf("create session lock directory: %w", err) + } + return filepath.Join(lockDir, sessionID+".lock"), nil +} + func stateStoreForWorktree(ctx context.Context, worktreePath string) (*session.StateStore, string, string, error) { absWorktree, err := filepath.Abs(worktreePath) if err != nil { diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index 9add6a5d01..5b2a54a0ca 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -14,6 +14,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/internal/flock" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/strategy" @@ -157,6 +158,175 @@ func TestSessionAdopt_RejectsUnexpectedSourceTranscriptPath(t *testing.T) { } } +func TestSessionAdopt_ExternalStoreRejectsSourceEndedAfterInitialSelection(t *testing.T) { + sourceRepo := setupAdoptRepo(t) + targetRepo := setupAdoptRepo(t) + + sessionID := "test-adopt-external-source-stale" + lastInteraction := time.Now().Add(-1 * time.Minute) + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + AttributionBaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + }); err != nil { + t.Fatal(err) + } + if _, err := selectAdoptSourceSession(context.Background(), sourceStore, sourceRepo, sessionID); err != nil { + t.Fatalf("initial source selection failed: %v", err) + } + + endedAt := time.Now() + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + EndedAt: &endedAt, + Phase: session.PhaseIdle, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + AttributionBaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + t.Chdir(targetRepo) + targetStore, err := session.NewStateStore(context.Background()) + if err != nil { + t.Fatal(err) + } + _, _, sourceCommonDir, err := stateStoreForWorktree(context.Background(), sourceRepo) + if err != nil { + t.Fatal(err) + } + _, _, targetCommonDir, err := stateStoreForWorktree(context.Background(), targetRepo) + if err != nil { + t.Fatal(err) + } + + _, _, err = adoptFromExternalSessionStore( + context.Background(), + sourceStore, + sourceRepo, + sourceCommonDir, + targetStore, + targetCommonDir, + sessionID, + adoptOptions{Force: true}, + ) + if err == nil { + t.Fatal("adoptFromExternalSessionStore succeeded from stale ended source, want refusal") + } + if !strings.Contains(err.Error(), "ended or fully condensed") { + t.Fatalf("adoptFromExternalSessionStore error = %v, want ended-session refusal", err) + } +} + +func TestSessionAdopt_ExternalStoreChecksTargetStateAfterLockWait(t *testing.T) { + sourceRepo := setupAdoptRepo(t) + targetRepo := setupAdoptRepo(t) + + sessionID := "test-adopt-external-target-race" + lastInteraction := time.Now().Add(-1 * time.Minute) + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + AttributionBaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + t.Chdir(targetRepo) + targetStore, err := session.NewStateStore(context.Background()) + if err != nil { + t.Fatal(err) + } + _, _, sourceCommonDir, err := stateStoreForWorktree(context.Background(), sourceRepo) + if err != nil { + t.Fatal(err) + } + _, _, targetCommonDir, err := stateStoreForWorktree(context.Background(), targetRepo) + if err != nil { + t.Fatal(err) + } + + lockPath, err := adoptSessionLockPath(targetCommonDir, sessionID) + if err != nil { + t.Fatal(err) + } + release, err := flock.Acquire(lockPath) + if err != nil { + t.Fatal(err) + } + + done := make(chan error, 1) + go func() { + _, _, adoptErr := adoptFromExternalSessionStore( + context.Background(), + sourceStore, + sourceRepo, + sourceCommonDir, + targetStore, + targetCommonDir, + sessionID, + adoptOptions{}, + ) + done <- adoptErr + }() + + select { + case err := <-done: + release() + t.Fatalf("adoptFromExternalSessionStore finished before target lock released: %v", err) + case <-time.After(100 * time.Millisecond): + } + + if err := targetStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now(), + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, targetRepo), + AttributionBaseCommit: testutil.GetHeadHash(t, targetRepo), + WorktreePath: targetRepo, + LastPrompt: "concurrent target state", + }); err != nil { + release() + t.Fatal(err) + } + release() + + err = <-done + if err == nil { + t.Fatal("adoptFromExternalSessionStore succeeded, want existing target refusal") + } + if !strings.Contains(err.Error(), "already tracked in this repo") { + t.Fatalf("adoptFromExternalSessionStore error = %v, want existing-state refusal", err) + } + + loaded, err := targetStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if loaded.LastPrompt != "concurrent target state" { + t.Fatalf("target state LastPrompt = %q, want concurrent target state", loaded.LastPrompt) + } +} + func TestSessionAdopt_EnablesPrepareCommitMsgTrailer(t *testing.T) { sourceRepo := setupAdoptRepo(t) targetRepo := setupAdoptRepo(t) From 3d3df242756e255095d12c4e35c00448e4695189 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 23 Jun 2026 19:33:31 -0400 Subject: [PATCH 17/30] refactor(session): share session state locks Entire-Checkpoint: 03f1aba5f48f --- cmd/entire/cli/session_adopt.go | 61 +----------------------- cmd/entire/cli/session_adopt_test.go | 4 +- cmd/entire/cli/strategy/session_state.go | 55 +++++++++++++++++++-- 3 files changed, 56 insertions(+), 64 deletions(-) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index b95f66fa9f..5a28093aab 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "maps" - "os" "os/exec" "path/filepath" "slices" @@ -17,11 +16,9 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" - "github.com/entireio/cli/cmd/entire/cli/internal/flock" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/strategy" - "github.com/entireio/cli/cmd/entire/cli/validation" "github.com/entireio/cli/cmd/entire/cli/versioninfo" "github.com/spf13/cobra" ) @@ -142,7 +139,7 @@ func adoptFromExternalSessionStore( var adopted *session.State var filesTouched []string - err := withAdoptSessionLocks(ctx, sessionID, []string{sourceCommonDir, targetCommonDir}, func() error { + err := strategy.WithSessionStateLocks(ctx, sessionID, []string{sourceCommonDir, targetCommonDir}, func() error { sourceState, err := sourceStore.Load(ctx, sessionID) if err != nil { return fmt.Errorf("load source session state: %w", err) @@ -180,7 +177,7 @@ func adoptFromExternalSessionStore( return nil }) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("adopt external session state: %w", err) } return adopted, filesTouched, nil } @@ -245,60 +242,6 @@ func validateAdoptSourceTranscript(source *session.State, sourceWorktree string) return nil } -func withAdoptSessionLocks(ctx context.Context, sessionID string, commonDirs []string, fn func() error) error { - lockPaths := make([]string, 0, len(commonDirs)) - seen := make(map[string]struct{}, len(commonDirs)) - for _, commonDir := range commonDirs { - lockPath, err := adoptSessionLockPath(commonDir, sessionID) - if err != nil { - return err - } - if _, ok := seen[lockPath]; ok { - continue - } - seen[lockPath] = struct{}{} - lockPaths = append(lockPaths, lockPath) - } - sort.Strings(lockPaths) - - releases := make([]func(), 0, len(lockPaths)) - for _, lockPath := range lockPaths { - if err := ctx.Err(); err != nil { - releaseAdoptSessionLocks(releases) - return fmt.Errorf("adopt session lock canceled: %w", err) - } - release, err := flock.Acquire(lockPath) - if err != nil { - releaseAdoptSessionLocks(releases) - return fmt.Errorf("acquire session state lock: %w", err) - } - releases = append(releases, release) - } - defer releaseAdoptSessionLocks(releases) - - return fn() -} - -func releaseAdoptSessionLocks(releases []func()) { - for i := len(releases) - 1; i >= 0; i-- { - releases[i]() - } -} - -func adoptSessionLockPath(commonDir, sessionID string) (string, error) { - if strings.TrimSpace(commonDir) == "" { - return "", errors.New("resolve session state lock: empty git common dir") - } - if err := validation.ValidateSessionID(sessionID); err != nil { - return "", fmt.Errorf("invalid session ID: %w", err) - } - lockDir := filepath.Join(commonDir, "entire-session-locks") - if err := os.MkdirAll(lockDir, 0o750); err != nil { - return "", fmt.Errorf("create session lock directory: %w", err) - } - return filepath.Join(lockDir, sessionID+".lock"), nil -} - func stateStoreForWorktree(ctx context.Context, worktreePath string) (*session.StateStore, string, string, error) { absWorktree, err := filepath.Abs(worktreePath) if err != nil { diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index 5b2a54a0ca..0e97271165 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -264,8 +264,8 @@ func TestSessionAdopt_ExternalStoreChecksTargetStateAfterLockWait(t *testing.T) t.Fatal(err) } - lockPath, err := adoptSessionLockPath(targetCommonDir, sessionID) - if err != nil { + lockPath := filepath.Join(targetCommonDir, "entire-session-locks", sessionID+".lock") + if err := os.MkdirAll(filepath.Dir(lockPath), 0o750); err != nil { t.Fatal(err) } release, err := flock.Acquire(lockPath) diff --git a/cmd/entire/cli/strategy/session_state.go b/cmd/entire/cli/strategy/session_state.go index 84675c3302..5aeca89c64 100644 --- a/cmd/entire/cli/strategy/session_state.go +++ b/cmd/entire/cli/strategy/session_state.go @@ -592,6 +592,48 @@ func acquireSessionGate(ctx context.Context, sessionID string) (gate *sessionGat }, nil } +// WithSessionStateLocks acquires the per-session state lock in each git common +// dir, then runs fn. Lock paths are deduplicated and sorted so callers that +// span repositories or worktrees can safely acquire more than one lock. +func WithSessionStateLocks(ctx context.Context, sessionID string, commonDirs []string, fn func() error) error { + lockPaths := make([]string, 0, len(commonDirs)) + seen := make(map[string]struct{}, len(commonDirs)) + for _, commonDir := range commonDirs { + lockPath, err := stateLockPathInCommonDir(commonDir, sessionID) + if err != nil { + return err + } + if _, ok := seen[lockPath]; ok { + continue + } + seen[lockPath] = struct{}{} + lockPaths = append(lockPaths, lockPath) + } + slices.Sort(lockPaths) + + releases := make([]func(), 0, len(lockPaths)) + releaseAll := func() { + for i := len(releases) - 1; i >= 0; i-- { + releases[i]() + } + } + for _, lockPath := range lockPaths { + if err := ctx.Err(); err != nil { + releaseAll() + return fmt.Errorf("session state lock canceled: %w", err) + } + release, err := flock.Acquire(lockPath) + if err != nil { + releaseAll() + return fmt.Errorf("acquire session state lock: %w", err) + } + releases = append(releases, release) + } + defer releaseAll() + + return fn() +} + // ErrMutationSkip signals MutateSessionState to skip the save without // treating fn's return as an error. Use it when the mutation function // observes the loaded state and decides no write is needed (for example, @@ -634,13 +676,20 @@ func RecordFilesTouched(ctx context.Context, sessionID string, modified, added, // holder distinct from the data — Save's atomic-rename pattern would // otherwise unlink the inode the flock is held on. func stateLockPath(ctx context.Context, sessionID string) (string, error) { - if err := validation.ValidateSessionID(sessionID); err != nil { - return "", fmt.Errorf("invalid session ID: %w", err) - } commonDir, err := GetGitCommonDir(ctx) if err != nil { return "", err } + return stateLockPathInCommonDir(commonDir, sessionID) +} + +func stateLockPathInCommonDir(commonDir, sessionID string) (string, error) { + if strings.TrimSpace(commonDir) == "" { + return "", errors.New("empty git common dir") + } + if err := validation.ValidateSessionID(sessionID); err != nil { + return "", fmt.Errorf("invalid session ID: %w", err) + } lockDir := filepath.Join(commonDir, "entire-session-locks") if err := os.MkdirAll(lockDir, 0o750); err != nil { return "", fmt.Errorf("create session lock directory: %w", err) From 8379e34f90643ec3d12f8297c2245790b634985e Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 24 Jun 2026 10:40:28 -0400 Subject: [PATCH 18/30] fix(session): clear adopted source owner Entire-Checkpoint: ed2ff36c6c0e --- cmd/entire/cli/session_adopt.go | 3 ++ cmd/entire/cli/session_adopt_test.go | 50 ++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index 5a28093aab..0c376a19f4 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -452,6 +452,9 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess adopted.PromptWindowBase = adopted.SessionTurnCount adopted.PromptWindowResetPending = false adopted.AttachedManually = false + // The source process owner may already be gone; a new turn will capture the + // current owner, and until then liveness should fall back to the timeout. + adopted.Owner = nil return &adopted, filesTouched, nil } diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index 0e97271165..62c77a78c8 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -16,6 +16,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/internal/flock" "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/proclive" "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/testutil" @@ -103,6 +104,55 @@ func TestSessionAdopt_CopiesExternalSessionIntoCurrentWorktree(t *testing.T) { } } +func TestSessionAdopt_ClearsSourceOwner(t *testing.T) { + sourceRepo := setupAdoptRepo(t) + targetRepo := setupAdoptRepo(t) + + sessionID := "test-adopt-clear-owner" + lastInteraction := time.Now().Add(-1 * time.Minute) + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + AttributionBaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + Owner: &proclive.Identity{PID: os.Getpid(), Start: "source-owner"}, + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + t.Chdir(targetRepo) + + var out bytes.Buffer + err := runAdopt(context.Background(), &out, sessionID, adoptOptions{ + FromWorktree: sourceRepo, + Force: true, + }) + if err != nil { + t.Fatalf("runAdopt failed: %v", err) + } + + targetStore, err := session.NewStateStore(context.Background()) + if err != nil { + t.Fatal(err) + } + adopted, err := targetStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if adopted == nil { + t.Fatal("expected adopted session state in target repo") + } + if adopted.Owner != nil { + t.Fatalf("Owner = %#v, want nil so source process liveness cannot finalize adopted session", adopted.Owner) + } +} + func TestSessionAdopt_RejectsUnexpectedSourceTranscriptPath(t *testing.T) { sourceRepo := setupAdoptRepo(t) targetRepo := setupAdoptRepo(t) From 4f4a780f8bc9c0ed9b5b935797dfd783b6f491c6 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 24 Jun 2026 11:58:27 -0400 Subject: [PATCH 19/30] fix(session): clear adopted review metadata Entire-Checkpoint: b95acc5a8b76 --- cmd/entire/cli/session_adopt.go | 8 +++-- cmd/entire/cli/session_adopt_test.go | 54 ++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index 0c376a19f4..6f9ddeb469 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -419,12 +419,17 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess adopted.CLIVersion = versioninfo.Version adopted.TranscriptPath = source.TranscriptPath adopted.BaseCommit = head.Hash().String() - adopted.AttributionBaseCommit = head.Hash().String() + adopted.RealignAttributionBase(head.Hash().String()) adopted.WorktreePath = worktreeRoot adopted.WorktreeID = worktreeID adopted.Branch = branch adopted.LastInteractionTime = &now adopted.Phase = session.PhaseActive + adopted.Kind = "" + adopted.ReviewSkills = nil + adopted.ReviewPrompt = "" + adopted.InvestigateRunID = "" + adopted.InvestigateTopic = "" adopted.EndedAt = nil adopted.FilesTouched = filesTouched @@ -442,7 +447,6 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess adopted.LastCheckpointCommitHash = "" adopted.FullyCondensed = false - adopted.DivergenceNoticeShown = false adopted.UntrackedFilesAtStart = nil adopted.PromptAttributions = nil adopted.PendingPromptAttribution = nil diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index 62c77a78c8..df8da2fbcd 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -744,6 +744,60 @@ func TestSessionAdopt_ClearsLegacyTranscriptOffsets(t *testing.T) { } } +func TestSessionAdopt_ClearsReviewAndInvestigateMetadata(t *testing.T) { + for _, tc := range []struct { + name string + kind session.Kind + }{ + {name: "review", kind: session.KindAgentReview}, + {name: "investigate", kind: session.KindAgentInvestigate}, + } { + t.Run(tc.name, func(t *testing.T) { + targetRepo := setupAdoptRepo(t) + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + t.Chdir(targetRepo) + + adopted, _, err := buildAdoptedSessionState(context.Background(), &session.State{ + SessionID: "test-adopt-kind-" + tc.name, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + Phase: session.PhaseActive, + Kind: tc.kind, + ReviewSkills: []string{"/review"}, + ReviewPrompt: "review this branch", + InvestigateRunID: "abcdef012345", + InvestigateTopic: "Why is adoption misclassified?", + BaseCommit: "source-head", + WorktreePath: "/source/repo", + LastCheckpointID: id.MustCheckpointID("abc123def456"), + TurnCheckpointIDs: []string{"abc123def456"}, + PromptWindowBase: 3, + SessionTurnCount: 7, + AttachedManually: true, + }) + if err != nil { + t.Fatalf("buildAdoptedSessionState failed: %v", err) + } + + if adopted.Kind != "" { + t.Fatalf("Kind = %q, want empty normal session kind", adopted.Kind) + } + if len(adopted.ReviewSkills) != 0 { + t.Fatalf("ReviewSkills = %v, want empty", adopted.ReviewSkills) + } + if adopted.ReviewPrompt != "" { + t.Fatalf("ReviewPrompt = %q, want empty", adopted.ReviewPrompt) + } + if adopted.InvestigateRunID != "" { + t.Fatalf("InvestigateRunID = %q, want empty", adopted.InvestigateRunID) + } + if adopted.InvestigateTopic != "" { + t.Fatalf("InvestigateTopic = %q, want empty", adopted.InvestigateTopic) + } + }) + } +} + func TestSessionAdopt_CloneSourceStateDoesNotShareMutableFields(t *testing.T) { lastInteraction := time.Now().Add(-1 * time.Minute) endedAt := time.Now() From 739e4e7fcd604c112f90b3b9ca979dac5d292f4b Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 24 Jun 2026 12:11:06 -0400 Subject: [PATCH 20/30] fix(session): preserve adopted review metadata Entire-Checkpoint: 9190dd16dddb --- cmd/entire/cli/session_adopt.go | 5 ----- cmd/entire/cli/session_adopt_test.go | 22 +++++++++++----------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index 6f9ddeb469..d2831f5103 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -425,11 +425,6 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess adopted.Branch = branch adopted.LastInteractionTime = &now adopted.Phase = session.PhaseActive - adopted.Kind = "" - adopted.ReviewSkills = nil - adopted.ReviewPrompt = "" - adopted.InvestigateRunID = "" - adopted.InvestigateTopic = "" adopted.EndedAt = nil adopted.FilesTouched = filesTouched diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index df8da2fbcd..894bfec8b7 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -744,7 +744,7 @@ func TestSessionAdopt_ClearsLegacyTranscriptOffsets(t *testing.T) { } } -func TestSessionAdopt_ClearsReviewAndInvestigateMetadata(t *testing.T) { +func TestSessionAdopt_PreservesReviewAndInvestigateMetadata(t *testing.T) { for _, tc := range []struct { name string kind session.Kind @@ -779,20 +779,20 @@ func TestSessionAdopt_ClearsReviewAndInvestigateMetadata(t *testing.T) { t.Fatalf("buildAdoptedSessionState failed: %v", err) } - if adopted.Kind != "" { - t.Fatalf("Kind = %q, want empty normal session kind", adopted.Kind) + if adopted.Kind != tc.kind { + t.Fatalf("Kind = %q, want %q", adopted.Kind, tc.kind) } - if len(adopted.ReviewSkills) != 0 { - t.Fatalf("ReviewSkills = %v, want empty", adopted.ReviewSkills) + if len(adopted.ReviewSkills) != 1 || adopted.ReviewSkills[0] != "/review" { + t.Fatalf("ReviewSkills = %v, want [/review]", adopted.ReviewSkills) } - if adopted.ReviewPrompt != "" { - t.Fatalf("ReviewPrompt = %q, want empty", adopted.ReviewPrompt) + if adopted.ReviewPrompt != "review this branch" { + t.Fatalf("ReviewPrompt = %q, want review prompt", adopted.ReviewPrompt) } - if adopted.InvestigateRunID != "" { - t.Fatalf("InvestigateRunID = %q, want empty", adopted.InvestigateRunID) + if adopted.InvestigateRunID != "abcdef012345" { + t.Fatalf("InvestigateRunID = %q, want source run ID", adopted.InvestigateRunID) } - if adopted.InvestigateTopic != "" { - t.Fatalf("InvestigateTopic = %q, want empty", adopted.InvestigateTopic) + if adopted.InvestigateTopic != "Why is adoption misclassified?" { + t.Fatalf("InvestigateTopic = %q, want source topic", adopted.InvestigateTopic) } }) } From 4b00f105ac88dd143ede9514cf1cb0f1c6d7f2fe Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 24 Jun 2026 14:18:42 -0400 Subject: [PATCH 21/30] fix(session): reset adopted turn id Entire-Checkpoint: 21807790c5ff --- cmd/entire/cli/session_adopt.go | 1 + cmd/entire/cli/session_adopt_test.go | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index d2831f5103..d1e22e6c78 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -437,6 +437,7 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess adopted.CheckpointTranscriptSize = 0 adopted.TranscriptIdentifierAtStart = "" adopted.ClearLegacyTranscriptOffsets() + adopted.TurnID = "" adopted.TurnCheckpointIDs = nil adopted.LastCheckpointID = id.EmptyCheckpointID adopted.LastCheckpointCommitHash = "" diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index 894bfec8b7..1be77e8414 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -620,6 +620,7 @@ func TestSessionAdopt_ResetsSourceCheckpointWindow(t *testing.T) { CondensedTranscriptLines: 2, TranscriptLinesAtStart: 2, TranscriptIdentifierAtStart: "source-assistant", + TurnID: "source-turn", TurnCheckpointIDs: []string{"abc123def456"}, LastCheckpointID: id.MustCheckpointID("abc123def456"), LastCheckpointCommitHash: "source-commit", @@ -686,6 +687,9 @@ func TestSessionAdopt_ResetsSourceCheckpointWindow(t *testing.T) { if len(adopted.TurnCheckpointIDs) != 0 { t.Fatalf("TurnCheckpointIDs = %v, want empty", adopted.TurnCheckpointIDs) } + if adopted.TurnID != "" { + t.Fatalf("TurnID = %q, want empty target-local turn ID", adopted.TurnID) + } if !adopted.LastCheckpointID.IsEmpty() { t.Fatalf("LastCheckpointID = %s, want empty", adopted.LastCheckpointID.String()) } From 1b1026c344ca60901fb21a1c28f5e1b966ed8b41 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 24 Jun 2026 15:21:47 -0400 Subject: [PATCH 22/30] fix(session): preserve target untracked files on adopt Entire-Checkpoint: 7d71da57444e --- cmd/entire/cli/session_adopt.go | 6 +++++- cmd/entire/cli/session_adopt_test.go | 5 +++++ cmd/entire/cli/strategy/common.go | 6 ++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index d1e22e6c78..a63ef26765 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -409,6 +409,10 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess if err != nil { return nil, nil, err } + untrackedFiles, err := strategy.CollectUntrackedFiles(ctx) + if err != nil { + untrackedFiles = nil + } now := time.Now() adopted := cloneAdoptSourceState(source) @@ -443,7 +447,7 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess adopted.LastCheckpointCommitHash = "" adopted.FullyCondensed = false - adopted.UntrackedFilesAtStart = nil + adopted.UntrackedFilesAtStart = untrackedFiles adopted.PromptAttributions = nil adopted.PendingPromptAttribution = nil // Preserve cumulative turn/context metrics for the continuing agent session, diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index 1be77e8414..985ddd68db 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -624,6 +624,7 @@ func TestSessionAdopt_ResetsSourceCheckpointWindow(t *testing.T) { TurnCheckpointIDs: []string{"abc123def456"}, LastCheckpointID: id.MustCheckpointID("abc123def456"), LastCheckpointCommitHash: "source-commit", + UntrackedFilesAtStart: []string{"source-only.txt"}, PromptWindowBase: 3, PromptWindowResetPending: true, }); err != nil { @@ -632,6 +633,7 @@ func TestSessionAdopt_ResetsSourceCheckpointWindow(t *testing.T) { testutil.WriteFile(t, targetRepo, targetRelPath, "package src\n") testutil.GitAdd(t, targetRepo, targetRelPath) + testutil.WriteFile(t, targetRepo, "target-notes.txt", "user notes\n") t.Chdir(targetRepo) var out bytes.Buffer @@ -690,6 +692,9 @@ func TestSessionAdopt_ResetsSourceCheckpointWindow(t *testing.T) { if adopted.TurnID != "" { t.Fatalf("TurnID = %q, want empty target-local turn ID", adopted.TurnID) } + if len(adopted.UntrackedFilesAtStart) != 1 || adopted.UntrackedFilesAtStart[0] != "target-notes.txt" { + t.Fatalf("UntrackedFilesAtStart = %v, want target worktree snapshot [target-notes.txt]", adopted.UntrackedFilesAtStart) + } if !adopted.LastCheckpointID.IsEmpty() { t.Fatalf("LastCheckpointID = %s, want empty", adopted.LastCheckpointID.String()) } diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index 9a0084d146..1d06e9dfb0 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -1557,6 +1557,12 @@ func collectUntrackedFiles(ctx context.Context) ([]string, error) { return files, nil } +// CollectUntrackedFiles collects untracked, non-ignored paths relative to the +// repository root. +func CollectUntrackedFiles(ctx context.Context) ([]string, error) { + return collectUntrackedFiles(ctx) +} + // NOTE: The following git tree helper functions have been moved to checkpoint/ package: // - FlattenTree -> checkpoint.FlattenTree // - CreateBlobFromContent -> checkpoint.CreateBlobFromContent From cf4e18d12f5b66e2bfd37c31264594dda9f8591c Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 29 Jun 2026 17:25:01 +0200 Subject: [PATCH 23/30] fix: reset adopted checkpoint token usage Entire-Checkpoint: 0ed46044b4c8 --- cmd/entire/cli/session_adopt.go | 1 + cmd/entire/cli/session_adopt_test.go | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index a63ef26765..892eb2a47e 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -445,6 +445,7 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess adopted.TurnCheckpointIDs = nil adopted.LastCheckpointID = id.EmptyCheckpointID adopted.LastCheckpointCommitHash = "" + adopted.CheckpointTokenUsage = nil adopted.FullyCondensed = false adopted.UntrackedFilesAtStart = untrackedFiles diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index 985ddd68db..02aa04daad 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -624,6 +624,7 @@ func TestSessionAdopt_ResetsSourceCheckpointWindow(t *testing.T) { TurnCheckpointIDs: []string{"abc123def456"}, LastCheckpointID: id.MustCheckpointID("abc123def456"), LastCheckpointCommitHash: "source-commit", + CheckpointTokenUsage: &agent.TokenUsage{InputTokens: 100, OutputTokens: 25, APICallCount: 1}, UntrackedFilesAtStart: []string{"source-only.txt"}, PromptWindowBase: 3, PromptWindowResetPending: true, @@ -701,6 +702,9 @@ func TestSessionAdopt_ResetsSourceCheckpointWindow(t *testing.T) { if adopted.LastCheckpointCommitHash != "" { t.Fatalf("LastCheckpointCommitHash = %q, want empty", adopted.LastCheckpointCommitHash) } + if adopted.CheckpointTokenUsage != nil { + t.Fatalf("CheckpointTokenUsage = %#v, want nil for first target checkpoint", adopted.CheckpointTokenUsage) + } commitMsgFile := filepath.Join(targetRepo, "COMMIT_EDITMSG") if err := os.WriteFile(commitMsgFile, []byte("add target feature\n"), 0o600); err != nil { From 99e8234747b419529b19b47d3ae4109efa3d47c8 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 29 Jun 2026 17:47:04 +0200 Subject: [PATCH 24/30] fix: require adoption worktree ownership metadata Entire-Checkpoint: 0ed46044b4c8 --- cmd/entire/cli/session_adopt.go | 2 +- cmd/entire/cli/session_adopt_test.go | 34 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index 892eb2a47e..f0d8749600 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -339,7 +339,7 @@ func sessionBelongsToSourceWorktree(state *session.State, sourceWorktree, source if state.WorktreePath != "" { return sameAdoptPath(state.WorktreePath, sourceWorktree) } - return true + return false } func adoptSessionWorktreeLabel(state *session.State) string { diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index 02aa04daad..870c26e4c5 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -1022,6 +1022,40 @@ func TestSessionAdopt_FiltersSharedSourceStoreByFromWorktree(t *testing.T) { } } +func TestSessionAdopt_RejectsSourceSessionWithoutWorktreeMetadata(t *testing.T) { + sourceRepo := setupAdoptRepo(t) + + sessionID := "missing-worktree-metadata" + lastInteraction := time.Now().Add(-1 * time.Minute) + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + }); err != nil { + t.Fatal(err) + } + + _, err := selectAdoptSourceSession(context.Background(), sourceStore, sourceRepo, sessionID) + if err == nil { + t.Fatal("selectAdoptSourceSession succeeded for explicit session without worktree metadata, want refusal") + } + if !strings.Contains(err.Error(), "belongs to") || !strings.Contains(err.Error(), "unknown") { + t.Fatalf("selectAdoptSourceSession error = %v, want missing-worktree ownership refusal", err) + } + + _, err = selectAdoptSourceSession(context.Background(), sourceStore, sourceRepo, "") + if err == nil { + t.Fatal("selectAdoptSourceSession auto-selected session without worktree metadata, want no candidate") + } + if !strings.Contains(err.Error(), "no recent active sessions") { + t.Fatalf("selectAdoptSourceSession error = %v, want no recent active sessions", err) + } +} + func TestStateStoreForWorktreeIgnoresGitStderrOnSuccess(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses a POSIX shell script fake git") From 6c132f2919c29426f9eb408d7816e681d307fa02 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 29 Jun 2026 18:54:50 +0200 Subject: [PATCH 25/30] fix: preserve git common dir for adoption locks Entire-Checkpoint: 0ed46044b4c8 --- cmd/entire/cli/session_adopt.go | 3 --- cmd/entire/cli/session_adopt_test.go | 38 +++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index f0d8749600..c38f28e62f 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -270,9 +270,6 @@ func stateStoreForWorktree(ctx context.Context, worktreePath string) (*session.S commonDir = filepath.Join(absWorktree, commonDir) } commonDir = filepath.Clean(commonDir) - if resolved, err := filepath.EvalSymlinks(commonDir); err == nil { - commonDir = resolved - } return session.NewStateStoreWithDir(filepath.Join(commonDir, session.SessionStateDirName)), sourceRoot, commonDir, nil } diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index 870c26e4c5..dc3430e74d 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -1057,7 +1057,7 @@ func TestSessionAdopt_RejectsSourceSessionWithoutWorktreeMetadata(t *testing.T) } func TestStateStoreForWorktreeIgnoresGitStderrOnSuccess(t *testing.T) { - if runtime.GOOS == "windows" { + if runtime.GOOS == windowsGOOS { t.Skip("uses a POSIX shell script fake git") } @@ -1089,6 +1089,42 @@ printf '%s\n%s\n' "$FAKE_WORKTREE_ROOT" "$FAKE_GIT_COMMON_DIR" } } +func TestStateStoreForWorktreePreservesGitCommonDirSymlink(t *testing.T) { + if runtime.GOOS == windowsGOOS { + t.Skip("uses a POSIX shell script fake git") + } + + fakeBin := t.TempDir() + fakeGit := filepath.Join(fakeBin, "git") + script := `#!/bin/sh +printf '%s\n%s\n' "$FAKE_WORKTREE_ROOT" "$FAKE_GIT_COMMON_DIR" +` + if err := os.WriteFile(fakeGit, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + sourceRoot := filepath.Join(t.TempDir(), "source") + realCommonDir := filepath.Join(t.TempDir(), "real-common.git") + if err := os.MkdirAll(realCommonDir, 0o750); err != nil { + t.Fatal(err) + } + commonDirLink := filepath.Join(t.TempDir(), "common-link.git") + if err := os.Symlink(realCommonDir, commonDirLink); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("FAKE_WORKTREE_ROOT", sourceRoot) + t.Setenv("FAKE_GIT_COMMON_DIR", commonDirLink) + + _, _, gotCommonDir, err := stateStoreForWorktree(context.Background(), ".") + if err != nil { + t.Fatalf("stateStoreForWorktree failed: %v", err) + } + if gotCommonDir != filepath.Clean(commonDirLink) { + t.Fatalf("commonDir = %q, want git-reported symlink path %q", gotCommonDir, filepath.Clean(commonDirLink)) + } +} + func TestSessionAdopt_SameStoreReloadsSourceStateUnderLock(t *testing.T) { sourceRepo := setupAdoptRepo(t) targetWorktree := filepath.Join(t.TempDir(), "target-worktree") From 3087e412422a56acc1fdef40fb49e0ff3568dc39 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 29 Jun 2026 19:00:03 +0200 Subject: [PATCH 26/30] docs: document session adoption Entire-Checkpoint: 0ed46044b4c8 --- CLAUDE.md | 5 ++++- docs/architecture/sessions-and-checkpoints.md | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index ea85804431..b1d7c26c6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,12 +29,15 @@ pointing at the canonical group form. Newer experimental command families are discoverable through `entire labs` and may remain hidden from root help while their canonical paths are still runnable. -- `session` (alias: `sessions`): `list`, `info`, `tokens`, `stop`, `attach`, `resume`, `current`. +- `session` (alias: `sessions`): `list`, `info`, `tokens`, `stop`, `attach`, `adopt`, `resume`, `current`. `resume` with a branch arg switches to it and resumes its session; with no arg it opens an interactive picker of stopped sessions (across all worktrees), resolving each to its branch and pointing at the owning worktree when the branch is checked out elsewhere. Resume keeps an existing local session log as-is by default (`--force` overwrites it from the checkpoint). + `adopt` moves an active session from another repo or worktree into the current + worktree and resets target-local checkpoint bookkeeping so future commits link + to the adopted session from the new location. - `checkpoint` (aliases: `cp`, `checkpoints`): `list`, `explain`, `tokens`, `search`, plus the deprecated `rewind` (functional, prints a cobra deprecation message, will be removed in a future release) diff --git a/docs/architecture/sessions-and-checkpoints.md b/docs/architecture/sessions-and-checkpoints.md index d3512f69a0..95abac397f 100644 --- a/docs/architecture/sessions-and-checkpoints.md +++ b/docs/architecture/sessions-and-checkpoints.md @@ -159,6 +159,13 @@ and map each back to its branch; for sessions recorded before the field existed it falls back to deriving the branch from the session's last checkpoint ID found in branch-only commit trailers. +`entire session adopt` moves an active session from a source repo or worktree +into the current worktree. Adoption preserves the live transcript path, validates +that the source state still belongs to the requested source worktree, rewrites +the session's branch/worktree/base metadata to the target, clears target-local +checkpoint windows and checkpoint IDs, and snapshots the target's current file +changes so the next commit can link to the adopted session. + ### Temporary Checkpoints Branch: `entire/-` From 04464a2a36e571aa4eb5cbdd891e5e9efd4073e4 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 29 Jun 2026 23:02:44 +0200 Subject: [PATCH 27/30] fix: clarify session adopt yes flag Entire-Checkpoint: 0ed46044b4c8 --- cmd/entire/cli/session_adopt.go | 2 +- cmd/entire/cli/session_adopt_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index c38f28e62f..5fe37dc453 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -60,7 +60,7 @@ session state file to the current worktree and requires --force or --yes.`, cmd.Flags().StringVar(&opts.FromWorktree, "from", "", "source worktree that already tracks the session") cmd.Flags().BoolVar(&opts.Force, "force", false, "replace an existing local state file for the same session") - cmd.Flags().BoolVar(&opts.Force, "yes", false, "replace an existing local state file for the same session") + cmd.Flags().BoolVar(&opts.Force, "yes", false, "confirm same-store adoption and replacement without prompting") return cmd } diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index dc3430e74d..9f89d0e5a0 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -22,6 +22,32 @@ import ( "github.com/entireio/cli/cmd/entire/cli/testutil" ) +func TestSessionAdopt_HelpDistinguishesForceAndYes(t *testing.T) { + cmd := newAdoptCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"--help"}) + + if err := cmd.ExecuteContext(context.Background()); err != nil { + t.Fatalf("expected help to render without error, got: %v", err) + } + + out := stdout.String() + for _, want := range []string{ + "--force", + "replace an existing local state file for the same session", + "--yes", + "confirm same-store adoption and replacement without prompting", + } { + if !strings.Contains(out, want) { + t.Fatalf("help missing %q:\n%s", want, out) + } + } + if strings.Count(out, "replace an existing local state file for the same session") != 1 { + t.Fatalf("--force and --yes should not share replacement help text:\n%s", out) + } +} + func TestSessionAdopt_CopiesExternalSessionIntoCurrentWorktree(t *testing.T) { sourceRepo := setupAdoptRepo(t) targetRepo := setupAdoptRepo(t) From 381955758efd6ae53f8687814eebd5d7f970b0af Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 30 Jun 2026 12:03:42 +0200 Subject: [PATCH 28/30] fix: retire source session after external adoption Entire-Checkpoint: 0ed46044b4c8 --- cmd/entire/cli/lifecycle.go | 5 + cmd/entire/cli/session/state.go | 10 ++ cmd/entire/cli/session_adopt.go | 23 +++- cmd/entire/cli/session_adopt_test.go | 121 +++++++++++++++++- .../cli/strategy/manual_commit_hooks.go | 6 + .../cli/strategy/manual_commit_session.go | 6 + 6 files changed, 169 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 873403b951..0b4dd89c91 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -242,6 +242,11 @@ func handleLifecycleSessionStart(ctx context.Context, ag agent.Agent, event *age // so ErrStateNotFound is the normal first-session path — only warn on // genuinely unexpected errors, matching the rest of this file. mutErr := strategy.MutateSessionState(ctx, event.SessionID, func(state *strategy.SessionState) error { + if state.AdoptedIntoWorktreePath != "" { + logging.Info(logCtx, "skipping adopted-away source session start", + slog.String("adopted_into_worktree", state.AdoptedIntoWorktreePath)) + return strategy.ErrMutationSkip + } persistEventMetadataToState(event, state) if transErr := strategy.TransitionAndLog(ctx, state, session.EventSessionStart, session.TransitionContext{}, session.NoOpActionHandler{}); transErr != nil { logging.Warn(logCtx, "session start transition failed", diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index ce97997433..760d5f4c03 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -113,6 +113,16 @@ type State struct { // Derived from .git/worktrees//, stable across git worktree move WorktreeID string `json:"worktree_id,omitempty"` + // AdoptedIntoWorktreePath marks a source-side tombstone left behind after + // `entire session adopt` moves this session into another repository/worktree. + // Hook TurnStart must not reactivate tombstoned source records, otherwise the + // same session ID can diverge in two session stores. + AdoptedIntoWorktreePath string `json:"adopted_into_worktree_path,omitempty"` + + // AdoptedIntoWorktreeID is the target worktree ID paired with + // AdoptedIntoWorktreePath when available. + AdoptedIntoWorktreeID string `json:"adopted_into_worktree_id,omitempty"` + // Branch is the git branch HEAD pointed at the last time this session took a // turn. Captured on each turn start so it tracks branches created or renamed // after the session began. Empty when HEAD was detached or for sessions diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index 5fe37dc453..401ec10a61 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -39,7 +39,7 @@ func newAdoptCmd() *cobra.Command { Long: `Adopt an active session from another worktree into the current repository. This is useful when an agent starts in one repository or worktree, then moves -and makes changes in another. Adoption copies the live session state into the +and makes changes in another. Adoption moves the live session state into the current repo and seeds it with the current repo's uncommitted file changes so the next commit can be linked normally. @@ -172,6 +172,10 @@ func adoptFromExternalSessionStore( if err := targetStore.Save(ctx, next); err != nil { return fmt.Errorf("save adopted session state: %w", err) } + retired := retireAdoptedSourceSession(sourceState, next) + if err := sourceStore.Save(ctx, &retired); err != nil { + return fmt.Errorf("retire source session state: %w", err) + } adopted = next filesTouched = touched return nil @@ -182,6 +186,21 @@ func adoptFromExternalSessionStore( return adopted, filesTouched, nil } +func retireAdoptedSourceSession(source, target *session.State) session.State { + now := time.Now() + retired := cloneAdoptSourceState(source) + retired.Phase = session.PhaseEnded + retired.EndedAt = &now + retired.FullyCondensed = true + retired.Owner = nil + retired.FilesTouched = nil + retired.TurnID = "" + retired.TurnCheckpointIDs = nil + retired.AdoptedIntoWorktreePath = target.WorktreePath + retired.AdoptedIntoWorktreeID = target.WorktreeID + return retired +} + func adoptFromSameSessionStore(ctx context.Context, sourceWorktree string, sourceState *session.State, opts adoptOptions) (*session.State, []string, error) { if !opts.Force { return nil, nil, fmt.Errorf("session %s is already tracked in this repo; rerun with --force to replace it", sourceState.SessionID) @@ -423,6 +442,8 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess adopted.RealignAttributionBase(head.Hash().String()) adopted.WorktreePath = worktreeRoot adopted.WorktreeID = worktreeID + adopted.AdoptedIntoWorktreePath = "" + adopted.AdoptedIntoWorktreeID = "" adopted.Branch = branch adopted.LastInteractionTime = &now adopted.Phase = session.PhaseActive diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index 9f89d0e5a0..57962cb0b8 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -48,7 +48,7 @@ func TestSessionAdopt_HelpDistinguishesForceAndYes(t *testing.T) { } } -func TestSessionAdopt_CopiesExternalSessionIntoCurrentWorktree(t *testing.T) { +func TestSessionAdopt_MovesExternalSessionIntoCurrentWorktree(t *testing.T) { sourceRepo := setupAdoptRepo(t) targetRepo := setupAdoptRepo(t) @@ -130,6 +130,125 @@ func TestSessionAdopt_CopiesExternalSessionIntoCurrentWorktree(t *testing.T) { } } +func TestSessionAdopt_ExternalStoreRetiresSourceSession(t *testing.T) { + sourceRepo := setupAdoptRepo(t) + targetRepo := setupAdoptRepo(t) + + sessionID := "test-adopt-external-retire-source" + lastInteraction := time.Now().Add(-1 * time.Minute) + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + AttributionBaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + LastPrompt: "continue work in target repo", + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + t.Chdir(targetRepo) + + var out bytes.Buffer + err := runAdopt(context.Background(), &out, sessionID, adoptOptions{ + FromWorktree: sourceRepo, + Force: true, + }) + if err != nil { + t.Fatalf("runAdopt failed: %v", err) + } + + targetStore, err := session.NewStateStore(context.Background()) + if err != nil { + t.Fatal(err) + } + adopted, err := targetStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if adopted == nil { + t.Fatal("expected adopted target session state") + } + if adopted.Phase != session.PhaseActive || adopted.EndedAt != nil { + t.Fatalf("target state Phase/EndedAt = %q/%v, want active/nil", adopted.Phase, adopted.EndedAt) + } + + sourceAfter, err := sourceStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if sourceAfter == nil { + t.Fatal("expected source session state to remain as a retired record") + } + if sourceAfter.Phase != session.PhaseEnded { + t.Fatalf("source Phase = %q, want ended", sourceAfter.Phase) + } + if sourceAfter.EndedAt == nil { + t.Fatal("source EndedAt = nil, want retirement timestamp") + } + if isAdoptableSourceSession(sourceAfter) { + t.Fatalf("source state remains adoptable after external adoption: %#v", sourceAfter) + } + + t.Chdir(sourceRepo) + sourceAgent := &mockLifecycleAgent{name: agent.AgentNameClaudeCode, agentType: agent.AgentTypeClaudeCode} + if err := handleLifecycleSessionStart(context.Background(), sourceAgent, &agent.Event{ + Type: agent.SessionStart, + SessionID: sessionID, + }); err != nil { + t.Fatalf("SessionStart in the adopted-away source repo should no-op without disrupting the hook, got: %v", err) + } + sourceAfterSessionStart, err := sourceStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if sourceAfterSessionStart == nil { + entries, readErr := os.ReadDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + if readErr != nil { + t.Fatalf("source state disappeared after SessionStart; read state dir: %v", readErr) + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + t.Fatalf("source state disappeared after SessionStart; state dir contains %v", names) + } + if sourceAfterSessionStart.Phase != session.PhaseEnded { + t.Fatalf("source Phase after SessionStart = %q, want ended", sourceAfterSessionStart.Phase) + } + if sourceAfterSessionStart.EndedAt == nil { + t.Fatal("source EndedAt after SessionStart = nil, want retirement timestamp") + } + + err = strategy.NewManualCommitStrategy().InitializeSession( + context.Background(), + sessionID, + agent.AgentTypeClaudeCode, + "", + "source prompt after adoption", + "", + ) + if err != nil { + t.Fatalf("InitializeSession in the adopted-away source repo should no-op without disrupting the hook, got: %v", err) + } + + sourceAfterTurnStart, err := sourceStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if sourceAfterTurnStart.Phase != session.PhaseEnded { + t.Fatalf("source Phase after rejected TurnStart = %q, want ended", sourceAfterTurnStart.Phase) + } + if sourceAfterTurnStart.EndedAt == nil { + t.Fatal("source EndedAt after rejected TurnStart = nil, want retirement timestamp") + } +} + func TestSessionAdopt_ClearsSourceOwner(t *testing.T) { sourceRepo := setupAdoptRepo(t) targetRepo := setupAdoptRepo(t) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index c6dc78d1ee..542c6e4295 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -2284,6 +2284,12 @@ func (s *ManualCommitStrategy) InitializeSession(ctx context.Context, sessionID if state.BaseCommit == "" { return errPartialState } + if state.AdoptedIntoWorktreePath != "" { + logging.Info(logging.WithComponent(ctx, "hooks"), "skipping adopted-away source session", + slog.String("session_id", sessionID), + slog.String("adopted_into_worktree", state.AdoptedIntoWorktreePath)) + return ErrMutationSkip + } if transErr := TransitionAndLog(ctx, state, session.EventTurnStart, session.TransitionContext{}, session.NoOpActionHandler{}); transErr != nil { logging.Warn(logging.WithComponent(ctx, "hooks"), "turn start transition failed", slog.String("session_id", sessionID), diff --git a/cmd/entire/cli/strategy/manual_commit_session.go b/cmd/entire/cli/strategy/manual_commit_session.go index f920f9bf96..fd87921610 100644 --- a/cmd/entire/cli/strategy/manual_commit_session.go +++ b/cmd/entire/cli/strategy/manual_commit_session.go @@ -82,6 +82,12 @@ func (s *ManualCommitStrategy) listAllSessionStates(ctx context.Context) ([]*Ses var states []*SessionState for _, sessionState := range sessionStates { state := sessionState + // Adopted-away source records are tombstones: keep them until normal stale + // expiry so old source hooks cannot recreate a second live state. + if state.AdoptedIntoWorktreePath != "" { + states = append(states, state) + continue + } // Skip and cleanup orphaned sessions whose shadow branch no longer exists. // Keep active sessions (shadow branch may not be created yet) and sessions From 9cab96ca748f706c41f0d82b5abb2d0c06a6c64e Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 30 Jun 2026 12:15:08 +0200 Subject: [PATCH 29/30] fix: canonicalize adopt session stores Entire-Checkpoint: 0ed46044b4c8 --- cmd/entire/cli/session_adopt.go | 6 +++++- cmd/entire/cli/session_adopt_test.go | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index 401ec10a61..aae950b73a 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -79,7 +79,7 @@ func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOpti if err != nil { return fmt.Errorf("open current session store: %w", err) } - sameSessionStore := sourceCommonDir == targetCommonDir + sameSessionStore := sameAdoptStore(sourceCommonDir, targetCommonDir) if sameSessionStore && sameAdoptPath(sourceWorktree, targetWorktree) { return errors.New("source and target are the same worktree; no session adoption is needed") } @@ -548,6 +548,10 @@ func sameAdoptPath(a, b string) bool { return canonicalAdoptPath(a) == canonicalAdoptPath(b) } +func sameAdoptStore(a, b string) bool { + return canonicalAdoptPath(a) == canonicalAdoptPath(b) +} + func canonicalAdoptPath(path string) string { if path == "" { return "" diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index 57962cb0b8..b9214eb543 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -1270,6 +1270,25 @@ printf '%s\n%s\n' "$FAKE_WORKTREE_ROOT" "$FAKE_GIT_COMMON_DIR" } } +func TestSameAdoptStoreCanonicalizesGitCommonDirSymlinks(t *testing.T) { + if runtime.GOOS == windowsGOOS { + t.Skip("symlink path canonicalization is POSIX-only in this test") + } + + realCommonDir := filepath.Join(t.TempDir(), "real-common.git") + if err := os.MkdirAll(realCommonDir, 0o750); err != nil { + t.Fatal(err) + } + commonDirLink := filepath.Join(t.TempDir(), "common-link.git") + if err := os.Symlink(realCommonDir, commonDirLink); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + if !sameAdoptStore(commonDirLink, realCommonDir) { + t.Fatalf("sameAdoptStore(%q, %q) = false, want true", commonDirLink, realCommonDir) + } +} + func TestSessionAdopt_SameStoreReloadsSourceStateUnderLock(t *testing.T) { sourceRepo := setupAdoptRepo(t) targetWorktree := filepath.Join(t.TempDir(), "target-worktree") From 3734030e214308e1b606e2428c5a8f3ec8dbdb40 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 30 Jun 2026 13:04:56 +0200 Subject: [PATCH 30/30] fix: roll back target on adopt retire failure Entire-Checkpoint: 0ed46044b4c8 --- cmd/entire/cli/session_adopt.go | 16 +++ cmd/entire/cli/session_adopt_test.go | 199 +++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index aae950b73a..b4a00a0f5d 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -174,6 +174,9 @@ func adoptFromExternalSessionStore( } retired := retireAdoptedSourceSession(sourceState, next) if err := sourceStore.Save(ctx, &retired); err != nil { + if rollbackErr := rollbackExternalAdoptTarget(ctx, targetStore, next.SessionID, existing); rollbackErr != nil { + return fmt.Errorf("retire source session state: %w; rollback adopted target session state: %w", err, rollbackErr) + } return fmt.Errorf("retire source session state: %w", err) } adopted = next @@ -186,6 +189,19 @@ func adoptFromExternalSessionStore( return adopted, filesTouched, nil } +func rollbackExternalAdoptTarget(ctx context.Context, targetStore *session.StateStore, sessionID string, previous *session.State) error { + if previous == nil { + if err := targetStore.Clear(ctx, sessionID); err != nil { + return fmt.Errorf("clear adopted target session state: %w", err) + } + return nil + } + if err := targetStore.Save(ctx, previous); err != nil { + return fmt.Errorf("restore previous target session state: %w", err) + } + return nil +} + func retireAdoptedSourceSession(source, target *session.State) session.State { now := time.Now() retired := cloneAdoptSourceState(source) diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index b9214eb543..ffaae93ecb 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -249,6 +249,205 @@ func TestSessionAdopt_ExternalStoreRetiresSourceSession(t *testing.T) { } } +func TestSessionAdopt_ExternalStoreRollsBackTargetWhenSourceRetireFails(t *testing.T) { + if runtime.GOOS == windowsGOOS { + t.Skip("uses POSIX directory permissions to force source save failure") + } + + sourceRepo := setupAdoptRepo(t) + targetRepo := setupAdoptRepo(t) + + sessionID := "test-adopt-retire-rollback" + lastInteraction := time.Now().Add(-1 * time.Minute) + sourceStateDir := filepath.Join(sourceRepo, ".git", session.SessionStateDirName) + sourceStore := session.NewStateStoreWithDir(sourceStateDir) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + AttributionBaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + LastPrompt: "move this session", + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + t.Chdir(targetRepo) + targetStore, err := session.NewStateStore(context.Background()) + if err != nil { + t.Fatal(err) + } + if err := targetStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-10 * time.Minute), + Phase: session.PhaseIdle, + BaseCommit: testutil.GetHeadHash(t, targetRepo), + AttributionBaseCommit: testutil.GetHeadHash(t, targetRepo), + WorktreePath: targetRepo, + LastPrompt: "preexisting target state", + }); err != nil { + t.Fatal(err) + } + + _, _, sourceCommonDir, err := stateStoreForWorktree(context.Background(), sourceRepo) + if err != nil { + t.Fatal(err) + } + _, _, targetCommonDir, err := stateStoreForWorktree(context.Background(), targetRepo) + if err != nil { + t.Fatal(err) + } + + info, err := os.Stat(sourceStateDir) + if err != nil { + t.Fatal(err) + } + restoreSourceStateDir := func() error { + return os.Chmod(sourceStateDir, info.Mode().Perm()) + } + if err := os.Chmod(sourceStateDir, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := restoreSourceStateDir(); err != nil { + t.Logf("restore source state dir permissions: %v", err) + } + }) + + _, _, err = adoptFromExternalSessionStore( + context.Background(), + sourceStore, + sourceRepo, + sourceCommonDir, + targetStore, + targetCommonDir, + sessionID, + adoptOptions{Force: true}, + ) + if err := restoreSourceStateDir(); err != nil { + t.Fatalf("restore source state dir permissions: %v", err) + } + if err == nil { + t.Fatal("adoptFromExternalSessionStore succeeded, want source-retire failure") + } + if !strings.Contains(err.Error(), "retire source session state") { + t.Fatalf("adoptFromExternalSessionStore error = %v, want source-retire failure", err) + } + + loadedTarget, err := targetStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if loadedTarget == nil { + t.Fatal("target rollback removed preexisting state, want restore") + } + if loadedTarget.LastPrompt != "preexisting target state" { + t.Fatalf("target LastPrompt after rollback = %q, want preexisting target state", loadedTarget.LastPrompt) + } + if loadedTarget.Phase != session.PhaseIdle { + t.Fatalf("target Phase after rollback = %q, want idle", loadedTarget.Phase) + } + + sourceAfter, err := sourceStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if sourceAfter == nil || sourceAfter.Phase != session.PhaseActive { + t.Fatalf("source state after failed adoption = %#v, want original active state", sourceAfter) + } +} + +func TestSessionAdopt_ExternalStoreClearsNewTargetWhenSourceRetireFails(t *testing.T) { + if runtime.GOOS == windowsGOOS { + t.Skip("uses POSIX directory permissions to force source save failure") + } + + sourceRepo := setupAdoptRepo(t) + targetRepo := setupAdoptRepo(t) + + sessionID := "test-adopt-retire-clear-target" + lastInteraction := time.Now().Add(-1 * time.Minute) + sourceStateDir := filepath.Join(sourceRepo, ".git", session.SessionStateDirName) + sourceStore := session.NewStateStoreWithDir(sourceStateDir) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + AttributionBaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + LastPrompt: "move this session", + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + t.Chdir(targetRepo) + targetStore, err := session.NewStateStore(context.Background()) + if err != nil { + t.Fatal(err) + } + _, _, sourceCommonDir, err := stateStoreForWorktree(context.Background(), sourceRepo) + if err != nil { + t.Fatal(err) + } + _, _, targetCommonDir, err := stateStoreForWorktree(context.Background(), targetRepo) + if err != nil { + t.Fatal(err) + } + + info, err := os.Stat(sourceStateDir) + if err != nil { + t.Fatal(err) + } + restoreSourceStateDir := func() error { + return os.Chmod(sourceStateDir, info.Mode().Perm()) + } + if err := os.Chmod(sourceStateDir, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := restoreSourceStateDir(); err != nil { + t.Logf("restore source state dir permissions: %v", err) + } + }) + + _, _, err = adoptFromExternalSessionStore( + context.Background(), + sourceStore, + sourceRepo, + sourceCommonDir, + targetStore, + targetCommonDir, + sessionID, + adoptOptions{Force: true}, + ) + if err := restoreSourceStateDir(); err != nil { + t.Fatalf("restore source state dir permissions: %v", err) + } + if err == nil { + t.Fatal("adoptFromExternalSessionStore succeeded, want source-retire failure") + } + if !strings.Contains(err.Error(), "retire source session state") { + t.Fatalf("adoptFromExternalSessionStore error = %v, want source-retire failure", err) + } + + loadedTarget, err := targetStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if loadedTarget != nil { + t.Fatalf("target state after rollback = %#v, want nil", loadedTarget) + } +} + func TestSessionAdopt_ClearsSourceOwner(t *testing.T) { sourceRepo := setupAdoptRepo(t) targetRepo := setupAdoptRepo(t)