Files
kandev/apps/backend/internal/task/service/service_branches_test.go
T
João Salavisa cb7b5429d5 fix(tasks): harden multi-branch add_branch and surface it in UI (#1239)
Three areas were broken in the MCP add_branch_to_task flow:

1. Predictable branch names. autoBranchName counted up from existing
   branch-N rows. Concurrent add_branch calls raced onto the same name
   and the worktree directory leaked ordering. Now uses a random
   branch-<3 random chars> suffix via worktree.SmallSuffix.

2. Sibling materialize did not flow to the UI. The materializer
   published AgentctlReady with the NEW sibling worktree's path, which
   the frontend handler wrote onto the session row — clobbering the
   primary's worktree_id/branch and pointing the file browser at the
   sibling instead of the task root. Added TaskWorkspacePath to the
   payload, detected the sibling case on the frontend (existing
   worktree_id != payload's), kept the primary's id/branch, repointed
   worktree_path to the task root, bumped commits refetch, and dropped
   the pre-multi-repo legacy git-status entry so the Changes panel
   stops surfacing the frozen single-repo snapshot. File browser's
   resetKey now includes the worktree count so the tree reloads when
   the sibling lands.

3. workspace_path promotion + agentctl rescan refused to act on
   historical envs. A few envs had workspace_path pointing at the
   source repo's local_path (orchestrator computeWorkspacePath fallback
   to req.RepositoryPath when resp.WorktreePath is empty) instead of
   the primary worktree. The materializer's promotion gate compared
   against env.WorkspacePath's parent, so those envs were never
   promoted, and agentctl's resolveRescanPath only accepted
   parent-of-current so the rescan to the real task root was rejected.
   Trackers stayed on the wrong directory and the sibling's commits
   never appeared in the Commits panel even after a full reload.
   Anchored promotion on env.TaskDirName (basename cross-check) and
   added a recovery branch to resolveRescanPath that accepts any
   absolute directory which actually contains git-repo subdirs — the
   bearer-token middleware already gates the endpoint and
   scanRepositorySubdirs validates the children, so the relaxed check
   stays safe.

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 00:14:00 +01:00

470 lines
16 KiB
Go

package service
import (
"context"
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
)
// TestAddBranchToTask_HappyPath attaches a second branch to a task that
// already has one TaskRepository row on the same repository and verifies a
// fresh row was created with the right position.
func TestAddBranchToTask_HappyPath(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "WS"})
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"})
_ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "frontend", DefaultBranch: "main"})
req := &CreateTaskRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
WorkflowStepID: "step-1",
Title: "Multi-branch",
Repositories: []TaskRepositoryInput{
{RepositoryID: "repo-1", BaseBranch: "main", CheckoutBranch: "feature/a"},
},
}
task, err := svc.CreateTask(ctx, req)
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
added, err := svc.AddBranchToTask(ctx, AddBranchToTaskRequest{
TaskID: task.ID,
RepositoryID: "repo-1",
BaseBranch: "main",
CheckoutBranch: "feature/b",
})
if err != nil {
t.Fatalf("AddBranchToTask: %v", err)
}
if added.RepositoryID != "repo-1" || added.CheckoutBranch != "feature/b" {
t.Errorf("unexpected row: %+v", added)
}
if added.Position == 0 {
t.Errorf("expected position > 0, got %d", added.Position)
}
rows, err := repo.ListTaskRepositories(ctx, task.ID)
if err != nil {
t.Fatalf("ListTaskRepositories: %v", err)
}
if len(rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(rows))
}
gotBranches := map[string]bool{}
for _, r := range rows {
if r.RepositoryID != "repo-1" {
t.Errorf("unexpected repo id %q", r.RepositoryID)
}
gotBranches[r.CheckoutBranch] = true
}
if !gotBranches["feature/a"] || !gotBranches["feature/b"] {
t.Errorf("missing branches in rows: %+v", gotBranches)
}
}
// TestAddBranchToTask_RejectsDuplicate ensures the same (repo, branch) pair
// cannot be attached twice — the second call returns an error rather than
// silently no-op'ing.
func TestAddBranchToTask_RejectsDuplicate(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "WS"})
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"})
_ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "frontend"})
task, err := svc.CreateTask(ctx, &CreateTaskRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
WorkflowStepID: "step-1",
Title: "Dup",
Repositories: []TaskRepositoryInput{
{RepositoryID: "repo-1", BaseBranch: "main", CheckoutBranch: "feature/a"},
},
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
_, err = svc.AddBranchToTask(ctx, AddBranchToTaskRequest{
TaskID: task.ID,
RepositoryID: "repo-1",
BaseBranch: "main",
CheckoutBranch: "feature/a",
})
if err == nil {
t.Fatal("expected error for duplicate (repo, branch)")
}
if !strings.Contains(err.Error(), "already attached") {
t.Errorf("unexpected error: %v", err)
}
}
// TestCreateTask_AllowsSameRepoDifferentBranches verifies that the relaxed
// unique constraint lets a task be born multi-branch (two rows sharing
// repository_id, distinguished by checkout_branch).
func TestCreateTask_AllowsSameRepoDifferentBranches(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "WS"})
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"})
_ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "frontend"})
task, err := svc.CreateTask(ctx, &CreateTaskRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
WorkflowStepID: "step-1",
Title: "Born multi-branch",
Repositories: []TaskRepositoryInput{
{RepositoryID: "repo-1", BaseBranch: "main", CheckoutBranch: "feature/a"},
{RepositoryID: "repo-1", BaseBranch: "main", CheckoutBranch: "feature/b"},
},
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
rows, err := repo.ListTaskRepositories(ctx, task.ID)
if err != nil {
t.Fatalf("ListTaskRepositories: %v", err)
}
if len(rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(rows))
}
}
// TestAddBranchToTask_RejectsNonWorktreeExecutor guards against using the
// MCP tool on tasks whose execution environment is docker / sprites /
// local — sibling worktrees are a git-worktree layout and other executors
// would silently swallow the new branch. The service must reject before
// inserting a task_repositories row so the DB stays consistent with disk.
func TestAddBranchToTask_RejectsNonWorktreeExecutor(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "WS"})
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"})
_ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "frontend"})
task, err := svc.CreateTask(ctx, &CreateTaskRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
WorkflowStepID: "step-1",
Title: "Docker task",
Repositories: []TaskRepositoryInput{
{RepositoryID: "repo-1", BaseBranch: "main"},
},
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
// Seed a task_environments row with a containerized executor type.
now := time.Now().UTC()
if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{
ID: "env-1",
TaskID: task.ID,
ExecutorType: string(models.ExecutorTypeLocalDocker),
Status: "ready",
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("CreateTaskEnvironment: %v", err)
}
_, err = svc.AddBranchToTask(ctx, AddBranchToTaskRequest{
TaskID: task.ID,
CheckoutBranch: "feature/x",
})
if err == nil {
t.Fatal("expected error rejecting non-worktree executor")
}
if !strings.Contains(err.Error(), "worktree executor") {
t.Errorf("unexpected error: %v", err)
}
}
// TestAddBranchToTask_AllowsWorktreeExecutor guards the happy path: a task
// running on the worktree executor accepts add_branch normally.
func TestAddBranchToTask_AllowsWorktreeExecutor(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "WS"})
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"})
_ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "frontend", DefaultBranch: "main"})
task, err := svc.CreateTask(ctx, &CreateTaskRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
WorkflowStepID: "step-1",
Title: "Worktree task",
Repositories: []TaskRepositoryInput{
{RepositoryID: "repo-1", BaseBranch: "main"},
},
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
now := time.Now().UTC()
if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{
ID: "env-1",
TaskID: task.ID,
ExecutorType: string(models.ExecutorTypeWorktree),
WorkspacePath: "/tmp/task-env",
Status: "ready",
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("CreateTaskEnvironment: %v", err)
}
if _, err := svc.AddBranchToTask(ctx, AddBranchToTaskRequest{
TaskID: task.ID,
CheckoutBranch: "feature/x",
}); err != nil {
t.Fatalf("AddBranchToTask on worktree executor: %v", err)
}
}
// TestAddBranchToTask_AllowsBeforeLaunch verifies the pre-launch escape
// hatch: when a task hasn't created its task_environments row yet (no
// executor type fixed), add_branch is allowed so users can stage multiple
// branches before starting the agent.
func TestAddBranchToTask_AllowsBeforeLaunch(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "WS"})
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"})
_ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "frontend", DefaultBranch: "main"})
task, err := svc.CreateTask(ctx, &CreateTaskRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
WorkflowStepID: "step-1",
Title: "Unlaunched task",
Repositories: []TaskRepositoryInput{
{RepositoryID: "repo-1", BaseBranch: "main"},
},
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
// No task_environments row inserted — simulates a task created but
// never started.
if _, err := svc.AddBranchToTask(ctx, AddBranchToTaskRequest{
TaskID: task.ID,
CheckoutBranch: "feature/x",
}); err != nil {
t.Fatalf("AddBranchToTask before launch: %v", err)
}
}
// TestAddBranchToTask_AutoGeneratesNameWhenWouldCollide exercises the
// no-args agent flow: "add a branch" with no checkout_branch should produce
// a fresh row instead of erroring out because the primary already occupies
// the (repo, base, "") triple. Generated name follows `branch-<random>` so
// concurrent adders don't clash on a predictable counter.
func TestAddBranchToTask_AutoGeneratesNameWhenWouldCollide(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "WS"})
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"})
_ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "frontend", DefaultBranch: "main"})
task, err := svc.CreateTask(ctx, &CreateTaskRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
WorkflowStepID: "step-1",
Title: "Single-repo primary",
Repositories: []TaskRepositoryInput{
{RepositoryID: "repo-1", BaseBranch: "main"},
},
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
added, err := svc.AddBranchToTask(ctx, AddBranchToTaskRequest{
TaskID: task.ID,
})
if err != nil {
t.Fatalf("AddBranchToTask: %v", err)
}
if !strings.HasPrefix(added.CheckoutBranch, "branch-") || len(added.CheckoutBranch) != len("branch-")+3 {
t.Errorf("expected auto-name branch-<3 random chars>, got %q", added.CheckoutBranch)
}
added2, err := svc.AddBranchToTask(ctx, AddBranchToTaskRequest{
TaskID: task.ID,
})
if err != nil {
t.Fatalf("AddBranchToTask (second auto): %v", err)
}
if !strings.HasPrefix(added2.CheckoutBranch, "branch-") || len(added2.CheckoutBranch) != len("branch-")+3 {
t.Errorf("expected auto-name branch-<3 random chars>, got %q", added2.CheckoutBranch)
}
if added2.CheckoutBranch == added.CheckoutBranch {
t.Errorf("expected distinct auto-names, both got %q", added.CheckoutBranch)
}
}
// TestAddBranchToTask_DefaultsRepositoryWhenSingleRepoTask exercises the
// agent-friendly path: omitting repository_id on a single-repo task
// auto-resolves to that repo. Agents inside a task only have task-mode MCP
// tools (no list_repositories_kandev), so requiring the UUID would force
// them to hit the DB directly.
func TestAddBranchToTask_DefaultsRepositoryWhenSingleRepoTask(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "WS"})
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"})
_ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "frontend", DefaultBranch: "main"})
task, err := svc.CreateTask(ctx, &CreateTaskRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
WorkflowStepID: "step-1",
Title: "Single repo task",
Repositories: []TaskRepositoryInput{
{RepositoryID: "repo-1", BaseBranch: "main"},
},
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
added, err := svc.AddBranchToTask(ctx, AddBranchToTaskRequest{
TaskID: task.ID,
CheckoutBranch: "feature/x",
})
if err != nil {
t.Fatalf("AddBranchToTask without repository_id: %v", err)
}
if added.RepositoryID != "repo-1" {
t.Errorf("expected auto-resolved repository_id=repo-1, got %q", added.RepositoryID)
}
}
// TestAddBranchToTask_RejectsMissingRepoOnMultiRepoTask guards the
// ambiguous case: multi-repo tasks must force the agent to disambiguate
// rather than silently picking one repo.
func TestAddBranchToTask_RejectsMissingRepoOnMultiRepoTask(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "WS"})
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"})
_ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "frontend"})
_ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-2", WorkspaceID: "ws-1", Name: "backend"})
task, err := svc.CreateTask(ctx, &CreateTaskRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
WorkflowStepID: "step-1",
Title: "Multi-repo task",
Repositories: []TaskRepositoryInput{
{RepositoryID: "repo-1", BaseBranch: "main"},
{RepositoryID: "repo-2", BaseBranch: "main"},
},
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
_, err = svc.AddBranchToTask(ctx, AddBranchToTaskRequest{
TaskID: task.ID,
CheckoutBranch: "feature/x",
})
if err == nil {
t.Fatal("expected error when repository_id is missing on a multi-repo task")
}
if !strings.Contains(err.Error(), "repository_id is required") {
t.Errorf("unexpected error: %v", err)
}
}
// TestCreateTask_AllowsSameRepoDifferentBaseBranches mirrors the worktree-
// executor flow where the branch lives in base_branch and checkout_branch is
// empty. Two rows differing only in base_branch must both be accepted —
// previously they collided because the constraint did not include base_branch.
func TestCreateTask_AllowsSameRepoDifferentBaseBranches(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "WS"})
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"})
_ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "frontend"})
task, err := svc.CreateTask(ctx, &CreateTaskRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
WorkflowStepID: "step-1",
Title: "Worktree multi-branch",
Repositories: []TaskRepositoryInput{
{RepositoryID: "repo-1", BaseBranch: "feature/a"},
{RepositoryID: "repo-1", BaseBranch: "feature/b"},
},
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
rows, err := repo.ListTaskRepositories(ctx, task.ID)
if err != nil {
t.Fatalf("ListTaskRepositories: %v", err)
}
if len(rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(rows))
}
gotBases := map[string]bool{}
for _, r := range rows {
gotBases[r.BaseBranch] = true
}
if !gotBases["feature/a"] || !gotBases["feature/b"] {
t.Errorf("missing base_branch values in rows: %+v", gotBases)
}
}
// TestCreateTask_RejectsSameRepoSameBranch verifies the dedup guard still
// fires when the exact (repo, branch) pair is duplicated in the create
// payload — the migration-layer constraint mirrors the in-memory guard.
func TestCreateTask_RejectsSameRepoSameBranch(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "WS"})
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"})
_ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "frontend"})
_, err := svc.CreateTask(ctx, &CreateTaskRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
WorkflowStepID: "step-1",
Title: "Bad dup",
Repositories: []TaskRepositoryInput{
{RepositoryID: "repo-1", BaseBranch: "main", CheckoutBranch: "feature/a"},
{RepositoryID: "repo-1", BaseBranch: "main", CheckoutBranch: "feature/a"},
},
})
if err == nil {
t.Fatal("expected error for duplicate (repo, branch)")
}
if !strings.Contains(err.Error(), "more than once") {
t.Errorf("unexpected error: %v", err)
}
}