fix: reuse worktrees for same-task handoffs (#1510)

This commit is contained in:
Carlos Florêncio
2026-06-27 15:00:38 +01:00
committed by GitHub
parent 2ec30ef8cb
commit 211e738ec0
31 changed files with 2025 additions and 74 deletions
@@ -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.
@@ -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"`
@@ -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) {
@@ -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")
@@ -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
@@ -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
@@ -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,
}}
}
@@ -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) {
+6 -2
View File
@@ -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,
@@ -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
@@ -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 {
@@ -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
@@ -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 (<task>/<repo>/) and subsequent occurrences nest
// under <task>/<repo>/<branch-slug>/. 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 (<task>/<repo>/). Other branches use sibling directories like
// <task>/<repo>-<branch-slug>/.
// 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 {
@@ -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 }
@@ -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"
@@ -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,
@@ -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)
}
@@ -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
}
@@ -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
@@ -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 {
@@ -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 '',
@@ -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.
@@ -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")
}
}
@@ -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)
@@ -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) {
@@ -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,
)
@@ -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
@@ -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,
@@ -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
@@ -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);
});
});
+2
View File
@@ -38,6 +38,7 @@ Workarounds (sibling tasks, manually managing two worktrees) lost shared context
- Single-branch tasks: `~/.kandev/tasks/<task-dir>/<repo>/` (unchanged).
- Multi-branch tasks: first occurrence of each repo keeps `~/.kandev/tasks/<task-dir>/<repo>/`; additional occurrences sit as siblings at `~/.kandev/tasks/<task-dir>/<repo>-<branch-slug>/`. 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