From 211e738ec059363507f6cbab9ce6266a02d5d978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 27 Jun 2026 15:00:38 +0100 Subject: [PATCH] fix: reuse worktrees for same-task handoffs (#1510) --- .../lifecycle/base_branches_metadata_test.go | 43 + .../agent/runtime/lifecycle/env_preparer.go | 17 +- .../lifecycle/env_preparer_worktree.go | 14 +- .../env_preparer_worktree_multi_test.go | 125 ++- .../agent/runtime/lifecycle/manager_launch.go | 19 +- .../runtime/lifecycle/manager_launch_test.go | 31 + .../internal/agent/runtime/lifecycle/types.go | 15 +- .../runtime/lifecycle/types_repospecs_test.go | 18 +- apps/backend/internal/backendapp/adapters.go | 8 +- .../orchestrator/executor/executor.go | 17 +- .../executor/executor_environment_reuse.go | 254 +++++- .../executor/executor_environment_test.go | 33 + .../orchestrator/executor/executor_execute.go | 216 ++++- .../executor/executor_mocks_test.go | 26 +- .../executor/executor_multi_repo_test.go | 781 ++++++++++++++++++ .../orchestrator/executor/executor_resume.go | 3 + apps/backend/internal/orchestrator/service.go | 2 + apps/backend/internal/task/models/models.go | 5 + .../internal/task/models/models_test.go | 20 + .../task/repository/sqlite/base_migrations.go | 96 ++- .../task/repository/sqlite/base_schema.go | 4 +- .../task/repository/sqlite/heal_test.go | 44 + .../repository/sqlite/postgres_schema_test.go | 49 ++ .../task/repository/sqlite/session.go | 21 +- .../task/repository/sqlite/session_test.go | 65 ++ .../repository/sqlite/task_environment.go | 17 +- .../internal/worktree/manager_lifecycle.go | 19 +- .../internal/worktree/manager_state.go | 2 +- apps/backend/internal/worktree/worktree.go | 5 + .../e2e/tests/session/multi-session.spec.ts | 128 +++ docs/specs/tasks/multi-branch/spec.md | 2 + 31 files changed, 2025 insertions(+), 74 deletions(-) diff --git a/apps/backend/internal/agent/runtime/lifecycle/base_branches_metadata_test.go b/apps/backend/internal/agent/runtime/lifecycle/base_branches_metadata_test.go index ae77a897ab..32eb6f2853 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/base_branches_metadata_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/base_branches_metadata_test.go @@ -23,6 +23,49 @@ func TestCollectBaseBranches_MultiRepo(t *testing.T) { } } +func TestCollectBaseBranches_MultiBranchKeysUseWorktreeSubpath(t *testing.T) { + req := &LaunchRequest{ + Repositories: []RepoLaunchSpec{ + {RepoName: "kandev", BaseBranch: "main"}, + {RepoName: "kandev", BranchSlug: "feature-x", BaseBranch: "feature/x"}, + }, + } + got := collectBaseBranches(req) + want := map[string]string{"kandev": "main", "kandev-feature-x": "feature/x"} + if !reflect.DeepEqual(got, want) { + t.Errorf("collectBaseBranches = %v, want %v", got, want) + } +} + +func TestBaseBranchMetadataKey_FallsBackToRawRepoNameWhenSanitizedEmpty(t *testing.T) { + spec := RepoLaunchSpec{RepoName: "/", BaseBranch: "main"} + if got := baseBranchMetadataKey(spec); got != "/" { + t.Fatalf("baseBranchMetadataKey = %q, want raw repo name", got) + } + req := &LaunchRequest{ + Repositories: []RepoLaunchSpec{spec}, + } + got := collectBaseBranches(req) + want := map[string]string{"/": "main"} + if !reflect.DeepEqual(got, want) { + t.Errorf("collectBaseBranches = %v, want %v", got, want) + } +} + +func TestCollectBaseBranches_SkipsMalformedEmptyRepoSpec(t *testing.T) { + req := &LaunchRequest{ + BaseBranch: "legacy-main", + Repositories: []RepoLaunchSpec{ + {BaseBranch: "malformed-main"}, + }, + } + got := collectBaseBranches(req) + want := map[string]string{"": "legacy-main"} + if !reflect.DeepEqual(got, want) { + t.Errorf("collectBaseBranches = %v, want %v", got, want) + } +} + // TestCollectBaseBranches_SingleRepoLegacyKey verifies the synthesized // single-repo path: when only top-level BaseBranch is set, it lands under the // empty key so the root WorkspaceTracker (repositoryName == "") finds it. diff --git a/apps/backend/internal/agent/runtime/lifecycle/env_preparer.go b/apps/backend/internal/agent/runtime/lifecycle/env_preparer.go index 425384360d..adb388db38 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/env_preparer.go +++ b/apps/backend/internal/agent/runtime/lifecycle/env_preparer.go @@ -38,9 +38,14 @@ type RepoPrepareSpec struct { WorktreeBranchPrefix string PullBeforeWorktree bool RepoSetupScript string - // BranchSlug, when set, nests the worktree path under {RepoName}/{BranchSlug}/ - // so two specs sharing a RepositoryID don't collide on disk. + // BranchSlug, when set, suffixes the worktree path as + // {RepoName}-{BranchSlug} so two specs sharing a RepositoryID don't collide + // on disk. BranchSlug string + // BranchIdentitySlug is the stable branch key used for worktree reuse and + // persisted environment metadata. It may be non-empty even when BranchSlug + // is empty so primary branches can keep the flat legacy path. + BranchIdentitySlug string } // EnvPrepareRequest contains the parameters for environment preparation. @@ -68,7 +73,11 @@ type EnvPrepareRequest struct { TaskDirName string // Per-task directory name within the workspace (e.g. "task-abc123") RepoName string // Repository slug used with TaskDirName to locate checkouts - BranchSlug string // Optional branch subdir for multi-branch tasks (legacy single-repo path) + BranchSlug string // Optional branch directory suffix for multi-branch tasks + // BranchIdentitySlug is the stable branch key for worktree cache/persistence. + // It may be non-empty when BranchSlug is empty to preserve a flat path. + // Empty leaves the synthesized spec identity empty; worktree code falls back. + BranchIdentitySlug string // Repositories carries one entry per repository when the request is // multi-repo. When non-empty it is the source of truth; the legacy @@ -104,6 +113,7 @@ func (r *EnvPrepareRequest) RepoSpecs() []RepoPrepareSpec { PullBeforeWorktree: r.PullBeforeWorktree, RepoSetupScript: r.RepoSetupScript, BranchSlug: r.BranchSlug, + BranchIdentitySlug: r.BranchIdentitySlug, }} } @@ -125,6 +135,7 @@ type PrepareStep struct { // to one RepoPrepareSpec from the request. type RepoWorktreeResult struct { RepositoryID string `json:"repository_id"` + BranchSlug string `json:"branch_slug,omitempty"` WorktreeID string `json:"worktree_id,omitempty"` WorktreeBranch string `json:"worktree_branch,omitempty"` WorktreePath string `json:"worktree_path,omitempty"` diff --git a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree.go b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree.go index 8286bee2e1..e49327760a 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree.go +++ b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree.go @@ -262,6 +262,7 @@ func buildWorktreeCreateRequest(req *EnvPrepareRequest) worktree.CreateRequest { TaskDirName: req.TaskDirName, RepoName: req.RepoName, BranchSlug: req.BranchSlug, + BranchIdentitySlug: req.BranchIdentitySlug, } } @@ -372,9 +373,12 @@ func (p *WorktreePreparer) prepareMultiRepo( Duration: time.Since(start), }, nil } - createdIDs = append(createdIDs, wt.ID) + if spec.WorktreeID == "" || wt.ID != spec.WorktreeID { + createdIDs = append(createdIDs, wt.ID) + } worktrees = append(worktrees, RepoWorktreeResult{ RepositoryID: spec.RepositoryID, + BranchSlug: repoBranchIdentitySlug(spec), WorktreeID: wt.ID, WorktreeBranch: wt.Branch, WorktreePath: wt.Path, @@ -451,6 +455,7 @@ func (p *WorktreePreparer) prepareOneRepo( subReq.WorktreeBranchPrefix = spec.WorktreeBranchPrefix subReq.PullBeforeWorktree = spec.PullBeforeWorktree subReq.BranchSlug = spec.BranchSlug + subReq.BranchIdentitySlug = repoBranchIdentitySlug(spec) // Strip the multi-repo list to avoid re-entering the multi-repo branch. subReq.Repositories = nil @@ -490,6 +495,13 @@ func (p *WorktreePreparer) prepareOneRepo( return wt, steps, stepIdx, nil } +func repoBranchIdentitySlug(spec RepoPrepareSpec) string { + if spec.BranchIdentitySlug != "" { + return spec.BranchIdentitySlug + } + return spec.BranchSlug +} + // rollbackWorktrees removes any worktrees created during a failed multi-repo // preparation. Best effort: failures are logged and do not interrupt teardown. func (p *WorktreePreparer) rollbackWorktrees(ctx context.Context, ids []string) { diff --git a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree_multi_test.go b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree_multi_test.go index 73bff333c9..5683df5295 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree_multi_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree_multi_test.go @@ -170,6 +170,12 @@ func initBareGitRepo(t *testing.T, name string) string { } func newPreparerForTest(t *testing.T) (*WorktreePreparer, *worktree.Manager) { + t.Helper() + preparer, mgr, _ := newPreparerForTestWithStore(t) + return preparer, mgr +} + +func newPreparerForTestWithStore(t *testing.T) (*WorktreePreparer, *worktree.Manager, *inMemoryWorktreeStore) { t.Helper() tmp := t.TempDir() cfg := worktree.Config{ @@ -183,7 +189,7 @@ func newPreparerForTest(t *testing.T) (*WorktreePreparer, *worktree.Manager) { if err != nil { t.Fatalf("worktree manager: %v", err) } - return NewWorktreePreparer(mgr, log), mgr + return NewWorktreePreparer(mgr, log), mgr, store } func TestWorktreePreparer_MultiRepo_CreatesWorktreePerRepo(t *testing.T) { @@ -280,6 +286,123 @@ func TestWorktreePreparer_MultiRepo_RollbackOnPartialFailure(t *testing.T) { } } +func TestWorktreePreparer_MultiRepo_RollbackKeepsReusedWorktrees(t *testing.T) { + repoExisting := initBareGitRepo(t, "existing") + repoNew := initBareGitRepo(t, "new") + repoBad := t.TempDir() + + preparer, mgr, store := newPreparerForTestWithStore(t) + existingPath := filepath.Join(t.TempDir(), "existing-worktree") + if err := os.MkdirAll(existingPath, 0755); err != nil { + t.Fatalf("mkdir existing worktree: %v", err) + } + if err := os.WriteFile(filepath.Join(existingPath, ".git"), []byte("gitdir: /tmp/existing.git\n"), 0644); err != nil { + t.Fatalf("write existing .git file: %v", err) + } + if err := store.CreateWorktree(context.Background(), &worktree.Worktree{ + ID: "wt-existing", + SessionID: "source-session", + TaskID: "task-reuse-fail", + RepositoryID: "repo-existing", + BranchSlug: "main", + Path: existingPath, + Status: worktree.StatusActive, + }); err != nil { + t.Fatalf("seed existing worktree: %v", err) + } + + req := &EnvPrepareRequest{ + TaskID: "task-reuse-fail", + SessionID: "sess-reuse-fail", + TaskTitle: "Reuse Then Fail", + ExecutorType: executor.NameStandalone, + TaskDirName: "reuse-fail_ccc", + Repositories: []RepoPrepareSpec{ + { + RepositoryID: "repo-existing", + RepositoryPath: repoExisting, + RepoName: "existing", + BaseBranch: "main", + WorktreeID: "wt-existing", + BranchIdentitySlug: "main", + }, + {RepositoryID: "repo-new", RepositoryPath: repoNew, RepoName: "new", BaseBranch: "main"}, + {RepositoryID: "repo-bad", RepositoryPath: repoBad, RepoName: "bad", BaseBranch: "main"}, + }, + } + + res, err := preparer.Prepare(context.Background(), req, nil) + if err != nil { + t.Fatalf("prepare returned hard error: %v", err) + } + if res.Success { + t.Fatal("expected failure when final repo is not a git repo") + } + + existing, err := store.GetWorktreeByID(context.Background(), "wt-existing") + if err != nil { + t.Fatalf("get existing worktree: %v", err) + } + if existing == nil || existing.Status != worktree.StatusActive { + t.Fatalf("reused worktree was rolled back: %+v", existing) + } + if !mgr.IsValid(existingPath) { + t.Fatalf("reused worktree path should remain valid: %s", existingPath) + } + + all, err := mgr.GetAllByTaskID(context.Background(), "task-reuse-fail") + if err != nil { + t.Fatalf("list worktrees: %v", err) + } + for _, wt := range all { + if wt.ID != "wt-existing" && wt.Status == worktree.StatusActive { + t.Fatalf("new worktree %s should have been rolled back", wt.ID) + } + } +} + +func TestWorktreePreparer_MultiRepo_RollbackRemovesWorktreeCreatedForStaleReuseID(t *testing.T) { + repoNew := initBareGitRepo(t, "stale-new") + repoBad := t.TempDir() + + preparer, mgr := newPreparerForTest(t) + req := &EnvPrepareRequest{ + TaskID: "task-stale-reuse-fail", + SessionID: "sess-stale-reuse-fail", + TaskTitle: "Stale Reuse Then Fail", + ExecutorType: executor.NameStandalone, + TaskDirName: "stale-reuse-fail_ddd", + Repositories: []RepoPrepareSpec{ + { + RepositoryID: "repo-new", + RepositoryPath: repoNew, + RepoName: "stale-new", + BaseBranch: "main", + WorktreeID: "wt-stale", + }, + {RepositoryID: "repo-bad", RepositoryPath: repoBad, RepoName: "bad", BaseBranch: "main"}, + }, + } + + res, err := preparer.Prepare(context.Background(), req, nil) + if err != nil { + t.Fatalf("prepare returned hard error: %v", err) + } + if res.Success { + t.Fatal("expected failure when final repo is not a git repo") + } + + all, err := mgr.GetAllByTaskID(context.Background(), "task-stale-reuse-fail") + if err != nil { + t.Fatalf("list worktrees: %v", err) + } + for _, wt := range all { + if wt.Status == worktree.StatusActive { + t.Fatalf("worktree %s should have been rolled back after stale reuse ID created it", wt.ID) + } + } +} + func TestWorktreePreparer_MultiRepo_RunsPerRepoSetupScript(t *testing.T) { repoA := initBareGitRepo(t, "frontend") repoB := initBareGitRepo(t, "backend") diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index bfe135c165..f45f1ca7f3 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -19,6 +19,7 @@ import ( "github.com/kandev/kandev/internal/agent/settings/cliflags" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/worktree" ) // resolveAgentProfile resolves the agent profile and returns the agent type name and profile info. @@ -155,7 +156,9 @@ func collectBaseBranches(req *LaunchRequest) map[string]string { if spec.BaseBranch == "" { continue } - out[spec.RepoName] = spec.BaseBranch + if key := baseBranchMetadataKey(spec); key != "" { + out[key] = spec.BaseBranch + } } if req.BaseBranch != "" { if _, ok := out[""]; !ok { @@ -168,6 +171,18 @@ func collectBaseBranches(req *LaunchRequest) map[string]string { return out } +func baseBranchMetadataKey(spec RepoLaunchSpec) string { + repoName := worktree.SanitizeRepoDirName(spec.RepoName) + if repoName == "" { + repoName = spec.RepoName + } + branchSlug := worktree.SanitizeBranchSlug(spec.BranchSlug) + if branchSlug == "" { + return repoName + } + return repoName + "-" + branchSlug +} + // agentCommands holds the initial and continue command strings for an agent execution. type agentCommands struct { initial string @@ -645,6 +660,7 @@ func buildEnvPrepareRequest(req *LaunchRequest, workspacePath string, execName e TaskDirName: req.TaskDirName, RepoName: req.RepoName, BranchSlug: req.BranchSlug, + BranchIdentitySlug: req.BranchIdentitySlug, Env: req.Env, } // Multi-repo: forward the repo list when the launch request carries one. @@ -670,6 +686,7 @@ func buildEnvPrepareRequest(req *LaunchRequest, workspacePath string, execName e PullBeforeWorktree: r.PullBeforeWorktree, RepoSetupScript: setup, BranchSlug: r.BranchSlug, + BranchIdentitySlug: r.BranchIdentitySlug, }) } prepReq.Repositories = specs diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go index 25617e6c8e..3f1194def9 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go @@ -48,6 +48,37 @@ func (a *resumeTestAgent) Runtime() *agents.RuntimeConfig { } } +func TestBuildEnvPrepareRequest_CarriesTopLevelBranchIdentity(t *testing.T) { + req := &LaunchRequest{ + TaskID: "task-1", + SessionID: "session-1", + RepositoryID: "repo-1", + RepositoryPath: "/repos/repo-1", + RepoName: "repo-1", + BaseBranch: "feature/x", + BranchSlug: "feature-x", + BranchIdentitySlug: "feature-x", + UseWorktree: true, + } + + prepReq := buildEnvPrepareRequest(req, "/workspace", executor.NameStandalone) + + if prepReq.BranchSlug != "feature-x" { + t.Fatalf("BranchSlug = %q, want feature-x", prepReq.BranchSlug) + } + if prepReq.BranchIdentitySlug != "feature-x" { + t.Fatalf("BranchIdentitySlug = %q, want feature-x", prepReq.BranchIdentitySlug) + } + specs := prepReq.RepoSpecs() + if len(specs) != 1 { + t.Fatalf("RepoSpecs length = %d, want 1", len(specs)) + } + if specs[0].BranchIdentitySlug != "feature-x" || specs[0].BranchSlug != "feature-x" { + t.Fatalf("repo spec branch fields = identity %q path %q, want feature-x/feature-x", + specs[0].BranchIdentitySlug, specs[0].BranchSlug) + } +} + func TestBuildAgentCommand_ResumeFlag(t *testing.T) { mgr := newTestManager(t) canRecoverTrue := true diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index 598ce616ba..7701903723 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -308,9 +308,14 @@ type RepoLaunchSpec struct { RepoSetupScript string // Repository-level setup script (optional) RepoCleanupScript string // Repository-level cleanup script (optional) CopyFiles string // Comma-separated paths/globs to copy from the source repo (gitignored .env / config files) - // BranchSlug, when set, nests the worktree under {RepoName}/{BranchSlug}/ - // so multi-branch tasks (same repo, multiple branches) don't collide. + // BranchSlug, when set, suffixes the worktree directory as + // {RepoName}-{BranchSlug} so multi-branch tasks (same repo, multiple + // branches) don't collide. BranchSlug string + // BranchIdentitySlug is the stable branch key used for worktree reuse and + // persisted environment metadata. It may differ from BranchSlug when a + // primary branch keeps the flat legacy path. + BranchIdentitySlug string } // RouteOverride carries a fully resolved provider profile for one @@ -387,7 +392,10 @@ type LaunchRequest struct { // Task directory mode: place worktree at ~/.kandev/tasks/{TaskDirName}/{RepoName}/ TaskDirName string // Semantic task directory name (e.g. "fix-bug_ab12") RepoName string // Repository name used as subdirectory inside the task directory - BranchSlug string // Optional branch subdir for multi-branch tasks (legacy single-repo path) + BranchSlug string // Optional branch directory suffix for multi-branch tasks + // BranchIdentitySlug is the stable branch key used for single-repo reuse. + // It may be non-empty when BranchSlug is empty to preserve a flat path. + BranchIdentitySlug string // Repositories carries one entry per repository when the launch is multi-repo. // When non-empty it is the source of truth; the legacy single-repo top-level @@ -420,6 +428,7 @@ func (r *LaunchRequest) RepoSpecs() []RepoLaunchSpec { PullBeforeWorktree: r.PullBeforeWorktree, CopyFiles: r.CopyFiles, BranchSlug: r.BranchSlug, + BranchIdentitySlug: r.BranchIdentitySlug, }} } diff --git a/apps/backend/internal/agent/runtime/lifecycle/types_repospecs_test.go b/apps/backend/internal/agent/runtime/lifecycle/types_repospecs_test.go index b9cb9d1edb..4926a19b16 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types_repospecs_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types_repospecs_test.go @@ -33,6 +33,8 @@ func TestLaunchRequest_RepoSpecs_SynthesizesFromLegacyFields(t *testing.T) { WorktreeBranchPrefix: "feat/", PullBeforeWorktree: true, RepoName: "x", + BranchSlug: "feature-y", + BranchIdentitySlug: "feature-y", } specs := req.RepoSpecs() if len(specs) != 1 { @@ -42,7 +44,8 @@ func TestLaunchRequest_RepoSpecs_SynthesizesFromLegacyFields(t *testing.T) { if got.RepositoryID != "repo-x" || got.RepositoryPath != "/x" || got.BaseBranch != "main" || got.CheckoutBranch != "feature/y" || got.WorktreeID != "wt-1" || got.WorktreeBranchPrefix != "feat/" || - !got.PullBeforeWorktree || got.RepoName != "x" { + !got.PullBeforeWorktree || got.RepoName != "x" || + got.BranchSlug != "feature-y" || got.BranchIdentitySlug != "feature-y" { t.Errorf("synthesized spec mismatch: %+v", got) } } @@ -70,10 +73,12 @@ func TestEnvPrepareRequest_RepoSpecs_ExplicitWins(t *testing.T) { func TestEnvPrepareRequest_RepoSpecs_SynthesizedCarriesRepoSetupScript(t *testing.T) { req := &EnvPrepareRequest{ - RepositoryID: "r1", - RepositoryPath: "/r1", - BaseBranch: "main", - RepoSetupScript: "make install", + RepositoryID: "r1", + RepositoryPath: "/r1", + BaseBranch: "main", + RepoSetupScript: "make install", + BranchSlug: "feature-y", + BranchIdentitySlug: "feature-y", } specs := req.RepoSpecs() if len(specs) != 1 { @@ -82,6 +87,9 @@ func TestEnvPrepareRequest_RepoSpecs_SynthesizedCarriesRepoSetupScript(t *testin if specs[0].RepoSetupScript != "make install" { t.Errorf("repo setup script not propagated: %+v", specs[0]) } + if specs[0].BranchSlug != "feature-y" || specs[0].BranchIdentitySlug != "feature-y" { + t.Errorf("branch identity not propagated: %+v", specs[0]) + } } func TestEnvPrepareRequest_RepoSpecs_NilForRepoLess(t *testing.T) { diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go index 9497e3fabc..68b2f31ea9 100644 --- a/apps/backend/internal/backendapp/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -120,8 +120,10 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch WorktreeBranchPrefix: req.WorktreeBranchPrefix, PullBeforeWorktree: req.PullBeforeWorktree, // Task directory mode - TaskDirName: req.TaskDirName, - RepoName: req.RepoName, + TaskDirName: req.TaskDirName, + RepoName: req.RepoName, + BranchSlug: req.BranchSlug, + BranchIdentitySlug: req.BranchIdentitySlug, } if req.RouteOverride != nil { @@ -155,6 +157,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch RepoCleanupScript: r.RepoCleanupScript, CopyFiles: r.CopyFiles, BranchSlug: r.BranchSlug, + BranchIdentitySlug: r.BranchIdentitySlug, }) } launchReq.Repositories = specs @@ -188,6 +191,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch for _, w := range execution.PrepareResult.Worktrees { worktrees = append(worktrees, executor.RepoWorktreeResult{ RepositoryID: w.RepositoryID, + BranchSlug: w.BranchSlug, WorktreeID: w.WorktreeID, WorktreeBranch: w.WorktreeBranch, WorktreePath: w.WorktreePath, diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 9ed24bf09c..27a69bff83 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -40,6 +40,7 @@ type executorStore interface { ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error) // Session worktree CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error + ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) // Repository entity GetRepository(ctx context.Context, id string) (*models.Repository, error) // Executor @@ -59,6 +60,7 @@ type executorStore interface { UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error CreateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error ListTaskEnvironmentRepos(ctx context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) + UpdateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error // Session history + plan (for context handover) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) @@ -311,6 +313,11 @@ type LaunchAgentRequest struct { // Task directory mode: place worktree at ~/.kandev/tasks/{TaskDirName}/{RepoName}/ TaskDirName string // Semantic task directory name (e.g. "fix-bug_ab12") RepoName string // Repository name used as subdirectory inside the task directory + // BranchSlug, when non-empty, suffixes the top-level single-repo path. + BranchSlug string + // BranchIdentitySlug is the stable branch key for top-level single-repo + // reuse. It may be non-empty when BranchSlug is empty to preserve a flat path. + BranchIdentitySlug string // Repositories carries one entry per repository when the launch is multi-repo. // When non-empty it is the source of truth and the legacy single-repo @@ -343,11 +350,16 @@ type RepoSpec struct { RepoSetupScript string RepoCleanupScript string CopyFiles string - // BranchSlug, when non-empty, nests the worktree under the repo dir so - // the same repo can host multiple branches within one task. Set by the + // BranchSlug, when non-empty, suffixes the repo dir so the same repo can + // host multiple branch worktrees as siblings within one task. Set by the // orchestrator when buildRepoSpecs detects multiple rows sharing a // RepositoryID; empty otherwise to preserve the single-branch layout. BranchSlug string + + // BranchIdentitySlug is the stable branch key used for worktree reuse and + // persisted environment metadata. It may be non-empty even when BranchSlug + // is empty so the primary branch can keep the legacy flat path. + BranchIdentitySlug string } // McpModeConfig activates config-mode MCP tools (workflow steps, agents, MCP @@ -424,6 +436,7 @@ type LaunchAgentResponse struct { // API surface. One entry per repository prepared during a multi-repo launch. type RepoWorktreeResult struct { RepositoryID string + BranchSlug string WorktreeID string WorktreeBranch string WorktreePath string diff --git a/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go b/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go index 99dfcd9205..006bf8a960 100644 --- a/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go +++ b/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go @@ -6,6 +6,7 @@ import ( "github.com/kandev/kandev/internal/agent/runtime/lifecycle" "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/worktree" "go.uber.org/zap" ) @@ -39,7 +40,14 @@ func (e *Executor) reuseExistingEnvironment(ctx context.Context, req *LaunchAgen return } - if env.WorktreeID != "" && req.UseWorktree { + if env.TaskDirName != "" && req.UseWorktree { + req.TaskDirName = env.TaskDirName + } + + if req.UseWorktree { + e.reuseExistingRepositoryWorktrees(ctx, req, env) + } + if req.WorktreeID == "" && env.WorktreeID != "" && req.UseWorktree && !hasBranchScopedEnvironmentWorktrees(env) { req.WorktreeID = env.WorktreeID e.logger.Info("reusing existing task environment worktree", zap.String("task_id", req.TaskID), @@ -72,6 +80,250 @@ func (e *Executor) reuseExistingEnvironment(ctx context.Context, req *LaunchAgen } } +type repositoryWorktreeKey struct { + repositoryID string + branchSlug string +} + +func (e *Executor) reuseExistingRepositoryWorktrees(ctx context.Context, req *LaunchAgentRequest, env *models.TaskEnvironment) { + if !req.UseWorktree { + return + } + repoSpecs := req.Repositories + usingTopLevelRepo := false + if len(repoSpecs) == 0 { + spec, ok := topLevelLaunchRepoSpec(req) + if !ok { + return + } + repoSpecs = []RepoSpec{spec} + usingTopLevelRepo = true + } + + if len(repoSpecs) == 0 { + return + } + + envWorktreeIDs := e.environmentRepoWorktreeIDs(req, env) + sessionWorktreeIDs := e.latestSessionWorktreeIDsForEnvironment(ctx, req.TaskID, env.ID) + if len(envWorktreeIDs) == 0 && len(sessionWorktreeIDs) == 0 { + return + } + hasScopedWorktrees := hasBranchScopedEnvironmentWorktrees(env) + allowLegacyEmptyBranchFallback := !hasScopedWorktrees + for i := range repoSpecs { + spec := &repoSpecs[i] + if id := reusableWorktreeIDForSpec(*spec, envWorktreeIDs, sessionWorktreeIDs, allowLegacyEmptyBranchFallback); id != "" { + spec.WorktreeID = id + } + } + if repoSpecs[0].WorktreeID == "" { + routeUnmatchedTopLevelRepoToScopedPath(req, repoSpecs[0], usingTopLevelRepo, hasScopedWorktrees) + return + } + req.WorktreeID = repoSpecs[0].WorktreeID + if usingTopLevelRepo { + routeMatchedTopLevelRepoToScopedIdentity(req, repoSpecs[0], hasScopedWorktrees) + } else { + req.Repositories = repoSpecs + } +} + +func reusableWorktreeIDForSpec( + spec RepoSpec, + envWorktreeIDs map[repositoryWorktreeKey]string, + sessionWorktreeIDs map[repositoryWorktreeKey]string, + allowLegacyEmptyBranchFallback bool, +) string { + branchIdentity := launchRepoBranchIdentitySlug(spec) + key := repositoryWorktreeKey{ + repositoryID: spec.RepositoryID, + branchSlug: branchIdentity, + } + if id := envWorktreeIDs[key]; id != "" { + return id + } + if id := sessionWorktreeIDs[key]; id != "" { + return id + } + if spec.BranchSlug != "" || !allowLegacyEmptyBranchFallback { + return "" + } + legacyKey := repositoryWorktreeKey{ + repositoryID: spec.RepositoryID, + branchSlug: "", + } + if id := envWorktreeIDs[legacyKey]; id != "" { + return id + } + if branchIdentity == "" { + if id := sessionWorktreeIDs[legacyKey]; id != "" { + return id + } + } + return "" +} + +func routeUnmatchedTopLevelRepoToScopedPath( + req *LaunchAgentRequest, + spec RepoSpec, + usingTopLevelRepo bool, + hasScopedWorktrees bool, +) { + if !usingTopLevelRepo || !hasScopedWorktrees { + return + } + pathSlug := launchRepoBranchIdentitySlug(spec) + if pathSlug == "" { + return + } + spec.BranchIdentitySlug = pathSlug + spec.BranchSlug = pathSlug + req.BranchIdentitySlug = pathSlug + req.BranchSlug = pathSlug + req.Repositories = []RepoSpec{spec} +} + +func routeMatchedTopLevelRepoToScopedIdentity(req *LaunchAgentRequest, spec RepoSpec, hasScopedWorktrees bool) { + if !hasScopedWorktrees { + return + } + identitySlug := launchRepoBranchIdentitySlug(spec) + if identitySlug == "" { + return + } + spec.BranchIdentitySlug = identitySlug + req.BranchIdentitySlug = identitySlug + req.Repositories = []RepoSpec{spec} +} + +func topLevelLaunchRepoSpec(req *LaunchAgentRequest) (RepoSpec, bool) { + if req.RepositoryID == "" { + return RepoSpec{}, false + } + return RepoSpec{ + RepositoryID: req.RepositoryID, + RepositoryPath: req.RepositoryPath, + RepositoryURL: req.RepositoryURL, + RepoName: req.RepoName, + BaseBranch: req.BaseBranch, + DefaultBranch: req.DefaultBranch, + CheckoutBranch: req.CheckoutBranch, + PRNumber: req.PRNumber, + WorktreeID: req.WorktreeID, + WorktreeBranchPrefix: req.WorktreeBranchPrefix, + PullBeforeWorktree: req.PullBeforeWorktree, + CopyFiles: req.CopyFiles, + BranchIdentitySlug: topLevelBranchIdentitySlug(req), + }, true +} + +func topLevelBranchIdentitySlug(req *LaunchAgentRequest) string { + branch := req.CheckoutBranch + if branch == "" { + branch = req.BaseBranch + } + if branch == "" { + branch = req.DefaultBranch + } + return worktree.SanitizeBranchSlug(branch) +} + +func launchRepoBranchIdentitySlug(spec RepoSpec) string { + if spec.BranchIdentitySlug != "" { + return worktree.SanitizeBranchSlug(spec.BranchIdentitySlug) + } + return worktree.SanitizeBranchSlug(spec.BranchSlug) +} + +func (e *Executor) environmentRepoWorktreeIDs(req *LaunchAgentRequest, env *models.TaskEnvironment) map[repositoryWorktreeKey]string { + result := make(map[repositoryWorktreeKey]string) + if env.WorktreeID != "" && !hasBranchScopedEnvironmentWorktrees(env) { + repoID := env.RepositoryID + if repoID == "" { + repoID = req.RepositoryID + } + if repoID != "" { + result[repositoryWorktreeKey{ + repositoryID: repoID, + branchSlug: "", + }] = env.WorktreeID + } + } + for _, repo := range env.Repos { + if repo.RepositoryID == "" || repo.WorktreeID == "" { + continue + } + result[repositoryWorktreeKey{ + repositoryID: repo.RepositoryID, + branchSlug: worktree.SanitizeBranchSlug(repo.BranchSlug), + }] = repo.WorktreeID + } + return result +} + +func hasBranchScopedEnvironmentWorktrees(env *models.TaskEnvironment) bool { + for _, repo := range env.Repos { + if repo.RepositoryID != "" && repo.WorktreeID != "" && worktree.SanitizeBranchSlug(repo.BranchSlug) != "" { + return true + } + } + return false +} + +func (e *Executor) latestSessionWorktreeIDsForEnvironment(ctx context.Context, taskID, envID string) map[repositoryWorktreeKey]string { + sessions, err := e.repo.ListTaskSessions(ctx, taskID) + if err != nil { + e.logger.Warn("failed to list sessions for per-repo worktree reuse", + zap.String("task_id", taskID), + zap.String("task_environment_id", envID), + zap.Error(err)) + return nil + } + sort.SliceStable(sessions, func(i, j int) bool { + if !sessions[i].StartedAt.Equal(sessions[j].StartedAt) { + return sessions[i].StartedAt.After(sessions[j].StartedAt) + } + if !sessions[i].UpdatedAt.Equal(sessions[j].UpdatedAt) { + return sessions[i].UpdatedAt.After(sessions[j].UpdatedAt) + } + return sessions[i].ID > sessions[j].ID + }) + for _, session := range sessions { + if envID != "" && session.TaskEnvironmentID != "" && session.TaskEnvironmentID != envID { + continue + } + worktrees, err := e.repo.ListTaskSessionWorktrees(ctx, session.ID) + if err != nil { + e.logger.Warn("failed to list session worktrees for reuse", + zap.String("task_id", taskID), + zap.String("session_id", session.ID), + zap.Error(err)) + continue + } + result := sessionWorktreeIDsByKey(worktrees) + if len(result) > 0 { + return result + } + } + return nil +} + +func sessionWorktreeIDsByKey(worktrees []*models.TaskSessionWorktree) map[repositoryWorktreeKey]string { + result := make(map[repositoryWorktreeKey]string, len(worktrees)) + for _, wt := range worktrees { + if wt.RepositoryID == "" || wt.WorktreeID == "" { + continue + } + key := repositoryWorktreeKey{ + repositoryID: wt.RepositoryID, + branchSlug: worktree.SanitizeBranchSlug(wt.BranchSlug), + } + result[key] = wt.WorktreeID + } + return result +} + func (e *Executor) latestExecutorRunningForEnvironment(ctx context.Context, taskID string, env *models.TaskEnvironment) *models.ExecutorRunning { sessions, err := e.repo.ListTaskSessions(ctx, taskID) if err != nil { diff --git a/apps/backend/internal/orchestrator/executor/executor_environment_test.go b/apps/backend/internal/orchestrator/executor/executor_environment_test.go index 1d1e0bd101..abd8a6e797 100644 --- a/apps/backend/internal/orchestrator/executor/executor_environment_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_environment_test.go @@ -48,6 +48,39 @@ func TestReuseExistingEnvironment_WorktreeReuse(t *testing.T) { } } +func TestReuseExistingEnvironment_WorktreeReuseKeepsTaskDirName(t *testing.T) { + repo := newMockRepository() + e := newTestExecutor(t, &mockAgentManager{}, repo) + req := &LaunchAgentRequest{ + TaskID: "task-1", + UseWorktree: true, + TaskDirName: "fresh-task-dir", + Repositories: []RepoSpec{ + {RepositoryID: "repo-kandev", BranchIdentitySlug: "main"}, + {RepositoryID: "repo-docs", BranchIdentitySlug: "main"}, + }, + } + env := &models.TaskEnvironment{ + ID: "env-existing", + TaskDirName: "persisted-task-dir", + Repos: []*models.TaskEnvironmentRepo{ + {TaskEnvironmentID: "env-existing", RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-kandev"}, + }, + } + + e.reuseExistingEnvironment(context.Background(), req, env) + + if req.TaskDirName != "persisted-task-dir" { + t.Fatalf("TaskDirName = %q, want persisted-task-dir", req.TaskDirName) + } + if req.Repositories[0].WorktreeID != "wt-kandev" { + t.Fatalf("first repo WorktreeID = %q, want wt-kandev", req.Repositories[0].WorktreeID) + } + if req.Repositories[1].WorktreeID != "" { + t.Fatalf("second repo WorktreeID = %q, want empty for new checkout", req.Repositories[1].WorktreeID) + } +} + func TestReuseExistingEnvironment_SkipsReuseOnExecutorTypeMismatch(t *testing.T) { // Switching the task's executor profile to a different type must invalidate // reuse: stale PreviousExecutionID/ContainerID/sprite_name from the old diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 663a2d9916..fee99c2d7e 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "hash/fnv" "os/exec" + "sort" "strings" "sync" "time" @@ -706,20 +708,14 @@ func (e *Executor) applyContainerCredentials(ctx context.Context, req *LaunchAge // buildRepoSpecs converts resolved repoInfos into per-repo launch specs for // the lifecycle layer. Used only when the task has more than one repository. -// When the same RepositoryID appears more than once, the FIRST occurrence -// keeps the flat layout (//) and subsequent occurrences nest -// under ///. This preserves the legacy single- -// branch path for any worktree that already exists on disk, so a task -// upgraded from single-branch to multi-branch doesn't orphan its primary -// worktree directory. +// When the same RepositoryID appears more than once, each row gets a stable +// BranchIdentitySlug for reuse while the lowest-position branch keeps the flat +// layout (//). Other branches use sibling directories like +// /-/. +// This preserves the legacy single-branch path when a task later gains another +// branch of the same repository. func buildRepoSpecs(allRepos []*repoInfo) []RepoSpec { - repoCounts := make(map[string]int, len(allRepos)) - for _, info := range allRepos { - if info.RepositoryID != "" { - repoCounts[info.RepositoryID]++ - } - } - seenCount := make(map[string]int, len(allRepos)) + branchPlans := buildRepoBranchPlans(allRepos) out := make([]RepoSpec, 0, len(allRepos)) for _, info := range allRepos { spec := RepoSpec{ @@ -745,26 +741,131 @@ func buildRepoSpecs(allRepos []*repoInfo) []RepoSpec { spec.RepositoryURL = u } } - if repoCounts[info.RepositoryID] > 1 && seenCount[info.RepositoryID] > 0 { - slug := worktree.SanitizeBranchSlug(info.CheckoutBranch) - if slug == "" { - slug = worktree.SanitizeBranchSlug(info.BaseBranch) - } - // Branches whose names sanitize to "" (or two duplicate rows - // without explicit branch names) would otherwise collapse onto - // the same on-disk path as the first row. Fall back to a - // position-derived slug so siblings get distinct directories. - if slug == "" { - slug = fmt.Sprintf("branch-%d", seenCount[info.RepositoryID]+1) - } - spec.BranchSlug = slug + if plan, ok := branchPlans[info]; ok { + spec.BranchIdentitySlug = plan.identitySlug + spec.BranchSlug = plan.pathSlug } - seenCount[info.RepositoryID]++ out = append(out, spec) } return out } +type repoBranchPlan struct { + identitySlug string + pathSlug string +} + +func buildRepoBranchPlans(allRepos []*repoInfo) map[*repoInfo]repoBranchPlan { + groups := make(map[string][]*repoInfo, len(allRepos)) + for _, info := range allRepos { + if info == nil || info.RepositoryID == "" { + continue + } + groups[info.RepositoryID] = append(groups[info.RepositoryID], info) + } + + plans := make(map[*repoInfo]repoBranchPlan, len(allRepos)) + for repoID, group := range groups { + identities := branchIdentitySlugsForGroup(repoID, group) + if len(group) < 2 { + for _, info := range group { + plans[info] = repoBranchPlan{identitySlug: identities[info]} + } + continue + } + flatIdentity := selectFlatBranchIdentity(group, identities) + for _, info := range group { + identity := identities[info] + pathSlug := identity + if identity == flatIdentity { + pathSlug = "" + } + plans[info] = repoBranchPlan{identitySlug: identity, pathSlug: pathSlug} + } + } + return plans +} + +func branchIdentitySlugsForGroup(repoID string, group []*repoInfo) map[*repoInfo]string { + raw := make(map[*repoInfo]string, len(group)) + counts := make(map[string]int, len(group)) + for _, info := range group { + slug := preferredBranchIdentitySlug(info) + if slug == "" { + slug = "branch-" + branchIdentityHash(repoID, info) + } + raw[info] = slug + counts[slug]++ + } + + out := make(map[*repoInfo]string, len(group)) + for _, info := range group { + slug := raw[info] + if counts[slug] > 1 { + slug += "-" + branchIdentityHash(repoID, info) + } + slug = worktree.SanitizeBranchSlug(slug) + if slug == "" { + slug = "branch-" + branchIdentityHash(repoID, info) + } + out[info] = slug + } + return out +} + +func preferredBranchIdentitySlug(info *repoInfo) string { + branch := info.CheckoutBranch + if branch == "" { + branch = info.BaseBranch + } + if branch == "" && info.Repository != nil { + branch = info.Repository.DefaultBranch + } + return worktree.SanitizeBranchSlug(branch) +} + +func branchIdentityHash(repoID string, info *repoInfo) string { + seed := strings.Join([]string{ + repoID, + info.BaseBranch, + info.CheckoutBranch, + fmt.Sprintf("%d", info.PRNumber), + }, "\x00") + h := fnv.New32a() + _, _ = h.Write([]byte(seed)) + return fmt.Sprintf("%08x", h.Sum32()) +} + +func selectFlatBranchIdentity(group []*repoInfo, identities map[*repoInfo]string) string { + candidates := make([]*repoInfo, 0, len(group)) + candidates = append(candidates, group...) + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].Position != candidates[j].Position { + return candidates[i].Position < candidates[j].Position + } + leftRank := flatBranchRank(candidates[i]) + rightRank := flatBranchRank(candidates[j]) + if leftRank != rightRank { + return leftRank < rightRank + } + return identities[candidates[i]] < identities[candidates[j]] + }) + return identities[candidates[0]] +} + +func flatBranchRank(info *repoInfo) int { + if info.CheckoutBranch != "" { + return 3 + } + if info.Repository != nil && info.Repository.DefaultBranch != "" && info.BaseBranch == info.Repository.DefaultBranch { + return 0 + } + if info.BaseBranch == defaultBaseBranch { + return 1 + } + return 2 +} + // applyRepositoryConfig sets repository-related fields on the request and resolves clone URLs. func (e *Executor) applyRepositoryConfig(req *LaunchAgentRequest, task *v1.Task, repoInfo *repoInfo, execConfig executorConfig, metadata map[string]interface{}) (map[string]interface{}, error) { if repoInfo.RepositoryPath != "" { @@ -1155,6 +1256,7 @@ func buildTaskEnvironmentRepos(worktrees []RepoWorktreeResult) []*models.TaskEnv for i, w := range worktrees { out = append(out, &models.TaskEnvironmentRepo{ RepositoryID: w.RepositoryID, + BranchSlug: w.BranchSlug, WorktreeID: w.WorktreeID, WorktreePath: w.WorktreePath, WorktreeBranch: w.WorktreeBranch, @@ -1165,9 +1267,10 @@ func buildTaskEnvironmentRepos(worktrees []RepoWorktreeResult) []*models.TaskEnv return out } -// persistTaskEnvironmentRepos inserts per-repo rows under an existing env id, -// skipping any (env, repo) pair that already exists. Used when an existing -// environment is reused (resume / re-launch on the same task). +// persistTaskEnvironmentRepos upserts per-repo rows under an existing env id. +// Used when an existing environment is reused (resume / re-launch on the same +// task), including cases where stale or legacy rows need the successful launch +// result written back for the next handoff. func (e *Executor) persistTaskEnvironmentRepos(ctx context.Context, envID string, worktrees []RepoWorktreeResult) { if envID == "" || len(worktrees) == 0 { return @@ -1179,17 +1282,36 @@ func (e *Executor) persistTaskEnvironmentRepos(ctx context.Context, envID string zap.Error(err)) return } - have := make(map[string]bool, len(existing)) + byKey := make(map[string]*models.TaskEnvironmentRepo, len(existing)) + legacyFlatByRepo := make(map[string]*models.TaskEnvironmentRepo) for _, row := range existing { - have[row.RepositoryID] = true + key := row.RepositoryID + "\x00" + row.BranchSlug + byKey[key] = row + if row.RepositoryID != "" && row.BranchSlug == "" { + legacyFlatByRepo[row.RepositoryID] = row + } } for i, w := range worktrees { - if w.RepositoryID == "" || have[w.RepositoryID] { + if w.RepositoryID == "" { continue } + key := w.RepositoryID + "\x00" + w.BranchSlug + if row := byKey[key]; row != nil { + e.refreshTaskEnvironmentRepo(ctx, row, w, i) + continue + } + if w.BranchSlug != "" { + if row := legacyFlatByRepo[w.RepositoryID]; row != nil { + e.refreshTaskEnvironmentRepo(ctx, row, w, i) + delete(legacyFlatByRepo, w.RepositoryID) + byKey[key] = row + continue + } + } row := &models.TaskEnvironmentRepo{ TaskEnvironmentID: envID, RepositoryID: w.RepositoryID, + BranchSlug: w.BranchSlug, WorktreeID: w.WorktreeID, WorktreePath: w.WorktreePath, WorktreeBranch: w.WorktreeBranch, @@ -1205,6 +1327,34 @@ func (e *Executor) persistTaskEnvironmentRepos(ctx context.Context, envID string } } +func (e *Executor) refreshTaskEnvironmentRepo(ctx context.Context, row *models.TaskEnvironmentRepo, w RepoWorktreeResult, position int) { + if !taskEnvironmentRepoNeedsRefresh(row, w, position) { + return + } + row.BranchSlug = w.BranchSlug + row.WorktreeID = w.WorktreeID + row.WorktreePath = w.WorktreePath + row.WorktreeBranch = w.WorktreeBranch + row.Position = position + row.ErrorMessage = w.ErrorMessage + if err := e.repo.UpdateTaskEnvironmentRepo(ctx, row); err != nil { + e.logger.Warn("failed to update task environment repo", + zap.String("env_id", row.TaskEnvironmentID), + zap.String("repository_id", row.RepositoryID), + zap.String("branch_slug", row.BranchSlug), + zap.Error(err)) + } +} + +func taskEnvironmentRepoNeedsRefresh(row *models.TaskEnvironmentRepo, w RepoWorktreeResult, position int) bool { + return row.BranchSlug != w.BranchSlug || + row.WorktreeID != w.WorktreeID || + row.WorktreePath != w.WorktreePath || + row.WorktreeBranch != w.WorktreeBranch || + row.Position != position || + row.ErrorMessage != w.ErrorMessage +} + // extractSandboxID extracts the sandbox identifier from launch response metadata. // For Sprites executors, this is the sprite_name. func extractSandboxID(metadata map[string]interface{}) string { diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index 25003313e1..fae265b79d 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -642,7 +642,13 @@ func (m *mockRepository) GetLastAgentMessage(_ context.Context, _ string) (strin // Task Session Worktree operations func (m *mockRepository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) { - return nil, nil + var out []*models.TaskSessionWorktree + for _, wt := range m.sessionWorktrees { + if wt.SessionID == sessionID { + out = append(out, wt) + } + } + return out, nil } func (m *mockRepository) ListWorktreesBySessionIDs(_ context.Context, _ []string) (map[string][]*models.TaskSessionWorktree, error) { return make(map[string][]*models.TaskSessionWorktree), nil @@ -819,12 +825,30 @@ func (m *mockRepository) UpdateTaskEnvironment(_ context.Context, env *models.Ta return nil } func (m *mockRepository) CreateTaskEnvironmentRepo(_ context.Context, repo *models.TaskEnvironmentRepo) error { + if repo.ID == "" { + repo.ID = repo.TaskEnvironmentID + "-repo-" + repo.RepositoryID + if repo.BranchSlug != "" { + repo.ID += "-branch-" + repo.BranchSlug + } + } m.taskEnvironmentRepos[repo.TaskEnvironmentID] = append(m.taskEnvironmentRepos[repo.TaskEnvironmentID], repo) return nil } func (m *mockRepository) ListTaskEnvironmentRepos(_ context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) { return m.taskEnvironmentRepos[envID], nil } +func (m *mockRepository) UpdateTaskEnvironmentRepo(_ context.Context, repo *models.TaskEnvironmentRepo) error { + rows := m.taskEnvironmentRepos[repo.TaskEnvironmentID] + for i, row := range rows { + if row.ID == repo.ID { + rows[i] = repo + m.taskEnvironmentRepos[repo.TaskEnvironmentID] = rows + return nil + } + } + m.taskEnvironmentRepos[repo.TaskEnvironmentID] = append(rows, repo) + return nil +} // Task Plan operations func (m *mockRepository) CreateTaskPlan(ctx context.Context, plan *models.TaskPlan) error { return nil } diff --git a/apps/backend/internal/orchestrator/executor/executor_multi_repo_test.go b/apps/backend/internal/orchestrator/executor/executor_multi_repo_test.go index caf392218d..b1aeb5d85f 100644 --- a/apps/backend/internal/orchestrator/executor/executor_multi_repo_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_multi_repo_test.go @@ -34,6 +34,15 @@ func seedMultiRepoTask(t *testing.T, repo *mockRepository, taskID string) { } } +func seedWorktreeExecutor(repo *mockRepository) { + repo.executors[models.ExecutorIDWorktree] = &models.Executor{ + ID: models.ExecutorIDWorktree, + Type: models.ExecutorTypeWorktree, + Status: models.ExecutorStatusActive, + Resumable: true, + } +} + func TestLaunchPreparedSession_MultiRepo_PopulatesRequestRepositories(t *testing.T) { repo := newMockRepository() taskID := "task-multi-1" @@ -165,6 +174,778 @@ func TestLaunchPreparedSession_MultiRepo_PersistsPerRepoEnvironmentAndWorktreeRo } } +func TestLaunchPreparedSession_MultiRepo_ReusesPerRepoWorktreeIDsFromEnvironment(t *testing.T) { + repo := newMockRepository() + taskID := "task-multi-reuse" + sessionID := "session-multi-reuse" + seedMultiRepoTask(t, repo, taskID) + seedWorktreeExecutor(repo) + + repo.taskEnvironments["env-existing"] = &models.TaskEnvironment{ + ID: "env-existing", + TaskID: taskID, + ExecutorType: string(models.ExecutorTypeWorktree), + Status: models.TaskEnvironmentStatusReady, + WorktreeID: "wt-front", + Repos: []*models.TaskEnvironmentRepo{ + { + TaskEnvironmentID: "env-existing", + RepositoryID: "repo-front", + WorktreeID: "wt-front", + Position: 0, + }, + { + TaskEnvironmentID: "env-existing", + RepositoryID: "repo-back", + WorktreeID: "wt-back", + Position: 1, + }, + }, + } + repo.taskEnvironmentRepos["env-existing"] = repo.taskEnvironments["env-existing"].Repos + repo.sessions[sessionID] = &models.TaskSession{ + ID: sessionID, + TaskID: taskID, + AgentProfileID: "profile-123", + ExecutorID: models.ExecutorIDWorktree, + State: models.TaskSessionStateCreated, + StartedAt: time.Now(), + UpdatedAt: time.Now(), + } + + var captured *LaunchAgentRequest + agentManager := &mockAgentManager{ + launchAgentFunc: func(_ context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) { + captured = req + return &LaunchAgentResponse{AgentExecutionID: "exec-reuse"}, nil + }, + } + exec := newTestExecutor(t, agentManager, repo) + + task := &v1.Task{ID: taskID, WorkspaceID: "ws-1", Title: "Multi"} + _, err := exec.LaunchPreparedSession(context.Background(), task, sessionID, LaunchOptions{ + AgentProfileID: "profile-123", + ExecutorID: models.ExecutorIDWorktree, + StartAgent: false, + }) + if err != nil { + t.Fatalf("LaunchPreparedSession: %v", err) + } + + if captured == nil { + t.Fatal("expected launch request to be captured") + } + if len(captured.Repositories) != 2 { + t.Fatalf("expected 2 repo specs, got %d", len(captured.Repositories)) + } + if captured.Repositories[0].WorktreeID != "wt-front" { + t.Errorf("front WorktreeID = %q, want wt-front", captured.Repositories[0].WorktreeID) + } + if captured.Repositories[1].WorktreeID != "wt-back" { + t.Errorf("back WorktreeID = %q, want wt-back", captured.Repositories[1].WorktreeID) + } +} + +func TestReuseExistingRepositoryWorktrees_EnvironmentRowsWinOverSessionRows(t *testing.T) { + repo := newMockRepository() + taskID := "task-env-wins" + envID := "env-env-wins" + now := time.Now().UTC() + repo.sessions["session-prev"] = &models.TaskSession{ + ID: "session-prev", + TaskID: taskID, + TaskEnvironmentID: envID, + StartedAt: now.Add(-time.Minute), + UpdatedAt: now.Add(-time.Minute), + } + repo.sessionWorktrees = append(repo.sessionWorktrees, + &models.TaskSessionWorktree{ + SessionID: "session-prev", + RepositoryID: "repo-a", + BranchSlug: "main", + WorktreeID: "wt-session-a", + }, + &models.TaskSessionWorktree{ + SessionID: "session-prev", + RepositoryID: "repo-b", + BranchSlug: "feature", + WorktreeID: "wt-session-b", + }, + ) + exec := newTestExecutor(t, &mockAgentManager{}, repo) + req := &LaunchAgentRequest{ + TaskID: taskID, + UseWorktree: true, + Repositories: []RepoSpec{ + {RepositoryID: "repo-a", BranchIdentitySlug: "main"}, + {RepositoryID: "repo-b", BranchIdentitySlug: "feature"}, + }, + } + env := &models.TaskEnvironment{ + ID: envID, + Repos: []*models.TaskEnvironmentRepo{ + {RepositoryID: "repo-a", BranchSlug: "main", WorktreeID: "wt-env-a"}, + }, + } + + exec.reuseExistingRepositoryWorktrees(context.Background(), req, env) + + if req.Repositories[0].WorktreeID != "wt-env-a" { + t.Fatalf("repo-a WorktreeID = %q, want env row to win with wt-env-a", req.Repositories[0].WorktreeID) + } + if req.Repositories[1].WorktreeID != "wt-session-b" { + t.Fatalf("repo-b WorktreeID = %q, want session fallback wt-session-b", req.Repositories[1].WorktreeID) + } + if req.WorktreeID != "wt-env-a" { + t.Fatalf("top-level WorktreeID = %q, want first repo env worktree wt-env-a", req.WorktreeID) + } +} + +func TestReuseExistingRepositoryWorktrees_LegacyFlatEnvWorktreeFeedsFlatBranchSpec(t *testing.T) { + repo := newMockRepository() + exec := newTestExecutor(t, &mockAgentManager{}, repo) + req := &LaunchAgentRequest{ + TaskID: "task-legacy-flat", + RepositoryID: "repo-kandev", + UseWorktree: true, + Repositories: []RepoSpec{ + { + RepositoryID: "repo-kandev", + BranchIdentitySlug: "release-1-2", + BranchSlug: "", + }, + { + RepositoryID: "repo-kandev", + BranchIdentitySlug: "main", + BranchSlug: "main", + }, + }, + } + env := &models.TaskEnvironment{ + ID: "env-legacy-flat", + RepositoryID: "repo-kandev", + WorktreeID: "wt-legacy-flat", + } + + exec.reuseExistingRepositoryWorktrees(context.Background(), req, env) + + if req.Repositories[0].WorktreeID != "wt-legacy-flat" { + t.Fatalf("flat repo WorktreeID = %q, want legacy top-level wt-legacy-flat", req.Repositories[0].WorktreeID) + } + if req.Repositories[1].WorktreeID != "" { + t.Fatalf("nested repo WorktreeID = %q, want empty", req.Repositories[1].WorktreeID) + } + if req.WorktreeID != "wt-legacy-flat" { + t.Fatalf("top-level WorktreeID = %q, want wt-legacy-flat", req.WorktreeID) + } +} + +func TestReuseExistingRepositoryWorktrees_LegacyEmptyBranchRowFeedsFlatBranchSpec(t *testing.T) { + repo := newMockRepository() + exec := newTestExecutor(t, &mockAgentManager{}, repo) + req := &LaunchAgentRequest{ + TaskID: "task-legacy-empty-row", + UseWorktree: true, + Repositories: []RepoSpec{ + { + RepositoryID: "repo-kandev", + BranchIdentitySlug: "main", + BranchSlug: "", + }, + { + RepositoryID: "repo-kandev", + BranchIdentitySlug: "feature-x", + BranchSlug: "feature-x", + }, + }, + } + env := &models.TaskEnvironment{ + ID: "env-legacy-empty-row", + Repos: []*models.TaskEnvironmentRepo{ + {RepositoryID: "repo-kandev", BranchSlug: "", WorktreeID: "wt-flat-row"}, + }, + } + + exec.reuseExistingRepositoryWorktrees(context.Background(), req, env) + + if req.Repositories[0].WorktreeID != "wt-flat-row" { + t.Fatalf("flat repo WorktreeID = %q, want legacy row wt-flat-row", req.Repositories[0].WorktreeID) + } + if req.Repositories[1].WorktreeID != "" { + t.Fatalf("nested repo WorktreeID = %q, want empty", req.Repositories[1].WorktreeID) + } +} + +func TestReuseExistingEnvironment_SingleRepoUsesBranchScopedEnvRow(t *testing.T) { + repo := newMockRepository() + exec := newTestExecutor(t, &mockAgentManager{}, repo) + req := &LaunchAgentRequest{ + TaskID: "task-single-branch-row", + RepositoryID: "repo-kandev", + BaseBranch: "feature-x", + UseWorktree: true, + } + env := &models.TaskEnvironment{ + ID: "env-single-branch-row", + RepositoryID: "repo-kandev", + WorktreeID: "wt-main", + Repos: []*models.TaskEnvironmentRepo{ + {RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-main"}, + {RepositoryID: "repo-kandev", BranchSlug: "feature-x", WorktreeID: "wt-feature"}, + }, + } + + exec.reuseExistingEnvironment(context.Background(), req, env) + + if req.WorktreeID != "wt-feature" { + t.Fatalf("top-level WorktreeID = %q, want branch-scoped wt-feature", req.WorktreeID) + } +} + +func TestReuseExistingEnvironment_SingleRepoBranchMatchKeepsScopedRepoSpec(t *testing.T) { + repo := newMockRepository() + exec := newTestExecutor(t, &mockAgentManager{}, repo) + req := &LaunchAgentRequest{ + TaskID: "task-single-branch-row", + RepositoryID: "repo-kandev", + RepositoryPath: "/repos/kandev", + RepoName: "kandev", + BaseBranch: "feature-x", + UseWorktree: true, + } + env := &models.TaskEnvironment{ + ID: "env-single-branch-row", + RepositoryID: "repo-kandev", + WorktreeID: "wt-main", + Repos: []*models.TaskEnvironmentRepo{ + {RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-main"}, + {RepositoryID: "repo-kandev", BranchSlug: "feature-x", WorktreeID: "wt-feature"}, + }, + } + + exec.reuseExistingEnvironment(context.Background(), req, env) + + if req.WorktreeID != "wt-feature" { + t.Fatalf("top-level WorktreeID = %q, want branch-scoped wt-feature", req.WorktreeID) + } + if len(req.Repositories) != 1 { + t.Fatalf("Repositories length = %d, want one branch-scoped reuse spec", len(req.Repositories)) + } + got := req.Repositories[0] + if got.WorktreeID != "wt-feature" { + t.Fatalf("spec WorktreeID = %q, want wt-feature", got.WorktreeID) + } + if got.BranchIdentitySlug != "feature-x" { + t.Fatalf("spec BranchIdentitySlug = %q, want feature-x", got.BranchIdentitySlug) + } + if req.BranchIdentitySlug != "feature-x" { + t.Fatalf("top-level BranchIdentitySlug = %q, want feature-x", req.BranchIdentitySlug) + } + if got.BranchSlug != "" { + t.Fatalf("spec BranchSlug = %q, want empty to preserve the existing path", got.BranchSlug) + } + if req.BranchSlug != "" { + t.Fatalf("top-level BranchSlug = %q, want empty to preserve the existing path", req.BranchSlug) + } +} + +func TestReuseExistingEnvironment_SingleRepoUsesDefaultBranchScopedEnvRow(t *testing.T) { + repo := newMockRepository() + exec := newTestExecutor(t, &mockAgentManager{}, repo) + req := &LaunchAgentRequest{ + TaskID: "task-single-default-branch-row", + RepositoryID: "repo-kandev", + DefaultBranch: "main", + UseWorktree: true, + } + env := &models.TaskEnvironment{ + ID: "env-single-default-branch-row", + RepositoryID: "repo-kandev", + WorktreeID: "wt-feature", + Repos: []*models.TaskEnvironmentRepo{ + {RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-main"}, + }, + } + + exec.reuseExistingEnvironment(context.Background(), req, env) + + if req.WorktreeID != "wt-main" { + t.Fatalf("top-level WorktreeID = %q, want default-branch wt-main", req.WorktreeID) + } +} + +func TestReuseExistingEnvironment_SingleRepoDoesNotFallBackToWrongScopedEnvWorktree(t *testing.T) { + repo := newMockRepository() + exec := newTestExecutor(t, &mockAgentManager{}, repo) + req := &LaunchAgentRequest{ + TaskID: "task-single-no-match", + RepositoryID: "repo-kandev", + BaseBranch: "feature-x", + UseWorktree: true, + } + env := &models.TaskEnvironment{ + ID: "env-single-no-match", + RepositoryID: "repo-kandev", + WorktreeID: "wt-main", + Repos: []*models.TaskEnvironmentRepo{ + {RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-main"}, + }, + } + + exec.reuseExistingEnvironment(context.Background(), req, env) + + if req.WorktreeID != "" { + t.Fatalf("top-level WorktreeID = %q, want no stale env-level fallback", req.WorktreeID) + } +} + +func TestReuseExistingEnvironment_SingleRepoUnmatchedScopedEnvUsesBranchPathSlug(t *testing.T) { + repo := newMockRepository() + exec := newTestExecutor(t, &mockAgentManager{}, repo) + req := &LaunchAgentRequest{ + TaskID: "task-single-new-scoped-branch", + RepositoryID: "repo-kandev", + RepositoryPath: "/repos/kandev", + RepoName: "kandev", + BaseBranch: "feature/new-path", + DefaultBranch: "main", + WorktreeBranchPrefix: "task/", + UseWorktree: true, + } + env := &models.TaskEnvironment{ + ID: "env-single-new-scoped-branch", + RepositoryID: "repo-kandev", + WorktreeID: "wt-main", + Repos: []*models.TaskEnvironmentRepo{ + {RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-main"}, + }, + } + + exec.reuseExistingEnvironment(context.Background(), req, env) + + if req.WorktreeID != "" { + t.Fatalf("top-level WorktreeID = %q, want no stale env-level fallback", req.WorktreeID) + } + if len(req.Repositories) != 1 { + t.Fatalf("Repositories length = %d, want one branch-scoped prepare spec", len(req.Repositories)) + } + got := req.Repositories[0] + if got.BranchSlug != "feature-new-path" { + t.Fatalf("BranchSlug = %q, want feature-new-path", got.BranchSlug) + } + if got.BranchIdentitySlug != "feature-new-path" { + t.Fatalf("BranchIdentitySlug = %q, want feature-new-path", got.BranchIdentitySlug) + } + if req.BranchSlug != "feature-new-path" { + t.Fatalf("top-level BranchSlug = %q, want feature-new-path", req.BranchSlug) + } + if req.BranchIdentitySlug != "feature-new-path" { + t.Fatalf("top-level BranchIdentitySlug = %q, want feature-new-path", req.BranchIdentitySlug) + } + if got.RepositoryPath != req.RepositoryPath || got.RepoName != req.RepoName { + t.Fatalf("repo fields not carried into scoped spec: %+v", got) + } + if got.WorktreeBranchPrefix != req.WorktreeBranchPrefix || got.DefaultBranch != req.DefaultBranch { + t.Fatalf("branch defaults not carried into scoped spec: %+v", got) + } +} + +func TestReuseExistingEnvironment_SingleRepoIgnoresEmptySessionBranchWhenEnvIsScoped(t *testing.T) { + repo := newMockRepository() + taskID := "task-single-empty-session-branch" + envID := "env-single-empty-session-branch" + now := time.Now().UTC() + repo.sessions["session-prev"] = &models.TaskSession{ + ID: "session-prev", + TaskID: taskID, + TaskEnvironmentID: envID, + StartedAt: now.Add(-time.Minute), + UpdatedAt: now.Add(-time.Minute), + } + repo.sessionWorktrees = append(repo.sessionWorktrees, &models.TaskSessionWorktree{ + SessionID: "session-prev", + RepositoryID: "repo-kandev", + BranchSlug: "", + WorktreeID: "wt-stale-empty-branch", + }) + exec := newTestExecutor(t, &mockAgentManager{}, repo) + req := &LaunchAgentRequest{ + TaskID: taskID, + RepositoryID: "repo-kandev", + BaseBranch: "feature-x", + UseWorktree: true, + } + env := &models.TaskEnvironment{ + ID: envID, + RepositoryID: "repo-kandev", + WorktreeID: "wt-main", + Repos: []*models.TaskEnvironmentRepo{ + {RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-main"}, + }, + } + + exec.reuseExistingEnvironment(context.Background(), req, env) + + if req.WorktreeID != "" { + t.Fatalf("top-level WorktreeID = %q, want no empty-branch session fallback", req.WorktreeID) + } +} + +func TestReuseExistingEnvironment_SingleRepoIgnoresEmptySessionBranchForNewBranch(t *testing.T) { + repo := newMockRepository() + taskID := "task-single-empty-session-new-branch" + envID := "env-single-empty-session-new-branch" + now := time.Now().UTC() + repo.sessions["session-prev"] = &models.TaskSession{ + ID: "session-prev", + TaskID: taskID, + TaskEnvironmentID: envID, + StartedAt: now.Add(-time.Minute), + UpdatedAt: now.Add(-time.Minute), + } + repo.sessionWorktrees = append(repo.sessionWorktrees, &models.TaskSessionWorktree{ + SessionID: "session-prev", + RepositoryID: "repo-kandev", + BranchSlug: "", + WorktreeID: "wt-stale-empty-branch", + }) + exec := newTestExecutor(t, &mockAgentManager{}, repo) + req := &LaunchAgentRequest{ + TaskID: taskID, + RepositoryID: "repo-kandev", + BaseBranch: "newbranch", + UseWorktree: true, + } + env := &models.TaskEnvironment{ + ID: envID, + RepositoryID: "repo-kandev", + } + + exec.reuseExistingEnvironment(context.Background(), req, env) + + if req.WorktreeID != "" { + t.Fatalf("top-level WorktreeID = %q, want no empty-branch session fallback", req.WorktreeID) + } +} + +func TestLaunchPreparedSession_MultiBranch_ReusesWorktreeIDsByBranchSlug(t *testing.T) { + repo := newMockRepository() + taskID := "task-multi-branch-reuse" + sessionID := "session-multi-branch-reuse" + sourceSessionID := "session-source" + now := time.Now().UTC() + seedWorktreeExecutor(repo) + + repo.repositories["repo-kandev"] = &models.Repository{ + ID: "repo-kandev", + Name: "kandev", + LocalPath: "/repos/kandev", + WorktreeBranchPrefix: "feature/", + } + repo.taskRepositories["tr-main"] = &models.TaskRepository{ + ID: "tr-main", TaskID: taskID, RepositoryID: "repo-kandev", Position: 0, BaseBranch: "main", + } + repo.taskRepositories["tr-branch"] = &models.TaskRepository{ + ID: "tr-branch", TaskID: taskID, RepositoryID: "repo-kandev", Position: 1, BaseBranch: "main", CheckoutBranch: "branch-5hn", + } + repo.taskEnvironments["env-existing"] = &models.TaskEnvironment{ + ID: "env-existing", + TaskID: taskID, + ExecutorType: string(models.ExecutorTypeWorktree), + Status: models.TaskEnvironmentStatusReady, + WorktreeID: "wt-main", + } + repo.sessions[sourceSessionID] = &models.TaskSession{ + ID: sourceSessionID, + TaskID: taskID, + TaskEnvironmentID: "env-existing", + StartedAt: now.Add(-time.Minute), + UpdatedAt: now.Add(-time.Minute), + } + repo.sessions[sessionID] = &models.TaskSession{ + ID: sessionID, + TaskID: taskID, + AgentProfileID: "profile-123", + ExecutorID: models.ExecutorIDWorktree, + State: models.TaskSessionStateCreated, + StartedAt: now, + UpdatedAt: now, + } + repo.sessionWorktrees = append(repo.sessionWorktrees, + &models.TaskSessionWorktree{ + SessionID: sourceSessionID, + RepositoryID: "repo-kandev", + WorktreeID: "wt-main", + BranchSlug: "main", + WorktreePath: "/tasks/t/kandev", + WorktreeBranch: "feature/t", + }, + &models.TaskSessionWorktree{ + SessionID: sourceSessionID, + RepositoryID: "repo-kandev", + WorktreeID: "wt-branch", + BranchSlug: "branch-5hn", + WorktreePath: "/tasks/t/kandev-branch-5hn", + WorktreeBranch: "branch-5hn", + }, + ) + + var captured *LaunchAgentRequest + agentManager := &mockAgentManager{ + launchAgentFunc: func(_ context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) { + captured = req + return &LaunchAgentResponse{AgentExecutionID: "exec-reuse-branch"}, nil + }, + } + exec := newTestExecutor(t, agentManager, repo) + + task := &v1.Task{ID: taskID, WorkspaceID: "ws-1", Title: "Multi Branch"} + _, err := exec.LaunchPreparedSession(context.Background(), task, sessionID, LaunchOptions{ + AgentProfileID: "profile-123", + ExecutorID: models.ExecutorIDWorktree, + StartAgent: false, + }) + if err != nil { + t.Fatalf("LaunchPreparedSession: %v", err) + } + + if captured == nil { + t.Fatal("expected launch request to be captured") + } + if len(captured.Repositories) != 2 { + t.Fatalf("expected 2 repo specs, got %d", len(captured.Repositories)) + } + if captured.Repositories[0].WorktreeID != "wt-main" { + t.Errorf("main WorktreeID = %q, want wt-main", captured.Repositories[0].WorktreeID) + } + if captured.Repositories[0].BranchIdentitySlug != "main" { + t.Errorf("main BranchIdentitySlug = %q, want main", captured.Repositories[0].BranchIdentitySlug) + } + if captured.Repositories[0].BranchSlug != "" { + t.Errorf("main BranchSlug = %q, want empty flat-path slug", captured.Repositories[0].BranchSlug) + } + if captured.Repositories[1].BranchSlug != "branch-5hn" { + t.Errorf("branch spec BranchSlug = %q, want branch-5hn", captured.Repositories[1].BranchSlug) + } + if captured.Repositories[1].BranchIdentitySlug != "branch-5hn" { + t.Errorf("branch spec BranchIdentitySlug = %q, want branch-5hn", captured.Repositories[1].BranchIdentitySlug) + } + if captured.Repositories[1].WorktreeID != "wt-branch" { + t.Errorf("branch WorktreeID = %q, want wt-branch", captured.Repositories[1].WorktreeID) + } + if captured.WorktreeID != "wt-main" { + t.Errorf("top-level WorktreeID = %q, want wt-main", captured.WorktreeID) + } +} + +func TestBuildRepoSpecs_MultiBranchIdentityStableAcrossReorder(t *testing.T) { + repo := &models.Repository{ID: "repo-kandev", Name: "kandev", DefaultBranch: "main"} + mainInfo := &repoInfo{ + RepositoryID: "repo-kandev", + RepositoryPath: "/repos/kandev", + BaseBranch: "main", + Position: 0, + Repository: repo, + } + branchInfo := &repoInfo{ + RepositoryID: "repo-kandev", + RepositoryPath: "/repos/kandev", + BaseBranch: "main", + CheckoutBranch: "branch-5hn", + Position: 1, + Repository: repo, + } + + first := buildRepoSpecs([]*repoInfo{mainInfo, branchInfo}) + second := buildRepoSpecs([]*repoInfo{branchInfo, mainInfo}) + + if first[0].BranchIdentitySlug != "main" || first[0].BranchSlug != "" { + t.Fatalf("first main plan = identity %q path %q, want main/empty", first[0].BranchIdentitySlug, first[0].BranchSlug) + } + if first[1].BranchIdentitySlug != "branch-5hn" || first[1].BranchSlug != "branch-5hn" { + t.Fatalf("first branch plan = identity %q path %q, want branch-5hn/branch-5hn", first[1].BranchIdentitySlug, first[1].BranchSlug) + } + if second[0].BranchIdentitySlug != "branch-5hn" || second[0].BranchSlug != "branch-5hn" { + t.Fatalf("reordered branch plan = identity %q path %q, want branch-5hn/branch-5hn", second[0].BranchIdentitySlug, second[0].BranchSlug) + } + if second[1].BranchIdentitySlug != "main" || second[1].BranchSlug != "" { + t.Fatalf("reordered main plan = identity %q path %q, want main/empty", second[1].BranchIdentitySlug, second[1].BranchSlug) + } +} + +func TestBuildRepoSpecs_MultiBranchFlatPathFollowsLowestTaskRepoPosition(t *testing.T) { + repo := &models.Repository{ID: "repo-kandev", Name: "kandev", DefaultBranch: "main"} + releaseInfo := &repoInfo{ + RepositoryID: "repo-kandev", + RepositoryPath: "/repos/kandev", + BaseBranch: "release/1.2", + Position: 0, + Repository: repo, + } + mainInfo := &repoInfo{ + RepositoryID: "repo-kandev", + RepositoryPath: "/repos/kandev", + BaseBranch: "main", + Position: 1, + Repository: repo, + } + + specs := buildRepoSpecs([]*repoInfo{mainInfo, releaseInfo}) + + if specs[0].BranchIdentitySlug != "main" || specs[0].BranchSlug != "main" { + t.Fatalf("main plan = identity %q path %q, want main/main", specs[0].BranchIdentitySlug, specs[0].BranchSlug) + } + if specs[1].BranchIdentitySlug != "release-1.2" || specs[1].BranchSlug != "" { + t.Fatalf("release plan = identity %q path %q, want release-1.2/empty", specs[1].BranchIdentitySlug, specs[1].BranchSlug) + } +} + +func TestBuildRepoSpecs_AssignsBranchIdentityForDistinctRepos(t *testing.T) { + frontendInfo := &repoInfo{ + RepositoryID: "repo-front", + RepositoryPath: "/repos/frontend", + BaseBranch: "main", + Position: 0, + Repository: &models.Repository{ID: "repo-front", Name: "frontend", DefaultBranch: "main"}, + } + backendInfo := &repoInfo{ + RepositoryID: "repo-back", + RepositoryPath: "/repos/backend", + BaseBranch: "release/1.2", + Position: 1, + Repository: &models.Repository{ID: "repo-back", Name: "backend", DefaultBranch: "main"}, + } + + specs := buildRepoSpecs([]*repoInfo{frontendInfo, backendInfo}) + + if specs[0].BranchIdentitySlug != "main" || specs[0].BranchSlug != "" { + t.Fatalf("frontend plan = identity %q path %q, want main/empty", specs[0].BranchIdentitySlug, specs[0].BranchSlug) + } + if specs[1].BranchIdentitySlug != "release-1.2" || specs[1].BranchSlug != "" { + t.Fatalf("backend plan = identity %q path %q, want release-1.2/empty", specs[1].BranchIdentitySlug, specs[1].BranchSlug) + } +} + +func TestPersistTaskEnvironmentRepos_RefreshesExistingRows(t *testing.T) { + repo := newMockRepository() + exec := newTestExecutor(t, &mockAgentManager{}, repo) + repo.taskEnvironmentRepos["env-refresh"] = []*models.TaskEnvironmentRepo{ + { + ID: "env-refresh-repo-main", + TaskEnvironmentID: "env-refresh", + RepositoryID: "repo-kandev", + BranchSlug: "main", + WorktreeID: "wt-stale", + WorktreePath: "/old/path", + WorktreeBranch: "old-branch", + Position: 5, + ErrorMessage: "old error", + }, + } + + exec.persistTaskEnvironmentRepos(context.Background(), "env-refresh", []RepoWorktreeResult{ + { + RepositoryID: "repo-kandev", + BranchSlug: "main", + WorktreeID: "wt-repaired", + WorktreePath: "/new/path", + WorktreeBranch: "new-branch", + ErrorMessage: "", + }, + }) + + rows := repo.taskEnvironmentRepos["env-refresh"] + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + row := rows[0] + if row.WorktreeID != "wt-repaired" || row.WorktreePath != "/new/path" || row.WorktreeBranch != "new-branch" || row.Position != 0 || row.ErrorMessage != "" { + t.Fatalf("row was not refreshed: %+v", row) + } +} + +func TestPersistTaskEnvironmentRepos_MigratesLegacyFlatRowToBranchIdentity(t *testing.T) { + repo := newMockRepository() + exec := newTestExecutor(t, &mockAgentManager{}, repo) + repo.taskEnvironmentRepos["env-legacy"] = []*models.TaskEnvironmentRepo{ + { + ID: "env-legacy-repo-flat", + TaskEnvironmentID: "env-legacy", + RepositoryID: "repo-kandev", + BranchSlug: "", + WorktreeID: "wt-flat", + WorktreePath: "/old/flat", + }, + } + + exec.persistTaskEnvironmentRepos(context.Background(), "env-legacy", []RepoWorktreeResult{ + { + RepositoryID: "repo-kandev", + BranchSlug: "release-1.2", + WorktreeID: "wt-flat", + WorktreePath: "/new/flat", + WorktreeBranch: "feature/task", + }, + }) + + rows := repo.taskEnvironmentRepos["env-legacy"] + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + row := rows[0] + if row.BranchSlug != "release-1.2" || row.WorktreeID != "wt-flat" || row.WorktreePath != "/new/flat" || row.WorktreeBranch != "feature/task" { + t.Fatalf("legacy row was not migrated: %+v", row) + } +} + +func TestMockRepositoryTaskEnvironmentRepoUpdatesAreBranchScoped(t *testing.T) { + repo := newMockRepository() + ctx := context.Background() + + if err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ + TaskEnvironmentID: "env-branches", + RepositoryID: "repo-kandev", + BranchSlug: "main", + WorktreeID: "wt-main", + }); err != nil { + t.Fatalf("CreateTaskEnvironmentRepo(main): %v", err) + } + if err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ + TaskEnvironmentID: "env-branches", + RepositoryID: "repo-kandev", + BranchSlug: "feature", + WorktreeID: "wt-feature", + }); err != nil { + t.Fatalf("CreateTaskEnvironmentRepo(feature): %v", err) + } + rows := repo.taskEnvironmentRepos["env-branches"] + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2", len(rows)) + } + if rows[0].ID == rows[1].ID { + t.Fatalf("branch-specific rows should have distinct IDs, both got %q", rows[0].ID) + } + featureRowID := rows[1].ID + + if err := repo.UpdateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ + ID: featureRowID, + TaskEnvironmentID: "env-branches", + RepositoryID: "repo-kandev", + BranchSlug: "feature", + WorktreeID: "wt-feature-updated", + }); err != nil { + t.Fatalf("UpdateTaskEnvironmentRepo(feature): %v", err) + } + + rows = repo.taskEnvironmentRepos["env-branches"] + if rows[0].WorktreeID != "wt-main" { + t.Fatalf("main WorktreeID = %q, want wt-main", rows[0].WorktreeID) + } + if rows[1].WorktreeID != "wt-feature-updated" { + t.Fatalf("feature WorktreeID = %q, want wt-feature-updated", rows[1].WorktreeID) + } +} + func TestLaunchPreparedSession_SingleRepo_DoesNotPopulateRequestRepositories(t *testing.T) { repo := newMockRepository() taskID := "task-single-1" diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index 029f6cd9bd..f32e43beb9 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -40,6 +40,7 @@ type repoInfo struct { BaseBranch string CheckoutBranch string PRNumber int // GitHub PR number when CheckoutBranch is a PR head; sourced from task_repositories.metadata["pr_number"]. + Position int WorktreeBranchPrefix string PullBeforeWorktree bool Repository *models.Repository @@ -95,6 +96,7 @@ func (e *Executor) resolveTaskRepoInfo(ctx context.Context, tr *models.TaskRepos BaseBranch: tr.BaseBranch, CheckoutBranch: tr.CheckoutBranch, PRNumber: prNumberFromMetadata(tr.Metadata), + Position: tr.Position, } if info.RepositoryID == "" { return info, nil @@ -276,6 +278,7 @@ func (e *Executor) persistWorktreeAssociation(ctx context.Context, taskID string SessionID: session.ID, WorktreeID: w.WorktreeID, RepositoryID: w.RepositoryID, + BranchSlug: w.BranchSlug, Position: i, WorktreePath: w.WorktreePath, WorktreeBranch: w.WorktreeBranch, diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 42d5dccf17..e106053fda 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -132,6 +132,7 @@ type repoStore interface { ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error) CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error + ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) ListSessionsWithBranches(ctx context.Context) ([]models.SessionBranchInfo, error) GetRepository(ctx context.Context, id string) (*models.Repository, error) UpdateRepository(ctx context.Context, repository *models.Repository) error @@ -140,6 +141,7 @@ type repoStore interface { // Multi-repo task environment children CreateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error ListTaskEnvironmentRepos(ctx context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) + UpdateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error // Session history + plan (for context handover) GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) } diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index f8b2eda516..1294f55968 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -642,6 +642,7 @@ type TaskSessionWorktree struct { SessionID string `json:"session_id"` WorktreeID string `json:"worktree_id"` RepositoryID string `json:"repository_id"` + BranchSlug string `json:"branch_slug,omitempty"` Position int `json:"position"` CreatedAt time.Time `json:"created_at"` @@ -1025,6 +1026,7 @@ type TaskEnvironmentRepo struct { ID string `json:"id"` TaskEnvironmentID string `json:"task_environment_id"` RepositoryID string `json:"repository_id"` + BranchSlug string `json:"branch_slug,omitempty"` WorktreeID string `json:"worktree_id,omitempty"` WorktreePath string `json:"worktree_path,omitempty"` WorktreeBranch string `json:"worktree_branch,omitempty"` @@ -1093,6 +1095,9 @@ func (r *TaskEnvironmentRepo) ToAPI() map[string]interface{} { if r.WorktreeID != "" { out["worktree_id"] = r.WorktreeID } + if r.BranchSlug != "" { + out["branch_slug"] = r.BranchSlug + } if r.WorktreePath != "" { out["worktree_path"] = r.WorktreePath } diff --git a/apps/backend/internal/task/models/models_test.go b/apps/backend/internal/task/models/models_test.go index 6571403a90..6bda7e14e5 100644 --- a/apps/backend/internal/task/models/models_test.go +++ b/apps/backend/internal/task/models/models_test.go @@ -272,6 +272,26 @@ func TestTaskToAPIWithEmptyOptionalFields(t *testing.T) { } } +func TestTaskEnvironmentRepoToAPIIncludesBranchSlug(t *testing.T) { + now := time.Now().UTC() + repo := &TaskEnvironmentRepo{ + ID: "ter-1", + TaskEnvironmentID: "env-1", + RepositoryID: "repo-1", + BranchSlug: "branch-5hn", + WorktreeID: "wt-1", + Position: 1, + CreatedAt: now, + UpdatedAt: now, + } + + api := repo.ToAPI() + + if api["branch_slug"] != "branch-5hn" { + t.Fatalf("branch_slug = %v, want branch-5hn", api["branch_slug"]) + } +} + // TestTaskIsFromOfficeField verifies the IsFromOffice field round-trips // through the model. The actual office-vs-kanban predicate is computed in // SQL by isFromOfficeProjection (see repository/sqlite/task.go) so the diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index 520ba48d87..37b861d883 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -57,6 +57,7 @@ func (r *Repository) runMigrations() error { r.migrate.Apply("task_sessions.base_commit_sha", `ALTER TABLE task_sessions ADD COLUMN base_commit_sha TEXT DEFAULT ''`) r.migrate.Apply("workspaces.default_config_agent_profile_id", `ALTER TABLE workspaces ADD COLUMN default_config_agent_profile_id TEXT DEFAULT ''`) r.migrate.Apply("task_sessions.task_environment_id", `ALTER TABLE task_sessions ADD COLUMN task_environment_id TEXT DEFAULT ''`) + r.migrate.Apply("task_session_worktrees.branch_slug", `ALTER TABLE task_session_worktrees ADD COLUMN branch_slug TEXT NOT NULL DEFAULT ''`) r.migrate.Apply("tasks.parent_id", `ALTER TABLE tasks ADD COLUMN parent_id TEXT DEFAULT ''`) // Remove FK constraint on workflow_id to allow ephemeral tasks without workflows if err := r.migrateTasksRemoveWorkflowFK(); err != nil { @@ -82,6 +83,9 @@ func (r *Repository) runMigrations() error { if err := r.migrateTaskEnvironmentsRemoveAgentExecutionID(); err != nil { return err } + if err := r.migrateTaskEnvironmentReposAllowMultiBranch(); err != nil { + return err + } r.migrate.Apply("workflows.sort_order", `ALTER TABLE workflows ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`) r.migrate.Apply("workflows.agent_profile_id", `ALTER TABLE workflows ADD COLUMN agent_profile_id TEXT DEFAULT ''`) r.migrate.Apply("workflows.hidden", `ALTER TABLE workflows ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0`) @@ -394,6 +398,94 @@ func (r *Repository) migrateTaskEnvironmentsRemoveAgentExecutionID() error { }) } +func (r *Repository) migrateTaskEnvironmentReposAllowMultiBranch() error { + if dialect.IsPostgres(r.db.DriverName()) { + return r.migrateTaskEnvironmentReposAllowMultiBranchPostgres() + } + return r.recreateTableNamed( + "task_environment_repos.recreate_allow_multi_branch", + "task_environment_repos", + "UNIQUE(task_environment_id, repository_id)", + []string{ + `CREATE TABLE task_environment_repos_new ( + id TEXT PRIMARY KEY, + task_environment_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + branch_slug TEXT NOT NULL DEFAULT '', + worktree_id TEXT DEFAULT '', + worktree_path TEXT DEFAULT '', + worktree_branch TEXT DEFAULT '', + position INTEGER DEFAULT 0, + error_message TEXT DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_environment_id) REFERENCES task_environments(id) ON DELETE CASCADE, + UNIQUE(task_environment_id, repository_id, branch_slug) + )`, + `INSERT INTO task_environment_repos_new SELECT + id, task_environment_id, repository_id, '', + worktree_id, worktree_path, worktree_branch, + position, error_message, created_at, updated_at + FROM task_environment_repos`, + `DROP TABLE task_environment_repos`, + `ALTER TABLE task_environment_repos_new RENAME TO task_environment_repos`, + `CREATE INDEX IF NOT EXISTS idx_task_environment_repos_env_id ON task_environment_repos(task_environment_id)`, + `CREATE INDEX IF NOT EXISTS idx_task_environment_repos_repository_id ON task_environment_repos(repository_id)`, + }, + ) +} + +func (r *Repository) migrateTaskEnvironmentReposAllowMultiBranchPostgres() error { + if _, err := r.db.Exec(`ALTER TABLE task_environment_repos ADD COLUMN IF NOT EXISTS branch_slug TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("add task_environment_repos.branch_slug: %w", err) + } + if _, err := r.db.Exec(` +DO $$ +DECLARE + old_constraint_name text; +BEGIN + SELECT con.conname INTO old_constraint_name + FROM pg_constraint con + JOIN pg_class rel ON rel.oid = con.conrelid + JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace + WHERE rel.relname = 'task_environment_repos' + AND nsp.nspname = current_schema() + AND con.contype = 'u' + AND ( + SELECT array_agg(attr.attname ORDER BY cols.ordinality) + FROM unnest(con.conkey) WITH ORDINALITY AS cols(attnum, ordinality) + JOIN pg_attribute attr ON attr.attrelid = con.conrelid AND attr.attnum = cols.attnum + ) = ARRAY['task_environment_id', 'repository_id']; + + IF old_constraint_name IS NOT NULL THEN + EXECUTE format('ALTER TABLE task_environment_repos DROP CONSTRAINT %I', old_constraint_name); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint con + JOIN pg_class rel ON rel.oid = con.conrelid + JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace + WHERE rel.relname = 'task_environment_repos' + AND nsp.nspname = current_schema() + AND con.contype = 'u' + AND ( + SELECT array_agg(attr.attname ORDER BY cols.ordinality) + FROM unnest(con.conkey) WITH ORDINALITY AS cols(attnum, ordinality) + JOIN pg_attribute attr ON attr.attrelid = con.conrelid AND attr.attnum = cols.attnum + ) = ARRAY['task_environment_id', 'repository_id', 'branch_slug'] + ) THEN + ALTER TABLE task_environment_repos + ADD CONSTRAINT task_environment_repos_env_repo_branch_key + UNIQUE (task_environment_id, repository_id, branch_slug); + END IF; +END $$; + `); err != nil { + return fmt.Errorf("migrate task_environment_repos unique constraint: %w", err) + } + return nil +} + // migrateTaskRepositoriesAllowMultiBranch swaps the legacy // UNIQUE(task_id, repository_id) constraint on task_repositories for // UNIQUE(task_id, repository_id, base_branch, checkout_branch). The wider @@ -851,10 +943,10 @@ func (r *Repository) backfillTaskEnvironmentRepos() error { for _, row := range pending { if _, err := tx.Exec(` INSERT INTO task_environment_repos ( - id, task_environment_id, repository_id, + id, task_environment_id, repository_id, branch_slug, worktree_id, worktree_path, worktree_branch, position, error_message, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, 0, '', ?, ?) + ) VALUES (?, ?, ?, '', ?, ?, ?, 0, '', ?, ?) `, uuid.New().String(), row.envID, row.repoID, row.wtID, row.wtPath, row.wtBranch, row.createdAt, row.createdAt); err != nil { diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index f0cc844199..363d8ec296 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -578,6 +578,7 @@ const sessionWorktreeSchemaDDL = ` id TEXT PRIMARY KEY, task_environment_id TEXT NOT NULL, repository_id TEXT NOT NULL, + branch_slug TEXT NOT NULL DEFAULT '', worktree_id TEXT DEFAULT '', worktree_path TEXT DEFAULT '', worktree_branch TEXT DEFAULT '', @@ -586,7 +587,7 @@ const sessionWorktreeSchemaDDL = ` created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (task_environment_id) REFERENCES task_environments(id) ON DELETE CASCADE, - UNIQUE(task_environment_id, repository_id) + UNIQUE(task_environment_id, repository_id, branch_slug) ); CREATE INDEX IF NOT EXISTS idx_task_environment_repos_env_id ON task_environment_repos(task_environment_id); @@ -597,6 +598,7 @@ const sessionWorktreeSchemaDDL = ` session_id TEXT NOT NULL, worktree_id TEXT NOT NULL, repository_id TEXT NOT NULL, + branch_slug TEXT NOT NULL DEFAULT '', position INTEGER DEFAULT 0, worktree_path TEXT DEFAULT '', worktree_branch TEXT DEFAULT '', diff --git a/apps/backend/internal/task/repository/sqlite/heal_test.go b/apps/backend/internal/task/repository/sqlite/heal_test.go index cdc5912cd6..2a43e60c70 100644 --- a/apps/backend/internal/task/repository/sqlite/heal_test.go +++ b/apps/backend/internal/task/repository/sqlite/heal_test.go @@ -374,6 +374,50 @@ func TestCreateTaskEnvironment_AllowsNonWorktreeWithEmptyWorkspace(t *testing.T) } } +func TestCreateTaskEnvironment_AllowsSameRepoMultiBranchRows(t *testing.T) { + repo := newRepoForHealTests(t) + insertTask(t, repo.db, "task-H2") + + err := repo.CreateTaskEnvironment(context.Background(), &models.TaskEnvironment{ + ID: "env-H2", + TaskID: "task-H2", + ExecutorType: string(models.ExecutorTypeWorktree), + WorktreePath: "/workspace/main", + WorkspacePath: "/workspace", + Repos: []*models.TaskEnvironmentRepo{ + { + RepositoryID: "repo-shared", + WorktreeID: "wt-main", + WorktreePath: "/workspace/repo", + WorktreeBranch: "feature/main", + Position: 0, + }, + { + RepositoryID: "repo-shared", + BranchSlug: "branch-5hn", + WorktreeID: "wt-branch", + WorktreePath: "/workspace/repo/branch-5hn", + WorktreeBranch: "feature/branch", + Position: 1, + }, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + rows, err := repo.ListTaskEnvironmentRepos(context.Background(), "env-H2") + if err != nil { + t.Fatalf("list repos: %v", err) + } + if len(rows) != 2 { + t.Fatalf("expected 2 environment repo rows, got %d", len(rows)) + } + if rows[0].BranchSlug != "" || rows[1].BranchSlug != "branch-5hn" { + t.Fatalf("unexpected branch slugs: %+v", rows) + } +} + // TestUpdateTaskEnvironment_RejectsClearingWorkspaceForWorktree — symmetric // guard: a writer must not be able to clear a previously-populated // workspace_path on a worktree env. diff --git a/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go b/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go index 38c67f8835..d7a53eb382 100644 --- a/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go +++ b/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go @@ -53,3 +53,52 @@ func TestPostgresSkipsLegacyTaskEnvironmentBackfill(t *testing.T) { t.Fatalf("task environment count = %d, want 0", count) } } + +func TestPostgresTaskEnvironmentReposMultiBranchMigration(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + if _, err := db.Exec(` + CREATE TABLE task_environment_repos ( + id TEXT PRIMARY KEY, + task_environment_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + worktree_id TEXT DEFAULT '', + worktree_path TEXT DEFAULT '', + worktree_branch TEXT DEFAULT '', + position INTEGER DEFAULT 0, + error_message TEXT DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + UNIQUE(task_environment_id, repository_id) + ) + `); err != nil { + t.Fatalf("create legacy task_environment_repos: %v", err) + } + + repo := &Repository{db: db} + if err := repo.migrateTaskEnvironmentReposAllowMultiBranch(); err != nil { + t.Fatalf("migrate task_environment_repos: %v", err) + } + if err := repo.migrateTaskEnvironmentReposAllowMultiBranch(); err != nil { + t.Fatalf("rerun migration: %v", err) + } + + now := time.Now().UTC() + if _, err := db.Exec(` + INSERT INTO task_environment_repos ( + id, task_environment_id, repository_id, branch_slug, + worktree_id, created_at, updated_at + ) VALUES + ('ter-main', 'env-1', 'repo-1', '', 'wt-main', $1, $1), + ('ter-branch', 'env-1', 'repo-1', 'branch-5hn', 'wt-branch', $1, $1) + `, now); err != nil { + t.Fatalf("insert same repo multi-branch rows: %v", err) + } + if _, err := db.Exec(` + INSERT INTO task_environment_repos ( + id, task_environment_id, repository_id, branch_slug, + worktree_id, created_at, updated_at + ) VALUES ('ter-dupe', 'env-1', 'repo-1', '', 'wt-dupe', $1, $1) + `, now); err == nil { + t.Fatal("expected duplicate env/repo/branch insert to fail") + } +} diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 43a3d89ecb..a9484a409f 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -1071,11 +1071,15 @@ func (r *Repository) CreateTaskSessionWorktree(ctx context.Context, sessionWorkt _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_session_worktrees ( - id, session_id, worktree_id, repository_id, position, + id, session_id, worktree_id, repository_id, branch_slug, position, worktree_path, worktree_branch, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(session_id, worktree_id) DO UPDATE SET repository_id = excluded.repository_id, + branch_slug = CASE + WHEN excluded.branch_slug != '' THEN excluded.branch_slug + ELSE task_session_worktrees.branch_slug + END, position = excluded.position, worktree_path = excluded.worktree_path, worktree_branch = excluded.worktree_branch, @@ -1085,6 +1089,7 @@ func (r *Repository) CreateTaskSessionWorktree(ctx context.Context, sessionWorkt sessionWorktree.SessionID, sessionWorktree.WorktreeID, sessionWorktree.RepositoryID, + sessionWorktree.BranchSlug, sessionWorktree.Position, sessionWorktree.WorktreePath, sessionWorktree.WorktreeBranch, @@ -1110,10 +1115,13 @@ func (r *Repository) UpdateTaskSessionWorktreeBranch(ctx context.Context, sessio func (r *Repository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT - tsw.id, tsw.session_id, tsw.worktree_id, tsw.repository_id, tsw.position, + tsw.id, tsw.session_id, tsw.worktree_id, tsw.repository_id, + COALESCE(tsw.branch_slug, ''), tsw.position, tsw.worktree_path, tsw.worktree_branch, tsw.created_at FROM task_session_worktrees tsw WHERE tsw.session_id = ? + AND tsw.deleted_at IS NULL + AND tsw.status = 'active' ORDER BY tsw.position ASC, tsw.created_at ASC `), sessionID) if err != nil { @@ -1129,6 +1137,7 @@ func (r *Repository) ListTaskSessionWorktrees(ctx context.Context, sessionID str &wt.SessionID, &wt.WorktreeID, &wt.RepositoryID, + &wt.BranchSlug, &wt.Position, &wt.WorktreePath, &wt.WorktreeBranch, @@ -1173,9 +1182,11 @@ func (r *Repository) appendWorktreesForSessionChunk( ) error { placeholders, args := buildInPlaceholders(sessionIDs) query := `SELECT tsw.id, tsw.session_id, tsw.worktree_id, tsw.repository_id, tsw.position, - tsw.worktree_path, tsw.worktree_branch, tsw.created_at + COALESCE(tsw.branch_slug, ''), tsw.worktree_path, tsw.worktree_branch, tsw.created_at FROM task_session_worktrees tsw WHERE tsw.session_id IN (` + placeholders + `) + AND tsw.deleted_at IS NULL + AND tsw.status = 'active' ORDER BY tsw.position ASC, tsw.created_at ASC` rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...) @@ -1187,7 +1198,7 @@ func (r *Repository) appendWorktreesForSessionChunk( for rows.Next() { var wt models.TaskSessionWorktree if err := rows.Scan(&wt.ID, &wt.SessionID, &wt.WorktreeID, &wt.RepositoryID, - &wt.Position, &wt.WorktreePath, &wt.WorktreeBranch, &wt.CreatedAt); err != nil { + &wt.Position, &wt.BranchSlug, &wt.WorktreePath, &wt.WorktreeBranch, &wt.CreatedAt); err != nil { return err } result[wt.SessionID] = append(result[wt.SessionID], &wt) diff --git a/apps/backend/internal/task/repository/sqlite/session_test.go b/apps/backend/internal/task/repository/sqlite/session_test.go index 3becbc251d..aeaf2a8c0d 100644 --- a/apps/backend/internal/task/repository/sqlite/session_test.go +++ b/apps/backend/internal/task/repository/sqlite/session_test.go @@ -111,6 +111,71 @@ func TestTaskSessionNotFoundErrorsAreTyped(t *testing.T) { } } +func TestListTaskSessionWorktreesFiltersInactiveRows(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-worktrees", "session-worktrees", "turn-worktrees") + worktrees := []*models.TaskSessionWorktree{ + { + ID: "wt-active", + SessionID: "session-worktrees", + WorktreeID: "worktree-active", + RepositoryID: "repo-1", + BranchSlug: "main", + }, + { + ID: "wt-status-deleted", + SessionID: "session-worktrees", + WorktreeID: "worktree-status-deleted", + RepositoryID: "repo-1", + BranchSlug: "deleted-status", + }, + { + ID: "wt-timestamp-deleted", + SessionID: "session-worktrees", + WorktreeID: "worktree-timestamp-deleted", + RepositoryID: "repo-1", + BranchSlug: "deleted-at", + }, + } + for _, wt := range worktrees { + if err := repo.CreateTaskSessionWorktree(ctx, wt); err != nil { + t.Fatalf("CreateTaskSessionWorktree(%s): %v", wt.ID, err) + } + } + now := time.Now().UTC() + if _, err := repo.db.Exec(repo.db.Rebind(` + UPDATE task_session_worktrees + SET status = 'deleted', updated_at = ? + WHERE id = ? + `), now, "wt-status-deleted"); err != nil { + t.Fatalf("mark status deleted: %v", err) + } + if _, err := repo.db.Exec(repo.db.Rebind(` + UPDATE task_session_worktrees + SET deleted_at = ?, updated_at = ? + WHERE id = ? + `), now, now, "wt-timestamp-deleted"); err != nil { + t.Fatalf("mark timestamp deleted: %v", err) + } + + listed, err := repo.ListTaskSessionWorktrees(ctx, "session-worktrees") + if err != nil { + t.Fatalf("ListTaskSessionWorktrees: %v", err) + } + if len(listed) != 1 || listed[0].ID != "wt-active" { + t.Fatalf("ListTaskSessionWorktrees = %+v, want only wt-active", listed) + } + batched, err := repo.ListWorktreesBySessionIDs(ctx, []string{"session-worktrees"}) + if err != nil { + t.Fatalf("ListWorktreesBySessionIDs: %v", err) + } + rows := batched["session-worktrees"] + if len(rows) != 1 || rows[0].ID != "wt-active" { + t.Fatalf("ListWorktreesBySessionIDs = %+v, want only wt-active", rows) + } +} + // TestGetLastAgentMessage_NoMessages verifies that a session with no messages // returns an empty string and sql.ErrNoRows. func TestGetLastAgentMessage_NoMessages(t *testing.T) { diff --git a/apps/backend/internal/task/repository/sqlite/task_environment.go b/apps/backend/internal/task/repository/sqlite/task_environment.go index 3e496bbee9..885e78912c 100644 --- a/apps/backend/internal/task/repository/sqlite/task_environment.go +++ b/apps/backend/internal/task/repository/sqlite/task_environment.go @@ -211,12 +211,12 @@ func (r *Repository) CreateTaskEnvironmentRepo(ctx context.Context, repo *models _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_environment_repos ( - id, task_environment_id, repository_id, + id, task_environment_id, repository_id, branch_slug, worktree_id, worktree_path, worktree_branch, position, error_message, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), - repo.ID, repo.TaskEnvironmentID, repo.RepositoryID, + repo.ID, repo.TaskEnvironmentID, repo.RepositoryID, repo.BranchSlug, repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch, repo.Position, repo.ErrorMessage, repo.CreatedAt, repo.UpdatedAt, ) @@ -237,12 +237,12 @@ func (r *Repository) insertTaskEnvironmentRepoTx(ctx context.Context, tx *sql.Tx _, err := tx.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_environment_repos ( - id, task_environment_id, repository_id, + id, task_environment_id, repository_id, branch_slug, worktree_id, worktree_path, worktree_branch, position, error_message, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), - repo.ID, repo.TaskEnvironmentID, repo.RepositoryID, + repo.ID, repo.TaskEnvironmentID, repo.RepositoryID, repo.BranchSlug, repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch, repo.Position, repo.ErrorMessage, repo.CreatedAt, repo.UpdatedAt, ) @@ -253,6 +253,7 @@ func (r *Repository) insertTaskEnvironmentRepoTx(ctx context.Context, tx *sql.Tx func (r *Repository) ListTaskEnvironmentRepos(ctx context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT id, task_environment_id, repository_id, + COALESCE(branch_slug, ''), worktree_id, worktree_path, worktree_branch, position, error_message, created_at, updated_at FROM task_environment_repos @@ -269,6 +270,7 @@ func (r *Repository) ListTaskEnvironmentRepos(ctx context.Context, envID string) repo := &models.TaskEnvironmentRepo{} if err := rows.Scan( &repo.ID, &repo.TaskEnvironmentID, &repo.RepositoryID, + &repo.BranchSlug, &repo.WorktreeID, &repo.WorktreePath, &repo.WorktreeBranch, &repo.Position, &repo.ErrorMessage, &repo.CreatedAt, &repo.UpdatedAt, ); err != nil { @@ -285,11 +287,12 @@ func (r *Repository) UpdateTaskEnvironmentRepo(ctx context.Context, repo *models result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_environment_repos SET + branch_slug = ?, worktree_id = ?, worktree_path = ?, worktree_branch = ?, position = ?, error_message = ?, updated_at = ? WHERE id = ? `), - repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch, + repo.BranchSlug, repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch, repo.Position, repo.ErrorMessage, repo.UpdatedAt, repo.ID, ) diff --git a/apps/backend/internal/worktree/manager_lifecycle.go b/apps/backend/internal/worktree/manager_lifecycle.go index 6a6d47df33..10696d1b51 100644 --- a/apps/backend/internal/worktree/manager_lifecycle.go +++ b/apps/backend/internal/worktree/manager_lifecycle.go @@ -25,13 +25,15 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*Worktree, err return nil, err } - // Reject an invalid explicit slug up-front. If req.BranchSlug is non-empty - // but normalizes to "", the reuse lookup downstream would silently match - // the bare-slug worktree (or none) and createInTaskDir would later fail - // with a less obvious error. + // Reject invalid explicit slugs up-front. If either slug is non-empty but + // normalizes to "", reuse/path lookup would silently target the wrong + // worktree or fail later with a less obvious error. if req.BranchSlug != "" && SanitizeBranchSlug(req.BranchSlug) == "" { return nil, ErrInvalidBranchSlug } + if req.BranchIdentitySlug != "" && SanitizeBranchSlug(req.BranchIdentitySlug) == "" { + return nil, ErrInvalidBranchSlug + } if wt, handled, err := m.tryReuseExisting(ctx, req); handled { return wt, err @@ -83,7 +85,7 @@ func (m *Manager) tryReuseExisting(ctx context.Context, req CreateRequest) (*Wor // lookup by RepositoryID AND BranchSlug — otherwise the second branch's // Create would return the first branch's worktree, silently collapsing // two distinct worktrees into one on-disk directory. - reuseSlug := SanitizeBranchSlug(req.BranchSlug) + reuseSlug := requestBranchIdentitySlug(req) if req.SessionID != "" { existing, err := m.GetBySessionAndRepo(ctx, req.SessionID, req.RepositoryID, reuseSlug) if err == nil && existing != nil { @@ -135,6 +137,13 @@ func (m *Manager) tryReuseExisting(ctx context.Context, req CreateRequest) (*Wor return nil, false, nil } +func requestBranchIdentitySlug(req CreateRequest) string { + if req.BranchIdentitySlug != "" { + return SanitizeBranchSlug(req.BranchIdentitySlug) + } + return SanitizeBranchSlug(req.BranchSlug) +} + // resolveBaseRefWithFallback resolves the base ref for a new worktree, optionally // pulling from origin first, and falling back to req.FallbackBaseBranch when the // requested base branch is missing. When the fallback path is taken, req.BaseBranch diff --git a/apps/backend/internal/worktree/manager_state.go b/apps/backend/internal/worktree/manager_state.go index 11df795d47..8a83c55bfa 100644 --- a/apps/backend/internal/worktree/manager_state.go +++ b/apps/backend/internal/worktree/manager_state.go @@ -19,7 +19,7 @@ func (m *Manager) buildWorktreeRecord(worktreeID string, req CreateRequest, work SessionID: req.SessionID, TaskID: req.TaskID, RepositoryID: req.RepositoryID, - BranchSlug: SanitizeBranchSlug(req.BranchSlug), + BranchSlug: requestBranchIdentitySlug(req), RepositoryPath: req.RepositoryPath, Path: worktreePath, Branch: branchName, diff --git a/apps/backend/internal/worktree/worktree.go b/apps/backend/internal/worktree/worktree.go index 05e0062eb4..5841e2257e 100644 --- a/apps/backend/internal/worktree/worktree.go +++ b/apps/backend/internal/worktree/worktree.go @@ -185,6 +185,11 @@ type CreateRequest struct { // derive a deterministic, filesystem-safe slug (see SanitizeBranchSlug). BranchSlug string + // BranchIdentitySlug, when non-empty, is the stable branch key used for + // reuse lookup, cache keys, and persisted task_session_worktrees rows. It + // may differ from BranchSlug when the primary branch keeps the flat path. + BranchIdentitySlug string + // OnSyncProgress receives progress updates for pre-worktree branch sync. OnSyncProgress SyncProgressCallback diff --git a/apps/web/e2e/tests/session/multi-session.spec.ts b/apps/web/e2e/tests/session/multi-session.spec.ts index 976a15602c..714f686416 100644 --- a/apps/web/e2e/tests/session/multi-session.spec.ts +++ b/apps/web/e2e/tests/session/multi-session.spec.ts @@ -1,9 +1,55 @@ +import fs from "node:fs"; +import path from "node:path"; + import { test, expect } from "../../fixtures/test-base"; import { KanbanPage } from "../../pages/kanban-page"; import { SessionPage } from "../../pages/session-page"; const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"]; +function listTaskWorktreeRoots(tmpDir: string): string[] { + const tasksRoot = path.join(tmpDir, ".kandev", "tasks"); + if (!fs.existsSync(tasksRoot)) return []; + return fs + .readdirSync(tasksRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(tasksRoot, entry.name)) + .sort(); +} + +async function waitForDoneSessionCount( + apiClient: import("../../helpers/api-client").ApiClient, + taskId: string, + count: number, + message: string, +) { + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(taskId); + return sessions.filter((s) => DONE_STATES.includes(s.state)).length; + }, + { timeout: 45_000, message }, + ) + .toBe(count); +} + +async function waitForTaskEnvironmentId( + apiClient: import("../../helpers/api-client").ApiClient, + taskId: string, + message: string, +) { + await expect + .poll( + async () => { + const env = await apiClient.getTaskEnvironment(taskId); + return env?.id ?? ""; + }, + { timeout: 45_000, message }, + ) + .not.toBe(""); +} + /** * Tests for launching multiple sessions on the same task. * Verifies session handover context injection and environment reuse. @@ -140,4 +186,86 @@ test.describe("Multi-session", () => { // finds the existing environment and reuses its WorktreeID. expect(firstSession.task_environment_id).toBe(env!.id); }); + + test("same-repo multi-branch second session reuses the existing task workspace", async ({ + apiClient, + seedData, + backend, + }) => { + test.setTimeout(120_000); + + const rootsBefore = listTaskWorktreeRoots(backend.tmpDir); + const task = await apiClient.createTask( + seedData.workspaceId, + "Multi-Branch Workspace Reuse Task", + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repositories: [ + { repository_id: seedData.repositoryId, base_branch: "main" }, + { + repository_id: seedData.repositoryId, + base_branch: "main", + checkout_branch: "branch-5hn", + }, + ], + }, + ); + + await apiClient.launchSession( + { + task_id: task.id, + agent_profile_id: seedData.agentProfileId, + executor_profile_id: seedData.worktreeExecutorProfileId, + workflow_step_id: seedData.startStepId, + prompt: "/e2e:simple-message", + }, + 60_000, + ); + + await waitForDoneSessionCount( + apiClient, + task.id, + 1, + "Waiting for first multi-branch session to finish", + ); + + await waitForTaskEnvironmentId(apiClient, task.id, "Waiting for first task environment"); + const envBefore = await apiClient.getTaskEnvironment(task.id); + expect(envBefore?.id, "first session must create a task environment").toBeTruthy(); + const rootsAfterFirst = listTaskWorktreeRoots(backend.tmpDir); + const createdRoots = rootsAfterFirst.filter((root) => !rootsBefore.includes(root)); + expect( + createdRoots, + "first session should create exactly one task workspace root", + ).toHaveLength(1); + expect( + fs.readdirSync(createdRoots[0]).filter((name) => name.includes("branch-5hn")).length, + ).toBe(1); + const rootEntriesAfterFirst = fs.readdirSync(createdRoots[0]).sort(); + + const launched = await apiClient.launchSession( + { + task_id: task.id, + agent_profile_id: seedData.agentProfileId, + executor_profile_id: seedData.worktreeExecutorProfileId, + workflow_step_id: seedData.startStepId, + prompt: "/e2e:simple-message", + }, + 60_000, + ); + + await waitForDoneSessionCount( + apiClient, + task.id, + 2, + "Waiting for second multi-branch session to finish", + ); + + const { sessions } = await apiClient.listTaskSessions(task.id); + const launchedSession = sessions.find((s) => s.id === launched.session_id); + expect(launchedSession?.task_environment_id).toBe(envBefore!.id); + expect(listTaskWorktreeRoots(backend.tmpDir)).toEqual(rootsAfterFirst); + expect(fs.readdirSync(createdRoots[0]).sort()).toEqual(rootEntriesAfterFirst); + }); }); diff --git a/docs/specs/tasks/multi-branch/spec.md b/docs/specs/tasks/multi-branch/spec.md index c43cb082cd..c3dbad0edf 100644 --- a/docs/specs/tasks/multi-branch/spec.md +++ b/docs/specs/tasks/multi-branch/spec.md @@ -38,6 +38,7 @@ Workarounds (sibling tasks, manually managing two worktrees) lost shared context - Single-branch tasks: `~/.kandev/tasks///` (unchanged). - Multi-branch tasks: first occurrence of each repo keeps `~/.kandev/tasks///`; additional occurrences sit as siblings at `~/.kandev/tasks//-/`. The slug is derived deterministically from `CheckoutBranch` (or `BaseBranch` when the checkout branch is empty) via `worktree.SanitizeBranchSlug`. - The orchestrator (`buildRepoSpecs`) detects same-repo duplicates and tags additional rows with a `BranchSlug`; the worktree manager applies the slug as a suffix at the task-root level. Sibling siting (rather than nesting inside the primary) keeps each worktree's git scope isolated. +- Subsequent sessions on the same task, including handoffs and additional agents, reuse the task's existing worktree IDs for every `(repository, branch)` pair. They do not create a new task directory or sibling worktree set unless the task itself gains a new branch/repository pair. ### PRs @@ -70,6 +71,7 @@ Workarounds (sibling tasks, manually managing two worktrees) lost shared context - `TestCreateTask_RejectsSameRepoSameBranch` — dedup guard still rejects exact duplicates. - `TestAddBranchToTask_HappyPath` — second branch appended after the fact lands as a new row. - `TestAddBranchToTask_RejectsDuplicate` — re-adding the same `(repo, branch)` errors. +- `TestLaunchPreparedSession_MultiBranch_ReusesWorktreeIDsByBranchSlug` — a follow-on session for the same task reuses each existing branch worktree instead of preparing a new task directory. ## Open questions