feat: add workspace project lifecycle teardown
This commit is contained in:
parent
4f0843337d
commit
a81b293d96
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -523,7 +523,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
|
||||
|
|
@ -707,7 +718,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)
|
||||
}
|
||||
}
|
||||
|
|
@ -718,10 +729,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)
|
||||
|
|
@ -736,6 +752,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)
|
||||
|
|
@ -748,7 +765,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)
|
||||
}
|
||||
|
|
@ -790,38 +807,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
|
||||
}
|
||||
|
|
@ -916,14 +906,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
|
||||
|
|
@ -933,28 +918,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).
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -970,6 +991,9 @@ func (m *Manager) RestoreAll(ctx context.Context) error {
|
|||
}
|
||||
continue
|
||||
}
|
||||
if err := m.markSessionWorktreesActive(ctx, rows); err != nil {
|
||||
m.logger.Warn("restore-all: marking worktrees active failed", "sessionID", rec.ID, "error", err)
|
||||
}
|
||||
if err := m.store.DeleteSessionWorktrees(ctx, rec.ID); err != nil {
|
||||
m.logger.Error("restore-all: delete consumed worktree marker failed", "sessionID", rec.ID, "error", err)
|
||||
}
|
||||
|
|
@ -977,6 +1001,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 {
|
||||
|
|
@ -1018,7 +1268,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.
|
||||
|
|
@ -1529,3 +1791,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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -345,6 +345,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)
|
||||
}
|
||||
|
|
@ -775,6 +815,79 @@ func TestKill_OtherWorkspaceErrorStillFails(t *testing.T) {
|
|||
t.Fatalf("kill err = %v, want workspace error surfaced", err)
|
||||
}
|
||||
}
|
||||
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"})
|
||||
|
|
@ -868,6 +981,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})
|
||||
|
|
@ -1908,6 +2102,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) {
|
||||
|
|
@ -1934,8 +2166,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)
|
||||
|
|
@ -2085,6 +2317,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.
|
||||
|
|
@ -2101,7 +2359,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 {
|
||||
|
|
@ -2152,7 +2410,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 {
|
||||
|
|
@ -2163,6 +2421,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
|
||||
|
|
|
|||
Loading…
Reference in New Issue