From 57d9c9319c4513956d3a942e7b7d06ce167ea8f5 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Sat, 27 Jun 2026 12:55:34 +0530 Subject: [PATCH 1/2] feat: materialize workspace project sessions --- .../workspace/gitworktree/workspace.go | 258 ++++++++++++++++++ backend/internal/ports/outbound.go | 47 ++++ backend/internal/session_manager/manager.go | 101 +++++-- .../internal/session_manager/manager_test.go | 202 +++++++++++++- 4 files changed, 579 insertions(+), 29 deletions(-) diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index 39bb01b9c1..548e73466f 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,6 +144,115 @@ 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 { @@ -507,6 +617,95 @@ func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch, baseBra 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 { + 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.run(ctx, w.binary, worktreePruneArgs(repo)...); err != nil { + return fmt.Errorf("gitworktree: worktree prune: %w", err) + } + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("gitworktree: force remove path %q: %w", path, err) + } + return nil +} + +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) @@ -649,6 +848,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 @@ -688,6 +918,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) diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index 8f6b75f09d..32c34f95ae 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). @@ -187,3 +196,41 @@ type WorkspaceInfo struct { SessionID domain.SessionID ProjectID domain.ProjectID } + +// 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 +} + +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 +} + +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/session_manager/manager.go b/backend/internal/session_manager/manager.go index 48d93fe26f..5c8c6b45dd 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -77,6 +77,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) @@ -215,16 +216,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 @@ -236,19 +230,19 @@ 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.markSpawnFailedTerminated(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.markSpawnFailedTerminated(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: no agent adapter for harness %q", id, cfg.Harness) } if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil { - _ = m.workspace.Destroy(ctx, ws) + m.destroySpawnWorkspace(ctx, ws, workspaceProject) m.markSpawnFailedTerminated(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err) } @@ -263,7 +257,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.markSpawnFailedTerminated(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: launch command: %w", id, err) } @@ -272,7 +266,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.markSpawnFailedTerminated(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err) } @@ -283,7 +277,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.markSpawnFailedTerminated(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: runtime: %w", id, err) } @@ -291,7 +285,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) } @@ -314,6 +308,74 @@ 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)), + BaseBranch: project.Config.WithDefaults().DefaultBranch, + }) + } + 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. @@ -939,6 +1001,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 } diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 82ff2c491a..2743708085 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -19,11 +19,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 @@ -33,16 +35,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)) @@ -95,6 +101,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)) } @@ -243,10 +252,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 @@ -275,10 +288,54 @@ 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) 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, ports.WorkspaceInfo) error { 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) { return w.Create(ctx, cfg) } @@ -504,6 +561,125 @@ 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() + 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"}, + {Name: "web", RelativePath: "apps/web"}, + } + rt := &fakeRuntime{} + ws := &fakeWorkspace{path: "/managed/mer-1"} + 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 != "/managed/mer-1" { + 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 != "/repo/mer" { + t.Fatalf("root repo path = %q, want /repo/mer", got) + } + if len(ws.lastProjectCfg.Repos) != 2 { + t.Fatalf("child repo configs = %d, want 2", len(ws.lastProjectCfg.Repos)) + } + if got := ws.lastProjectCfg.Repos[0].RepoPath; got != "/repo/mer/services/api" { + t.Fatalf("api repo path = %q, want /repo/mer/services/api", got) + } + if got := ws.lastProjectCfg.Repos[1].RepoPath; got != "/repo/mer/apps/web" { + t.Fatalf("web repo path = %q, want /repo/mer/apps/web", got) + } + 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: "/managed/mer-1", + "api": "/managed/mer-1/services/api", + "web": "/managed/mer-1/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 !st.sessions["mer-1"].IsTerminated { + t.Fatal("orphaned spawn should be terminated") + } +} + +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") From 30909c0946ae63f54966ea29e1a844f8f38f7549 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Sat, 27 Jun 2026 13:29:52 +0530 Subject: [PATCH 2/2] fix: clear workspace project PR CI failures --- backend/internal/ports/outbound.go | 4 +++ backend/internal/processalive/process_unix.go | 22 ++++++++++++-- .../processalive/process_unix_test.go | 29 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 backend/internal/processalive/process_unix_test.go diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index 32c34f95ae..8e8701469a 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -210,6 +210,8 @@ type WorkspaceProjectConfig struct { Repos []WorkspaceProjectRepoConfig } +// WorkspaceProjectRepoConfig describes one registered child repo in a +// workspace project session. type WorkspaceProjectRepoConfig struct { Name string RelativePath string @@ -224,6 +226,8 @@ type WorkspaceProjectInfo struct { Worktrees []WorkspaceRepoInfo } +// WorkspaceRepoInfo describes one materialized repo worktree in a workspace +// project session. type WorkspaceRepoInfo struct { RepoName string RepoPath string diff --git a/backend/internal/processalive/process_unix.go b/backend/internal/processalive/process_unix.go index bf9349ad2c..56221f888d 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 0000000000..9d53093277 --- /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") +}