From 7a87caae79efaf57b71046f84898faca4e4a9be6 Mon Sep 17 00:00:00 2001 From: neversettle <41864816+neversettle17-101@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:25:50 +0530 Subject: [PATCH] feat: combine workspace project stack (#2460) * feat: materialize workspace project sessions * fix: clear workspace project PR CI failures * fix: finalize workspace lifecycle row handling * feat: add workspace session prompt context * fix: preallocate workspace prompt repo list * test: use os paths for workspace project records * chore: format spawn orchestrator helper * feat: add workspace project lifecycle teardown * refactor: reuse workspace lifecycle for child repos * feat: observe workspace project SCM repos * fix: require workspace child origin remotes * fix: keep workspace teardown minimal * fix: prune stale workspace worktree registrations * fix: restore legacy saved worktree markers * fix: retain restored workspace project inventory * fix: address workspace lifecycle review gaps * chore: resync workspace scm PR * feat: add workspace import UI flow * chore: format with prettier [skip ci] * fix: refresh frontend npm lockfile * fix: address workspace import review feedback * fix: harden workspace import edge cases * chore: format sidebar import flow * fix: harden workspace lifecycle cleanup * fix: satisfy workspace lifecycle lint --------- Co-authored-by: github-actions[bot] --- .../workspace/gitworktree/workspace.go | 390 ++++++- .../workspace/gitworktree/workspace_test.go | 155 +++ backend/internal/observe/scm/observer.go | 115 +- backend/internal/observe/scm/observer_test.go | 167 ++- backend/internal/ports/outbound.go | 59 + backend/internal/processalive/process_unix.go | 22 +- .../processalive/process_unix_test.go | 29 + .../internal/service/project/service_test.go | 26 + .../service/project/workspace_registration.go | 6 + backend/internal/session_manager/manager.go | 737 ++++++++++-- .../internal/session_manager/manager_test.go | 1001 ++++++++++++++++- frontend/package-lock.json | 152 ++- frontend/pnpm-lock.yaml | 84 +- frontend/pnpm-workspace.yaml | 1 + frontend/src/main.ts | 195 +++- frontend/src/preload.ts | 22 +- .../CreateProjectAgentSheet.test.tsx | 1 + .../components/CreateProjectAgentSheet.tsx | 9 +- .../renderer/components/CreateProjectFlow.tsx | 1 + .../components/ProjectSettingsForm.tsx | 27 + .../src/renderer/components/Sidebar.test.tsx | 129 ++- frontend/src/renderer/components/Sidebar.tsx | 464 +++++++- .../src/renderer/hooks/useWorkspaceQuery.ts | 1 + frontend/src/renderer/lib/bridge.ts | 1 + frontend/src/renderer/routes/_shell.tsx | 5 +- frontend/src/renderer/test/setup.ts | 1 + frontend/src/renderer/types/workspace.ts | 10 + frontend/src/shared/shell-env.test.ts | 5 + frontend/src/shared/shell-env.ts | 10 +- 29 files changed, 3549 insertions(+), 276 deletions(-) create mode 100644 backend/internal/processalive/process_unix_test.go diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index 39bb01b9c..ea59a6754 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -84,6 +84,7 @@ type Workspace struct { type commandRunner func(ctx context.Context, binary string, args ...string) ([]byte, error) var _ ports.Workspace = (*Workspace)(nil) +var _ ports.WorkspaceProject = (*Workspace)(nil) // New builds a gitworktree Workspace, validating that ManagedRoot and // RepoResolver are set and resolving the root to an absolute, symlink-free path. @@ -143,16 +144,122 @@ func (w *Workspace) Create(ctx context.Context, cfg ports.WorkspaceConfig) (port return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil } +// CreateWorkspaceProject materialises a root-as-repo workspace session: the +// parent repo worktree is created at the session root, then each registered +// child repo is created at its relative path inside that root. All repos share +// one branch name; if the requested branch already exists in any repo, one +// suffixed branch that is free in every repo is selected and used everywhere. +func (w *Workspace) CreateWorkspaceProject(ctx context.Context, cfg ports.WorkspaceProjectConfig) (ports.WorkspaceProjectInfo, error) { + if err := validateWorkspaceProjectConfig(cfg); err != nil { + return ports.WorkspaceProjectInfo{}, err + } + rootRepo, err := physicalAbs(cfg.RootRepoPath) + if err != nil { + return ports.WorkspaceProjectInfo{}, fmt.Errorf("gitworktree: root repo path: %w", err) + } + rootPath, err := w.managedPath(ports.WorkspaceConfig{ + ProjectID: cfg.ProjectID, + SessionID: cfg.SessionID, + Kind: cfg.Kind, + SessionPrefix: cfg.SessionPrefix, + Branch: firstNonEmpty(cfg.Branch, defaultSessionBranchName(cfg.SessionID)), + }) + if err != nil { + return ports.WorkspaceProjectInfo{}, err + } + repos := make([]workspaceProjectRepo, 0, len(cfg.Repos)+1) + repos = append(repos, workspaceProjectRepo{ + name: domain.RootWorkspaceRepoName, + repoPath: rootRepo, + outputPath: rootPath, + baseBranch: cfg.BaseBranch, + }) + for _, child := range cfg.Repos { + repoPath, err := physicalAbs(child.RepoPath) + if err != nil { + return ports.WorkspaceProjectInfo{}, fmt.Errorf("gitworktree: child repo %q path: %w", child.Name, err) + } + rel, err := cleanRelativePath(child.RelativePath) + if err != nil { + return ports.WorkspaceProjectInfo{}, fmt.Errorf("gitworktree: child repo %q: %w", child.Name, err) + } + outPath, err := w.validateManagedPath(filepath.Join(rootPath, filepath.FromSlash(rel))) + if err != nil { + return ports.WorkspaceProjectInfo{}, fmt.Errorf("gitworktree: child repo %q path: %w", child.Name, err) + } + repos = append(repos, workspaceProjectRepo{ + name: child.Name, + relativePath: rel, + repoPath: repoPath, + outputPath: outPath, + baseBranch: firstNonEmpty(child.BaseBranch, cfg.BaseBranch), + }) + } + branch, err := w.workspaceProjectBranch(ctx, repos, firstNonEmpty(cfg.Branch, defaultSessionBranchName(cfg.SessionID))) + if err != nil { + return ports.WorkspaceProjectInfo{}, err + } + created := make([]workspaceProjectRepo, 0, len(repos)) + out := ports.WorkspaceProjectInfo{Worktrees: make([]ports.WorkspaceRepoInfo, 0, len(repos))} + for _, repo := range repos { + baseSHA, err := w.createWorkspaceProjectRepo(ctx, repo, branch) + if err != nil { + for i := len(created) - 1; i >= 0; i-- { + _ = w.forceDestroyPath(ctx, created[i].repoPath, created[i].outputPath) + } + return ports.WorkspaceProjectInfo{}, err + } + created = append(created, repo) + info := ports.WorkspaceRepoInfo{ + RepoName: repo.name, + RepoPath: repo.repoPath, + Path: repo.outputPath, + Branch: branch, + BaseSHA: baseSHA, + SessionID: cfg.SessionID, + ProjectID: cfg.ProjectID, + RelativePath: repo.relativePath, + } + out.Worktrees = append(out.Worktrees, info) + if repo.name == domain.RootWorkspaceRepoName { + out.Root = ports.WorkspaceInfo{Path: repo.outputPath, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID} + } + } + return out, nil +} + +// DestroyWorkspaceProject removes every worktree in a workspace project, +// children first and the parent/root last. It uses the same force path as spawn +// rollback because normal interactive cleanup still goes through Destroy and +// the full dirty-preserve matrix is implemented separately. +func (w *Workspace) DestroyWorkspaceProject(ctx context.Context, info ports.WorkspaceProjectInfo) error { + var firstErr error + for i := len(info.Worktrees) - 1; i >= 0; i-- { + wt := info.Worktrees[i] + if wt.Path == "" { + continue + } + repoPath := wt.RepoPath + if repoPath == "" { + if firstErr == nil { + firstErr = fmt.Errorf("gitworktree: missing repo path for worktree %q", wt.Path) + } + continue + } + if err := w.forceDestroyPath(ctx, repoPath, wt.Path); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + // 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 { - if info.ProjectID == "" { - return errors.New("gitworktree: project id is required") - } if info.Path == "" { return fmt.Errorf("%w: empty path", ErrUnsafePath) } - repo, err := w.repoPath(info.ProjectID) + repo, err := w.repoPathForInfo(info) if err != nil { return err } @@ -201,13 +308,10 @@ func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error // discards agent work. For interactive teardown (ao session kill, ao cleanup) // use Destroy, which refuses dirty worktrees via ErrWorkspaceDirty. func (w *Workspace) ForceDestroy(ctx context.Context, info ports.WorkspaceInfo) error { - if info.ProjectID == "" { - return errors.New("gitworktree: project id is required") - } if info.Path == "" { return fmt.Errorf("%w: empty path", ErrUnsafePath) } - repo, err := w.repoPath(info.ProjectID) + repo, err := w.repoPathForInfo(info) if err != nil { return err } @@ -417,11 +521,11 @@ func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (por if err := validateConfig(cfg); err != nil { return ports.WorkspaceInfo{}, err } - repo, err := w.repoPath(cfg.ProjectID) + repo, err := w.repoPathForConfig(cfg) if err != nil { return ports.WorkspaceInfo{}, err } - path, err := w.managedPath(cfg) + path, err := w.restorePath(cfg) if err != nil { return ports.WorkspaceInfo{}, err } @@ -434,12 +538,17 @@ func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (por if branch == "" { branch = cfg.Branch } - return ports.WorkspaceInfo{Path: path, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil + return ports.WorkspaceInfo{Path: path, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID, RepoPath: repo}, nil } if nonEmpty, err := pathExistsNonEmpty(path); err != nil { return ports.WorkspaceInfo{}, err } else if nonEmpty { - return ports.WorkspaceInfo{}, fmt.Errorf("gitworktree: refusing to restore %q: path exists and is not a registered worktree", path) + if cfg.Path == "" { + return ports.WorkspaceInfo{}, fmt.Errorf("gitworktree: refusing to restore %q: path exists and is not a registered worktree", path) + } + if _, err := moveStrayPathAside(path); err != nil { + return ports.WorkspaceInfo{}, err + } } if err := w.validateBranch(ctx, repo, cfg.Branch); err != nil { return ports.WorkspaceInfo{}, err @@ -447,7 +556,7 @@ func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (por if err := w.addWorktree(ctx, repo, path, cfg.Branch, cfg.BaseBranch); err != nil { return ports.WorkspaceInfo{}, err } - return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil + return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID, RepoPath: repo}, nil } func (w *Workspace) existingWorktree(ctx context.Context, repo, path string, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, bool, error) { @@ -502,11 +611,127 @@ func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch, baseBra return err } if _, err := w.run(ctx, w.binary, worktreeAddNewBranchArgs(repo, branch, path, baseRef)...); err != nil { + if isMissingRegisteredWorktreeError(err) { + if pruneErr := w.pruneWorktrees(ctx, repo); pruneErr != nil { + return fmt.Errorf("gitworktree: worktree add branch %q from %q: recover stale registration: %w", branch, baseRef, pruneErr) + } + if _, retryErr := w.run(ctx, w.binary, worktreeAddNewBranchArgs(repo, branch, path, baseRef)...); retryErr == nil { + return nil + } + } return fmt.Errorf("gitworktree: worktree add branch %q from %q: %w", branch, baseRef, err) } return nil } +type workspaceProjectRepo struct { + name string + relativePath string + repoPath string + outputPath string + baseBranch string +} + +func (w *Workspace) workspaceProjectBranch(ctx context.Context, repos []workspaceProjectRepo, requested string) (string, error) { + branch := strings.TrimSpace(requested) + if branch == "" { + return "", errors.New("gitworktree: branch is required") + } + for i := 0; i < 100; i++ { + candidate := branch + if i > 0 { + candidate = fmt.Sprintf("%s-%d", branch, i+1) + } + free, err := w.workspaceProjectBranchFree(ctx, repos, candidate) + if err != nil { + return "", err + } + if free { + return candidate, nil + } + } + return "", fmt.Errorf("gitworktree: could not find free workspace branch for %q", branch) +} + +func (w *Workspace) workspaceProjectBranchFree(ctx context.Context, repos []workspaceProjectRepo, branch string) (bool, error) { + for _, repo := range repos { + if err := w.validateBranch(ctx, repo.repoPath, branch); err != nil { + return false, err + } + exists, err := w.refExists(ctx, repo.repoPath, "refs/heads/"+branch) + if err != nil { + return false, err + } + if exists { + return false, nil + } + records, err := w.listRecords(ctx, repo.repoPath) + if err != nil { + return false, err + } + if conflict, ok := findWorktreeByBranch(records, branch); ok && filepath.Clean(conflict.Path) != filepath.Clean(repo.outputPath) { + return false, nil + } + } + return true, nil +} + +func (w *Workspace) createWorkspaceProjectRepo(ctx context.Context, repo workspaceProjectRepo, branch string) (string, error) { + baseRef, err := w.resolveBaseRef(ctx, repo.repoPath, branch, repo.baseBranch) + if err != nil { + if errors.Is(err, errNoBaseRef) { + return "", fmt.Errorf("%w: %q has no local head, no remote, and no tag — run `git fetch` then retry", ErrBranchNotFetched, branch) + } + return "", err + } + baseSHA, err := w.revParse(ctx, repo.repoPath, baseRef) + if err != nil { + return "", err + } + if _, err := w.run(ctx, w.binary, worktreeAddNewBranchArgs(repo.repoPath, branch, repo.outputPath, baseRef)...); err != nil { + if isMissingRegisteredWorktreeError(err) { + if pruneErr := w.pruneWorktrees(ctx, repo.repoPath); pruneErr != nil { + return "", fmt.Errorf("gitworktree: workspace repo %q worktree add branch %q from %q: recover stale registration: %w", repo.name, branch, baseRef, pruneErr) + } + if _, retryErr := w.run(ctx, w.binary, worktreeAddNewBranchArgs(repo.repoPath, branch, repo.outputPath, baseRef)...); retryErr == nil { + return baseSHA, nil + } + } + return "", fmt.Errorf("gitworktree: workspace repo %q worktree add branch %q from %q: %w", repo.name, branch, baseRef, err) + } + return baseSHA, nil +} + +func (w *Workspace) forceDestroyPath(ctx context.Context, repo, path string) error { + _, _ = w.run(ctx, w.binary, worktreeForceRemoveArgs(repo, path)...) + if err := w.pruneWorktrees(ctx, repo); err != nil { + return err + } + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("gitworktree: force remove path %q: %w", path, err) + } + return nil +} + +func (w *Workspace) pruneWorktrees(ctx context.Context, repo string) error { + if _, err := w.run(ctx, w.binary, worktreePruneArgs(repo)...); err != nil { + return fmt.Errorf("gitworktree: worktree prune: %w", err) + } + return nil +} + +func isMissingRegisteredWorktreeError(err error) bool { + return strings.Contains(err.Error(), "is a missing but already registered worktree") +} + +func (w *Workspace) revParse(ctx context.Context, repo, ref string) (string, error) { + out, err := w.run(ctx, w.binary, "-C", repo, "rev-parse", "--verify", ref) + if err != nil { + return "", fmt.Errorf("gitworktree: rev-parse %q: %w", ref, err) + } + return strings.TrimSpace(string(out)), nil +} + func (w *Workspace) validateBranch(ctx context.Context, repo, branch string) error { if _, err := w.run(ctx, w.binary, checkRefFormatBranchArgs(repo, branch)...); err != nil { return fmt.Errorf("%w: %q (%w)", ErrBranchInvalid, branch, err) @@ -519,13 +744,14 @@ func (w *Workspace) validateBranch(ctx context.Context, repo, branch string) err var errNoBaseRef = errors.New("gitworktree: no base ref found") func (w *Workspace) resolveBaseRef(ctx context.Context, repo, branch, baseBranch string) (string, error) { - // A per-project base branch (cfg.BaseBranch) overrides the adapter default, - // so a project that branches off e.g. "develop" materialises worktrees from - // there. Empty falls back to the adapter's configured default. - defaultBranch := w.defaultBranch if strings.TrimSpace(baseBranch) != "" { - defaultBranch = baseBranch + return w.resolveBaseRefFromDefault(ctx, repo, branch, baseBranch) } + defaultBranch := w.inferRepoDefaultBranch(ctx, repo) + return w.resolveBaseRefFromDefault(ctx, repo, branch, defaultBranch) +} + +func (w *Workspace) resolveBaseRefFromDefault(ctx context.Context, repo, branch, defaultBranch string) (string, error) { candidates := baseRefCandidates(branch, defaultBranch) for _, ref := range candidates { exists, err := w.refExists(ctx, repo, ref) @@ -549,6 +775,24 @@ func (w *Workspace) resolveBaseRef(ctx context.Context, repo, branch, baseBranch return "", fmt.Errorf("%w for branch %q (tried %s, %s)", errNoBaseRef, branch, strings.Join(candidates, ", "), tagRef) } +func (w *Workspace) inferRepoDefaultBranch(ctx context.Context, repo string) string { + for _, args := range [][]string{ + {"symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"}, + {"branch", "--show-current"}, + } { + out, err := w.run(ctx, w.binary, append([]string{"-C", repo}, args...)...) + if err != nil { + continue + } + branch := strings.TrimSpace(string(out)) + branch = strings.TrimPrefix(branch, "origin/") + if branch != "" { + return branch + } + } + return w.defaultBranch +} + func (w *Workspace) refExists(ctx context.Context, repo, ref string) (bool, error) { _, err := w.run(ctx, w.binary, revParseVerifyArgs(repo, ref)...) if err == nil { @@ -599,6 +843,31 @@ func (w *Workspace) repoPath(project domain.ProjectID) (string, error) { return abs, nil } +func (w *Workspace) repoPathForInfo(info ports.WorkspaceInfo) (string, error) { + if info.RepoPath != "" { + repo, err := physicalAbs(info.RepoPath) + if err != nil { + return "", fmt.Errorf("gitworktree: repo path: %w", err) + } + return repo, nil + } + if info.ProjectID == "" { + return "", errors.New("gitworktree: project id is required") + } + return w.repoPath(info.ProjectID) +} + +func (w *Workspace) repoPathForConfig(cfg ports.WorkspaceConfig) (string, error) { + if cfg.RepoPath != "" { + repo, err := physicalAbs(cfg.RepoPath) + if err != nil { + return "", fmt.Errorf("gitworktree: repo path: %w", err) + } + return repo, nil + } + return w.repoPath(cfg.ProjectID) +} + func physicalAbs(path string) (string, error) { abs, err := filepath.Abs(path) if err != nil { @@ -649,6 +918,37 @@ func validateConfig(cfg ports.WorkspaceConfig) error { return nil } +func validateWorkspaceProjectConfig(cfg ports.WorkspaceProjectConfig) error { + if err := validateConfig(ports.WorkspaceConfig{ + ProjectID: cfg.ProjectID, + SessionID: cfg.SessionID, + Kind: cfg.Kind, + SessionPrefix: cfg.SessionPrefix, + Branch: firstNonEmpty(cfg.Branch, defaultSessionBranchName(cfg.SessionID)), + BaseBranch: cfg.BaseBranch, + }); err != nil { + return err + } + if strings.TrimSpace(cfg.RootRepoPath) == "" { + return errors.New("gitworktree: root repo path is required") + } + for _, repo := range cfg.Repos { + if strings.TrimSpace(repo.Name) == "" { + return errors.New("gitworktree: child repo name is required") + } + if err := validatePathComponent("child repo name", repo.Name); err != nil { + return err + } + if strings.TrimSpace(repo.RepoPath) == "" { + return fmt.Errorf("gitworktree: child repo %q path is required", repo.Name) + } + if _, err := cleanRelativePath(repo.RelativePath); err != nil { + return fmt.Errorf("gitworktree: child repo %q: %w", repo.Name, err) + } + } + return nil +} + // validatePathComponent rejects id values that could escape the managed root // once joined into a path. filepath.Join cleans `..` before validateManagedPath // runs, so a session id of "../other" would otherwise resolve back inside @@ -675,6 +975,13 @@ func (w *Workspace) managedPath(cfg ports.WorkspaceConfig) (string, error) { return w.validateManagedPath(path) } +func (w *Workspace) restorePath(cfg ports.WorkspaceConfig) (string, error) { + if cfg.Path != "" { + return w.validateManagedPath(cfg.Path) + } + return w.managedPath(cfg) +} + // resolvedSessionPrefix returns cfg.SessionPrefix when set, otherwise the first // 12 characters of the project ID (matching the display-prefix convention). func resolvedSessionPrefix(cfg ports.WorkspaceConfig) string { @@ -688,6 +995,34 @@ func resolvedSessionPrefix(cfg ports.WorkspaceConfig) string { return id[:12] } +func defaultSessionBranchName(id domain.SessionID) string { + return "ao/" + string(id) +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +func cleanRelativePath(path string) (string, error) { + rel := filepath.ToSlash(strings.TrimSpace(path)) + if rel == "" { + return "", errors.New("relative path is required") + } + if strings.HasPrefix(rel, "/") { + return "", fmt.Errorf("%w: relative path %q must not be absolute", ErrUnsafePath, path) + } + clean := filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("%w: relative path %q escapes the workspace root", ErrUnsafePath, path) + } + return clean, nil +} + func (w *Workspace) validateManagedPath(path string) (string, error) { if path == "" { return "", fmt.Errorf("%w: empty path", ErrUnsafePath) @@ -752,6 +1087,25 @@ func pathExistsNonEmpty(path string) (bool, error) { return false, fmt.Errorf("gitworktree: inspect path %q: %w", path, err) } +func moveStrayPathAside(path string) (string, error) { + for i := 0; i < 100; i++ { + candidate := path + ".stray" + if i > 0 { + candidate = fmt.Sprintf("%s.stray-%d", path, i+1) + } + if _, err := os.Lstat(candidate); err == nil { + continue + } else if !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("gitworktree: inspect stray destination %q: %w", candidate, err) + } + if err := os.Rename(path, candidate); err != nil { + return "", fmt.Errorf("gitworktree: move stray path %q aside to %q: %w", path, candidate, err) + } + return candidate, nil + } + return "", fmt.Errorf("gitworktree: move stray path %q aside: no available destination", path) +} + func runCommand(ctx context.Context, binary string, args ...string) ([]byte, error) { cmd := aoprocess.CommandContext(ctx, binary, args...) out, err := cmd.CombinedOutput() diff --git a/backend/internal/adapters/workspace/gitworktree/workspace_test.go b/backend/internal/adapters/workspace/gitworktree/workspace_test.go index 1fa0f6116..855ded477 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace_test.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace_test.go @@ -211,6 +211,70 @@ func TestCreateReusesRegisteredWorktreeAtExpectedPath(t *testing.T) { } } +func TestCreateWorkspaceProjectRepoPrunesStaleRegisteredWorktree(t *testing.T) { + root := t.TempDir() + repo := t.TempDir() + output := filepath.Join(root, "proj", "orchestrator", "proj-orchestrator", "api") + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + exitErr := exec.Command("sh", "-c", "exit 1").Run() + if exitErr == nil { + t.Fatal("expected exit error") + } + var calls []string + addAttempts := 0 + ws.run = func(_ context.Context, binary string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + calls = append(calls, joined) + switch { + case strings.Contains(joined, "symbolic-ref --quiet --short refs/remotes/origin/HEAD"): + return []byte("origin/main\n"), nil + case strings.Contains(joined, "rev-parse --verify --quiet origin/feature/test"): + return nil, commandError{args: append([]string{binary}, args...), err: exitErr} + case strings.Contains(joined, "rev-parse --verify --quiet origin/main"): + return nil, nil + case strings.Contains(joined, "rev-parse --verify origin/main"): + return []byte("abc123\n"), nil + case strings.Contains(joined, "worktree add -b feature/test "+output+" origin/main"): + addAttempts++ + if addAttempts == 1 { + return nil, commandError{ + args: append([]string{binary}, args...), + output: "Preparing worktree (new branch 'feature/test')\nfatal: '" + output + "' is a missing but already registered worktree;\nuse 'add -f' to override, or 'prune' or 'remove' to clear", + err: errors.New("exit status 128"), + } + } + return nil, nil + case strings.Contains(joined, "worktree prune"): + return nil, nil + default: + t.Fatalf("unexpected git invocation: %v", args) + return nil, nil + } + } + + baseSHA, err := ws.createWorkspaceProjectRepo(context.Background(), workspaceProjectRepo{ + name: "api", + repoPath: repo, + outputPath: output, + }, "feature/test") + if err != nil { + t.Fatalf("createWorkspaceProjectRepo: %v", err) + } + if baseSHA != "abc123" { + t.Fatalf("baseSHA = %q, want abc123", baseSHA) + } + if addAttempts != 2 { + t.Fatalf("add attempts = %d, want 2", addAttempts) + } + got := strings.Join(calls, "\n") + if !strings.Contains(got, "worktree prune") { + t.Fatalf("calls missing worktree prune:\n%s", got) + } +} + // TestValidateConfigRejectsPathEscapingIDs covers review item RB: filepath.Join // in managedPath cleans `..` segments before validateManagedPath sees them, so a // session id of "../other" would stay inside managedRoot while jumping projects. @@ -282,6 +346,62 @@ func TestRestoreRefusesNonEmptyUnregisteredPath(t *testing.T) { } } +func TestRestoreWithRepoPathMovesStrayPathAside(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.Restore(context.Background(), ports.WorkspaceConfig{ + ProjectID: "proj", + SessionID: "proj-1", + Branch: "ao/proj-1", + RepoPath: repo, + Path: path, + }) + if err != nil { + t.Fatalf("Restore: %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() @@ -457,6 +577,10 @@ func TestAddWorktreeReportsBranchNotFetched(t *testing.T) { return nil, nil case strings.Contains(joined, "worktree list --porcelain"): return nil, nil + case strings.Contains(joined, "symbolic-ref --quiet --short refs/remotes/origin/HEAD"): + return nil, commandError{args: args, err: exitOne} + case strings.Contains(joined, "branch --show-current"): + return nil, commandError{args: args, err: exitOne} case strings.Contains(joined, "rev-parse"): return nil, commandError{args: args, err: exitOne} default: @@ -470,6 +594,37 @@ func TestAddWorktreeReportsBranchNotFetched(t *testing.T) { } } +func TestResolveBaseRefInfersRepoDefaultBranchWhenUnset(t *testing.T) { + ws, err := New(Options{ManagedRoot: t.TempDir(), RepoResolver: StaticRepoResolver{"proj": t.TempDir()}}) + if err != nil { + t.Fatalf("new: %v", err) + } + exitOne := func() error { + cmd := exec.Command("sh", "-c", "exit 1") + return cmd.Run() + }() + ws.run = func(_ context.Context, _ string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + switch { + case strings.Contains(joined, "symbolic-ref --quiet --short refs/remotes/origin/HEAD"): + return []byte("origin/master\n"), nil + case strings.Contains(joined, "origin/master"): + return []byte("sha\n"), nil + case strings.Contains(joined, "rev-parse --verify"): + return nil, commandError{args: args, err: exitOne} + default: + return nil, nil + } + } + ref, err := ws.resolveBaseRef(context.Background(), "/repo/child", "ao/work", "") + if err != nil { + t.Fatalf("resolveBaseRef err = %v", err) + } + if ref != "origin/master" { + t.Fatalf("base ref = %q, want child origin/master", ref) + } +} + func mkdirFile(dir, name string) error { if err := os.MkdirAll(dir, 0o755); err != nil { return err diff --git a/backend/internal/observe/scm/observer.go b/backend/internal/observe/scm/observer.go index 780e669fc..3614dc3b8 100644 --- a/backend/internal/observe/scm/observer.go +++ b/backend/internal/observe/scm/observer.go @@ -12,6 +12,7 @@ import ( "errors" "fmt" "log/slog" + "path/filepath" "strings" "sync" "time" @@ -51,6 +52,7 @@ type Store interface { ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) UpsertProject(ctx context.Context, row domain.ProjectRecord) error + ListWorkspaceRepos(ctx context.Context, projectID string) ([]domain.WorkspaceRepoRecord, error) ListPRsBySession(ctx context.Context, sessionID domain.SessionID) ([]domain.PullRequest, error) ListChecks(ctx context.Context, prURL string) ([]domain.PullRequestCheck, error) WriteSCMObservation(ctx context.Context, pr domain.PullRequest, checks []domain.PullRequestCheck, reviews []domain.PullRequestReview, threads []domain.PullRequestReviewThread, comments []domain.PullRequestComment, reviewMode ports.ReviewWriteMode) error @@ -466,23 +468,35 @@ func (o *Observer) discoverSubjects(ctx context.Context) (map[string]*subject, [ scanRepos[sess.ProjectID] = o.resolveScanRepos(p, origin) } } - origin, ok := originRepos[sess.ProjectID] - if !ok { - o.logger.Debug("scm observer: project has no supported SCM origin", "project", proj.ID, "origin", proj.RepoOriginURL) - continue + repos := make([]ports.SCMRepo, 0, len(scanRepos[sess.ProjectID])) + if origin, ok := originRepos[sess.ProjectID]; ok { + for _, repo := range scanRepos[sess.ProjectID] { + sessionRepos = append(sessionRepos, sessionRepo{session: sess, repo: repo, headRepo: origin, branch: branch}) + repos = append(repos, repo) + } } - for _, repo := range scanRepos[sess.ProjectID] { - sessionRepos = append(sessionRepos, sessionRepo{session: sess, repo: repo, headRepo: origin, branch: branch}) + childRepos, err := o.workspaceSCMSessionRepos(ctx, proj, sess, branch) + if err != nil { + return nil, nil, err + } + for _, child := range childRepos { + sessionRepos = append(sessionRepos, child) + repos = append(repos, child.repo) + } + if len(repos) == 0 { + o.logger.Debug("scm observer: project has no supported SCM origins", "project", proj.ID) + continue } prs, err := o.store.ListPRsBySession(ctx, sess.ID) if err != nil { return nil, nil, err } for _, pr := range openTrackedPRs(prs) { - // A tracked PR may live on an upstream repo (cross-fork), so its - // refresh subject is keyed by the PR's own recorded repo, not the - // push origin, or the GraphQL refetch would target the wrong repo. - prRepo := subjectRepoForPR(pr, origin) + prRepo, ok := repoForTrackedPR(pr, repos) + if !ok { + o.logger.Warn("scm observer: tracked PR repo no longer belongs to project", "session", sess.ID, "pr", pr.URL, "repo", pr.Repo) + continue + } key := prKey(prRepo, pr.Number) if existing, ok := out[key]; ok { o.logger.Warn("scm observer: duplicate tracked PR ownership skipped", "pr", key, "kept_session", existing.session.ID, "skipped_session", sess.ID) @@ -510,7 +524,7 @@ func (o *Observer) resolveScanRepos(proj domain.ProjectRecord, origin ports.SCMR return repos } seen := map[string]bool{prKey(origin, 0): true} - for _, url := range gitRemoteURLs(proj.Path) { + for _, url := range gitRemoteURLsFunc(proj.Path) { repo, ok := o.provider.ParseRepository(url) if !ok { continue @@ -525,21 +539,72 @@ func (o *Observer) resolveScanRepos(proj domain.ProjectRecord, origin ports.SCMR return repos } -// subjectRepoForPR resolves the repo that owns a tracked PR's number for refresh. -// A cross-fork PR lives on the base/upstream repo recorded in pr.Repo rather than -// the push origin, so the refresh/GraphQL fetch must target pr.Repo. Legacy rows -// written before pr.Repo was populated fall back to the origin. -func subjectRepoForPR(pr domain.PullRequest, origin ports.SCMRepo) ports.SCMRepo { - if strings.TrimSpace(pr.Repo) == "" { - return origin +func (o *Observer) workspaceSCMSessionRepos(ctx context.Context, proj domain.ProjectRecord, sess domain.SessionRecord, branch string) ([]sessionRepo, error) { + if proj.Kind.WithDefault() != domain.ProjectKindWorkspace { + return nil, nil } - return ports.SCMRepo{ - Provider: firstNonEmpty(pr.Provider, origin.Provider), - Host: firstNonEmpty(pr.Host, origin.Host), - Repo: pr.Repo, - Owner: ownerOf(pr.Repo), - Name: nameOf(pr.Repo), + childRepos, err := o.store.ListWorkspaceRepos(ctx, proj.ID) + if err != nil { + return nil, err } + repos := make([]sessionRepo, 0, len(childRepos)) + seen := map[string]bool{} + for _, child := range childRepos { + if strings.TrimSpace(child.RepoOriginURL) == "" { + continue + } + repo, ok := o.provider.ParseRepository(child.RepoOriginURL) + if !ok { + o.logger.Debug("scm observer: unsupported SCM origin", "project", proj.ID, "repo", child.Name, "origin", child.RepoOriginURL) + continue + } + childPath := filepath.Join(proj.Path, filepath.FromSlash(child.RelativePath)) + for _, scanRepo := range o.resolveScanRepos(domain.ProjectRecord{Path: childPath}, repo) { + key := prKey(scanRepo, 0) + if seen[key] { + continue + } + seen[key] = true + repos = append(repos, sessionRepo{session: sess, repo: scanRepo, headRepo: repo, branch: branch}) + } + } + return repos, nil +} + +func repoForTrackedPR(pr domain.PullRequest, repos []ports.SCMRepo) (ports.SCMRepo, bool) { + if pr.Provider != "" && pr.Host != "" && pr.Repo != "" { + return ports.SCMRepo{Provider: pr.Provider, Host: pr.Host, Repo: pr.Repo}, true + } + if pr.Repo != "" { + for _, repo := range repos { + if matchesTrackedPRRepo(pr, repo) { + return repo, true + } + } + return ports.SCMRepo{}, false + } + if len(repos) == 1 { + return repos[0], true + } + for _, repo := range repos { + if strings.EqualFold(repo.Repo, repos[0].Repo) { + return repo, true + } + } + return repos[0], len(repos) > 0 +} + +func matchesTrackedPRRepo(pr domain.PullRequest, repo ports.SCMRepo) bool { + if pr.Provider != "" && !strings.EqualFold(pr.Provider, repo.Provider) { + return false + } + if pr.Host != "" && !strings.EqualFold(pr.Host, repo.Host) { + return false + } + if pr.Repo != "" && !strings.EqualFold(pr.Repo, repoFullName(repo)) { + return false + } + return true } func openTrackedPRs(prs []domain.PullRequest) []domain.PullRequest { @@ -1336,6 +1401,8 @@ func gitRemoteURLs(path string) []string { return urls } +var gitRemoteURLsFunc = gitRemoteURLs + func scrubLine(s string) string { s = strings.ReplaceAll(s, "\n", " ") s = strings.ReplaceAll(s, "\r", " ") diff --git a/backend/internal/observe/scm/observer_test.go b/backend/internal/observe/scm/observer_test.go index d75d217a7..a7bb54ee3 100644 --- a/backend/internal/observe/scm/observer_test.go +++ b/backend/internal/observe/scm/observer_test.go @@ -21,16 +21,20 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -var testRepo = ports.SCMRepo{Provider: "github", Host: "github.com", Owner: "o", Name: "r", Repo: "o/r"} +var ( + testRepo = ports.SCMRepo{Provider: "github", Host: "github.com", Owner: "o", Name: "r", Repo: "o/r"} + testAPIRepo = ports.SCMRepo{Provider: "github", Host: "github.com", Owner: "o", Name: "api", Repo: "o/api"} +) type fakeStore struct { mu sync.Mutex - sessions []domain.SessionRecord - projects map[string]domain.ProjectRecord - prs map[domain.SessionID][]domain.PullRequest - checks map[string][]domain.PullRequestCheck - writeErr error + sessions []domain.SessionRecord + projects map[string]domain.ProjectRecord + workspaceRepos map[string][]domain.WorkspaceRepoRecord + prs map[domain.SessionID][]domain.PullRequest + checks map[string][]domain.PullRequestCheck + writeErr error writes []fakeWrite @@ -83,6 +87,12 @@ func (s *fakeStore) UpsertProject(_ context.Context, row domain.ProjectRecord) e return nil } +func (s *fakeStore) ListWorkspaceRepos(_ context.Context, projectID string) ([]domain.WorkspaceRepoRecord, error) { + s.mu.Lock() + defer s.mu.Unlock() + return append([]domain.WorkspaceRepoRecord(nil), s.workspaceRepos[projectID]...), nil +} + func (s *fakeStore) ListPRsBySession(_ context.Context, id domain.SessionID) ([]domain.PullRequest, error) { s.mu.Lock() defer s.mu.Unlock() @@ -245,6 +255,34 @@ func knownPR(num int) domain.PullRequest { return pr } +func TestRepoForTrackedPRMatchesLegacyRepoOnlyRows(t *testing.T) { + pr := knownPR(1) + pr.Provider = "" + pr.Host = "" + pr.Repo = "o/r" + repo, ok := repoForTrackedPR(pr, []ports.SCMRepo{testRepo}) + if !ok { + t.Fatal("legacy repo-only row should match candidate repo") + } + if repoFullName(repo) != "o/r" { + t.Fatalf("matched repo = %q, want o/r", repoFullName(repo)) + } +} + +func TestRepoForTrackedPRUsesPersistedRepoWhenCurrentScanDropsUpstream(t *testing.T) { + pr := knownPR(42) + pr.Provider = "github" + pr.Host = "github.com" + pr.Repo = "upstream/api" + repo, ok := repoForTrackedPR(pr, []ports.SCMRepo{testAPIRepo}) + if !ok { + t.Fatal("persisted tracked PR repo should refresh even when current remotes no longer include it") + } + if repo.Provider != "github" || repo.Host != "github.com" || repo.Repo != "upstream/api" { + t.Fatalf("repo = %#v, want persisted upstream/api tuple", repo) + } +} + func TestStartAsyncPerformsImmediatePollAndStopsOnCancel(t *testing.T) { store := testStoreWithSession() store.listEntered = make(chan struct{}) @@ -529,6 +567,123 @@ func TestPoll_DiscoversSiblingUnderRootSessionNamespace(t *testing.T) { } } +func TestPoll_DiscoversWorkspaceChildRepoPR(t *testing.T) { + store := testStoreWithSession() + store.sessions[0].Metadata.Branch = "ao/p-1/root" + store.projects["p"] = domain.ProjectRecord{ + ID: "p", + RepoOriginURL: "https://github.com/o/r.git", + Kind: domain.ProjectKindWorkspace, + } + store.workspaceRepos = map[string][]domain.WorkspaceRepoRecord{ + "p": {{ProjectID: "p", Name: "api", RelativePath: "api", RepoOriginURL: "https://github.com/o/api.git"}}, + } + prObs := testObs(12) + prObs.Repo = "o/api" + prObs.PR.URL = "https://github.com/o/api/pull/12" + prObs.PR.HTMLURL = "https://github.com/o/api/pull/12" + prObs.PR.Number = 12 + prObs.PR.SourceBranch = "ao/p-1/api-billing" + prObs.PR.TargetBranch = "main" + prObs.PR.HeadSHA = "api-sha" + prObs.CI.HeadSHA = "api-sha" + provider := &fakeProvider{ + repoGuards: map[string]ports.SCMGuardResult{ + prKey(testRepo, 0): {ETag: "root-v2"}, + prKey(testAPIRepo, 0): {ETag: "api-v2"}, + }, + openPRs: map[string][]ports.SCMPRObservation{ + prKey(testAPIRepo, 0): { + {URL: "https://github.com/o/api/pull/12", Number: 12, SourceBranch: "ao/p-1/api-billing", HeadRepo: "o/api", TargetBranch: "main", HeadSHA: "api-sha"}, + }, + }, + observations: map[string]ports.SCMObservation{prKey(testAPIRepo, 12): prObs}, + } + lc := &fakeLifecycle{} + obs := newTestObserver(store, provider, lc, time.Unix(1, 0).UTC()) + if err := obs.Poll(context.Background()); err != nil { + t.Fatal(err) + } + if len(provider.fetchBatches) != 1 || len(provider.fetchBatches[0]) != 1 { + t.Fatalf("child repo PR not refreshed: %#v", provider.fetchBatches) + } + ref := provider.fetchBatches[0][0] + if ref.Repo.Repo != "o/api" || ref.Number != 12 { + t.Fatalf("fetched ref = %#v, want o/api#12", ref) + } + if len(store.writes) == 0 { + t.Fatal("expected child repo PR write") + } + if got := store.writes[0].pr.Repo; got != "o/api" { + t.Fatalf("persisted repo = %q, want o/api", got) + } + if got := store.writes[0].pr.SessionID; got != "p-1" { + t.Fatalf("session id = %q, want p-1", got) + } + if len(lc.observed) != 1 { + t.Fatalf("lifecycle observations = %d, want 1", len(lc.observed)) + } +} + +func TestPoll_DiscoversWorkspaceChildRepoUpstreamPR(t *testing.T) { + oldRemoteURLs := gitRemoteURLsFunc + gitRemoteURLsFunc = func(path string) []string { + if strings.HasSuffix(path, "/api") { + return []string{"https://github.com/o/api.git", "https://github.com/upstream/api.git"} + } + return nil + } + defer func() { gitRemoteURLsFunc = oldRemoteURLs }() + + store := testStoreWithSession() + store.sessions[0].Metadata.Branch = "ao/p-1/api-billing" + store.projects["p"] = domain.ProjectRecord{ + ID: "p", + Path: "/repo/workspace", + RepoOriginURL: "https://github.com/o/root.git", + Kind: domain.ProjectKindWorkspace, + } + store.workspaceRepos = map[string][]domain.WorkspaceRepoRecord{ + "p": {{ProjectID: "p", Name: "api", RelativePath: "api", RepoOriginURL: "https://github.com/o/api.git"}}, + } + upstreamRepo := ports.SCMRepo{Provider: "github", Host: "github.com", Owner: "upstream", Name: "api", Repo: "upstream/api"} + prObs := testObs(44) + prObs.Repo = "upstream/api" + prObs.PR.URL = "https://github.com/upstream/api/pull/44" + prObs.PR.HTMLURL = "https://github.com/upstream/api/pull/44" + prObs.PR.Number = 44 + prObs.PR.SourceBranch = "ao/p-1/api-billing" + prObs.PR.HeadRepo = "o/api" + prObs.PR.TargetBranch = "main" + prObs.PR.HeadSHA = "api-sha" + prObs.CI.HeadSHA = "api-sha" + provider := &fakeProvider{ + repoGuards: map[string]ports.SCMGuardResult{ + prKey(upstreamRepo, 0): {ETag: "upstream-v1"}, + }, + openPRs: map[string][]ports.SCMPRObservation{ + prKey(upstreamRepo, 0): { + {URL: "https://github.com/upstream/api/pull/44", Number: 44, SourceBranch: "ao/p-1/api-billing", HeadRepo: "o/api", TargetBranch: "main", HeadSHA: "api-sha"}, + }, + }, + observations: map[string]ports.SCMObservation{prKey(upstreamRepo, 44): prObs}, + } + lc := &fakeLifecycle{} + obs := newTestObserver(store, provider, lc, time.Unix(1, 0).UTC()) + if err := obs.Poll(context.Background()); err != nil { + t.Fatal(err) + } + if len(store.writes) == 0 { + t.Fatal("expected discovered upstream child PR write") + } + if got := store.writes[0].pr.Repo; got != "upstream/api" { + t.Fatalf("written repo = %q, want upstream/api", got) + } + if got := store.writes[0].pr.SessionID; got != "p-1" { + t.Fatalf("session id = %q, want p-1", got) + } +} + // A PR whose head branch matches a session branch but lives in a fork (its head // repo differs from the project repo) must not be auto-attributed: its commits // are not the session's work. It is neither fetched nor persisted. diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index f88e1b783..3cbfeb4b1 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -141,6 +141,15 @@ type Workspace interface { ApplyPreserved(ctx context.Context, info WorkspaceInfo, ref string) error } +// WorkspaceProject is an optional extension for projects composed from a +// root-as-repo parent plus child repositories. It materialises the parent +// worktree at the session root and each child repo at its registered relative +// path inside that root. +type WorkspaceProject interface { + CreateWorkspaceProject(ctx context.Context, cfg WorkspaceProjectConfig) (WorkspaceProjectInfo, error) + DestroyWorkspaceProject(ctx context.Context, info WorkspaceProjectInfo) error +} + // Workspace-level sentinels surfaced through Create/Restore/Destroy so callers // can map them to typed errors rather than collapsing every adapter failure // into an opaque 500. Adapters wrap these via fmt.Errorf("...: %w", sentinel). @@ -181,6 +190,10 @@ type WorkspaceConfig struct { // BaseBranch is the per-project default branch new session branches are // created from. Empty falls back to the workspace adapter's own default. BaseBranch string + // RepoPath optionally overrides ProjectID-based repo resolution. + RepoPath string + // Path optionally supplies an existing managed worktree path for restore. + Path string } // WorkspaceInfo describes a created workspace — where it lives and its branch. @@ -189,4 +202,50 @@ type WorkspaceInfo struct { Branch string SessionID domain.SessionID ProjectID domain.ProjectID + // RepoPath optionally overrides ProjectID-based repo resolution. It is used + // when the normal workspace lifecycle primitives operate on one child repo + // inside a workspace project. + RepoPath string +} + +// WorkspaceProjectConfig describes a multi-repo workspace session. RootRepoPath +// and child RepoPath values are absolute paths to the canonical repositories. +type WorkspaceProjectConfig struct { + ProjectID domain.ProjectID + SessionID domain.SessionID + Kind domain.SessionKind + SessionPrefix string + Branch string + RootRepoPath string + BaseBranch string + Repos []WorkspaceProjectRepoConfig +} + +// WorkspaceProjectRepoConfig describes one registered child repo in a +// workspace project session. +type WorkspaceProjectRepoConfig struct { + Name string + RelativePath string + RepoPath string + BaseBranch string +} + +// WorkspaceProjectInfo returns the root worktree plus every child worktree. +// Worktrees are ordered root first, then children in creation order. +type WorkspaceProjectInfo struct { + Root WorkspaceInfo + Worktrees []WorkspaceRepoInfo +} + +// WorkspaceRepoInfo describes one materialized repo worktree in a workspace +// project session. +type WorkspaceRepoInfo struct { + RepoName string + RepoPath string + Path string + Branch string + BaseSHA string + SessionID domain.SessionID + ProjectID domain.ProjectID + RelativePath string } diff --git a/backend/internal/processalive/process_unix.go b/backend/internal/processalive/process_unix.go index bf9349ad2..56221f888 100644 --- a/backend/internal/processalive/process_unix.go +++ b/backend/internal/processalive/process_unix.go @@ -5,16 +5,32 @@ package processalive import ( + "bytes" "errors" + "os/exec" + "strconv" "syscall" ) -// Alive reports whether pid exists. EPERM counts as alive: the process exists -// even if the current user cannot signal it. +// Alive reports whether pid maps to a running process. EPERM counts as alive: +// the process exists even if the current user cannot signal it. Zombies are +// treated as not alive because the executable has already exited; only its +// parent has not reaped the process table entry yet. func Alive(pid int) bool { if pid <= 0 { return false } err := syscall.Kill(pid, 0) - return err == nil || errors.Is(err, syscall.EPERM) + if err != nil && !errors.Is(err, syscall.EPERM) { + return false + } + return !isZombie(pid) +} + +func isZombie(pid int) bool { + out, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output() + if err != nil { + return false + } + return bytes.HasPrefix(bytes.TrimSpace(out), []byte("Z")) } diff --git a/backend/internal/processalive/process_unix_test.go b/backend/internal/processalive/process_unix_test.go new file mode 100644 index 000000000..9d5309327 --- /dev/null +++ b/backend/internal/processalive/process_unix_test.go @@ -0,0 +1,29 @@ +//go:build !windows + +package processalive + +import ( + "os/exec" + "testing" + "time" +) + +func TestAliveReportsZombieAsDead(t *testing.T) { + cmd := exec.Command("sh", "-c", "exit 0") + if err := cmd.Start(); err != nil { + t.Fatalf("start child: %v", err) + } + defer func() { _ = cmd.Wait() }() + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if isZombie(cmd.Process.Pid) { + if Alive(cmd.Process.Pid) { + t.Fatal("Alive returned true for zombie process") + } + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("child did not become a zombie before timeout") +} diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index 48e66a653..8088c9415 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -584,6 +584,16 @@ func configureCommitter(t *testing.T) { } func gitRepoWithCommit(t *testing.T, dir string) string { + t.Helper() + return gitRepoWithCommitWithOrigin(t, dir, "https://example.com/"+filepath.Base(dir)+".git") +} + +func gitRepoWithCommitNoOrigin(t *testing.T, dir string) string { + t.Helper() + return gitRepoWithCommitWithOrigin(t, dir, "") +} + +func gitRepoWithCommitWithOrigin(t *testing.T, dir, origin string) string { t.Helper() if out, err := exec.Command("git", "init", "-b", "main", dir).CombinedOutput(); err != nil { t.Fatalf("git init: %v (%s)", err, out) @@ -597,6 +607,11 @@ func gitRepoWithCommit(t *testing.T, dir string) string { if out, err := exec.Command("git", "-C", dir, "commit", "-m", "initial").CombinedOutput(); err != nil { t.Fatalf("git commit: %v (%s)", err, out) } + if origin != "" { + if out, err := exec.Command("git", "-C", dir, "remote", "add", "origin", origin).CombinedOutput(); err != nil { + t.Fatalf("git remote add: %v (%s)", err, out) + } + } return dir } @@ -664,6 +679,17 @@ func TestManager_AddWorkspaceRejectsUncommittedChild(t *testing.T) { wantCode(t, err, "WORKSPACE_CHILD_UNBORN") } +func TestManager_AddWorkspaceRejectsChildWithoutOrigin(t *testing.T) { + configureCommitter(t) + ctx := context.Background() + m := newManager(t) + parent := t.TempDir() + gitRepoWithCommitNoOrigin(t, filepath.Join(parent, "api")) + + _, err := m.Add(ctx, project.AddInput{Path: parent, ProjectID: ptr("ws"), AsWorkspace: true}) + wantCode(t, err, "WORKSPACE_CHILD_ORIGIN_REQUIRED") +} + // TestManager_AddWorkspaceAdoptsExistingParent verifies that when the parent is // already a git repo, Add commits only .gitignore changes, preserves the prior // commit history, and registers the children. diff --git a/backend/internal/service/project/workspace_registration.go b/backend/internal/service/project/workspace_registration.go index fe072984c..7f5616e9a 100644 --- a/backend/internal/service/project/workspace_registration.go +++ b/backend/internal/service/project/workspace_registration.go @@ -213,6 +213,12 @@ func validateWorkspaceChild(ctx context.Context, child string) error { "suggestedFix": "Check out the repository's default branch (for example `main`) and retry.", }) } + if origin := resolveGitOriginURL(child); origin == "" { + return apierr.Invalid("WORKSPACE_CHILD_ORIGIN_REQUIRED", "Workspace child repositories must have an origin remote configured", map[string]any{ + "path": child, + "suggestedFix": "Run `git remote add origin ` in the child repository, then retry.", + }) + } return nil } diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index e14452912..46ab50808 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -92,6 +92,7 @@ type Store interface { // GetProject loads a project row so spawn can resolve its per-project agent // config into the launch command. ok=false means the project is unknown. GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) + ListWorkspaceRepos(ctx context.Context, projectID string) ([]domain.WorkspaceRepoRecord, error) CreateSession(ctx context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) @@ -238,16 +239,9 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess branch := cfg.Branch if branch == "" { - branch = defaultSessionBranch(id, cfg.Kind, sessionPrefix(project)) + branch = defaultSpawnBranch(id, cfg.Kind, sessionPrefix(project), project.Kind.WithDefault()) } - ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ - ProjectID: cfg.ProjectID, - SessionID: id, - Kind: cfg.Kind, - SessionPrefix: sessionPrefix(project), - Branch: branch, - BaseBranch: project.Config.WithDefaults().DefaultBranch, - }) + ws, workspaceProject, err := m.createSessionWorkspace(ctx, project, cfg, id, branch) if err != nil { // Nothing observable exists yet — no worktree, no runtime — so the seed // row is deleted outright instead of accumulating as a terminated orphan @@ -259,20 +253,20 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess // Per-project workspace provisioning: symlink shared files, then run any // post-create commands (e.g. `pnpm install`) before the agent launches. if err := m.provisionWorkspace(ctx, project, ws.Path); err != nil { - _ = m.workspace.Destroy(ctx, ws) + m.destroySpawnWorkspace(ctx, ws, workspaceProject) m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: provision: %w", id, err) } agent, ok := m.agents.Agent(cfg.Harness) if !ok { - _ = m.workspace.Destroy(ctx, ws) + m.destroySpawnWorkspace(ctx, ws, workspaceProject) m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: no agent adapter for harness %q", id, cfg.Harness) } agentConfig := effectiveAgentConfig(cfg.Kind, project.Config) if err := m.prepareWorkspace(ctx, agent, id, ws.Path, systemPrompt, agentConfig); err != nil { - _ = m.workspace.Destroy(ctx, ws) + m.destroySpawnWorkspace(ctx, ws, workspaceProject) m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err) } @@ -287,7 +281,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess Permissions: agentConfig.Permissions, }) if err != nil { - _ = m.workspace.Destroy(ctx, ws) + m.destroySpawnWorkspace(ctx, ws, workspaceProject) m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: launch command: %w", id, err) } @@ -296,7 +290,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess // tmux happily creates a session+pane around a missing command, so an // unresolved binary would leak through as a "live" session that never ran. if err := m.validateAgentBinary(argv); err != nil { - _ = m.workspace.Destroy(ctx, ws) + m.destroySpawnWorkspace(ctx, ws, workspaceProject) m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err) } @@ -307,7 +301,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess Env: m.runtimeEnv(id, cfg.ProjectID, cfg.IssueID, project.Config.Env), }) if err != nil { - _ = m.workspace.Destroy(ctx, ws) + m.destroySpawnWorkspace(ctx, ws, workspaceProject) m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: runtime: %w", id, err) } @@ -315,7 +309,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess metadata := domain.SessionMetadata{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandleID: handle.ID, Prompt: prompt} if err := m.lcm.MarkSpawned(ctx, id, metadata); err != nil { _ = m.runtime.Destroy(ctx, handle) - _ = m.workspace.Destroy(ctx, ws) + m.destroySpawnWorkspace(ctx, ws, workspaceProject) m.markSpawnFailedTerminated(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: completed: %w", id, err) } @@ -338,6 +332,73 @@ func (m *Manager) loadProject(ctx context.Context, projectID domain.ProjectID) ( return row, nil } +func (m *Manager) createSessionWorkspace(ctx context.Context, project domain.ProjectRecord, cfg ports.SpawnConfig, id domain.SessionID, branch string) (ports.WorkspaceInfo, *ports.WorkspaceProjectInfo, error) { + if project.Kind.WithDefault() != domain.ProjectKindWorkspace { + ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ + ProjectID: cfg.ProjectID, + SessionID: id, + Kind: cfg.Kind, + SessionPrefix: sessionPrefix(project), + Branch: branch, + BaseBranch: project.Config.WithDefaults().DefaultBranch, + }) + return ws, nil, err + } + workspaceProject, ok := m.workspace.(ports.WorkspaceProject) + if !ok { + return ports.WorkspaceInfo{}, nil, errors.New("workspace project materialization is not supported by workspace adapter") + } + repos, err := m.store.ListWorkspaceRepos(ctx, project.ID) + if err != nil { + return ports.WorkspaceInfo{}, nil, err + } + childRepos := make([]ports.WorkspaceProjectRepoConfig, 0, len(repos)) + for _, repo := range repos { + childRepos = append(childRepos, ports.WorkspaceProjectRepoConfig{ + Name: repo.Name, + RelativePath: repo.RelativePath, + RepoPath: filepath.Join(project.Path, filepath.FromSlash(repo.RelativePath)), + }) + } + info, err := workspaceProject.CreateWorkspaceProject(ctx, ports.WorkspaceProjectConfig{ + ProjectID: cfg.ProjectID, + SessionID: id, + Kind: cfg.Kind, + SessionPrefix: sessionPrefix(project), + Branch: branch, + RootRepoPath: project.Path, + BaseBranch: project.Config.WithDefaults().DefaultBranch, + Repos: childRepos, + }) + if err != nil { + return ports.WorkspaceInfo{}, nil, err + } + for _, wt := range info.Worktrees { + if err := m.store.UpsertSessionWorktree(ctx, domain.SessionWorktreeRecord{ + SessionID: id, + RepoName: wt.RepoName, + Branch: wt.Branch, + BaseSHA: wt.BaseSHA, + WorktreePath: wt.Path, + State: "active", + }); err != nil { + _ = workspaceProject.DestroyWorkspaceProject(ctx, info) + return ports.WorkspaceInfo{}, nil, fmt.Errorf("record workspace worktree %q: %w", wt.RepoName, err) + } + } + return info.Root, &info, nil +} + +func (m *Manager) destroySpawnWorkspace(ctx context.Context, ws ports.WorkspaceInfo, workspaceProject *ports.WorkspaceProjectInfo) { + if workspaceProject != nil { + if adapter, ok := m.workspace.(ports.WorkspaceProject); ok { + _ = adapter.DestroyWorkspaceProject(ctx, *workspaceProject) + return + } + } + _ = m.workspace.Destroy(ctx, ws) +} + // effectiveHarness resolves the harness for a spawn: an explicit harness wins; // otherwise the project's role override for the session kind applies. Empty is // invalid for new worker/orchestrator launches and is rejected by Spawn. @@ -442,15 +503,15 @@ func (m *Manager) RollbackSpawn(ctx context.Context, id domain.SessionID) (delet return m.rollbackSpawn(ctx, id) } -// Kill records terminal intent with the LCM, then tears down the runtime and -// workspace. A workspace teardown refused by the worktree-remove safety -// (uncommitted work) is never forced: the session still terminates and Kill -// succeeds with freed=false, signalling the workspace was preserved. +// Kill tears down the runtime and workspace, then records terminal intent with +// the LCM. A workspace teardown refused by the worktree-remove safety +// (uncommitted work) is never forced: Kill succeeds with freed=false, +// signalling the workspace was preserved and the session is left retryable. // // A session whose runtime handle or workspace path is missing (e.g. spawn -// failed partway, handle lost after a crash) is still terminated — the destroy -// steps are skipped for whatever is absent, but the session record always -// moves to terminal state so it can be cleaned up from the dashboard. +// failed partway, handle lost after a crash) is still terminated after the +// available destroy steps are skipped so it can be cleaned up from the +// dashboard. func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) { rec, ok, err := m.store.GetSession(ctx, id) if err != nil { @@ -462,24 +523,31 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) { handle := runtimeHandle(rec.Metadata) ws := workspaceInfo(rec) - // Always record terminal intent so the session is marked terminated even - // when the runtime/workspace handle is missing. - if err := m.lcm.MarkTerminated(ctx, id); err != nil { - return false, fmt.Errorf("kill %s: %w", id, err) - } - if err := m.store.DeleteSessionWorktrees(ctx, id); err != nil { - return false, fmt.Errorf("kill %s: delete restore marker: %w", id, err) + var workspaceProjectRows []ports.WorkspaceRepoInfo + workspaceProject := false + if rows, ok, rowErr := m.workspaceProjectRows(ctx, rec); rowErr != nil { + return false, fmt.Errorf("kill %s: workspace rows: %w", id, rowErr) + } else if ok { + workspaceProjectRows = rows + workspaceProject = true } - // Only tear down what exists. A session may have lost its handle after a - // crash or never acquired one if spawn failed partway. if handle.ID != "" { if err := m.runtime.Destroy(ctx, handle); err != nil { return false, fmt.Errorf("kill %s: runtime: %w", id, err) } } freed := false - if ws.Path != "" { + if workspaceProject { + cleaned, err := m.destroyWorkspaceProjectRows(ctx, workspaceProjectRows) + 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 @@ -488,6 +556,16 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) { } freed = true } + // Clear the restore marker so the next boot's RestoreAll cannot resurrect a + // killed session (#2319). For workspace projects this must happen after + // teardown reads the rows; dirty-preserved rows return above and are left as + // non-restorable inventory. + if err := m.store.DeleteSessionWorktrees(ctx, id); err != nil { + m.logger.Warn("kill: delete restore marker failed", "sessionID", id, "error", err) + } + if err := m.lcm.MarkTerminated(ctx, id); err != nil { + return false, fmt.Errorf("kill %s: %w", id, err) + } return freed, nil } @@ -521,6 +599,11 @@ func (m *Manager) RetireForReplacement(ctx context.Context, id domain.SessionID) } return nil } + if rows, ok, rowErr := m.workspaceProjectRows(ctx, rec); rowErr != nil { + return fmt.Errorf("retire replacement %s: workspace rows: %w", id, rowErr) + } else if ok { + return m.retireWorkspaceProjectForReplacement(ctx, rec, rows) + } ws := workspaceInfo(rec) if _, err := m.workspace.StashUncommitted(ctx, ws); err != nil { @@ -544,6 +627,32 @@ func (m *Manager) RetireForReplacement(ctx context.Context, id domain.SessionID) return nil } +func (m *Manager) retireWorkspaceProjectForReplacement(ctx context.Context, rec domain.SessionRecord, rows []ports.WorkspaceRepoInfo) error { + for _, row := range rows { + if _, err := m.workspace.StashUncommitted(ctx, workspaceInfoFromRepoInfo(row)); err != nil { + return fmt.Errorf("retire replacement %s repo %s: stash: %w", rec.ID, row.RepoName, err) + } + } + handle := runtimeHandle(rec.Metadata) + if handle.ID != "" { + if err := m.runtime.Destroy(ctx, handle); err != nil { + return fmt.Errorf("retire replacement %s: runtime: %w", rec.ID, err) + } + } + for i := len(rows) - 1; i >= 0; i-- { + if err := m.workspace.ForceDestroy(ctx, workspaceInfoFromRepoInfo(rows[i])); err != nil { + return fmt.Errorf("retire replacement %s repo %s: force destroy: %w", rec.ID, rows[i].RepoName, err) + } + } + if err := m.store.DeleteSessionWorktrees(ctx, rec.ID); err != nil { + return fmt.Errorf("retire replacement %s: clear restore markers: %w", rec.ID, err) + } + if err := m.lcm.MarkTerminated(ctx, rec.ID); err != nil { + return fmt.Errorf("retire replacement %s: mark terminated: %w", rec.ID, err) + } + return nil +} + // Restore relaunches a torn-down session in its workspace. The fallible I/O runs // before any durable session write, so a failure never resurrects the row or destroys // the worktree (it may hold the agent's prior work). @@ -576,51 +685,49 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess if err != nil { return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err) } - ws, err := m.workspace.Restore(ctx, ports.WorkspaceConfig{ - ProjectID: rec.ProjectID, - SessionID: id, - Kind: rec.Kind, - SessionPrefix: sessionPrefix(project), - Branch: meta.Branch, - }) + ws, err := m.restoreSessionWorkspace(ctx, project, rec) if err != nil { return domain.SessionRecord{}, fmt.Errorf("restore %s: workspace: %w", id, err) } + return m.relaunchRestoredSession(ctx, rec, project, ws) +} + +func (m *Manager) relaunchRestoredSession(ctx context.Context, rec domain.SessionRecord, project domain.ProjectRecord, ws ports.WorkspaceInfo) (domain.SessionRecord, error) { agent, ok := m.agents.Agent(rec.Harness) if !ok { - return domain.SessionRecord{}, fmt.Errorf("restore %s: no agent adapter for harness %q", id, rec.Harness) + return domain.SessionRecord{}, fmt.Errorf("restore %s: no agent adapter for harness %q", rec.ID, rec.Harness) } // The system prompt is derived, not persisted: recompute it so a restored // session keeps its standing instructions across the relaunch. systemPrompt, err := m.buildSystemPrompt(ctx, rec.Kind, rec.ProjectID) if err != nil { - return domain.SessionRecord{}, fmt.Errorf("restore %s: system prompt: %w", id, err) + return domain.SessionRecord{}, fmt.Errorf("restore %s: system prompt: %w", rec.ID, err) } // Restore re-applies the project's resolved agent config so a configured // model/permissions carry across a restore, matching fresh spawn. agentConfig := effectiveAgentConfig(rec.Kind, project.Config) - if err := m.prepareWorkspace(ctx, agent, id, ws.Path, systemPrompt, agentConfig); err != nil { - return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err) + if err := m.prepareWorkspace(ctx, agent, rec.ID, ws.Path, systemPrompt, agentConfig); err != nil { + return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", rec.ID, err) } - argv, err := restoreArgv(ctx, agent, id, ws.Path, meta, systemPrompt, agentConfig, rec.Kind) + argv, err := restoreArgv(ctx, agent, rec.ID, ws.Path, rec.Metadata, systemPrompt, agentConfig, rec.Kind) if err != nil { - return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err) + return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", rec.ID, err) } handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{ - SessionID: id, + SessionID: rec.ID, WorkspacePath: ws.Path, Argv: argv, - Env: m.runtimeEnv(id, rec.ProjectID, rec.IssueID, project.Config.Env), + Env: m.runtimeEnv(rec.ID, rec.ProjectID, rec.IssueID, project.Config.Env), }) if err != nil { - return domain.SessionRecord{}, fmt.Errorf("restore %s: runtime: %w", id, err) + return domain.SessionRecord{}, fmt.Errorf("restore %s: runtime: %w", rec.ID, err) } - metadata := domain.SessionMetadata{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandleID: handle.ID, AgentSessionID: meta.AgentSessionID, Prompt: meta.Prompt} - if err := m.lcm.MarkSpawned(ctx, id, metadata); err != nil { + metadata := domain.SessionMetadata{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandleID: handle.ID, AgentSessionID: rec.Metadata.AgentSessionID, Prompt: rec.Metadata.Prompt} + if err := m.lcm.MarkSpawned(ctx, rec.ID, metadata); err != nil { _ = m.runtime.Destroy(ctx, handle) - return domain.SessionRecord{}, fmt.Errorf("restore %s: completed: %w", id, err) + return domain.SessionRecord{}, fmt.Errorf("restore %s: completed: %w", rec.ID, err) } - return m.getRecord(ctx, id) + return m.getRecord(ctx, rec.ID) } // SwitchHarness re-points a session's agent to newHarness on the same worktree @@ -900,7 +1007,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) } } @@ -911,10 +1018,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) @@ -929,6 +1041,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) @@ -941,7 +1054,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) } @@ -983,38 +1096,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 } @@ -1109,14 +1195,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 @@ -1126,33 +1207,67 @@ 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 + restoredWorkspaceProject := project.Kind.WithDefault() == domain.ProjectKindWorkspace + var projectRows []ports.WorkspaceRepoInfo + if restoredWorkspaceProject { + var rowErr error + projectRows, rowErr = m.workspaceProjectRestoreRowsFromMarkers(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 restoredWorkspaceProject { + 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). } } - // Step 3: relaunch via the existing single-session Restore method. - if _, err := m.Restore(ctx, rec.ID); err != nil { + // Step 3: relaunch the agent in the restored workspace. + if _, err := m.relaunchRestoredSession(ctx, rec, project, ws); err != nil { // A promptless, unresumable worker is intentionally left terminated // (ErrNotResumable): expected, not an operational failure, so log it // quietly rather than as an error. @@ -1163,13 +1278,305 @@ func (m *Manager) RestoreAll(ctx context.Context) error { } continue } - 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) + + // One-shot: drop the consumed marker so it never outlives one restart + // (#2319). A still-live session re-acquires it at the next quit. + if restoredWorkspaceProject { + for _, row := range projectRows { + if err := m.upsertWorkspaceProjectRowState(ctx, row, "active"); err != nil { + m.logger.Warn("restore-all: marking workspace repo active failed", "sessionID", rec.ID, "repo", row.RepoName, "error", err) + } + } + } else { + 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.Warn("restore-all: delete restore marker failed", "sessionID", rec.ID, "error", err) + } } } return nil } +func restorableWorktreeRows(rows []domain.SessionWorktreeRecord) []domain.SessionWorktreeRecord { + out := make([]domain.SessionWorktreeRecord, 0, len(rows)) + for _, row := range rows { + if row.State == "removed" || legacyRestorableWorktreeRow(row) { + out = append(out, row) + } + } + return out +} + +func legacyRestorableWorktreeRow(row domain.SessionWorktreeRecord) bool { + return row.State == "" && (row.PreservedRef != "" || row.RepoName == domain.RootWorkspaceRepoName) +} + +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) restoreSessionWorkspace(ctx context.Context, project domain.ProjectRecord, rec domain.SessionRecord) (ports.WorkspaceInfo, error) { + if project.Kind.WithDefault() != domain.ProjectKindWorkspace { + return m.workspace.Restore(ctx, ports.WorkspaceConfig{ + ProjectID: rec.ProjectID, + SessionID: rec.ID, + Kind: rec.Kind, + SessionPrefix: sessionPrefix(project), + Branch: rec.Metadata.Branch, + }) + } + rows, err := m.workspaceProjectRestoreRows(ctx, project, rec) + if err != nil { + return ports.WorkspaceInfo{}, err + } + root, err := m.restoreWorkspaceProjectRows(ctx, rows) + if err != nil { + return ports.WorkspaceInfo{}, err + } + for _, row := range rows { + if err := m.upsertWorkspaceProjectRowState(ctx, row, "active"); err != nil { + return ports.WorkspaceInfo{}, fmt.Errorf("mark repo %s active: %w", row.RepoName, err) + } + } + return workspaceInfoFromRepoInfo(root), nil +} + +func (m *Manager) workspaceProjectRestoreRows(ctx context.Context, project domain.ProjectRecord, rec domain.SessionRecord) ([]ports.WorkspaceRepoInfo, error) { + rows, err := m.store.ListSessionWorktrees(ctx, rec.ID) + if err != nil { + return nil, err + } + return m.workspaceProjectRestoreRowsFromMarkers(ctx, project, rec, rows) +} + +func (m *Manager) workspaceProjectRestoreRowsFromMarkers(ctx context.Context, project domain.ProjectRecord, rec domain.SessionRecord, rows []domain.SessionWorktreeRecord) ([]ports.WorkspaceRepoInfo, error) { + if len(rows) > 1 { + return m.sessionWorktreeRowsToRepoInfos(ctx, project, rec, rows) + } + childRepos, err := m.store.ListWorkspaceRepos(ctx, project.ID) + if err != nil { + return nil, err + } + rootPath := rec.Metadata.WorkspacePath + rootBranch := rec.Metadata.Branch + var rootBaseSHA string + if len(rows) == 1 && (rows[0].RepoName == "" || rows[0].RepoName == domain.RootWorkspaceRepoName) { + rootPath = firstNonEmptyString(rows[0].WorktreePath, rootPath) + rootBranch = firstNonEmptyString(rows[0].Branch, rootBranch) + rootBaseSHA = rows[0].BaseSHA + } + out := []ports.WorkspaceRepoInfo{{ + RepoName: domain.RootWorkspaceRepoName, + RepoPath: project.Path, + Path: rootPath, + Branch: rootBranch, + BaseSHA: rootBaseSHA, + SessionID: rec.ID, + ProjectID: rec.ProjectID, + }} + for _, repo := range childRepos { + out = append(out, ports.WorkspaceRepoInfo{ + RepoName: repo.Name, + RepoPath: filepath.Join(project.Path, filepath.FromSlash(repo.RelativePath)), + Path: filepath.Join(rootPath, filepath.FromSlash(repo.RelativePath)), + Branch: rootBranch, + SessionID: rec.ID, + ProjectID: rec.ProjectID, + RelativePath: repo.RelativePath, + }) + } + return out, 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("session worktree row %q no longer matches workspace registry", row.RepoName) + } + 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 { + for _, row := range rows { + ref, err := m.workspace.StashUncommitted(ctx, workspaceInfoFromRepoInfo(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 := m.workspace.ForceDestroy(ctx, workspaceInfoFromRepoInfo(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) { + cleaned := false + var firstErr error + for i := len(rows) - 1; i >= 0; i-- { + if rows[i].Path == "" { + continue + } + info := workspaceInfoFromRepoInfo(rows[i]) + if err := m.workspace.Destroy(ctx, info); err != nil { + if errors.Is(err, ports.ErrWorkspaceDirty) { + return cleaned, err + } + if stateErr := m.upsertWorkspaceProjectRowState(ctx, rows[i], "retry_remove"); 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 string) error { + return m.store.UpsertSessionWorktree(ctx, domain.SessionWorktreeRecord{ + SessionID: row.SessionID, + RepoName: row.RepoName, + Branch: row.Branch, + BaseSHA: row.BaseSHA, + WorktreePath: row.Path, + State: state, + }) +} + +func (m *Manager) restoreWorkspaceProjectRows(ctx context.Context, rows []ports.WorkspaceRepoInfo) (ports.WorkspaceRepoInfo, error) { + var root ports.WorkspaceRepoInfo + for _, row := range rows { + restored, err := m.workspace.Restore(ctx, ports.WorkspaceConfig{ + ProjectID: row.ProjectID, + SessionID: row.SessionID, + Branch: row.Branch, + RepoPath: row.RepoPath, + Path: row.Path, + }) + if err != nil { + return ports.WorkspaceRepoInfo{}, fmt.Errorf("repo %s: %w", row.RepoName, err) + } + row.Path = restored.Path + row.Branch = restored.Branch + if row.RepoName == domain.RootWorkspaceRepoName { + root = row + } + } + 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) { + 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 := m.workspace.ApplyPreserved(ctx, workspaceInfoFromRepoInfo(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 { @@ -1211,7 +1618,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. @@ -1269,6 +1688,13 @@ func defaultSessionBranch(id domain.SessionID, kind domain.SessionKind, prefix s return "ao/" + string(id) + "/root" } +func defaultSpawnBranch(id domain.SessionID, kind domain.SessionKind, prefix string, projectKind domain.ProjectKind) string { + if projectKind == domain.ProjectKindWorkspace { + return "ao/" + string(id) + } + return defaultSessionBranch(id, kind, prefix) +} + func buildPrompt(cfg ports.SpawnConfig) string { return cfg.Prompt } @@ -1311,6 +1737,13 @@ func (m *Manager) buildSystemPrompt(ctx context.Context, kind domain.SessionKind if base == "" { return "", nil } + workspacePrompt, err := m.workspaceProjectPrompt(ctx, kind, projectID) + if err != nil { + return "", err + } + if workspacePrompt != "" { + base += "\n\n" + workspacePrompt + } return base + m.aoSkillPointer() + systemPromptGuard, nil } @@ -1328,6 +1761,28 @@ func (m *Manager) aoSkillPointer() string { "When you need to use the `ao` CLI, read `" + skillFile + "` first (and the relevant `" + commandsGlob + "`) for the full command catalog, flags, and examples." } +func (m *Manager) workspaceProjectPrompt(ctx context.Context, kind domain.SessionKind, projectID domain.ProjectID) (string, error) { + project, err := m.loadProject(ctx, projectID) + if err != nil { + return "", err + } + if project.Kind.WithDefault() != domain.ProjectKindWorkspace { + return "", nil + } + repos, err := m.store.ListWorkspaceRepos(ctx, string(projectID)) + if err != nil { + return "", fmt.Errorf("list workspace repos for prompt: %w", err) + } + switch kind { + case domain.KindOrchestrator: + return workspaceOrchestratorPrompt(repos), nil + case domain.KindWorker: + return workspaceWorkerPrompt(repos), nil + default: + return "", nil + } +} + func (m *Manager) activeOrchestratorSessionID(ctx context.Context, project domain.ProjectID) (domain.SessionID, bool, error) { recs, err := m.store.ListSessions(ctx, project) if err != nil { @@ -1368,6 +1823,37 @@ To discover any other AO command, run `+"`ao --help`"+` (and `+"`ao -- Use workers for focused implementation tasks, track their progress, synthesize their results, and only step into implementation directly for true emergencies or small coordination fixes.`, project, project) } +func workspaceOrchestratorPrompt(repos []domain.WorkspaceRepoRecord) string { + return fmt.Sprintf(`## Workspace project + +This project is a multi-repository workspace. Sessions start at the workspace root. The root repository is %s at path `+"`.`"+`; child repositories are nested below it. + +Repositories: +%s + +When spawning workers, name the repository path or paths they should work in. Work can span multiple repositories, so track deliverables, pull requests, and checks by repository.`, domain.RootWorkspaceRepoName, workspaceRepoList(repos)) +} + +func workspaceWorkerPrompt(repos []domain.WorkspaceRepoRecord) string { + return fmt.Sprintf(`## Workspace project + +This session is a multi-repository workspace. You start at the workspace root. The root repository is %s at path `+"`.`"+`; child repositories are nested below it. + +Repositories: +%s + +Before editing, identify which repository owns the task and keep changes scoped to the requested repository or repositories. If you touch root files, call that out explicitly because root changes are separate from child-repository changes.`, domain.RootWorkspaceRepoName, workspaceRepoList(repos)) +} + +func workspaceRepoList(repos []domain.WorkspaceRepoRecord) string { + lines := make([]string, 0, 1+len(repos)) + lines = append(lines, fmt.Sprintf("- %s: .", domain.RootWorkspaceRepoName)) + for _, repo := range repos { + lines = append(lines, fmt.Sprintf("- %s: %s", repo.Name, repo.RelativePath)) + } + return strings.Join(lines, "\n") +} + func workerOrchestratorPrompt(orchestratorID domain.SessionID) string { return fmt.Sprintf(`## Orchestrator coordination @@ -1655,3 +2141,22 @@ 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, + RepoPath: info.RepoPath, + } +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 119a7946e..00219ad3f 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -20,11 +20,13 @@ import ( var ctx = context.Background() type fakeStore struct { - sessions map[domain.SessionID]domain.SessionRecord - pr map[domain.SessionID]domain.PRFacts - projects map[string]domain.ProjectRecord - num int - deleteErr error + sessions map[domain.SessionID]domain.SessionRecord + pr map[domain.SessionID]domain.PRFacts + projects map[string]domain.ProjectRecord + workspaceRepo map[string][]domain.WorkspaceRepoRecord + num int + deleteErr error + upsertWTErr error // worktrees maps session ID to its saved worktree rows (shutdown-saved marker). worktrees map[domain.SessionID][]domain.SessionWorktreeRecord // sharedLog, when non-nil, receives an ordered call entry for each @@ -34,16 +36,20 @@ type fakeStore struct { func newFakeStore() *fakeStore { return &fakeStore{ - sessions: map[domain.SessionID]domain.SessionRecord{}, - pr: map[domain.SessionID]domain.PRFacts{}, - projects: map[string]domain.ProjectRecord{}, - worktrees: map[domain.SessionID][]domain.SessionWorktreeRecord{}, + sessions: map[domain.SessionID]domain.SessionRecord{}, + pr: map[domain.SessionID]domain.PRFacts{}, + projects: map[string]domain.ProjectRecord{}, + workspaceRepo: map[string][]domain.WorkspaceRepoRecord{}, + worktrees: map[domain.SessionID][]domain.SessionWorktreeRecord{}, } } func (f *fakeStore) GetProject(_ context.Context, id string) (domain.ProjectRecord, bool, error) { r, ok := f.projects[id] return r, ok, nil } +func (f *fakeStore) ListWorkspaceRepos(_ context.Context, projectID string) ([]domain.WorkspaceRepoRecord, error) { + return f.workspaceRepo[projectID], nil +} func (f *fakeStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) { f.num++ rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, f.num)) @@ -96,6 +102,9 @@ func (f *fakeStore) GetDisplayPRFactsForSession(_ context.Context, id domain.Ses return domain.PRFacts{}, false, nil } func (f *fakeStore) UpsertSessionWorktree(_ context.Context, row domain.SessionWorktreeRecord) error { + if f.upsertWTErr != nil { + return f.upsertWTErr + } if f.sharedLog != nil { *f.sharedLog = append(*f.sharedLog, "UpsertSessionWorktree:"+string(row.SessionID)) } @@ -289,10 +298,14 @@ type missingAgents struct{} func (missingAgents) Agent(domain.AgentHarness) (ports.Agent, bool) { return nil, false } type fakeWorkspace struct { - createErr error - destroyErr error - destroyed int - lastCfg ports.WorkspaceConfig + createErr error + destroyErr error + destroyed int + lastCfg ports.WorkspaceConfig + projectErr error + projectDestroyed int + lastProjectCfg ports.WorkspaceProjectConfig + projectCreateInfo ports.WorkspaceProjectInfo // path, when set, is returned as the workspace path so provisioning tests // can point at a real temp directory. path string @@ -321,15 +334,78 @@ func (w *fakeWorkspace) Create(_ context.Context, cfg ports.WorkspaceConfig) (po } return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil } -func (w *fakeWorkspace) Destroy(context.Context, ports.WorkspaceInfo) error { +func (w *fakeWorkspace) CreateWorkspaceProject(_ context.Context, cfg ports.WorkspaceProjectConfig) (ports.WorkspaceProjectInfo, error) { + if w.projectErr != nil { + return ports.WorkspaceProjectInfo{}, w.projectErr + } + w.lastProjectCfg = cfg + if len(w.projectCreateInfo.Worktrees) > 0 { + return w.projectCreateInfo, nil + } + rootPath := w.path + if rootPath == "" { + rootPath = "/ws/" + string(cfg.SessionID) + } + branch := cfg.Branch + root := ports.WorkspaceInfo{Path: rootPath, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID} + out := ports.WorkspaceProjectInfo{ + Root: root, + Worktrees: []ports.WorkspaceRepoInfo{{ + RepoName: domain.RootWorkspaceRepoName, + RepoPath: cfg.RootRepoPath, + Path: rootPath, + Branch: branch, + BaseSHA: "root-base", + SessionID: cfg.SessionID, + ProjectID: cfg.ProjectID, + }}, + } + for _, repo := range cfg.Repos { + out.Worktrees = append(out.Worktrees, ports.WorkspaceRepoInfo{ + RepoName: repo.Name, + RepoPath: repo.RepoPath, + Path: filepath.Join(rootPath, filepath.FromSlash(repo.RelativePath)), + Branch: branch, + BaseSHA: repo.Name + "-base", + SessionID: cfg.SessionID, + ProjectID: cfg.ProjectID, + RelativePath: repo.RelativePath, + }) + } + return out, nil +} +func (w *fakeWorkspace) Destroy(_ context.Context, info ports.WorkspaceInfo) error { + if info.RepoPath != "" { + entry := "Destroy:" + fakeWorkspaceRepoName(info) + w.calls = append(w.calls, entry) + if w.sharedLog != nil { + *w.sharedLog = append(*w.sharedLog, entry) + } + } w.destroyed++ return w.destroyErr } +func (w *fakeWorkspace) DestroyWorkspaceProject(context.Context, ports.WorkspaceProjectInfo) error { + w.projectDestroyed++ + return w.destroyErr +} func (w *fakeWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { + if cfg.RepoPath != "" { + entry := "Restore:" + fakeWorkspaceRepoName(ports.WorkspaceInfo{ + Path: cfg.Path, + SessionID: cfg.SessionID, + RepoPath: cfg.RepoPath, + }) + w.calls = append(w.calls, entry) + return ports.WorkspaceInfo{Path: cfg.Path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID, RepoPath: cfg.RepoPath}, nil + } return w.Create(ctx, cfg) } func (w *fakeWorkspace) ForceDestroy(_ context.Context, info ports.WorkspaceInfo) error { entry := "ForceDestroy:" + string(info.SessionID) + if info.RepoPath != "" { + entry = "ForceDestroy:" + fakeWorkspaceRepoName(info) + } w.calls = append(w.calls, entry) if w.sharedLog != nil { *w.sharedLog = append(*w.sharedLog, entry) @@ -339,17 +415,34 @@ func (w *fakeWorkspace) ForceDestroy(_ context.Context, info ports.WorkspaceInfo func (w *fakeWorkspace) StashUncommitted(_ context.Context, info ports.WorkspaceInfo) (string, error) { w.stashCalls++ entry := "StashUncommitted:" + string(info.SessionID) + if info.RepoPath != "" { + entry = "StashUncommitted:" + fakeWorkspaceRepoName(info) + } w.calls = append(w.calls, entry) if w.sharedLog != nil { *w.sharedLog = append(*w.sharedLog, entry) } - return w.stashRef, w.stashErr + if w.stashErr != nil || w.stashRef == "" || info.RepoPath == "" { + return w.stashRef, w.stashErr + } + return w.stashRef + "/" + fakeWorkspaceRepoName(info), nil } func (w *fakeWorkspace) ApplyPreserved(_ context.Context, info ports.WorkspaceInfo, ref string) error { - w.calls = append(w.calls, "ApplyPreserved:"+string(info.SessionID)) + entry := "ApplyPreserved:" + string(info.SessionID) + if info.RepoPath != "" { + entry = "ApplyPreserved:" + fakeWorkspaceRepoName(info) + ":" + ref + } + w.calls = append(w.calls, entry) return w.applyErr } +func fakeWorkspaceRepoName(info ports.WorkspaceInfo) string { + if filepath.Base(info.Path) == string(info.SessionID) { + return domain.RootWorkspaceRepoName + } + return filepath.Base(info.Path) +} + type fakeMessenger struct{ msgs []string } func (m *fakeMessenger) Send(_ context.Context, _ domain.SessionID, msg string) error { @@ -794,6 +887,132 @@ func TestSpawn_ParksRowTerminatedWhenSeedDeleteFails(t *testing.T) { t.Fatal("row must fall back to terminated when the seed delete fails") } } + +func TestSpawn_WorkspaceProjectRecordsRootAndChildWorktrees(t *testing.T) { + st := newFakeStore() + projectPath := filepath.Join(string(filepath.Separator), "repo", "mer") + managedPath := filepath.Join(string(filepath.Separator), "managed", "mer-1") + st.projects["mer"] = domain.ProjectRecord{ + ID: "mer", + Path: projectPath, + Kind: domain.ProjectKindWorkspace, + Config: testRoleAgents(), + } + st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{ + {Name: "api", RelativePath: "services/api"}, + {Name: "web", RelativePath: "apps/web"}, + } + rt := &fakeRuntime{} + ws := &fakeWorkspace{path: managedPath} + m := New(Deps{ + Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, + Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, + LookPath: func(string) (string, error) { return "/bin/true", nil }, + }) + + rec, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + if err != nil { + t.Fatal(err) + } + if rec.Metadata.WorkspacePath != managedPath { + t.Fatalf("workspace path = %q, want root worktree path", rec.Metadata.WorkspacePath) + } + if rec.Metadata.Branch != "ao/mer-1" { + t.Fatalf("workspace branch = %q, want ao/mer-1", rec.Metadata.Branch) + } + if got := ws.lastProjectCfg.RootRepoPath; got != projectPath { + t.Fatalf("root repo path = %q, want %q", got, projectPath) + } + if len(ws.lastProjectCfg.Repos) != 2 { + t.Fatalf("child repo configs = %d, want 2", len(ws.lastProjectCfg.Repos)) + } + if want := filepath.Join(projectPath, "services", "api"); ws.lastProjectCfg.Repos[0].RepoPath != want { + t.Fatalf("api repo path = %q, want %q", ws.lastProjectCfg.Repos[0].RepoPath, want) + } + if want := filepath.Join(projectPath, "apps", "web"); ws.lastProjectCfg.Repos[1].RepoPath != want { + t.Fatalf("web repo path = %q, want %q", ws.lastProjectCfg.Repos[1].RepoPath, want) + } + for _, repo := range ws.lastProjectCfg.Repos { + if repo.BaseBranch != "" { + t.Fatalf("child repo %s base branch = %q, want empty so adapter infers per-repo default", repo.Name, repo.BaseBranch) + } + } + rows := st.worktrees["mer-1"] + if len(rows) != 3 { + t.Fatalf("session worktree rows = %d, want 3: %#v", len(rows), rows) + } + want := map[string]string{ + domain.RootWorkspaceRepoName: managedPath, + "api": filepath.Join(managedPath, "services", "api"), + "web": filepath.Join(managedPath, "apps", "web"), + } + for _, row := range rows { + if row.Branch != rec.Metadata.Branch { + t.Fatalf("row %s branch = %q, want %q", row.RepoName, row.Branch, rec.Metadata.Branch) + } + if want[row.RepoName] != row.WorktreePath { + t.Fatalf("row %s path = %q, want %q", row.RepoName, row.WorktreePath, want[row.RepoName]) + } + if row.BaseSHA == "" { + t.Fatalf("row %s missing base sha", row.RepoName) + } + } + if rt.created != 1 { + t.Fatal("runtime should be created") + } + if ws.destroyed != 0 || ws.projectDestroyed != 0 { + t.Fatal("successful spawn should not destroy workspaces") + } +} + +func TestSpawn_WorkspaceProjectRollsBackAllWorktreesOnRuntimeFailure(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"}} + m.runtime = &fakeRuntime{createErr: errors.New("boom")} + if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err == nil { + t.Fatal("expected failure") + } + if ws.projectDestroyed != 1 { + t.Fatalf("workspace project destroy calls = %d, want 1", ws.projectDestroyed) + } + if ws.destroyed != 0 { + t.Fatalf("single-workspace destroy calls = %d, want 0", ws.destroyed) + } + if _, present := st.sessions["mer-1"]; present { + t.Fatal("seed row should be deleted after runtime creation failure") + } +} + +func TestSpawn_WorkspaceProjectRollsBackWhenWorktreeRowsFail(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.upsertWTErr = errors.New("db locked") + if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err == nil || !strings.Contains(err.Error(), "record workspace worktree") { + t.Fatalf("err = %v, want worktree row failure", err) + } + if ws.projectDestroyed != 1 { + t.Fatalf("workspace project destroy calls = %d, want 1", ws.projectDestroyed) + } + if _, present := st.sessions["mer-1"]; present { + t.Fatal("seed row should be deleted after workspace row failure") + } + if rt.created != 0 { + t.Fatal("runtime.Create must not run when worktree row recording fails") + } +} + func TestKill_TearsDownRuntimeAndWorkspace(t *testing.T) { m, st, rt, ws := newManager() st.sessions["mer-1"] = mkLive("mer-1") @@ -825,12 +1044,11 @@ func TestKill_TerminatesIncompleteHandle(t *testing.T) { } } -// TestKill_DirtyWorkspaceTerminatesAndPreserves: a workspace teardown refused -// because of uncommitted work must NOT fail the kill — the session terminates, -// the runtime is gone, and freed=false reports the preserved worktree. This is -// the normal path for any session with in-progress changes, so an error here -// would turn every such kill into a 500. -func TestKill_DirtyWorkspaceTerminatesAndPreserves(t *testing.T) { +// TestKill_DirtyWorkspacePreservesAndRemainsRetryable: a workspace teardown +// refused because of uncommitted work must NOT force-remove the worktree. Kill +// succeeds with freed=false and leaves the session non-terminal so a later retry +// can complete cleanup. +func TestKill_DirtyWorkspacePreservesAndRemainsRetryable(t *testing.T) { m, st, rt, ws := newManager() st.sessions["mer-1"] = mkLive("mer-1") ws.destroyErr = fmt.Errorf("gitworktree: refusing to remove: %w", ports.ErrWorkspaceDirty) @@ -844,8 +1062,8 @@ func TestKill_DirtyWorkspaceTerminatesAndPreserves(t *testing.T) { if rt.destroyed != 1 { t.Fatal("runtime should be destroyed") } - if !st.sessions["mer-1"].IsTerminated { - t.Fatal("session should be terminated") + if st.sessions["mer-1"].IsTerminated { + t.Fatal("session should remain active so cleanup can be retried") } } @@ -878,6 +1096,114 @@ 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{"Destroy:api", "Destroy:__root__"} + if got := ws.calls; strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("destroy order = %v, want %v", got, want) + } +} + +func TestKill_WorkspaceProjectFailsClosedOnUnregisteredChildRows(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"}} + 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: "old-api", Branch: "ao/mer-1", WorktreePath: "/ws/mer-1/old-api"}, + {SessionID: "mer-1", RepoName: "api", Branch: "ao/mer-1", WorktreePath: "/ws/mer-1/api"}, + } + + freed, err := m.Kill(ctx, "mer-1") + if err == nil || !strings.Contains(err.Error(), "old-api") { + t.Fatalf("freed=%v err=%v, want unresolved historical row error", freed, err) + } + if freed { + t.Fatal("workspace must not be reported freed when historical rows are unresolved") + } + if len(ws.calls) != 0 { + t.Fatalf("destroy calls = %v, want none", ws.calls) + } + if st.sessions["mer-1"].IsTerminated { + t.Fatal("session must remain active when workspace rows cannot be resolved") + } +} + +func TestKill_WorkspaceProjectDirtyRowRefusesRemoval(t *testing.T) { + m, st, _, ws := newManager() + ws.destroyErr = fmt.Errorf("dirty: %w", ports.ErrWorkspaceDirty) + 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 row to preserve workspace", freed, err) + } + want := []string{"Destroy:api"} + if got := ws.calls; strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("calls = %v, want %v", got, want) + } + if st.sessions["mer-1"].IsTerminated { + t.Fatal("session should remain active so dirty workspace cleanup can be retried") + } +} + +func TestKill_RuntimeDestroyFailureLeavesSessionActive(t *testing.T) { + m, st, rt, ws := newManager() + rt.destroyErr = errors.New("tmux transient") + st.sessions["mer-1"] = mkLive("mer-1") + + freed, err := m.Kill(ctx, "mer-1") + if err == nil || !strings.Contains(err.Error(), "runtime") { + t.Fatalf("freed=%v err=%v, want runtime error", freed, err) + } + if freed { + t.Fatal("workspace must not be reported freed when runtime destroy fails") + } + if st.sessions["mer-1"].IsTerminated { + t.Fatal("session must remain active when runtime destroy fails") + } + if ws.destroyed != 0 { + t.Fatalf("workspace destroy calls = %d, want 0 after runtime failure", ws.destroyed) + } +} + 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"}) @@ -892,6 +1218,42 @@ func TestRestore_ReopensTerminal(t *testing.T) { t.Fatal("restore should relaunch") } } + +func TestRestore_WorkspaceProjectRestoresChildrenAndRecordsInventory(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: "services/api"}} + seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1", AgentSessionID: "agent-x"}) + + if _, err := m.Restore(ctx, "mer-1"); err != nil { + t.Fatal(err) + } + wantCalls := []string{"Restore:__root__", "Restore:api"} + if got := strings.Join(ws.calls, ","); got != strings.Join(wantCalls, ",") { + t.Fatalf("restore calls = %v, want %v", ws.calls, wantCalls) + } + if rt.created != 1 { + t.Fatalf("runtime.Create calls = %d, want 1", rt.created) + } + rows, err := st.ListSessionWorktrees(ctx, "mer-1") + if err != nil { + t.Fatal(err) + } + if len(rows) != 2 { + t.Fatalf("workspace rows = %v, want root and child inventory", rows) + } + byRepo := map[string]domain.SessionWorktreeRecord{} + for _, row := range rows { + byRepo[row.RepoName] = row + } + if byRepo[domain.RootWorkspaceRepoName].State != "active" || byRepo["api"].State != "active" { + t.Fatalf("row states = root:%q api:%q, want active inventory", byRepo[domain.RootWorkspaceRepoName].State, byRepo["api"].State) + } + if got := byRepo["api"].WorktreePath; got != filepath.Join("/ws/mer-1", "services", "api") { + t.Fatalf("api worktree path = %q", got) + } +} + func TestRestore_AppliesProjectAgentConfig(t *testing.T) { st := newFakeStore() st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{AgentConfig: domain.AgentConfig{Model: "restore-model"}}} @@ -971,6 +1333,85 @@ 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{"Destroy:api", "Destroy:__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_WorkspaceProjectDirtyRowsAreSkipped(t *testing.T) { + m, st, _, ws := newManager() + ws.destroyErr = fmt.Errorf("dirty: %w", ports.ErrWorkspaceDirty) + 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"] != "" || refs["api"] != "" { + t.Fatalf("api state/ref = %q/%q, want unchanged dirty row", 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}) @@ -1136,6 +1577,76 @@ func TestSpawnOrchestrator_UsesCoordinatorPrompt(t *testing.T) { } } +func TestSpawnOrchestrator_WorkspaceProjectPromptListsRepos(t *testing.T) { + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Kind: domain.ProjectKindWorkspace, Config: testRoleAgents()} + st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{ + {Name: "api", RelativePath: "services/api"}, + {Name: "web", RelativePath: "apps/web"}, + } + agent := &recordingAgent{} + rt := &fakeRuntime{} + ws := &fakeWorkspace{} + lookPath := func(string) (string, error) { return "/bin/true", nil } + m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) + + _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator}) + if err != nil { + t.Fatal(err) + } + + systemPrompt := agent.lastLaunch.SystemPrompt + for _, want := range []string{ + "## Workspace project", + "This project is a multi-repository workspace", + "- __root__: .", + "- api: services/api", + "- web: apps/web", + "When spawning workers, name the repository path", + "track deliverables, pull requests, and checks by repository", + } { + if !strings.Contains(systemPrompt, want) { + t.Fatalf("system prompt missing %q:\n%s", want, systemPrompt) + } + } + if strings.Contains(agent.lastLaunch.Prompt, "multi-repository workspace") { + t.Fatalf("workspace role context must not be in the user prompt:\n%s", agent.lastLaunch.Prompt) + } +} + +func TestSpawnWorker_WorkspaceProjectPromptListsRepos(t *testing.T) { + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Kind: domain.ProjectKindWorkspace, Config: testRoleAgents()} + st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{{Name: "api", RelativePath: "api"}} + agent := &recordingAgent{} + rt := &fakeRuntime{} + ws := &fakeWorkspace{} + lookPath := func(string) (string, error) { return "/bin/true", nil } + m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) + + _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "fix api"}) + if err != nil { + t.Fatal(err) + } + + systemPrompt := agent.lastLaunch.SystemPrompt + for _, want := range []string{ + "## Workspace project", + "This session is a multi-repository workspace", + "- __root__: .", + "- api: api", + "Before editing, identify which repository owns the task", + "If you touch root files, call that out explicitly", + } { + if !strings.Contains(systemPrompt, want) { + t.Fatalf("system prompt missing %q:\n%s", want, systemPrompt) + } + } + if strings.Contains(systemPrompt, "When spawning workers") { + t.Fatalf("worker prompt should not include orchestrator-specific spawn guidance:\n%s", systemPrompt) + } +} + // TestSystemPrompt_AppendsConfidentialityGuard: every non-empty system prompt // must carry the guard that tells the agent not to reveal its standing // instructions on request. Without it, "give me your system prompt" dumps the @@ -1766,6 +2277,149 @@ func TestRetireForReplacementCapturesAndReleasesWorkspace(t *testing.T) { } } +func TestRetireForReplacementWorkspaceProjectCapturesAndReleasesEveryRepo(t *testing.T) { + m, st, rt, ws := newLifecycleManager() + var sharedLog []string + st.sharedLog = &sharedLog + ws.sharedLog = &sharedLog + ws.stashRef = "refs/ao/preserved/mer-orch" + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Path: "/repos/mer", Kind: domain.ProjectKindWorkspace, Config: testRoleAgents()} + st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{{ + ProjectID: "mer", + Name: "api", + RelativePath: "api", + }} + st.sessions["mer-orch"] = domain.SessionRecord{ + ID: "mer-orch", + ProjectID: "mer", + Kind: domain.KindOrchestrator, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-orch", Branch: "ao/mer-orchestrator", RuntimeHandleID: "orch-handle"}, + Activity: domain.Activity{State: domain.ActivityActive}, + } + st.worktrees["mer-orch"] = []domain.SessionWorktreeRecord{ + { + SessionID: "mer-orch", + RepoName: domain.RootWorkspaceRepoName, + Branch: "ao/mer-orchestrator", + WorktreePath: "/ws/mer-orch", + PreservedRef: "refs/ao/preserved/old-root", + State: "active", + }, + { + SessionID: "mer-orch", + RepoName: "api", + Branch: "ao/mer-orchestrator", + WorktreePath: "/ws/mer-orch/api", + PreservedRef: "refs/ao/preserved/old-api", + State: "active", + }, + } + + if err := m.RetireForReplacement(ctx, "mer-orch"); err != nil { + t.Fatalf("RetireForReplacement err = %v", err) + } + + if rows := st.worktrees["mer-orch"]; len(rows) != 0 { + t.Fatalf("replacement retirement must not write restore markers, got %#v", rows) + } + if !st.sessions["mer-orch"].IsTerminated { + t.Fatal("retired orchestrator must be marked terminated") + } + if rt.destroyed != 1 || rt.destroyedIDs[0] != "orch-handle" { + t.Fatalf("runtime destroyed = %d ids=%v, want orch-handle", rt.destroyed, rt.destroyedIDs) + } + + wantOrder := []string{ + "StashUncommitted:__root__", + "StashUncommitted:api", + "ForceDestroy:api", + "ForceDestroy:__root__", + "DeleteSessionWorktrees:mer-orch", + } + next := 0 + for _, call := range sharedLog { + if next < len(wantOrder) && call == wantOrder[next] { + next++ + } + } + if next != len(wantOrder) { + t.Fatalf("workspace project retirement order missing %v in log %v", wantOrder, sharedLog) + } +} + +func TestRetireForReplacementWorkspaceProjectRuntimeDestroyFailureKeepsRepoInventory(t *testing.T) { + m, st, rt, ws := newLifecycleManager() + rt.destroyErr = errors.New("tmux transient") + ws.stashRef = "refs/ao/preserved/mer-orch" + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Path: "/repos/mer", Kind: domain.ProjectKindWorkspace, Config: testRoleAgents()} + st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{{ + ProjectID: "mer", + Name: "api", + RelativePath: "api", + }} + st.sessions["mer-orch"] = domain.SessionRecord{ + ID: "mer-orch", + ProjectID: "mer", + Kind: domain.KindOrchestrator, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-orch", Branch: "ao/mer-orchestrator", RuntimeHandleID: "orch-handle"}, + Activity: domain.Activity{State: domain.ActivityActive}, + } + st.worktrees["mer-orch"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-orch", RepoName: domain.RootWorkspaceRepoName, Branch: "ao/mer-orchestrator", WorktreePath: "/ws/mer-orch", State: "active"}, + {SessionID: "mer-orch", RepoName: "api", Branch: "ao/mer-orchestrator", WorktreePath: "/ws/mer-orch/api", State: "active"}, + } + + err := m.RetireForReplacement(ctx, "mer-orch") + if err == nil || !strings.Contains(err.Error(), "runtime") { + t.Fatalf("RetireForReplacement err = %v, want runtime failure", err) + } + if st.sessions["mer-orch"].IsTerminated { + t.Fatal("session must remain active when runtime destroy fails") + } + if rows := st.worktrees["mer-orch"]; len(rows) != 2 { + t.Fatalf("workspace repo inventory after runtime failure = %v, want root and child retained", rows) + } + for _, call := range ws.calls { + if strings.HasPrefix(call, "ForceDestroy:") { + t.Fatalf("ForceDestroy must not run after runtime destroy failure; calls=%v", ws.calls) + } + } +} + +func TestRetireForReplacementWorkspaceProjectForceDestroyFailureKeepsRepoInventory(t *testing.T) { + m, st, _, ws := newLifecycleManager() + ws.forceDestroyErr = errors.New("worktree still registered") + ws.stashRef = "refs/ao/preserved/mer-orch" + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Path: "/repos/mer", Kind: domain.ProjectKindWorkspace, Config: testRoleAgents()} + st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{{ + ProjectID: "mer", + Name: "api", + RelativePath: "api", + }} + st.sessions["mer-orch"] = domain.SessionRecord{ + ID: "mer-orch", + ProjectID: "mer", + Kind: domain.KindOrchestrator, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-orch", Branch: "ao/mer-orchestrator", RuntimeHandleID: "orch-handle"}, + Activity: domain.Activity{State: domain.ActivityActive}, + } + st.worktrees["mer-orch"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-orch", RepoName: domain.RootWorkspaceRepoName, Branch: "ao/mer-orchestrator", WorktreePath: "/ws/mer-orch", State: "active"}, + {SessionID: "mer-orch", RepoName: "api", Branch: "ao/mer-orchestrator", WorktreePath: "/ws/mer-orch/api", State: "active"}, + } + + err := m.RetireForReplacement(ctx, "mer-orch") + if err == nil || !strings.Contains(err.Error(), "force destroy") { + t.Fatalf("RetireForReplacement err = %v, want force destroy failure", err) + } + if st.sessions["mer-orch"].IsTerminated { + t.Fatal("session must remain active when force destroy fails") + } + if rows := st.worktrees["mer-orch"]; len(rows) != 2 { + t.Fatalf("workspace repo inventory after force destroy failure = %v, want root and child retained", rows) + } +} + func TestRetireForReplacementForceDestroyFailureLeavesSessionActive(t *testing.T) { m, st, rt, ws := newLifecycleManager() ws.forceDestroyErr = errors.New("worktree still registered") @@ -1941,6 +2595,81 @@ 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{"ForceDestroy:api", "ForceDestroy:__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) + } +} + +func TestSaveAndTeardownAll_WorkspaceProjectRegistryDriftPreservesWholeWorkspace(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"}} + 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/root", RuntimeHandleID: "h1"}, + Activity: domain.Activity{State: domain.ActivityActive}, + } + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, Branch: "ao/mer-1/root", WorktreePath: "/ws/mer-1", State: "active"}, + {SessionID: "mer-1", RepoName: "old-child", Branch: "ao/mer-1/root", WorktreePath: "/ws/mer-1/old-child", State: "active"}, + } + + if err := m.SaveAndTeardownAll(ctx); err != nil { + t.Fatalf("SaveAndTeardownAll err = %v", err) + } + if ws.stashCalls != 0 { + t.Fatalf("stash calls = %d, want 0 when registry drift makes rows unsafe", ws.stashCalls) + } + for _, call := range ws.calls { + if strings.HasPrefix(call, "ForceDestroy:") { + t.Fatalf("ForceDestroy must not run when a historical child row is unresolved; calls=%v", ws.calls) + } + } + if st.sessions["mer-1"].IsTerminated { + t.Fatal("session should remain live when teardown is skipped for registry drift") + } +} + // TestRestoreAll_RestoresBothWorkerAndOrchestrator verifies (b): RestoreAll // restores both a worker and an orchestrator session saved by SaveAndTeardownAll. func TestRestoreAll_RestoresBothWorkerAndOrchestrator(t *testing.T) { @@ -1967,8 +2696,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) @@ -1985,9 +2714,8 @@ func TestRestoreAll_RestoresBothWorkerAndOrchestrator(t *testing.T) { } } -func TestRestoreAll_ConsumesMarkersAfterSuccessfulRestore(t *testing.T) { +func TestRestoreAll_RestoresLegacyShutdownMarkerWithoutState(t *testing.T) { m, st, rt, _ := newLifecycleManager() - st.sessions["mer-1"] = domain.SessionRecord{ ID: "mer-1", ProjectID: "mer", @@ -2005,7 +2733,10 @@ func TestRestoreAll_ConsumesMarkersAfterSuccessfulRestore(t *testing.T) { t.Fatalf("RestoreAll err = %v", err) } if rt.created != 1 { - t.Fatalf("RestoreAll must relaunch session, runtime.Create called %d times", rt.created) + t.Fatalf("legacy shutdown marker must relaunch once, runtime.Create called %d times", rt.created) + } + if st.sessions["mer-1"].IsTerminated { + t.Fatal("legacy shutdown marker session must be live after RestoreAll") } if rows := st.worktrees["mer-1"]; len(rows) != 0 { t.Fatalf("consumed restore marker = %+v, want deleted", rows) @@ -2043,6 +2774,107 @@ func TestRestoreAll_SkipsSessionsKilledBeforeShutdown(t *testing.T) { } } +// TestRestoreAll_DeletesMarkerAfterRelaunch covers issue #2319 (b): the +// shutdown-saved marker is one-shot. After RestoreAll relaunches a session, its +// session_worktrees marker is deleted, so a second RestoreAll (with no fresh +// marker) does NOT relaunch it again. +func TestRestoreAll_DeletesMarkerAfterRelaunch(t *testing.T) { + m, st, rt, _ := newLifecycleManager() + + 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/root", AgentSessionID: "agent-w"}, + Activity: domain.Activity{State: domain.ActivityExited}, + } + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__", State: "removed"}} + + if err := m.RestoreAll(ctx); err != nil { + t.Fatalf("RestoreAll err = %v", err) + } + if rt.created != 1 { + t.Fatalf("first RestoreAll must relaunch once, runtime.Create called %d times", rt.created) + } + rows, err := st.ListSessionWorktrees(ctx, "mer-1") + if err != nil { + t.Fatal(err) + } + if len(rows) != 0 { + t.Fatalf("RestoreAll must delete the one-shot marker, got %d rows", len(rows)) + } +} + +// TestRestoreAll_KilledSessionNotResurrectedOnSecondBoot covers issue #2319 (c), +// the killed-session-resurrection scenario. A terminated session WITH a marker +// is relaunched exactly once; on a second RestoreAll (no new marker) it stays +// terminated and is not relaunched again. +func TestRestoreAll_KilledSessionNotResurrectedOnSecondBoot(t *testing.T) { + m, st, rt, _ := newLifecycleManager() + + 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/root", AgentSessionID: "agent-w"}, + Activity: domain.Activity{State: domain.ActivityExited}, + } + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__", State: "removed"}} + + // First boot: marker present, session relaunches once. + if err := m.RestoreAll(ctx); err != nil { + t.Fatalf("first RestoreAll err = %v", err) + } + if rt.created != 1 { + t.Fatalf("first RestoreAll must relaunch once, runtime.Create called %d times", rt.created) + } + + // Simulate the user killing the relaunched session before the next quit, so + // it has no fresh marker, then a second boot. + if _, err := m.Kill(ctx, "mer-1"); err != nil { + t.Fatalf("kill err = %v", err) + } + if err := m.RestoreAll(ctx); err != nil { + t.Fatalf("second RestoreAll err = %v", err) + } + if rt.created != 1 { + t.Fatalf("killed session must NOT be resurrected on second boot, runtime.Create total = %d, want 1", rt.created) + } + if !st.sessions["mer-1"].IsTerminated { + t.Error("killed session must remain terminated after second RestoreAll") + } +} + +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. @@ -2059,7 +2891,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 { @@ -2110,7 +2942,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 { @@ -2121,6 +2953,113 @@ 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{"Restore:__root__", "Restore: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, "ApplyPreserved:__root__:refs/ao/preserved/mer-1") || + !strings.Contains(applied, "ApplyPreserved: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) + } + rows, err := st.ListSessionWorktrees(ctx, "mer-1") + if err != nil { + t.Fatal(err) + } + if len(rows) != 2 { + t.Fatalf("workspace project rows after RestoreAll = %v, want root and child inventory", rows) + } + states := map[string]string{} + for _, row := range rows { + states[row.RepoName] = row.State + if row.PreservedRef != "" { + t.Fatalf("row %s preserved_ref = %q, want consumed", row.RepoName, row.PreservedRef) + } + } + if states[domain.RootWorkspaceRepoName] != "active" || states["api"] != "active" { + t.Fatalf("workspace project row states = %v, want active inventory", states) + } +} + +func TestRestoreAll_WorkspaceProjectRootOnlyMarkerRestoresRegisteredChildren(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/root", + State: "removed", + }} + + if err := m.RestoreAll(ctx); err != nil { + t.Fatalf("RestoreAll err = %v", err) + } + wantPrefix := []string{"Restore:__root__", "Restore: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, "ApplyPreserved:__root__:refs/ao/preserved/root") { + t.Fatalf("root preserved ref was not applied; calls=%v", ws.calls) + } + if rt.created != 1 { + t.Fatalf("runtime.Create calls = %d, want 1", rt.created) + } + rows, err := st.ListSessionWorktrees(ctx, "mer-1") + if err != nil { + t.Fatal(err) + } + if len(rows) != 2 { + t.Fatalf("workspace project rows after RestoreAll = %v, want root and registered child", rows) + } + states := map[string]string{} + for _, row := range rows { + states[row.RepoName] = row.State + if row.PreservedRef != "" { + t.Fatalf("row %s preserved_ref = %q, want consumed", row.RepoName, row.PreservedRef) + } + } + if states[domain.RootWorkspaceRepoName] != "active" || states["api"] != "active" { + t.Fatalf("workspace project row states = %v, want active root and child", states) + } +} + func TestReconcileLive_DeadSessionStashedAndTerminated(t *testing.T) { st := newFakeStore() rt := &fakeRuntime{aliveByHandle: map[string]bool{}} // handle not alive diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 67ed1ebd7..2f03ac723 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-orchestrator", - "version": "0.0.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-orchestrator", - "version": "0.0.0", + "version": "0.10.0", "license": "MIT", "dependencies": { "@radix-ui/react-dialog": "^1.1.16", @@ -2778,35 +2778,38 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -5676,6 +5679,40 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", @@ -5973,6 +6010,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", @@ -9393,31 +9496,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 63eb6d441..cf942f5ba 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -52,6 +52,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + electron-updater: + specifier: ^6.8.9 + version: 6.8.9 lucide-react: specifier: ^1.17.0 version: 1.17.0(react@19.2.7) @@ -76,9 +79,6 @@ importers: tailwind-merge: specifier: ^3.6.0 version: 3.6.0 - update-electron-app: - specifier: ^3.0.0 - version: 3.2.0 zustand: specifier: ^5.0.14 version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) @@ -439,7 +439,10 @@ packages: "@electron/node-gyp@https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2": resolution: - { tarball: https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2 } + { + gitHosted: true, + tarball: https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2, + } version: 10.2.0-electron.1 engines: { node: ">=12.13.0" } hasBin: true @@ -1618,6 +1621,7 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [arm64] os: [linux] + libc: [glibc] "@rolldown/binding-linux-arm64-musl@1.0.3": resolution: @@ -1625,6 +1629,7 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [arm64] os: [linux] + libc: [musl] "@rolldown/binding-linux-ppc64-gnu@1.0.3": resolution: @@ -1632,6 +1637,7 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [ppc64] os: [linux] + libc: [glibc] "@rolldown/binding-linux-s390x-gnu@1.0.3": resolution: @@ -1639,6 +1645,7 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [s390x] os: [linux] + libc: [glibc] "@rolldown/binding-linux-x64-gnu@1.0.3": resolution: @@ -1646,6 +1653,7 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [x64] os: [linux] + libc: [glibc] "@rolldown/binding-linux-x64-musl@1.0.3": resolution: @@ -1653,6 +1661,7 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [x64] os: [linux] + libc: [musl] "@rolldown/binding-openharmony-arm64@1.0.3": resolution: @@ -1744,6 +1753,7 @@ packages: engines: { node: ">= 20" } cpu: [arm64] os: [linux] + libc: [glibc] "@tailwindcss/oxide-linux-arm64-musl@4.3.0": resolution: @@ -1751,6 +1761,7 @@ packages: engines: { node: ">= 20" } cpu: [arm64] os: [linux] + libc: [musl] "@tailwindcss/oxide-linux-x64-gnu@4.3.0": resolution: @@ -1758,6 +1769,7 @@ packages: engines: { node: ">= 20" } cpu: [x64] os: [linux] + libc: [glibc] "@tailwindcss/oxide-linux-x64-musl@4.3.0": resolution: @@ -1765,6 +1777,7 @@ packages: engines: { node: ">= 20" } cpu: [x64] os: [linux] + libc: [musl] "@tailwindcss/oxide-wasm32-wasi@4.3.0": resolution: @@ -2834,6 +2847,10 @@ packages: resolution: { integrity: sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w== } + electron-updater@6.8.9: + resolution: + { integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig== } + electron-winstaller@5.4.0: resolution: { integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg== } @@ -3174,10 +3191,6 @@ packages: { integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== } engines: { node: ">=8" } - github-url-to-object@4.0.6: - resolution: - { integrity: sha512-NaqbYHMUAlPcmWFdrAB7bcxrNIiiJWJe8s/2+iOc9vlcHlwHqSGrPk+Yi3nu6ebTwgsZEa7igz+NH2vEq3gYwQ== } - glob-parent@5.1.2: resolution: { integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== } @@ -3412,10 +3425,6 @@ packages: { integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== } engines: { node: ">=10" } - is-url@1.2.4: - resolution: - { integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww== } - isarray@1.0.0: resolution: { integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== } @@ -3575,6 +3584,7 @@ packages: engines: { node: ">= 12.0.0" } cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: @@ -3582,6 +3592,7 @@ packages: engines: { node: ">= 12.0.0" } cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: @@ -3589,6 +3600,7 @@ packages: engines: { node: ">= 12.0.0" } cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: @@ -3596,6 +3608,7 @@ packages: engines: { node: ">= 12.0.0" } cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: @@ -3641,11 +3654,20 @@ packages: { integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== } engines: { node: ">=10" } + lodash.escaperegexp@4.1.2: + resolution: + { integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw== } + lodash.get@4.4.2: resolution: { integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== } deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. + lodash.isequal@4.5.0: + resolution: + { integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== } + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash@4.18.1: resolution: { integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== } @@ -4822,6 +4844,10 @@ packages: resolution: { integrity: sha512-5ROII7nElnAirvFn8g7H7MtpfV1daMcyfTGQwsn/x2VtyV+VPiO5CjReCJtWLvoKTDEDmZocf3cNPraiMnBXLA== } + tiny-typed-emitter@2.1.0: + resolution: + { integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA== } + tinybench@2.9.0: resolution: { integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== } @@ -4986,10 +5012,6 @@ packages: peerDependencies: browserslist: ">= 4.21.0" - update-electron-app@3.2.0: - resolution: - { integrity: sha512-l2e7bzsW+rw70pfyyQeA9E/ofpNY2ZS99XuYxD2qWL4fEy3qMjpqwwgB0me7ESpGogIQE1CM0SaDvKGsK4Jg3Q== } - uri-js-replace@1.0.1: resolution: { integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g== } @@ -8074,6 +8096,19 @@ snapshots: electron-to-chromium@1.5.371: {} + electron-updater@6.8.9: + dependencies: + builder-util-runtime: 9.7.0 + fs-extra: 10.1.0 + js-yaml: 4.1.1 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.4 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + electron-winstaller@5.4.0: dependencies: "@electron/asar": 3.4.1 @@ -8377,10 +8412,6 @@ snapshots: dependencies: pump: 3.0.4 - github-url-to-object@4.0.6: - dependencies: - is-url: 1.2.4 - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -8571,8 +8602,6 @@ snapshots: is-unicode-supported@0.1.0: {} - is-url@1.2.4: {} - isarray@1.0.0: {} isbinaryfile@4.0.10: {} @@ -8740,8 +8769,12 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.escaperegexp@4.1.2: {} + lodash.get@4.4.2: {} + lodash.isequal@4.5.0: {} + lodash@4.18.1: {} log-symbols@4.1.0: @@ -9677,6 +9710,8 @@ snapshots: tiny-each-async@2.0.3: optional: true + tiny-typed-emitter@2.1.0: {} + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -9783,11 +9818,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - update-electron-app@3.2.0: - dependencies: - github-url-to-object: 4.0.6 - ms: 2.1.3 - uri-js-replace@1.0.1: {} use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index 8530a6136..8a0662140 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -7,6 +7,7 @@ nodeLinker: hoisted # Electron's postinstall downloads the runtime binary into node_modules/electron/dist; # without this approval pnpm skips it and `electron-forge start` cannot launch. allowBuilds: + core-js: false electron: true electron-winstaller: true # @electron/rebuild (via electron-forge) depends on @electron/node-gyp as a git diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 334c6873a..bec40303d 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -26,12 +26,13 @@ import { type UpdateSettings, type UpdateStatus, } from "./main/update-settings"; -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { existsSync } from "node:fs"; -import { readFile, rm } from "node:fs/promises"; +import { readdir, readFile, rm, stat } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; import { type DaemonLaunchSpec, resolveDaemonLaunch } from "./shared/daemon-launch"; import { createListenPortScanner, defaultRunFilePath, parseRunFile } from "./shared/daemon-discovery"; import type { DaemonStatus } from "./shared/daemon-status"; @@ -96,6 +97,40 @@ let browserViewHost: BrowserViewHost | null = null; // Held for the app lifetime. Dropping it (on any exit) triggers daemon self-stop. let supervisorLink: SupervisorLinkHandle | null = null; +const execFileAsync = promisify(execFile); + +type GitRepoScanResult = { + name: string; + path: string; + relativePath: string; + branch: string; + remote: string; + hasRemote: boolean; + status: "ok" | "error"; + reason?: string; +}; + +type ImportFolderScanResult = { + path: string; + repos: GitRepoScanResult[]; +}; + +const IMPORT_SCAN_CONCURRENCY = 8; +const IMPORT_SCAN_MAX_ENTRIES = 200; +const IMPORT_SCAN_SKIP_DIRS = new Set([ + ".git", + "node_modules", + "dist", + "build", + ".cache", + ".turbo", + "target", + "coverage", + "tmp", + "temp", + "Library", +]); + const isDev = !app.isPackaged; const RENDERER_SCHEME = "app"; @@ -825,15 +860,167 @@ ipcMain.handle("app:getVersion", () => app.getVersion()); ipcMain.handle("telemetry:getBootstrap", () => buildTelemetryBootstrap(process.env, app.getVersion(), process.platform), ); -ipcMain.handle("app:chooseDirectory", async () => { +async function chooseDirectory(title: string): Promise { const options: OpenDialogOptions = { properties: ["openDirectory"], - title: "Choose a git repository", + title, }; const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); if (result.canceled) return null; return result.filePaths[0] ?? null; +} + +async function gitOutput(cwd: string, args: string[]): Promise { + const { stdout } = await execFileAsync("git", args, { cwd, env: daemonEnv(), timeout: 5000 }); + return String(stdout).trim(); +} + +async function isGitRepo(repoPath: string): Promise { + try { + const gitInfo = await stat(path.join(repoPath, ".git")); + if (!gitInfo.isDirectory()) return false; + await gitOutput(repoPath, ["rev-parse", "--show-toplevel"]); + return true; + } catch { + return false; + } +} + +async function resolveDefaultBranch(repoPath: string): Promise { + try { + const ref = await gitOutput(repoPath, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]); + if (ref) return ref.replace(/^origin\//, ""); + } catch { + // Fall back to the checked-out branch when origin/HEAD is unavailable. + } + try { + const branch = await gitOutput(repoPath, ["branch", "--show-current"]); + if (branch) return branch; + } catch { + // Detached or unreadable HEAD is represented below. + } + return "HEAD"; +} + +async function scanGitRepo(repoPath: string, rootPath: string): Promise { + const relativePath = repoPath === rootPath ? "." : path.relative(rootPath, repoPath); + const name = path.basename(repoPath); + try { + const gitInfo = await stat(path.join(repoPath, ".git")); + if (!gitInfo.isDirectory()) { + return { + name, + path: repoPath, + relativePath, + branch: "HEAD", + remote: "", + hasRemote: false, + status: "error", + reason: "Linked worktree children cannot be imported.", + }; + } + } catch { + try { + if ((await gitOutput(repoPath, ["rev-parse", "--is-bare-repository"])) === "true") { + return { + name, + path: repoPath, + relativePath, + branch: "HEAD", + remote: "", + hasRemote: false, + status: "error", + reason: "Bare repositories cannot be imported.", + }; + } + } catch { + // Not a git repository. + } + return null; + } + if (!(await isGitRepo(repoPath))) return null; + const [branchResult, remoteResult, bareResult, headResult] = await Promise.allSettled([ + resolveDefaultBranch(repoPath), + gitOutput(repoPath, ["remote", "get-url", "origin"]), + gitOutput(repoPath, ["rev-parse", "--is-bare-repository"]), + gitOutput(repoPath, ["rev-parse", "--verify", "HEAD"]), + ]); + const validationReason = scanRepoValidationReason( + name, + branchResult.status === "fulfilled" && branchResult.value ? branchResult.value : "HEAD", + remoteResult.status === "fulfilled" && remoteResult.value.length > 0, + bareResult.status === "fulfilled" && bareResult.value === "true", + headResult.status === "fulfilled", + ); + return { + name, + path: repoPath, + relativePath, + branch: branchResult.status === "fulfilled" && branchResult.value ? branchResult.value : "HEAD", + remote: remoteResult.status === "fulfilled" ? remoteResult.value : "", + hasRemote: remoteResult.status === "fulfilled" && remoteResult.value.length > 0, + status: validationReason ? "error" : "ok", + reason: validationReason, + }; +} + +function scanRepoValidationReason( + name: string, + branch: string, + hasRemote: boolean, + isBare: boolean, + hasHead: boolean, +): string | undefined { + if (name === "__root__") return "Repository name is reserved by AO."; + if (isBare) return "Bare repositories cannot be imported."; + if (!hasHead) return "Repository must have at least one commit."; + if (branch === "HEAD") return "Repository must have a checked-out branch."; + if (!hasRemote) return "Origin remote is required."; + return undefined; +} + +async function mapLimited(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const out = new Array(items.length); + let next = 0; + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, async () => { + for (;;) { + const index = next++; + if (index >= items.length) return; + out[index] = await fn(items[index]); + } + }), + ); + return out; +} + +async function scanImportFolder(rootPath: string, mode: "project" | "workspace"): Promise { + if (mode === "project") { + const repo = await scanGitRepo(rootPath, rootPath); + return { path: rootPath, repos: repo ? [repo] : [] }; + } + + const entries = (await readdir(rootPath, { withFileTypes: true })) + .filter((entry) => entry.isDirectory() && !IMPORT_SCAN_SKIP_DIRS.has(entry.name)) + .slice(0, IMPORT_SCAN_MAX_ENTRIES); + const repos = await mapLimited(entries, IMPORT_SCAN_CONCURRENCY, (entry) => + scanGitRepo(path.join(rootPath, entry.name), rootPath), + ); + return { + path: rootPath, + repos: repos + .filter((repo): repo is GitRepoScanResult => repo !== null) + .sort((a, b) => a.name.localeCompare(b.name)), + }; +} + +ipcMain.handle("app:chooseDirectory", async (_event, title?: string) => { + return chooseDirectory(typeof title === "string" && title.trim() ? title : "Choose a git repository"); +}); +ipcMain.handle("app:scanImportFolder", async (_event, input: { path: string; mode: "project" | "workspace" }) => { + await ensureShellEnv(); + return scanImportFolder(input.path, input.mode); }); ipcMain.handle("clipboard:writeText", (_event, text: string) => { clipboard.writeText(text, "clipboard"); diff --git a/frontend/src/preload.ts b/frontend/src/preload.ts index 2284e6c44..83cc2bef1 100644 --- a/frontend/src/preload.ts +++ b/frontend/src/preload.ts @@ -16,10 +16,30 @@ export type BrowserNavigateInput = { url: string; }; +export type ImportFolderMode = "project" | "workspace"; + +export type ImportRepoScan = { + name: string; + path: string; + relativePath: string; + branch: string; + remote: string; + hasRemote: boolean; + status?: "ok" | "error"; + reason?: string; +}; + +export type ImportFolderScan = { + path: string; + repos: ImportRepoScan[]; +}; + const api = { app: { getVersion: () => ipcRenderer.invoke("app:getVersion") as Promise, - chooseDirectory: () => ipcRenderer.invoke("app:chooseDirectory") as Promise, + chooseDirectory: (title?: string) => ipcRenderer.invoke("app:chooseDirectory", title) as Promise, + scanImportFolder: (input: { path: string; mode: ImportFolderMode }) => + ipcRenderer.invoke("app:scanImportFolder", input) as Promise, }, clipboard: { writeText: (text: string) => ipcRenderer.invoke("clipboard:writeText", text) as Promise, diff --git a/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx b/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx index 4c0f08697..32e61db71 100644 --- a/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx +++ b/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx @@ -25,6 +25,7 @@ function renderSheet(onSubmit = vi.fn().mockResolvedValue(undefined)) { undefined} onSubmit={onSubmit} open={true} diff --git a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx index a6959a954..8507fc323 100644 --- a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx +++ b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx @@ -6,6 +6,7 @@ import type { components } from "../../api/schema"; import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery"; import { AGENT_OPTIONS } from "../lib/agent-options"; import { buildIntake, type IntakeForm, IntakeFields, intakeNeedsRule } from "./IntakeFields"; +import type { ProjectKind } from "../types/workspace"; import { Button } from "./ui/button"; import { Label } from "./ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; @@ -25,6 +26,7 @@ const EMPTY_INTAKE: IntakeForm = { enabled: false, repo: "", assignee: "" }; type CreateProjectAgentSheetProps = { error?: string | null; isCreating: boolean; + kind: ProjectKind; onOpenChange: (open: boolean) => void; onSubmit: (selection: CreateProjectAgentSelection) => Promise; open: boolean; @@ -34,6 +36,7 @@ type CreateProjectAgentSheetProps = { export function CreateProjectAgentSheet({ error, isCreating, + kind, onOpenChange, onSubmit, open, @@ -85,7 +88,9 @@ export function CreateProjectAgentSheet({
- Project agents + + {kind === "workspace" ? "Workspace agents" : "Project agents"} + {path ?? ""} @@ -177,7 +182,7 @@ export function CreateProjectAgentSheet({ Cancel
diff --git a/frontend/src/renderer/components/CreateProjectFlow.tsx b/frontend/src/renderer/components/CreateProjectFlow.tsx index 1271718a1..67726673d 100644 --- a/frontend/src/renderer/components/CreateProjectFlow.tsx +++ b/frontend/src/renderer/components/CreateProjectFlow.tsx @@ -56,6 +56,7 @@ export function CreateProjectFlow({ { if (!open) { setSelectedPath(null); diff --git a/frontend/src/renderer/components/ProjectSettingsForm.tsx b/frontend/src/renderer/components/ProjectSettingsForm.tsx index f9a815678..8bcec6613 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.tsx @@ -195,11 +195,38 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje + + {project.kind === "workspace" && ( + + + Workspace repos + + + {project.workspaceRepos?.length ? ( + project.workspaceRepos.map((repo) => ( +
+ {repo.name} + + {repo.relativePath} + {repo.repo ? ` · ${repo.repo}` : ""} + +
+ )) + ) : ( +

No child repositories are registered.

+ )} +
+
+ )} + Worktrees diff --git a/frontend/src/renderer/components/Sidebar.test.tsx b/frontend/src/renderer/components/Sidebar.test.tsx index e55e93ccd..8228ce69d 100644 --- a/frontend/src/renderer/components/Sidebar.test.tsx +++ b/frontend/src/renderer/components/Sidebar.test.tsx @@ -58,7 +58,14 @@ const session: WorkspaceSession = { prs: [], }; -type CreateProjectHandler = (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise; +type CreateProjectInput = { + path: string; + workerAgent: string; + orchestratorAgent: string; + trackerIntake?: unknown; + asWorkspace?: boolean; +}; +type CreateProjectHandler = (input: CreateProjectInput) => Promise; type RemoveProjectHandler = (projectId: string) => Promise; function renderSidebar({ @@ -190,6 +197,12 @@ describe("Sidebar", () => { renderSidebar({ onCreateProject }); await user.click(screen.getByLabelText("New project")); + expect(screen.getByRole("dialog", { name: "Import to Agent Orchestrator" })).toBeInTheDocument(); + expect(window.ao!.app.chooseDirectory).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: /^Project/i })); + expect(await screen.findByRole("dialog", { name: "Import project" })).toBeInTheDocument(); + expect(window.ao!.app.chooseDirectory).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: /Choose a project folder/i })); expect(await screen.findByText("/repo/new-project")).toBeInTheDocument(); const dialog = screen.getByRole("dialog", { name: "Project agents" }); @@ -203,10 +216,118 @@ describe("Sidebar", () => { path: "/repo/new-project", workerAgent: "codex", orchestratorAgent: "claude-code", + asWorkspace: false, }), ); }); + it("can create a workspace project from the project add flow", async () => { + const user = userEvent.setup(); + const onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler; + window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/workspace"); + renderSidebar({ onCreateProject }); + + await user.click(screen.getByLabelText("New project")); + await user.click(screen.getByRole("button", { name: /^Workspace/i })); + expect(await screen.findByRole("dialog", { name: "Import workspace" })).toBeInTheDocument(); + expect(window.ao!.app.chooseDirectory).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: /Choose a folder/i })); + + expect(await screen.findByText("/repo/workspace")).toBeInTheDocument(); + expect(screen.getByRole("dialog", { name: "Workspace agents" })).toBeInTheDocument(); + await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "Codex"); + await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "Claude Code"); + await user.click(screen.getByRole("button", { name: "Create workspace and start" })); + + await waitFor(() => + expect(onCreateProject).toHaveBeenCalledWith({ + path: "/repo/workspace", + workerAgent: "codex", + orchestratorAgent: "claude-code", + asWorkspace: true, + }), + ); + }); + + it("shows detected repository validation when workspace import fails", async () => { + const user = userEvent.setup(); + const onCreateProject = vi.fn().mockRejectedValue(new Error("workspace not registered")) as CreateProjectHandler; + window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/Users/test/dev/acme"); + window.ao!.app.scanImportFolder = vi.fn().mockResolvedValue({ + path: "/Users/test/dev/acme", + repos: [ + { + name: "web", + path: "/Users/test/dev/acme/web", + relativePath: "web", + branch: "HEAD", + remote: "", + hasRemote: false, + status: "error", + reason: "Origin remote is required.", + }, + { + name: "api", + path: "/Users/test/dev/acme/api", + relativePath: "api", + branch: "main", + remote: "git@github.com:acme/api.git", + hasRemote: true, + status: "ok", + }, + ], + }); + renderSidebar({ onCreateProject }); + + await user.click(screen.getByLabelText("New project")); + await user.click(screen.getByRole("button", { name: /^Workspace/i })); + await user.click(await screen.findByRole("button", { name: /Choose a folder/i })); + await screen.findByRole("dialog", { name: "Workspace agents" }); + await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "Codex"); + await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "Claude Code"); + await user.click(screen.getByRole("button", { name: "Create workspace and start" })); + + expect(await screen.findByText(/Import failed · workspace not registered/i)).toBeInTheDocument(); + expect(screen.getByText("workspace not registered")).toBeInTheDocument(); + expect(screen.getByText("web")).toBeInTheDocument(); + expect(screen.getByText("Origin remote is required.")).toBeInTheDocument(); + expect(screen.getByText("api")).toBeInTheDocument(); + expect(screen.getByText("main github.com/acme/api")).toBeInTheDocument(); + expect(screen.getByText("Resolve 1 failed repository to continue")).toBeInTheDocument(); + expect(window.ao!.app.scanImportFolder).toHaveBeenCalledWith({ + path: "/Users/test/dev/acme", + mode: "workspace", + }); + }); + + it("does not rescan folders for non-validation create failures", async () => { + const user = userEvent.setup(); + const onCreateProject = vi.fn().mockRejectedValue(new Error("AO daemon is not ready.")) as CreateProjectHandler; + window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/workspace"); + window.ao!.app.scanImportFolder = vi.fn(); + renderSidebar({ onCreateProject }); + + await user.click(screen.getByLabelText("New project")); + await user.click(screen.getByRole("button", { name: /^Workspace/i })); + await user.click(await screen.findByRole("button", { name: /Choose a folder/i })); + await screen.findByRole("dialog", { name: "Workspace agents" }); + await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "Codex"); + await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "Claude Code"); + await user.click(screen.getByRole("button", { name: "Create workspace and start" })); + + expect(await screen.findByText("AO daemon is not ready.")).toBeInTheDocument(); + expect(window.ao!.app.scanImportFolder).not.toHaveBeenCalled(); + }); + + it("opens global settings from the footer menu when no project is selected", async () => { + const user = userEvent.setup(); + renderSidebar(); + + await user.click(screen.getByRole("button", { name: /project actions/i })); + + expect(await screen.findByRole("menuitem", { name: /settings/i })).toBeInTheDocument(); + }); + it("shows needs-auth agents as unavailable while keeping authorized agents selectable", async () => { const user = userEvent.setup(); const onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler; @@ -229,6 +350,8 @@ describe("Sidebar", () => { renderSidebar({ onCreateProject, seedAgents: false }); await user.click(screen.getByLabelText("New project")); + await user.click(screen.getByRole("button", { name: /^Project/i })); + await user.click(await screen.findByRole("button", { name: /Choose a project folder/i })); expect(await screen.findByText("/repo/new-project")).toBeInTheDocument(); await user.click(screen.getByRole("combobox", { name: "Worker agent" })); @@ -271,6 +394,8 @@ describe("Sidebar", () => { renderSidebar({ onCreateProject, seedAgents: false }); await user.click(screen.getByLabelText("New project")); + await user.click(screen.getByRole("button", { name: /^Project/i })); + await user.click(await screen.findByRole("button", { name: /Choose a project folder/i })); expect(await screen.findByText("/repo/new-project")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Create and start" })).toBeDisabled(); @@ -301,6 +426,8 @@ describe("Sidebar", () => { path: "/repo/new-project", workerAgent: "codex", orchestratorAgent: "claude-code", + trackerIntake: undefined, + asWorkspace: false, }), ); }); diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index 7a95264ed..49d2f3ef7 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -1,7 +1,11 @@ +import * as Dialog from "@radix-ui/react-dialog"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams, useRouterState } from "@tanstack/react-router"; import { ChevronRight, + CheckCircle2, + Folder, + FolderPlus, GitPullRequest, LayoutDashboard, Moon, @@ -12,12 +16,16 @@ import { Settings, Sun, Trash2, + X, + XCircle, } from "lucide-react"; -import { useRef, useState } from "react"; +import { useRef, useState, type ReactNode } from "react"; +import type { ImportFolderScan } from "../../preload"; import { attentionZone, newestActiveOrchestrator, sessionIsActive, + type ProjectKind, type WorkspaceSession, type WorkspaceSummary, workerSessions, @@ -57,7 +65,8 @@ import { OrchestratorIcon } from "./icons"; import aoLogo from "../assets/ao-logo.png"; import { cn } from "../lib/utils"; import { useUiStore } from "../stores/ui-store"; -import { CreateProjectFlow, type CreateProjectInput } from "./CreateProjectFlow"; +import { CreateProjectAgentSheet, type CreateProjectAgentSelection } from "./CreateProjectAgentSheet"; +import { Button } from "./ui/button"; // The macOS hiddenInset traffic lights and the fixed TitlebarNav overlay live // in the full-width topbar's left inset (_shell renders the bar above the @@ -81,7 +90,7 @@ type SidebarProps = { underTopbar?: boolean; workspaceError?: string; workspaces: WorkspaceSummary[]; - onCreateProject: (input: CreateProjectInput) => Promise; + onCreateProject: (input: { path: string; asWorkspace?: boolean } & CreateProjectAgentSelection) => Promise; onRemoveProject: (projectId: string) => Promise; }; @@ -253,7 +262,7 @@ export function Sidebar({

No projects yet.

- Click + above to register a git repo. + Click + above to register a repo or workspace.

) : ( @@ -761,3 +770,450 @@ function CreateProjectListItem({ onCreateProject }: Pick ); } + +function CreateProjectFlow({ + children, + onCreateProject, +}: Pick & { + children: (state: { choosePath: () => void; disabled: boolean; label: string }) => ReactNode; +}) { + const [error, setError] = useState(null); + const [modePickerOpen, setModePickerOpen] = useState(false); + const [folderPickerOpen, setFolderPickerOpen] = useState(false); + const [selectedKind, setSelectedKind] = useState("single_repo"); + const [selectedPath, setSelectedPath] = useState(null); + const [validationScan, setValidationScan] = useState(null); + const [isChoosingPath, setIsChoosingPath] = useState(false); + const [isCreating, setIsCreating] = useState(false); + + const openFolderStep = (kind: ProjectKind) => { + setError(null); + setValidationScan(null); + setSelectedKind(kind); + setModePickerOpen(false); + window.requestAnimationFrame(() => setFolderPickerOpen(true)); + }; + + const choosePath = async () => { + setError(null); + setIsChoosingPath(true); + try { + const path = await aoBridge.app.chooseDirectory( + selectedKind === "workspace" ? "Choose a workspace folder" : "Choose a project repository", + ); + if (path) { + setValidationScan(null); + setSelectedPath(path); + setFolderPickerOpen(false); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Could not add project"); + } finally { + setIsChoosingPath(false); + } + }; + + const createProject = async (selection: CreateProjectAgentSelection) => { + if (!selectedPath) return; + setError(null); + setIsCreating(true); + try { + await onCreateProject({ path: selectedPath, asWorkspace: selectedKind === "workspace", ...selection }); + setSelectedPath(null); + } catch (err) { + const message = err instanceof Error ? err.message : "Could not add project"; + setError(message); + if (shouldScanCreateFailure(message)) { + try { + const scan = await aoBridge.app.scanImportFolder({ + path: selectedPath, + mode: selectedKind === "workspace" ? "workspace" : "project", + }); + setValidationScan(scan); + } catch { + setValidationScan({ path: selectedPath, repos: [] }); + } + } else { + setValidationScan(null); + } + setSelectedPath(null); + setFolderPickerOpen(true); + } finally { + setIsCreating(false); + } + }; + + const label = isChoosingPath ? "Opening..." : isCreating ? "Creating..." : "New project"; + + return ( + <> + {children({ choosePath: () => setModePickerOpen(true), disabled: isChoosingPath || isCreating, label })} + !isChoosingPath && !isCreating && setModePickerOpen(open)} + onSelect={openFolderStep} + /> + { + setError(null); + setValidationScan(null); + setFolderPickerOpen(false); + window.requestAnimationFrame(() => setModePickerOpen(true)); + }} + onChooseFolder={() => void choosePath()} + onOpenChange={(open) => { + if (!isChoosingPath && !isCreating) { + setFolderPickerOpen(open); + if (!open) { + setError(null); + setValidationScan(null); + } + } + }} + /> + { + if (!open) { + setSelectedPath(null); + if (!folderPickerOpen) setError(null); + } + }} + onSubmit={createProject} + open={selectedPath !== null} + path={selectedPath} + /> + + ); +} + +function shouldScanCreateFailure(message: string): boolean { + if (/daemon|server|conflict|already exists|not ready|start|orchestrator|permission denied/i.test(message)) + return false; + if (/\b(?:PATH|ID)_ALREADY_REGISTERED\b/i.test(message) || /already registered/i.test(message)) return false; + return /workspace|repo|repository|git|path|folder|worktree|bare|branch|commit|remote/i.test(message); +} + +function CreateProjectModeDialog({ + disabled, + onOpenChange, + onSelect, + open, +}: { + disabled: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (kind: ProjectKind) => void; + open: boolean; +}) { + return ( + + + + +
+
+ + Import to Agent Orchestrator + + + What are you importing? + +
+ + + +
+
+ onSelect("workspace")} + /> + onSelect("single_repo")} + /> +
+
+
+
+ ); +} + +function ProjectModeButton({ + description, + disabled, + kind, + onClick, +}: { + description: string; + disabled: boolean; + kind: ProjectKind; + onClick: () => void; +}) { + const isWorkspace = kind === "workspace"; + return ( + + ); +} + +function CreateProjectFolderDialog({ + disabled, + error, + kind, + onBack, + onChooseFolder, + onOpenChange, + open, + scan, +}: { + disabled: boolean; + error: string | null; + kind: ProjectKind; + onBack: () => void; + onChooseFolder: () => void; + onOpenChange: (open: boolean) => void; + open: boolean; + scan: ImportFolderScan | null; +}) { + const isWorkspace = kind === "workspace"; + const failedRepos = scan?.repos.filter((repo) => repo.status === "error" || !repo.hasRemote) ?? []; + const hasScan = scan !== null; + return ( + + + + +
+ +
+ + {isWorkspace ? "Import workspace" : "Import project"} + + + {isWorkspace + ? "Pick a folder that contains your Git repositories. Each repo inside it joins the workspace." + : "Import a single Git repository as one project."} + +
+ + + +
+
+ {hasScan ? ( +
+
+
+ + {error && ( +
+
+
+
{error}
+ {failedRepos.length > 0 && ( +
+ {failedRepos.map((repo) => ( + + ))} +
+ )} +
+ )} + + {scan.repos + .filter((repo) => repo.status !== "error" && repo.hasRemote) + .map((repo) => ( +
+ +
+ ))} + + {scan.repos.length === 0 && ( +
+ No repositories detected in this folder. +
+ )} +
+ ) : ( + + )} + {error && !hasScan && ( +
+ {error} +
+ )} +
+
+

+ {hasScan && failedRepos.length > 0 + ? `Resolve ${failedRepos.length} failed ${failedRepos.length === 1 ? "repository" : "repositories"} to continue` + : isWorkspace + ? "No repositories to import" + : "No project selected"} +

+
+ + +
+
+
+
+
+ ); +} + +function ImportRepoRow({ failed = false, repo }: { failed?: boolean; repo: ImportFolderScan["repos"][number] }) { + return ( +
+ {failed ? ( +
+ ); +} + +function displayImportPath(value: string) { + return value.replace(/^\/Users\/[^/]+/, "~"); +} + +function remoteDisplay(remote: string) { + const ssh = remote.match(/^[^@]+@([^:]+):(.+)$/); + if (ssh?.[1] && ssh[2]) return `${ssh[1]}/${ssh[2].replace(/\.git$/, "")}`; + try { + const url = new URL(remote); + return `${url.host}${url.pathname.replace(/\.git$/, "")}`; + } catch { + return remote.replace(/^https?:\/\//, "").replace(/\.git$/, ""); + } +} diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.ts b/frontend/src/renderer/hooks/useWorkspaceQuery.ts index 479323c78..27e3f6c08 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.ts +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.ts @@ -43,6 +43,7 @@ async function fetchWorkspaces(): Promise { return (projectsData?.projects ?? []).map((project) => ({ id: project.id, name: project.name, + kind: project.kind === "workspace" ? "workspace" : "single_repo", path: project.path, orchestratorAgent: project.orchestratorAgent ? toAgentProvider(project.orchestratorAgent) : undefined, sessions: (sessionsData?.sessions ?? []) diff --git a/frontend/src/renderer/lib/bridge.ts b/frontend/src/renderer/lib/bridge.ts index 01f15a503..e0c13f8fb 100644 --- a/frontend/src/renderer/lib/bridge.ts +++ b/frontend/src/renderer/lib/bridge.ts @@ -6,6 +6,7 @@ export const aoBridge: AoBridge = app: { getVersion: async () => "0.0.0-preview", chooseDirectory: async () => null, + scanImportFolder: async ({ path }) => ({ path, repos: [] }), }, clipboard: { writeText: async (text: string) => { diff --git a/frontend/src/renderer/routes/_shell.tsx b/frontend/src/renderer/routes/_shell.tsx index 2f4957aa6..ff58b27cb 100644 --- a/frontend/src/renderer/routes/_shell.tsx +++ b/frontend/src/renderer/routes/_shell.tsx @@ -70,6 +70,7 @@ function ShellLayout() { workerAgent: string; orchestratorAgent: string; trackerIntake?: components["schemas"]["TrackerIntakeConfig"]; + asWorkspace?: boolean; }) => { void addRendererExceptionStep("Project add requested", { source: "project-add", @@ -84,6 +85,7 @@ function ShellLayout() { const { data, error } = await apiClient.POST("/api/v1/projects", { body: { path: input.path, + asWorkspace: input.asWorkspace || undefined, config: { worker: { agent: input.workerAgent }, orchestrator: { agent: input.orchestratorAgent }, @@ -105,7 +107,9 @@ function ShellLayout() { const workspace: WorkspaceSummary = { id: data.project.id, name: data.project.name, + kind: data.project.kind === "workspace" ? "workspace" : "single_repo", path: data.project.path, + workspaceRepos: data.project.workspaceRepos, type: "main", orchestratorAgent: input.orchestratorAgent as WorkspaceSummary["orchestratorAgent"], sessions: [], @@ -125,7 +129,6 @@ function ShellLayout() { const message = spawnError instanceof Error ? spawnError.message : "Could not start orchestrator"; const startupMessage = `Project added, but orchestrator did not start: ${message}`; setOrchestratorStartupError(workspace.id, startupMessage); - throw new Error(startupMessage); } }, [navigate, queryClient, setOrchestratorStartupError, updateWorkspaces], diff --git a/frontend/src/renderer/test/setup.ts b/frontend/src/renderer/test/setup.ts index 42215650e..98cee1a8a 100644 --- a/frontend/src/renderer/test/setup.ts +++ b/frontend/src/renderer/test/setup.ts @@ -58,6 +58,7 @@ if (typeof window !== "undefined") { app: { getVersion: async () => "0.0.0-test", chooseDirectory: async () => null, + scanImportFolder: async ({ path }: { path: string }) => ({ path, repos: [] }), }, clipboard: { writeText: async () => undefined, diff --git a/frontend/src/renderer/types/workspace.ts b/frontend/src/renderer/types/workspace.ts index 683478286..fc8ce08b8 100644 --- a/frontend/src/renderer/types/workspace.ts +++ b/frontend/src/renderer/types/workspace.ts @@ -174,6 +174,14 @@ export function canonicalTrackerIssueId(issueId?: string): string | undefined { return TRACKER_PROVIDER_PREFIXES.some((prefix) => issueId.startsWith(prefix)) ? issueId : undefined; } +export type ProjectKind = "single_repo" | "workspace"; + +export type WorkspaceRepoSummary = { + name: string; + relativePath: string; + repo: string; +}; + /** Glanceable worker status. Maps 1:1 to the accent colors in DESIGN.md. */ export type WorkerDisplayStatus = "working" | "needs_you" | "mergeable" | "ci_failed" | "no_signal" | "done" | "unknown"; @@ -355,7 +363,9 @@ export function attentionZone(session: WorkspaceSession): AttentionZone { export type WorkspaceSummary = { id: string; name: string; + kind?: ProjectKind; path: string; + workspaceRepos?: WorkspaceRepoSummary[]; type?: "main" | "worktree"; orchestratorAgent?: AgentProvider; accentColor?: string; diff --git a/frontend/src/shared/shell-env.test.ts b/frontend/src/shared/shell-env.test.ts index f5a1c40a2..6037c7bb9 100644 --- a/frontend/src/shared/shell-env.test.ts +++ b/frontend/src/shared/shell-env.test.ts @@ -92,6 +92,11 @@ describe("buildDaemonEnv", () => { const env = buildDaemonEnv({ ...minimalProcessEnv, TERM: "screen-256color" }, null, {}); expect(env.TERM).toBe("screen-256color"); }); + + it("replaces TERM=dumb with a tmux-usable default", () => { + const env = buildDaemonEnv({ ...minimalProcessEnv, TERM: "dumb" }, null, {}); + expect(env.TERM).toBe("xterm-256color"); + }); }); describe("resolveShellPath", () => { diff --git a/frontend/src/shared/shell-env.ts b/frontend/src/shared/shell-env.ts index d2da45c64..6af09b968 100644 --- a/frontend/src/shared/shell-env.ts +++ b/frontend/src/shared/shell-env.ts @@ -63,8 +63,15 @@ export function withFallbackPath(currentPath: string | undefined): string { return result.join(":"); } +function normalizeTerm(term: string | undefined): string { + const trimmed = term?.trim(); + if (!trimmed || trimmed === "dumb") return "xterm-256color"; + return trimmed; +} + // Base = shell env, overlaid by processEnv so Electron/AO runtime vars win, then -// PATH forced to the shell's PATH (with floor), then explicit overrides. +// PATH forced to the shell's PATH (with floor), TERM forced to a tmux-usable +// value, then explicit overrides. // // TERM defaults to xterm-256color (what the renderer's xterm.js emulates): a // Finder/Dock launch starts under launchd with no controlling tty, so TERM is @@ -78,6 +85,7 @@ export function buildDaemonEnv( ): NodeJS.ProcessEnv { const merged: NodeJS.ProcessEnv = { TERM: "xterm-256color", ...(shellEnv ?? {}), ...processEnv }; merged.PATH = withFallbackPath(shellEnv?.PATH ?? processEnv.PATH); + merged.TERM = normalizeTerm(merged.TERM); return { ...merged, ...overrides }; }