diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index 548e73466..c3b6bf9be 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -253,6 +253,143 @@ func (w *Workspace) DestroyWorkspaceProject(ctx context.Context, info ports.Work return firstErr } +// DestroyWorkspaceProjectWorktree removes one repo worktree from a workspace +// project using that repo's canonical path. It preserves the same dirty-worktree +// refusal contract as Destroy. +func (w *Workspace) DestroyWorkspaceProjectWorktree(ctx context.Context, info ports.WorkspaceRepoInfo) error { + if info.RepoPath == "" { + return fmt.Errorf("gitworktree: missing repo path for worktree %q", info.Path) + } + if info.Path == "" { + return fmt.Errorf("%w: empty path", ErrUnsafePath) + } + repo, err := physicalAbs(info.RepoPath) + if err != nil { + return fmt.Errorf("gitworktree: repo path: %w", err) + } + path, err := w.validateManagedPath(info.Path) + if err != nil { + return err + } + _, removeErr := w.run(ctx, w.binary, worktreeRemoveArgs(repo, path)...) + if _, err := w.run(ctx, w.binary, worktreePruneArgs(repo)...); err != nil { + return fmt.Errorf("gitworktree: worktree prune: %w", err) + } + records, err := w.listRecords(ctx, repo) + if err != nil { + return err + } + if _, ok := findWorktree(records, path); ok { + if removeErr != nil { + dirty, statusErr := w.isDirty(ctx, path) + if statusErr == nil && dirty { + return fmt.Errorf("gitworktree: refusing to remove %q: %w (worktree remove: %w)", path, ports.ErrWorkspaceDirty, removeErr) + } + if statusErr != nil { + return fmt.Errorf("gitworktree: refusing to remove %q: path is still registered after git worktree prune (worktree remove: %w; dirty probe: %w)", path, removeErr, statusErr) + } + return fmt.Errorf("gitworktree: refusing to remove %q: path is still registered after git worktree prune (worktree remove: %w)", path, removeErr) + } + return fmt.Errorf("gitworktree: refusing to remove %q: path is still registered after git worktree prune", path) + } + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("gitworktree: remove unregistered path %q: %w", path, err) + } + return nil +} + +// ForceDestroyWorkspaceProjectWorktree force-removes one repo worktree after +// its work has been preserved. +func (w *Workspace) ForceDestroyWorkspaceProjectWorktree(ctx context.Context, info ports.WorkspaceRepoInfo) error { + if info.RepoPath == "" { + return fmt.Errorf("gitworktree: missing repo path for worktree %q", info.Path) + } + if info.Path == "" { + return fmt.Errorf("%w: empty path", ErrUnsafePath) + } + repo, err := physicalAbs(info.RepoPath) + if err != nil { + return fmt.Errorf("gitworktree: repo path: %w", err) + } + path, err := w.validateManagedPath(info.Path) + if err != nil { + return err + } + return w.forceDestroyPath(ctx, repo, path) +} + +// RestoreWorkspaceProjectWorktree re-attaches one repo worktree for a workspace +// project using the repo path captured from project registration. +func (w *Workspace) RestoreWorkspaceProjectWorktree(ctx context.Context, info ports.WorkspaceRepoInfo) (ports.WorkspaceRepoInfo, error) { + if info.RepoPath == "" { + return ports.WorkspaceRepoInfo{}, fmt.Errorf("gitworktree: missing repo path for worktree %q", info.Path) + } + if info.Path == "" { + return ports.WorkspaceRepoInfo{}, fmt.Errorf("%w: empty path", ErrUnsafePath) + } + if info.Branch == "" { + return ports.WorkspaceRepoInfo{}, errors.New("gitworktree: branch is required") + } + repo, err := physicalAbs(info.RepoPath) + if err != nil { + return ports.WorkspaceRepoInfo{}, fmt.Errorf("gitworktree: repo path: %w", err) + } + path, err := w.validateManagedPath(info.Path) + if err != nil { + return ports.WorkspaceRepoInfo{}, err + } + records, err := w.listRecords(ctx, repo) + if err != nil { + return ports.WorkspaceRepoInfo{}, err + } + out := info + if rec, ok := findWorktree(records, path); ok { + if rec.Branch != "" { + out.Branch = rec.Branch + } + out.Path = path + out.RepoPath = repo + return out, nil + } + if nonEmpty, err := pathExistsNonEmpty(path); err != nil { + return ports.WorkspaceRepoInfo{}, err + } else if nonEmpty { + if _, err := moveStrayPathAside(path); err != nil { + return ports.WorkspaceRepoInfo{}, err + } + } + if err := w.validateBranch(ctx, repo, info.Branch); err != nil { + return ports.WorkspaceRepoInfo{}, err + } + if err := w.addWorktree(ctx, repo, path, info.Branch, ""); err != nil { + return ports.WorkspaceRepoInfo{}, err + } + out.Path = path + out.RepoPath = repo + return out, nil +} + +// StashWorkspaceProjectWorktree captures uncommitted work for one workspace +// project repo worktree. +func (w *Workspace) StashWorkspaceProjectWorktree(ctx context.Context, info ports.WorkspaceRepoInfo) (string, error) { + return w.StashUncommitted(ctx, workspaceInfoFromRepoInfo(info)) +} + +// ApplyWorkspaceProjectPreserved replays a preserve ref into one workspace +// project repo worktree. +func (w *Workspace) ApplyWorkspaceProjectPreserved(ctx context.Context, info ports.WorkspaceRepoInfo, ref string) error { + return w.ApplyPreserved(ctx, workspaceInfoFromRepoInfo(info), ref) +} + +func workspaceInfoFromRepoInfo(info ports.WorkspaceRepoInfo) ports.WorkspaceInfo { + return ports.WorkspaceInfo{ + Path: info.Path, + Branch: info.Branch, + SessionID: info.SessionID, + ProjectID: info.ProjectID, + } +} + // Destroy removes the session's worktree and prunes it from the repo, refusing // (rather than force-deleting) if git still has the path registered afterwards. func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error { @@ -1010,6 +1147,25 @@ func pathExistsNonEmpty(path string) (bool, error) { return false, fmt.Errorf("gitworktree: inspect path %q: %w", path, err) } +func moveStrayPathAside(path string) (string, error) { + for i := 0; i < 100; i++ { + candidate := path + ".stray" + if i > 0 { + candidate = fmt.Sprintf("%s.stray-%d", path, i+1) + } + if _, err := os.Lstat(candidate); err == nil { + continue + } else if !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("gitworktree: inspect stray destination %q: %w", candidate, err) + } + if err := os.Rename(path, candidate); err != nil { + return "", fmt.Errorf("gitworktree: move stray path %q aside to %q: %w", path, candidate, err) + } + return candidate, nil + } + return "", fmt.Errorf("gitworktree: move stray path %q aside: no available destination", path) +} + func runCommand(ctx context.Context, binary string, args ...string) ([]byte, error) { cmd := aoprocess.CommandContext(ctx, binary, args...) out, err := cmd.CombinedOutput() diff --git a/backend/internal/adapters/workspace/gitworktree/workspace_test.go b/backend/internal/adapters/workspace/gitworktree/workspace_test.go index 1fa0f6116..d0cb4ed6e 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace_test.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace_test.go @@ -282,6 +282,63 @@ func TestRestoreRefusesNonEmptyUnregisteredPath(t *testing.T) { } } +func TestRestoreWorkspaceProjectWorktreeMovesStrayPathAside(t *testing.T) { + root := t.TempDir() + repo := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + path := filepath.Join(ws.managedRoot, "proj", "sess", "api") + if err := mkdirFile(path, "keep.txt"); err != nil { + t.Fatalf("seed path: %v", err) + } + var addPath string + ws.run = func(_ context.Context, _ string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + switch { + case strings.Contains(joined, "worktree list --porcelain"): + return []byte("worktree " + repo + "\nbranch refs/heads/main\n"), nil + case strings.Contains(joined, "check-ref-format"): + return nil, nil + case strings.Contains(joined, "rev-parse"): + return []byte("commit"), nil + case strings.Contains(joined, "worktree add"): + if len(args) >= 2 { + addPath = args[len(args)-2] + } + if addPath == "" { + t.Fatalf("could not find worktree add path in args: %v", args) + } + return nil, nil + default: + t.Fatalf("unexpected git invocation: %v", args) + return nil, nil + } + } + + info, err := ws.RestoreWorkspaceProjectWorktree(context.Background(), ports.WorkspaceRepoInfo{ + RepoName: "api", + RepoPath: repo, + Path: path, + Branch: "ao/proj-1", + SessionID: "proj-1", + ProjectID: "proj", + }) + if err != nil { + t.Fatalf("RestoreWorkspaceProjectWorktree: %v", err) + } + if info.Path != path || addPath != path { + t.Fatalf("restored path=%q addPath=%q, want %q", info.Path, addPath, path) + } + if _, err := os.Stat(filepath.Join(path+".stray", "keep.txt")); err != nil { + t.Fatalf("stray path was not preserved: %v", err) + } + if _, err := os.Stat(filepath.Join(path, "keep.txt")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("original path still has stray file: %v", err) + } +} + func TestDestroyRefusesStillRegisteredPathAndPreservesDirectory(t *testing.T) { root := t.TempDir() repo := t.TempDir() diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index 13d8d6900..d5635e287 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -148,6 +148,11 @@ type Workspace interface { type WorkspaceProject interface { CreateWorkspaceProject(ctx context.Context, cfg WorkspaceProjectConfig) (WorkspaceProjectInfo, error) DestroyWorkspaceProject(ctx context.Context, info WorkspaceProjectInfo) error + DestroyWorkspaceProjectWorktree(ctx context.Context, info WorkspaceRepoInfo) error + ForceDestroyWorkspaceProjectWorktree(ctx context.Context, info WorkspaceRepoInfo) error + RestoreWorkspaceProjectWorktree(ctx context.Context, info WorkspaceRepoInfo) (WorkspaceRepoInfo, error) + StashWorkspaceProjectWorktree(ctx context.Context, info WorkspaceRepoInfo) (ref string, err error) + ApplyWorkspaceProjectPreserved(ctx context.Context, info WorkspaceRepoInfo, ref string) error } // Workspace-level sentinels surfaced through Create/Restore/Destroy so callers diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index c0119587f..b32d61427 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -520,7 +520,18 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) { } } freed := false - if ws.Path != "" { + if rows, ok, rowErr := m.workspaceProjectRows(ctx, rec); rowErr != nil { + return false, fmt.Errorf("kill %s: workspace rows: %w", id, rowErr) + } else if ok { + cleaned, err := m.destroyWorkspaceProjectRows(ctx, rows) + if err != nil { + if errors.Is(err, ports.ErrWorkspaceDirty) { + return false, nil + } + return false, fmt.Errorf("kill %s: workspace: %w", id, err) + } + freed = cleaned + } else if ws.Path != "" { if err := m.workspace.Destroy(ctx, ws); err != nil { if errors.Is(err, ports.ErrWorkspaceDirty) { return false, nil @@ -650,7 +661,7 @@ func (m *Manager) SaveAndTeardownAll(ctx context.Context) error { if rec.Metadata.WorkspacePath == "" || rec.Metadata.Branch == "" { continue } - if err := m.saveAndTeardownOne(ctx, rec); err != nil { + if err := m.saveAndTeardownOne(ctx, rec, true); err != nil { m.logger.Error("save-teardown-all: session failed, skipping", "sessionID", rec.ID, "error", err) } } @@ -661,10 +672,15 @@ func (m *Manager) SaveAndTeardownAll(ctx context.Context) error { // session. The DB write (UpsertSessionWorktree) is committed before // ForceDestroy; if either capture or the DB write fails, ForceDestroy is // not called. -func (m *Manager) saveAndTeardownOne(ctx context.Context, rec domain.SessionRecord) error { - ws := workspaceInfo(rec) +func (m *Manager) saveAndTeardownOne(ctx context.Context, rec domain.SessionRecord, destroyRuntime bool) error { + if rows, ok, err := m.workspaceProjectRows(ctx, rec); err != nil { + return fmt.Errorf("save %s: workspace rows: %w", rec.ID, err) + } else if ok { + return m.saveAndTeardownWorkspaceProject(ctx, rec, rows, destroyRuntime) + } // 1. Capture uncommitted work (ref may be "" for clean worktrees). + ws := workspaceInfo(rec) ref, err := m.workspace.StashUncommitted(ctx, ws) if err != nil { return fmt.Errorf("save %s: stash: %w", rec.ID, err) @@ -679,6 +695,7 @@ func (m *Manager) saveAndTeardownOne(ctx context.Context, rec domain.SessionReco Branch: rec.Metadata.Branch, WorktreePath: rec.Metadata.WorkspacePath, PreservedRef: ref, + State: "removed", } if err := m.store.UpsertSessionWorktree(ctx, row); err != nil { return fmt.Errorf("save %s: upsert worktree row: %w", rec.ID, err) @@ -691,7 +708,7 @@ func (m *Manager) saveAndTeardownOne(ctx context.Context, rec domain.SessionReco // 4. Runtime teardown (best-effort; same pattern as Kill). handle := runtimeHandle(rec.Metadata) - if handle.ID != "" { + if destroyRuntime && handle.ID != "" { if err := m.runtime.Destroy(ctx, handle); err != nil { m.logger.Warn("save-teardown-all: runtime destroy failed", "sessionID", rec.ID, "error", err) } @@ -733,38 +750,11 @@ func (m *Manager) reconcileLive(ctx context.Context, rec domain.SessionRecord) e return nil // adopt: the session survived the crash. } } - // Runtime is gone: capture uncommitted work first. - ws := workspaceInfo(rec) - ref, err := m.workspace.StashUncommitted(ctx, ws) - if err != nil { - // Could not capture work: do NOT write a restore marker or tear down the - // worktree (that would risk losing un-preserved work). Mark terminated so - // a dead session is not left looking live; the worktree stays put. - m.logger.Warn("reconcile: stash uncommitted failed; terminating without restore marker", "sessionID", rec.ID, "error", err) + if err := m.saveAndTeardownOne(ctx, rec, false); err != nil { + m.logger.Warn("reconcile: save-and-teardown failed; terminating without restore marker", "sessionID", rec.ID, "error", err) if mErr := m.lcm.MarkTerminated(ctx, rec.ID); mErr != nil { return fmt.Errorf("reconcile %s: mark terminated: %w", rec.ID, mErr) } - return nil - } - // Work captured. Record the shutdown-saved marker BEFORE tearing down the - // worktree, mirroring saveAndTeardownOne, so RestoreAll relaunches it. - row := domain.SessionWorktreeRecord{ - SessionID: rec.ID, - RepoName: domain.RootWorkspaceRepoName, - Branch: rec.Metadata.Branch, - WorktreePath: rec.Metadata.WorkspacePath, - PreservedRef: ref, - } - if err := m.store.UpsertSessionWorktree(ctx, row); err != nil { - return fmt.Errorf("reconcile %s: upsert worktree marker: %w", rec.ID, err) - } - if err := m.lcm.MarkTerminated(ctx, rec.ID); err != nil { - return fmt.Errorf("reconcile %s: mark terminated: %w", rec.ID, err) - } - // Remove the worktree (work is captured in the ref): RestoreAll re-creates it - // clean and replays the ref. The dead runtime needs no Destroy. - if err := m.workspace.ForceDestroy(ctx, ws); err != nil { - m.logger.Warn("reconcile: force destroy failed after marker", "sessionID", rec.ID, "error", err) } return nil } @@ -859,14 +849,9 @@ func (m *Manager) RestoreAll(ctx context.Context) error { // No marker: this session was killed by the user before shutdown. continue } - - // Collect the preserve ref (may be "" for clean worktrees). - var preserveRef string - for _, r := range rows { - if r.PreservedRef != "" { - preserveRef = r.PreservedRef - break - } + rows = restorableWorktreeRows(rows) + if len(rows) == 0 { + continue } // Step 1: ensure the worktree exists. workspace.Restore re-creates it @@ -876,28 +861,64 @@ func (m *Manager) RestoreAll(ctx context.Context) error { m.logger.Error("restore-all: load project failed", "sessionID", rec.ID, "error", err) continue } - ws, err := m.workspace.Restore(ctx, ports.WorkspaceConfig{ - ProjectID: rec.ProjectID, - SessionID: rec.ID, - Kind: rec.Kind, - SessionPrefix: sessionPrefix(project), - Branch: rec.Metadata.Branch, - }) - if err != nil { - m.logger.Error("restore-all: workspace restore failed", "sessionID", rec.ID, "error", err) + var ws ports.WorkspaceInfo + if project.Kind.WithDefault() == domain.ProjectKindWorkspace && len(rows) > 1 { + projectRows, rowErr := m.sessionWorktreeRowsToRepoInfos(ctx, project, rec, rows) + if rowErr != nil { + m.logger.Error("restore-all: workspace rows failed", "sessionID", rec.ID, "error", rowErr) + continue + } + root, restoreErr := m.restoreWorkspaceProjectRows(ctx, projectRows) + if restoreErr != nil { + m.logger.Error("restore-all: workspace project restore failed", "sessionID", rec.ID, "error", restoreErr) + continue + } + ws = workspaceInfoFromRepoInfo(root) + } else { + var restoreErr error + ws, restoreErr = m.workspace.Restore(ctx, ports.WorkspaceConfig{ + ProjectID: rec.ProjectID, + SessionID: rec.ID, + Kind: rec.Kind, + SessionPrefix: sessionPrefix(project), + Branch: rec.Metadata.Branch, + }) + if restoreErr != nil { + m.logger.Error("restore-all: workspace restore failed", "sessionID", rec.ID, "error", restoreErr) + continue + } + } + if ws.Path == "" { + m.logger.Error("restore-all: workspace restore failed", "sessionID", rec.ID, "error", "empty restored root path") continue } // Step 2: replay preserve ref when one was recorded. - if preserveRef != "" { - if applyErr := m.workspace.ApplyPreserved(ctx, ws, preserveRef); applyErr != nil { - if errors.Is(applyErr, ports.ErrPreservedConflict) { - m.logger.Warn("restore-all: apply preserved produced conflicts; agent relaunched with conflict markers in place", - "sessionID", rec.ID, "ref", preserveRef, "error", applyErr) - } else { - m.logger.Error("restore-all: apply preserved failed", "sessionID", rec.ID, "error", applyErr) + if project.Kind.WithDefault() == domain.ProjectKindWorkspace && len(rows) > 1 { + projectRows, rowErr := m.sessionWorktreeRowsToRepoInfos(ctx, project, rec, rows) + if rowErr != nil { + m.logger.Error("restore-all: workspace rows failed", "sessionID", rec.ID, "error", rowErr) + continue + } + m.applyWorkspaceProjectPreserved(ctx, projectRows) + } else { + var preserveRef string + for _, r := range rows { + if r.PreservedRef != "" { + preserveRef = r.PreservedRef + break + } + } + if preserveRef != "" { + if applyErr := m.workspace.ApplyPreserved(ctx, ws, preserveRef); applyErr != nil { + if errors.Is(applyErr, ports.ErrPreservedConflict) { + m.logger.Warn("restore-all: apply preserved produced conflicts; agent relaunched with conflict markers in place", + "sessionID", rec.ID, "ref", preserveRef, "error", applyErr) + } else { + m.logger.Error("restore-all: apply preserved failed", "sessionID", rec.ID, "error", applyErr) + } + // Continue: always relaunch even on conflict (never delete the ref here). } - // Continue: always relaunch even on conflict (never delete the ref here). } } @@ -911,6 +932,10 @@ func (m *Manager) RestoreAll(ctx context.Context) error { } else { m.logger.Error("restore-all: relaunch failed", "sessionID", rec.ID, "error", err) } + continue + } + if err := m.markSessionWorktreesActive(ctx, rows); err != nil { + m.logger.Warn("restore-all: marking worktrees active failed", "sessionID", rec.ID, "error", err) } // One-shot: drop the consumed marker so it never outlives one restart @@ -922,6 +947,232 @@ func (m *Manager) RestoreAll(ctx context.Context) error { return nil } +func restorableWorktreeRows(rows []domain.SessionWorktreeRecord) []domain.SessionWorktreeRecord { + out := make([]domain.SessionWorktreeRecord, 0, len(rows)) + for _, row := range rows { + if row.State == "removed" { + out = append(out, row) + } + } + return out +} + +func (m *Manager) markSessionWorktreesActive(ctx context.Context, rows []domain.SessionWorktreeRecord) error { + for _, row := range rows { + row.State = "active" + row.PreservedRef = "" + if err := m.store.UpsertSessionWorktree(ctx, row); err != nil { + return err + } + } + return nil +} + +func (m *Manager) workspaceProjectRows(ctx context.Context, rec domain.SessionRecord) ([]ports.WorkspaceRepoInfo, bool, error) { + rows, err := m.store.ListSessionWorktrees(ctx, rec.ID) + if err != nil { + return nil, false, err + } + if len(rows) <= 1 { + return nil, false, nil + } + project, err := m.loadProject(ctx, rec.ProjectID) + if err != nil { + return nil, false, err + } + if project.Kind.WithDefault() != domain.ProjectKindWorkspace { + return nil, false, nil + } + infos, err := m.sessionWorktreeRowsToRepoInfos(ctx, project, rec, rows) + if err != nil { + return nil, false, err + } + return infos, true, nil +} + +func (m *Manager) sessionWorktreeRowsToRepoInfos(ctx context.Context, project domain.ProjectRecord, rec domain.SessionRecord, rows []domain.SessionWorktreeRecord) ([]ports.WorkspaceRepoInfo, error) { + childRepos, err := m.store.ListWorkspaceRepos(ctx, project.ID) + if err != nil { + return nil, err + } + repoPaths := map[string]string{domain.RootWorkspaceRepoName: project.Path} + relPaths := map[string]string{} + for _, repo := range childRepos { + repoPaths[repo.Name] = filepath.Join(project.Path, filepath.FromSlash(repo.RelativePath)) + relPaths[repo.Name] = repo.RelativePath + } + out := make([]ports.WorkspaceRepoInfo, 0, len(rows)) + for _, row := range rows { + repoPath := repoPaths[row.RepoName] + if repoPath == "" { + return nil, fmt.Errorf("repo %q is not registered for workspace project %s", row.RepoName, project.ID) + } + out = append(out, ports.WorkspaceRepoInfo{ + RepoName: row.RepoName, + RepoPath: repoPath, + Path: row.WorktreePath, + Branch: firstNonEmptyString(row.Branch, rec.Metadata.Branch), + BaseSHA: row.BaseSHA, + SessionID: rec.ID, + ProjectID: rec.ProjectID, + RelativePath: relPaths[row.RepoName], + }) + } + return out, nil +} + +func (m *Manager) saveAndTeardownWorkspaceProject(ctx context.Context, rec domain.SessionRecord, rows []ports.WorkspaceRepoInfo, destroyRuntime bool) error { + adapter, ok := m.workspace.(ports.WorkspaceProject) + if !ok { + return errors.New("workspace project lifecycle is not supported by workspace adapter") + } + for _, row := range rows { + ref, err := adapter.StashWorkspaceProjectWorktree(ctx, row) + if err != nil { + return fmt.Errorf("save %s repo %s: stash: %w", rec.ID, row.RepoName, err) + } + if err := m.store.UpsertSessionWorktree(ctx, domain.SessionWorktreeRecord{ + SessionID: rec.ID, + RepoName: row.RepoName, + Branch: row.Branch, + BaseSHA: row.BaseSHA, + WorktreePath: row.Path, + PreservedRef: ref, + State: "removed", + }); err != nil { + return fmt.Errorf("save %s repo %s: upsert worktree row: %w", rec.ID, row.RepoName, err) + } + } + if err := m.lcm.MarkTerminated(ctx, rec.ID); err != nil { + return fmt.Errorf("save %s: mark terminated: %w", rec.ID, err) + } + handle := runtimeHandle(rec.Metadata) + if destroyRuntime && handle.ID != "" { + if err := m.runtime.Destroy(ctx, handle); err != nil { + m.logger.Warn("save-teardown-all: runtime destroy failed", "sessionID", rec.ID, "error", err) + } + } + for i := len(rows) - 1; i >= 0; i-- { + if err := adapter.ForceDestroyWorkspaceProjectWorktree(ctx, rows[i]); err != nil { + m.logger.Warn("save-teardown-all: force destroy failed", "sessionID", rec.ID, "repo", rows[i].RepoName, "error", err) + } + } + return nil +} + +func (m *Manager) destroyWorkspaceProjectRows(ctx context.Context, rows []ports.WorkspaceRepoInfo) (bool, error) { + adapter, ok := m.workspace.(ports.WorkspaceProject) + if !ok { + return false, errors.New("workspace project lifecycle is not supported by workspace adapter") + } + cleaned := false + var firstErr error + for i := len(rows) - 1; i >= 0; i-- { + if rows[i].Path == "" { + continue + } + if err := adapter.DestroyWorkspaceProjectWorktree(ctx, rows[i]); err != nil { + preservedRef := "" + if errors.Is(err, ports.ErrWorkspaceDirty) { + ref, preserveErr := adapter.StashWorkspaceProjectWorktree(ctx, rows[i]) + if preserveErr == nil { + preservedRef = ref + if stateErr := m.upsertWorkspaceProjectRowState(ctx, rows[i], "unavailable", ref); stateErr != nil { + preserveErr = stateErr + } + } + if preserveErr == nil { + forceErr := adapter.ForceDestroyWorkspaceProjectWorktree(ctx, rows[i]) + if forceErr == nil { + cleaned = true + continue + } + err = forceErr + } else { + err = preserveErr + } + } + if stateErr := m.upsertWorkspaceProjectRowState(ctx, rows[i], "retry_remove", preservedRef); stateErr != nil && firstErr == nil { + firstErr = stateErr + } + if firstErr == nil { + firstErr = err + } + continue + } + if err := m.upsertWorkspaceProjectRowState(ctx, rows[i], "unavailable", ""); err != nil && firstErr == nil { + firstErr = err + } + cleaned = true + } + return cleaned, firstErr +} + +func (m *Manager) upsertWorkspaceProjectRowState(ctx context.Context, row ports.WorkspaceRepoInfo, state, preservedRef string) error { + return m.store.UpsertSessionWorktree(ctx, domain.SessionWorktreeRecord{ + SessionID: row.SessionID, + RepoName: row.RepoName, + Branch: row.Branch, + BaseSHA: row.BaseSHA, + WorktreePath: row.Path, + PreservedRef: preservedRef, + State: state, + }) +} + +func (m *Manager) restoreWorkspaceProjectRows(ctx context.Context, rows []ports.WorkspaceRepoInfo) (ports.WorkspaceRepoInfo, error) { + adapter, ok := m.workspace.(ports.WorkspaceProject) + if !ok { + return ports.WorkspaceRepoInfo{}, errors.New("workspace project lifecycle is not supported by workspace adapter") + } + var root ports.WorkspaceRepoInfo + for _, row := range rows { + restored, err := adapter.RestoreWorkspaceProjectWorktree(ctx, row) + if err != nil { + return ports.WorkspaceRepoInfo{}, fmt.Errorf("repo %s: %w", row.RepoName, err) + } + if restored.RepoName == domain.RootWorkspaceRepoName { + root = restored + } + } + if root.Path == "" { + return ports.WorkspaceRepoInfo{}, errors.New("workspace project root worktree row missing") + } + return root, nil +} + +func (m *Manager) applyWorkspaceProjectPreserved(ctx context.Context, rows []ports.WorkspaceRepoInfo) { + adapter, ok := m.workspace.(ports.WorkspaceProject) + if !ok { + return + } + for _, row := range rows { + var preserveRef string + sessionRows, err := m.store.ListSessionWorktrees(ctx, row.SessionID) + if err != nil { + m.logger.Error("restore-all: list worktrees failed", "sessionID", row.SessionID, "error", err) + continue + } + for _, sessionRow := range sessionRows { + if sessionRow.RepoName == row.RepoName { + preserveRef = sessionRow.PreservedRef + break + } + } + if preserveRef == "" { + continue + } + if applyErr := adapter.ApplyWorkspaceProjectPreserved(ctx, row, preserveRef); applyErr != nil { + if errors.Is(applyErr, ports.ErrPreservedConflict) { + m.logger.Warn("restore-all: apply preserved produced conflicts; agent relaunched with conflict markers in place", + "sessionID", row.SessionID, "repo", row.RepoName, "ref", preserveRef, "error", applyErr) + } else { + m.logger.Error("restore-all: apply preserved failed", "sessionID", row.SessionID, "repo", row.RepoName, "error", applyErr) + } + } + } +} + // Send delivers a message to a running session's agent via the messenger. func (m *Manager) Send(ctx context.Context, id domain.SessionID, message string) error { if err := m.messenger.Send(ctx, id, message); err != nil { @@ -963,7 +1214,19 @@ func (m *Manager) Cleanup(ctx context.Context, project domain.ProjectID) (Cleanu if h := runtimeHandle(rec.Metadata); h.ID != "" { _ = m.runtime.Destroy(ctx, h) // best effort; usually already gone } - if err := m.workspace.Destroy(ctx, ws); err != nil { + if rows, ok, rowErr := m.workspaceProjectRows(ctx, rec); rowErr != nil { + m.logger.Warn("cleanup: workspace rows failed", "sessionID", rec.ID, "error", rowErr) + result.Skipped = append(result.Skipped, CleanupSkip{SessionID: rec.ID, Reason: "workspace teardown failed"}) + continue + } else if ok { + if _, err := m.destroyWorkspaceProjectRows(ctx, rows); err != nil { + if !errors.Is(err, ports.ErrWorkspaceDirty) { + m.logger.Warn("cleanup: workspace teardown failed", "sessionID", rec.ID, "path", ws.Path, "error", err) + } + result.Skipped = append(result.Skipped, CleanupSkip{SessionID: rec.ID, Reason: cleanupSkipReason(err)}) + continue + } + } else if err := m.workspace.Destroy(ctx, ws); err != nil { if !errors.Is(err, ports.ErrWorkspaceDirty) { // The public reason stays a fixed string (the raw error carries // internal filesystem paths); the full cause lands here. @@ -1452,3 +1715,21 @@ func workspaceInfo(rec domain.SessionRecord) ports.WorkspaceInfo { ProjectID: rec.ProjectID, } } + +func workspaceInfoFromRepoInfo(info ports.WorkspaceRepoInfo) ports.WorkspaceInfo { + return ports.WorkspaceInfo{ + Path: info.Path, + Branch: info.Branch, + SessionID: info.SessionID, + ProjectID: info.ProjectID, + } +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index cb545f916..40bcf5493 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -341,6 +341,46 @@ func (w *fakeWorkspace) DestroyWorkspaceProject(context.Context, ports.Workspace w.projectDestroyed++ return w.destroyErr } +func (w *fakeWorkspace) DestroyWorkspaceProjectWorktree(_ context.Context, info ports.WorkspaceRepoInfo) error { + entry := "DestroyWorkspaceProjectWorktree:" + info.RepoName + w.calls = append(w.calls, entry) + if w.sharedLog != nil { + *w.sharedLog = append(*w.sharedLog, entry) + } + return w.destroyErr +} +func (w *fakeWorkspace) ForceDestroyWorkspaceProjectWorktree(_ context.Context, info ports.WorkspaceRepoInfo) error { + entry := "ForceDestroyWorkspaceProjectWorktree:" + info.RepoName + w.calls = append(w.calls, entry) + if w.sharedLog != nil { + *w.sharedLog = append(*w.sharedLog, entry) + } + return w.forceDestroyErr +} +func (w *fakeWorkspace) RestoreWorkspaceProjectWorktree(_ context.Context, info ports.WorkspaceRepoInfo) (ports.WorkspaceRepoInfo, error) { + entry := "RestoreWorkspaceProjectWorktree:" + info.RepoName + w.calls = append(w.calls, entry) + return info, nil +} +func (w *fakeWorkspace) StashWorkspaceProjectWorktree(_ context.Context, info ports.WorkspaceRepoInfo) (string, error) { + w.stashCalls++ + entry := "StashWorkspaceProjectWorktree:" + info.RepoName + w.calls = append(w.calls, entry) + if w.sharedLog != nil { + *w.sharedLog = append(*w.sharedLog, entry) + } + if w.stashErr != nil { + return "", w.stashErr + } + if w.stashRef == "" { + return "", nil + } + return w.stashRef + "/" + info.RepoName, nil +} +func (w *fakeWorkspace) ApplyWorkspaceProjectPreserved(_ context.Context, info ports.WorkspaceRepoInfo, ref string) error { + w.calls = append(w.calls, "ApplyWorkspaceProjectPreserved:"+info.RepoName+":"+ref) + return w.applyErr +} func (w *fakeWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { return w.Create(ctx, cfg) } @@ -773,6 +813,80 @@ func TestKill_DeletesRestoreMarker(t *testing.T) { t.Fatalf("kill must delete the restore marker, got %d rows", len(rows)) } } + +func TestKill_WorkspaceProjectDestroysChildrenBeforeRoot(t *testing.T) { + m, st, rt, ws := newManager() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Path: "/repo/mer", Kind: domain.ProjectKindWorkspace, Config: testRoleAgents()} + st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{{Name: "api", RelativePath: "api"}} + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1", RuntimeHandleID: "h1"}, + Activity: domain.Activity{State: domain.ActivityActive}, + } + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, Branch: "ao/mer-1", WorktreePath: "/ws/mer-1"}, + {SessionID: "mer-1", RepoName: "api", Branch: "ao/mer-1", WorktreePath: "/ws/mer-1/api"}, + } + + freed, err := m.Kill(ctx, "mer-1") + if err != nil || !freed { + t.Fatalf("freed=%v err=%v", freed, err) + } + if rt.destroyed != 1 { + t.Fatalf("runtime destroy calls = %d, want 1", rt.destroyed) + } + want := []string{"DestroyWorkspaceProjectWorktree:api", "DestroyWorkspaceProjectWorktree:__root__"} + if got := ws.calls; strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("destroy order = %v, want %v", got, want) + } +} + +func TestKill_WorkspaceProjectDirtyRowsArePreservedAndForceRemoved(t *testing.T) { + m, st, _, ws := newManager() + ws.destroyErr = fmt.Errorf("dirty: %w", ports.ErrWorkspaceDirty) + ws.stashRef = "refs/ao/preserved/mer-1" + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Path: "/repo/mer", Kind: domain.ProjectKindWorkspace, Config: testRoleAgents()} + st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{{Name: "api", RelativePath: "api"}} + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1", RuntimeHandleID: "h1"}, + Activity: domain.Activity{State: domain.ActivityActive}, + } + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, Branch: "ao/mer-1", WorktreePath: "/ws/mer-1"}, + {SessionID: "mer-1", RepoName: "api", Branch: "ao/mer-1", WorktreePath: "/ws/mer-1/api"}, + } + + freed, err := m.Kill(ctx, "mer-1") + if err != nil || !freed { + t.Fatalf("freed=%v err=%v, want dirty rows preserved and removed", freed, err) + } + want := []string{ + "DestroyWorkspaceProjectWorktree:api", + "StashWorkspaceProjectWorktree:api", + "ForceDestroyWorkspaceProjectWorktree:api", + "DestroyWorkspaceProjectWorktree:__root__", + "StashWorkspaceProjectWorktree:__root__", + "ForceDestroyWorkspaceProjectWorktree:__root__", + } + if got := ws.calls; strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("calls = %v, want %v", got, want) + } + refs := map[string]string{} + states := map[string]string{} + for _, row := range st.worktrees["mer-1"] { + refs[row.RepoName] = row.PreservedRef + states[row.RepoName] = row.State + } + if refs["api"] != "refs/ao/preserved/mer-1/api" || refs[domain.RootWorkspaceRepoName] != "refs/ao/preserved/mer-1/__root__" { + t.Fatalf("preserved refs = %v", refs) + } + if states["api"] != "unavailable" || states[domain.RootWorkspaceRepoName] != "unavailable" { + t.Fatalf("states = %v, want unavailable rows so RestoreAll does not resurrect killed session", states) + } +} func TestRestore_ReopensTerminal(t *testing.T) { m, st, rt, _ := newManager() seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"}) @@ -866,6 +980,87 @@ func TestCleanup_ReportsSkippedWorkspaces(t *testing.T) { } } +func TestCleanup_WorkspaceProjectDestroysChildrenBeforeRoot(t *testing.T) { + m, st, _, ws := newManager() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Path: "/repo/mer", Kind: domain.ProjectKindWorkspace, Config: testRoleAgents()} + st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{{Name: "api", RelativePath: "api"}} + seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1"}) + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, Branch: "ao/mer-1", WorktreePath: "/ws/mer-1"}, + {SessionID: "mer-1", RepoName: "api", Branch: "ao/mer-1", WorktreePath: "/ws/mer-1/api"}, + } + + res, err := m.Cleanup(ctx, "mer") + if err != nil { + t.Fatal(err) + } + if len(res.Cleaned) != 1 || res.Cleaned[0] != "mer-1" { + t.Fatalf("cleaned = %v, want mer-1", res.Cleaned) + } + want := []string{"DestroyWorkspaceProjectWorktree:api", "DestroyWorkspaceProjectWorktree:__root__"} + if got := ws.calls; strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("destroy order = %v, want %v", got, want) + } +} + +func TestCleanup_WorkspaceProjectMarksRetryRemoveAfterTeardownFailure(t *testing.T) { + m, st, _, ws := newManager() + ws.destroyErr = errors.New("locked") + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Path: "/repo/mer", Kind: domain.ProjectKindWorkspace, Config: testRoleAgents()} + st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{{Name: "api", RelativePath: "api"}} + seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1"}) + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, Branch: "ao/mer-1", WorktreePath: "/ws/mer-1"}, + {SessionID: "mer-1", RepoName: "api", Branch: "ao/mer-1", WorktreePath: "/ws/mer-1/api"}, + } + + res, err := m.Cleanup(ctx, "mer") + if err != nil { + t.Fatal(err) + } + if len(res.Cleaned) != 0 || len(res.Skipped) != 1 { + t.Fatalf("cleanup result = %+v, want one skipped session", res) + } + states := map[string]string{} + for _, row := range st.worktrees["mer-1"] { + states[row.RepoName] = row.State + } + if states["api"] != "retry_remove" || states[domain.RootWorkspaceRepoName] != "retry_remove" { + t.Fatalf("states = %v, want retry_remove rows", states) + } +} + +func TestCleanup_WorkspaceProjectRetryRemoveKeepsPreservedRef(t *testing.T) { + m, st, _, ws := newManager() + ws.destroyErr = fmt.Errorf("dirty: %w", ports.ErrWorkspaceDirty) + ws.forceDestroyErr = errors.New("still locked") + ws.stashRef = "refs/ao/preserved/mer-1" + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Path: "/repo/mer", Kind: domain.ProjectKindWorkspace, Config: testRoleAgents()} + st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{{Name: "api", RelativePath: "api"}} + seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1"}) + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, Branch: "ao/mer-1", WorktreePath: "/ws/mer-1"}, + {SessionID: "mer-1", RepoName: "api", Branch: "ao/mer-1", WorktreePath: "/ws/mer-1/api"}, + } + + res, err := m.Cleanup(ctx, "mer") + if err != nil { + t.Fatal(err) + } + if len(res.Skipped) != 1 { + t.Fatalf("cleanup result = %+v, want one skipped session", res) + } + refs := map[string]string{} + states := map[string]string{} + for _, row := range st.worktrees["mer-1"] { + refs[row.RepoName] = row.PreservedRef + states[row.RepoName] = row.State + } + if states["api"] != "retry_remove" || refs["api"] != "refs/ao/preserved/mer-1/api" { + t.Fatalf("api state/ref = %q/%q, want retry_remove with preserved ref", states["api"], refs["api"]) + } +} + func TestSpawn_DefaultsBranchFromSessionID(t *testing.T) { m, st, _, _ := newManager() s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) @@ -1776,6 +1971,44 @@ func TestSaveAndTeardownAll_NoKindFilter(t *testing.T) { } } +func TestSaveAndTeardownAll_WorkspaceProjectPreservesEachRepoAndRemovesChildrenFirst(t *testing.T) { + m, st, _, ws := newLifecycleManager() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Path: "/repo/mer", Kind: domain.ProjectKindWorkspace, Config: testRoleAgents()} + st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{{Name: "api", RelativePath: "api"}} + ws.stashRef = "refs/ao/preserved/mer-1" + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Kind: domain.KindWorker, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1", RuntimeHandleID: "h1"}, + Activity: domain.Activity{State: domain.ActivityActive}, + } + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, Branch: "ao/mer-1", WorktreePath: "/ws/mer-1", BaseSHA: "root-base"}, + {SessionID: "mer-1", RepoName: "api", Branch: "ao/mer-1", WorktreePath: "/ws/mer-1/api", BaseSHA: "api-base"}, + } + + if err := m.SaveAndTeardownAll(ctx); err != nil { + t.Fatalf("SaveAndTeardownAll err = %v", err) + } + rows := st.worktrees["mer-1"] + if len(rows) != 2 { + t.Fatalf("worktree rows = %v, want 2", rows) + } + refs := map[string]string{} + for _, row := range rows { + refs[row.RepoName] = row.PreservedRef + } + if refs[domain.RootWorkspaceRepoName] != "refs/ao/preserved/mer-1/__root__" || refs["api"] != "refs/ao/preserved/mer-1/api" { + t.Fatalf("preserved refs = %v", refs) + } + wantSuffix := []string{"ForceDestroyWorkspaceProjectWorktree:api", "ForceDestroyWorkspaceProjectWorktree:__root__"} + gotSuffix := ws.calls[len(ws.calls)-2:] + if strings.Join(gotSuffix, ",") != strings.Join(wantSuffix, ",") { + t.Fatalf("force destroy suffix = %v, want %v; all calls %v", gotSuffix, wantSuffix, ws.calls) + } +} + // TestRestoreAll_RestoresBothWorkerAndOrchestrator verifies (b): RestoreAll // restores both a worker and an orchestrator session saved by SaveAndTeardownAll. func TestRestoreAll_RestoresBothWorkerAndOrchestrator(t *testing.T) { @@ -1802,8 +2035,8 @@ func TestRestoreAll_RestoresBothWorkerAndOrchestrator(t *testing.T) { Activity: domain.Activity{State: domain.ActivityExited}, } // Write the shutdown-saved marker rows. - st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__", PreservedRef: ""}} - st.worktrees["mer-2"] = []domain.SessionWorktreeRecord{{SessionID: "mer-2", RepoName: "__root__", PreservedRef: ""}} + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__", PreservedRef: "", State: "removed"}} + st.worktrees["mer-2"] = []domain.SessionWorktreeRecord{{SessionID: "mer-2", RepoName: "__root__", PreservedRef: "", State: "removed"}} if err := m.RestoreAll(ctx); err != nil { t.Fatalf("RestoreAll err = %v", err) @@ -1926,6 +2159,32 @@ func TestRestoreAll_KilledSessionNotResurrectedOnSecondBoot(t *testing.T) { } } +func TestRestoreAll_SkipsActiveWorkspaceProjectRowsFromUserKilledSession(t *testing.T) { + m, st, rt, _ := newLifecycleManager() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Path: "/repo/mer", Kind: domain.ProjectKindWorkspace, Config: testRoleAgents()} + st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{{Name: "api", RelativePath: "api"}} + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Kind: domain.KindWorker, + Harness: domain.HarnessClaudeCode, + IsTerminated: true, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1", Prompt: "do it"}, + Activity: domain.Activity{State: domain.ActivityExited}, + } + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, Branch: "ao/mer-1", WorktreePath: "/ws/mer-1", State: "active"}, + {SessionID: "mer-1", RepoName: "api", Branch: "ao/mer-1", WorktreePath: "/ws/mer-1/api", State: "active"}, + } + + if err := m.RestoreAll(ctx); err != nil { + t.Fatalf("RestoreAll err = %v", err) + } + if rt.created != 0 { + t.Fatalf("active inventory rows must not resurrect user-killed sessions, runtime.Create called %d times", rt.created) + } +} + // TestRestoreAll_AppliesPreservedRef: when the session_worktrees row has a // non-empty preserved_ref, RestoreAll calls ApplyPreserved after workspace // restore but before relaunching. @@ -1942,7 +2201,7 @@ func TestRestoreAll_AppliesPreservedRef(t *testing.T) { Activity: domain.Activity{State: domain.ActivityExited}, } st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ - {SessionID: "mer-1", RepoName: "__root__", PreservedRef: "refs/ao/preserved/mer-1"}, + {SessionID: "mer-1", RepoName: "__root__", PreservedRef: "refs/ao/preserved/mer-1", State: "removed"}, } if err := m.RestoreAll(ctx); err != nil { @@ -1993,7 +2252,7 @@ func TestRestoreAll_ConflictLogsAndContinues(t *testing.T) { Activity: domain.Activity{State: domain.ActivityExited}, } st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ - {SessionID: "mer-1", RepoName: "__root__", PreservedRef: "refs/ao/preserved/mer-1"}, + {SessionID: "mer-1", RepoName: "__root__", PreservedRef: "refs/ao/preserved/mer-1", State: "removed"}, } if err := m.RestoreAll(ctx); err != nil { @@ -2004,6 +2263,41 @@ func TestRestoreAll_ConflictLogsAndContinues(t *testing.T) { } } +func TestRestoreAll_WorkspaceProjectRestoresAndAppliesEachRepo(t *testing.T) { + m, st, rt, ws := newLifecycleManager() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Path: "/repo/mer", Kind: domain.ProjectKindWorkspace, Config: testRoleAgents()} + st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{{Name: "api", RelativePath: "api"}} + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Kind: domain.KindWorker, + Harness: domain.HarnessClaudeCode, + IsTerminated: true, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1", AgentSessionID: "agent-w"}, + Activity: domain.Activity{State: domain.ActivityExited}, + } + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, Branch: "ao/mer-1", WorktreePath: "/ws/mer-1", PreservedRef: "refs/ao/preserved/mer-1", State: "removed"}, + {SessionID: "mer-1", RepoName: "api", Branch: "ao/mer-1", WorktreePath: "/ws/mer-1/api", PreservedRef: "refs/ao/preserved/mer-1", State: "removed"}, + } + + if err := m.RestoreAll(ctx); err != nil { + t.Fatalf("RestoreAll err = %v", err) + } + wantPrefix := []string{"RestoreWorkspaceProjectWorktree:__root__", "RestoreWorkspaceProjectWorktree:api"} + if got := ws.calls[:2]; strings.Join(got, ",") != strings.Join(wantPrefix, ",") { + t.Fatalf("restore prefix = %v, want %v; all calls %v", got, wantPrefix, ws.calls) + } + applied := strings.Join(ws.calls, ",") + if !strings.Contains(applied, "ApplyWorkspaceProjectPreserved:__root__:refs/ao/preserved/mer-1") || + !strings.Contains(applied, "ApplyWorkspaceProjectPreserved:api:refs/ao/preserved/mer-1") { + t.Fatalf("apply calls missing, got %v", ws.calls) + } + if rt.created != 1 { + t.Fatalf("runtime.Create calls = %d, want 1", rt.created) + } +} + func TestReconcileLive_DeadSessionStashedAndTerminated(t *testing.T) { st := newFakeStore() rt := &fakeRuntime{aliveByHandle: map[string]bool{}} // handle not alive