feat: add workspace project lifecycle teardown

This commit is contained in:
Aditi Chauhan 2026-06-27 23:39:39 +05:30
parent aad3b89850
commit 582b10f035
5 changed files with 857 additions and 64 deletions

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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 ""
}

View File

@ -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