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/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 3173be8bb3..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 @@ -384,8 +394,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 @@ -402,6 +411,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 new file mode 100644 index 0000000000..b4a00a0f5d --- /dev/null +++ b/cmd/entire/cli/session_adopt.go @@ -0,0 +1,596 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "maps" + "os/exec" + "path/filepath" + "slices" + "sort" + "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" + "github.com/entireio/cli/cmd/entire/cli/strategy" + "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 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. + +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 ../source-worktree --yes`, + 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, "confirm same-store adoption and replacement without prompting") + + 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, sourceCommonDir, err := stateStoreForWorktree(ctx, opts.FromWorktree) + if err != nil { + return err + } + + targetStore, targetWorktree, targetCommonDir, err := stateStoreForWorktree(ctx, ".") + if err != nil { + return fmt.Errorf("open current session store: %w", err) + } + sameSessionStore := sameAdoptStore(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) + if err != nil { + return err + } + if err := validateAdoptSourceTranscript(sourceState, sourceWorktree); err != nil { + return err + } + + var adopted *session.State + var filesTouched []string + if sameSessionStore { + adopted, filesTouched, err = adoptFromSameSessionStore(ctx, sourceWorktree, sourceState, opts) + } else { + adopted, filesTouched, err = adoptFromExternalSessionStore( + ctx, + sourceStore, + sourceWorktree, + sourceCommonDir, + targetStore, + targetCommonDir, + sourceState.SessionID, + 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, + 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 = "" + } + + var adopted *session.State + var filesTouched []string + 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) + } + 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) + } + 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 + filesTouched = touched + return nil + }) + if err != nil { + return nil, nil, fmt.Errorf("adopt external session state: %w", err) + } + 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) + 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) + } + + 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) + } + 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 { + 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 { + return nil, "", "", fmt.Errorf("resolve source worktree: %w", err) + } + + cmd := exec.CommandContext(ctx, "git", "-C", absWorktree, "rev-parse", "--show-toplevel", "--git-common-dir") + var stderr bytes.Buffer + cmd.Stderr = &stderr + output, err := cmd.Output() + if err != nil { + msg := strings.TrimSpace(stderr.String()) + 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, commonDir, nil +} + +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 { + 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 !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 + } + + 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) && sessionBelongsToSourceWorktree(state, sourceWorktree, sourceWorktreeID) { + 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 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 false +} + +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 + } + lastSeen := sessionLastSeen(state) + if lastSeen.IsZero() { + return false + } + 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 + } + 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 + } + untrackedFiles, err := strategy.CollectUntrackedFiles(ctx) + if err != nil { + untrackedFiles = nil + } + + now := time.Now() + 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 + // 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.RealignAttributionBase(head.Hash().String()) + adopted.WorktreePath = worktreeRoot + adopted.WorktreeID = worktreeID + adopted.AdoptedIntoWorktreePath = "" + adopted.AdoptedIntoWorktreeID = "" + 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 + // 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.ClearLegacyTranscriptOffsets() + adopted.TurnID = "" + adopted.TurnCheckpointIDs = nil + adopted.LastCheckpointID = id.EmptyCheckpointID + adopted.LastCheckpointCommitHash = "" + adopted.CheckpointTokenUsage = nil + + adopted.FullyCondensed = false + adopted.UntrackedFilesAtStart = untrackedFiles + adopted.PromptAttributions = nil + adopted.PendingPromptAttribution = nil + // 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 + // 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 +} + +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) +} + +func sameAdoptStore(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 { + 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..ffaae93ecb --- /dev/null +++ b/cmd/entire/cli/session_adopt_test.go @@ -0,0 +1,1732 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "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/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" +) + +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_MovesExternalSessionIntoCurrentWorktree(t *testing.T) { + sourceRepo := setupAdoptRepo(t) + targetRepo := setupAdoptRepo(t) + + sessionID := "test-adopt-session-001" + transcriptPath := claudeAdoptTranscriptPath(t, sourceRepo, sessionID) + 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"}, + AttachedManually: true, + }); 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("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) + } + 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()) + } + if !bytes.Contains(out.Bytes(), []byte("Review tracked files before committing")) { + t.Fatalf("output = %q, want tracked-file attribution warning", out.String()) + } +} + +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_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) + + 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) + + 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_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 := 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) + 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) + + sessionID := "test-adopt-trailer-001" + targetRelPath := "src/feature.go" + targetAbsPath := filepath.Join(targetRepo, targetRelPath) + + transcriptPath := claudeAdoptTranscriptPath(t, sourceRepo, sessionID) + 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_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 := claudeAdoptTranscriptPath(t, sourceRepo, sessionID) + 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) + + sessionID := "test-adopt-reset-window" + targetRelPath := "src/feature.go" + targetAbsPath := filepath.Join(targetRepo, targetRelPath) + + transcriptPath := claudeAdoptTranscriptPath(t, sourceRepo, sessionID) + 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, + SessionDurationMs: 120_000, + SessionTurnCount: 7, + ContextTokens: 42_000, + ContextWindowSize: 200_000, + CheckpointTranscriptStart: 2, + CheckpointTranscriptSize: 1234, + CondensedTranscriptLines: 2, + TranscriptLinesAtStart: 2, + TranscriptIdentifierAtStart: "source-assistant", + TurnID: "source-turn", + 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, + }); err != nil { + t.Fatal(err) + } + + 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 + 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 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) + } + 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()) + } + 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 { + 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_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_PreservesReviewAndInvestigateMetadata(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 != tc.kind { + t.Fatalf("Kind = %q, want %q", adopted.Kind, tc.kind) + } + if len(adopted.ReviewSkills) != 1 || adopted.ReviewSkills[0] != "/review" { + t.Fatalf("ReviewSkills = %v, want [/review]", adopted.ReviewSkills) + } + if adopted.ReviewPrompt != "review this branch" { + t.Fatalf("ReviewPrompt = %q, want review prompt", adopted.ReviewPrompt) + } + if adopted.InvestigateRunID != "abcdef012345" { + t.Fatalf("InvestigateRunID = %q, want source run ID", adopted.InvestigateRunID) + } + if adopted.InvestigateTopic != "Why is adoption misclassified?" { + t.Fatalf("InvestigateTopic = %q, want source topic", adopted.InvestigateTopic) + } + }) + } +} + +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) + + 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 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 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 == windowsGOOS { + 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 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 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") + 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") + 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" + 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, + 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") + testutil.GitAdd(t, targetWorktree, "feature.txt") + t.Chdir(targetWorktree) + + var out bytes.Buffer + 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) + 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.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.LastCheckpointID.IsEmpty() { + t.Fatalf("LastCheckpointID = %s, want empty target-local checkpoint ID", loaded.LastCheckpointID.String()) + } + if loaded.LastCheckpointCommitHash != "" { + t.Fatalf("LastCheckpointCommitHash = %q, want empty target-local commit hash", loaded.LastCheckpointCommitHash) + } + + 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) + } + 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 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 +} + +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() + + 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) + } +} diff --git a/cmd/entire/cli/sessions.go b/cmd/entire/cli/sessions.go index 3c4cd60b2c..54823cebba 100644 --- a/cmd/entire/cli/sessions.go +++ b/cmd/entire/cli/sessions.go @@ -168,6 +168,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: @@ -178,6 +179,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 { @@ -193,6 +195,7 @@ Examples: cmd.AddCommand(newStopCmd()) cmd.AddCommand(newSessionCurrentCmd()) cmd.AddCommand(newAttachCmd()) + cmd.AddCommand(newAdoptCmd()) cmd.AddCommand(newResumeCmd()) return cmd diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index e44a43e3d8..a4c13ee504 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -1558,6 +1558,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 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 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) 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/-`