feat(tasks): multi-branch tasks — N (repo, branch) pairs per task (#1226)

* feat(tasks): multi-branch tasks — N (repo, branch) pairs per task

Extends multi-repo into multi-branch so a single task can hold multiple
worktrees on the same repo (one per branch). Sibling worktrees layout at
<task>/<repo>/ + <task>/<repo>-<slug>/, dedicated PRWatch per branch,
live agentctl tracker rescan + frontend store updates so the UI splits
PRs / files / commits per branch without a session restart. MCP
add_branch_to_task_kandev tool is shielded to the worktree executor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): unify multi-branch repo label as <repo> · <branch> across panels

PR Changes and Commits panels rendered repo labels differently for multi-branch
tasks — Commits showed "kandev · feature/x" while PR Changes leaked the raw
"kandev-feature-x" agentctl tracker tag. Reformat tracker-tagged repo names via
formatRepoLabel using the longest known-repo prefix so kandev-cli-* doesn't
collapse to kandev · cli-*.

* fix(tasks): address multi-branch PR review — data races, slug collisions, MCP error codes

Backend
- agentctl process.Manager: add repoTrackersMu RWMutex guarding repoTrackers
  + workspaceTracker. Reads snapshot under RLock; RescanRepositories builds
  trackers first, takes Lock for swap/append. Validates newWorkDir via
  os.Stat before publishing cfg.WorkDir.
- worktree.Manager: reject invalid explicit BranchSlug up-front; cache key
  uses sanitized wt.BranchSlug so read/write paths are symmetric.
- worktree.store: narrow isDuplicateColumnError to "duplicate column name".
- task service AddBranchToTask: slug-collision guard rejects feat/a vs feat-a.
- orchestrator resolvePushRepo: two-pass exact-name match before subdir
  prefix fallback so "backend" doesn't swallow pushes for "backend-admin".
- executor buildRepoSpecs: duplicate repos always get a non-empty BranchSlug
  (falls back to branch-N when both branch fields sanitize to empty).
- MCP handler: classifyAddBranchError maps service errors to NOT_FOUND /
  CONFLICT / VALIDATION_ERROR; add ErrorCodeConflict constant.
- MCP server: extract registerAddBranchToTaskTool, register in task mode
  only (external mode contract: config tools + create_task_kandev).
- workspace_rescan handler: reject malformed JSON with 400 instead of
  silently rescanning the current root.
- branch_materializer: split MaterializeBranch into prepare + finalize
  helpers to satisfy the 80-line cap.

Frontend
- existingPrUrl returns undefined when a task has >1 PR so per-repo View PR
  buttons take over (no silent linking to a sibling).
- formatRepoLabel checks exact known-repo match before prefix splitting so
  "kandev-cli" isn't mislabeled as "kandev · cli".
- Repo chip key now includes base_branch so siblings differing only on
  base render as distinct chips.

Docs
- multi-branch spec uniqueness wording aligned with the canonical 4-column
  key (task_id, repository_id, base_branch, checkout_branch).
- worktree.CreateRequest.BranchSlug doc describes the sibling layout
  (<repo>-<slug>) instead of the obsolete nested form.

* fix(tasks): scope slug-collision check to repo only + use errors.Is for ErrTaskNotFound

- service_branches: drop base_branch from the slug-collision check. Worktree
  path is (repo, slug); two rows on the same repo cut from different bases
  still collide on disk.
- service_branches: prepareBranchAdd wraps taskrepo.ErrTaskNotFound via
  fmt.Errorf with %w so classifiers don't have to substring-match the
  formatted task UUID.
- mcp handlers: classifyAddBranchError uses errors.Is(err, ErrTaskNotFound).

* fix(agentctl): sanitize rescan work_dir before os.Stat to clear CodeQL path-injection

CodeQL flagged the rescan endpoint as a path-injection sink (the
HTTP-supplied work_dir flowed straight into os.Stat and downstream
scanRepositorySubdirs). Add validateRescanPath which:
- requires the input to be a clean absolute path with no ".." segments
- rejects anything that isn't equal to or an ancestor of the current
  cfg.WorkDir — the only legitimate use case is the branch materializer
  promoting <task>/<repo>/ to <task>/

Defense in depth: agentctl binds to localhost, but any colocated process
could otherwise direct it at /etc or the user's home by POSTing
/api/v1/workspace/rescan.

* fix(agentctl): tighten validateRescanPath, close TOCTOU on existingTrackers, add tests

- Drop the strings.Contains(clean, "..") guard. filepath.Clean already
  resolves traversal; the substring check rejects legitimate names like
  /home/u/..hidden without adding defense (the ancestor check via
  filepath.Rel is the real boundary).
- Drop the rel == "." branch in the ancestor check — early return for
  clean == currentClean already covers identity.
- Read existingTrackers inside the write-lock section (not under RLock
  then re-Lock) so two concurrent rescans can't both see 0 trackers and
  double-bootstrap the multi-repo set.
- Add table-driven TestValidateRescanPath covering rejection paths
  (relative, descendant, sibling, traversal-cleaned, empty input),
  legitimate ancestor promotion, exact match, no-anchor first-launch,
  and a /home/u/..hidden dotted-segment case.

* fix(agentctl): break taint flow into os.Stat — derive rescan path from cfg, not HTTP body

CodeQL's go/path-injection trace still flagged manager_rescan.go after the
prior sanitizer because the user-supplied newWorkDir flowed (after
validation) into os.Stat and then into cfg.WorkDir → tracker paths →
workspace_monitor os.Stat sinks.

Replace validateRescanPath with resolveRescanPath: it returns a path
DERIVED from currentWorkDir (no-op = currentClean, promotion =
filepath.Dir(currentClean)) and uses newPath only for comparison, never
as a source for the returned string. os.Stat now receives a path whose
provenance is trusted manager config.

Functionally the legitimate caller (branch materializer promoting
<task>/<repo>/ to <task>/) is unchanged.

* test(agentctl): make TestResolveRescanPath windows-portable

The Linux Backend Tests job passed but the Backend (windows) job rejected
every "absolute" case because filepath.IsAbs("/task/repo") is false on
Windows (no volume letter).

Build inputs via filepath.Join against absRoot() (filepath.Abs of "/" /
"\\") so the same table covers POSIX (`/...`) and Windows (`C:\...`)
without per-OS branches. Keep the traversal-after-Clean case POSIX-only
since filepath.Clean is the shared primitive and that input form needs a
volume to be considered absolute on Windows anyway.

* fix(agentctl): serialize RescanRepositories + re-check trackers under write lock

Two concurrent RescanRepositories calls could both observe an empty
tracker set (between the snapshot read and the bootstrap branch) and both
enter transitionToMultiRepoMode, leaking a duplicate bare-root tracker
and duplicate per-repo trackers. Same race in appendNewRepoTrackers would
double-append the same new child across both callers.

Fix:
- Add rescanMu sync.Mutex on Manager. Acquired for the full body of
  RescanRepositories so the snapshot → branch → mutate sequence is
  serialized. repoTrackersMu still guards individual field reads from
  unrelated goroutines (subscribe/unsubscribe/poll-mode).
- appendNewRepoTrackers re-checks membership inside the write-lock
  before appending and drops already-present trackers (Stop + detach
  outside the lock). Defense in depth.
- Test: add `dotted segment is legal as promotion` case so the
  ..hidden-segment promotion path stays exercised.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-06-01 19:36:20 +01:00
committed by GitHub
parent 5591cdc0bf
commit fd79d6170f
64 changed files with 3949 additions and 252 deletions
+1
View File
@@ -151,6 +151,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch
RepoSetupScript: r.RepoSetupScript,
RepoCleanupScript: r.RepoCleanupScript,
CopyFiles: r.CopyFiles,
BranchSlug: r.BranchSlug,
})
}
launchReq.Repositories = specs
@@ -0,0 +1,321 @@
package main
import (
"context"
"fmt"
"path/filepath"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/task/models"
taskservice "github.com/kandev/kandev/internal/task/service"
"github.com/kandev/kandev/internal/worktree"
)
// branchMaterializerRepo is the slim repo surface the mid-session worktree
// materializer needs. Defined as an interface so the wiring stays decoupled
// from the concrete sqlite repo.
type branchMaterializerRepo interface {
GetTask(ctx context.Context, id string) (*models.Task, error)
GetTaskRepository(ctx context.Context, id string) (*models.TaskRepository, error)
ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error)
GetRepository(ctx context.Context, id string) (*models.Repository, error)
ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error)
GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error)
UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error
}
// agentctlRescanner is the lifecycle-tier surface the materializer uses to
// reach a running agentctl by session_id. The lifecycle manager owns the
// agentctl URL in memory — the executors_running row never persists it —
// so the materializer can't HTTP-POST agentctl directly. Defined as an
// interface so the materializer stays decoupled from the lifecycle package.
type agentctlRescanner interface {
RescanWorkspaceForSession(ctx context.Context, sessionID, workDir string) error
NotifyWorktreeMaterialized(ctx context.Context, wt lifecycle.MaterializedWorktree)
}
// branchMaterializer creates a worktree on disk for a newly added
// task_repositories row so MCP-driven "add branch" surfaces the worktree in
// the UI without waiting for a session relaunch. Implements
// taskservice.BranchMaterializer.
//
// It is best-effort: when there's no active session (no tasksession row yet,
// or the task has never launched), it no-ops and lets the standard
// multi-repo prepare path materialize on the next launch.
type branchMaterializer struct {
repo branchMaterializerRepo
worktreeMgr *worktree.Manager
rescanner agentctlRescanner
logger *logger.Logger
}
func newBranchMaterializer(repo branchMaterializerRepo, mgr *worktree.Manager, lc *lifecycle.Manager, log *logger.Logger) *branchMaterializer {
var rescanner agentctlRescanner
if lc != nil {
rescanner = lc
}
return &branchMaterializer{
repo: repo,
worktreeMgr: mgr,
rescanner: rescanner,
logger: log.WithFields(zap.String("component", "branch_materializer")),
}
}
// MaterializeBranch creates the worktree dir + persists the
// task_session_worktrees row for the just-inserted task_repositories row.
// Designed to be idempotent: the worktree manager's reuse path catches a
// rerun for the same (session, repo, branch_slug) triple and returns the
// existing worktree.
func (b *branchMaterializer) MaterializeBranch(ctx context.Context, taskID, taskRepositoryID string) error {
if b == nil || b.worktreeMgr == nil {
return nil
}
req, env, session, slug, ok, err := b.prepareMaterializeRequest(ctx, taskID, taskRepositoryID)
if err != nil {
return err
}
if !ok {
return nil
}
wt, err := b.worktreeMgr.Create(ctx, req)
if err != nil {
return fmt.Errorf("create worktree: %w", err)
}
b.logger.Info("materialized branch worktree",
zap.String("task_id", taskID),
zap.String("task_repository_id", taskRepositoryID),
zap.String("worktree_id", wt.ID),
zap.String("path", wt.Path),
zap.String("branch", wt.Branch))
b.finalizeMaterialize(ctx, env, session, wt, req.RepositoryID, slug, taskID)
return nil
}
// prepareMaterializeRequest builds the worktree.CreateRequest plus the
// (env, session, slug) context the finalize step needs. Returns ok=false
// for the legitimate "skip materialize" cases (no local path, no active
// session, env not provisioned); ok=true means CreateRequest is ready.
func (b *branchMaterializer) prepareMaterializeRequest(
ctx context.Context, taskID, taskRepositoryID string,
) (worktree.CreateRequest, *models.TaskEnvironment, *models.TaskSession, string, bool, error) {
task, tr, repo, err := b.loadContext(ctx, taskID, taskRepositoryID)
if err != nil {
return worktree.CreateRequest{}, nil, nil, "", false, err
}
if repo.LocalPath == "" {
// Provider-backed repos that haven't been cloned yet need the
// orchestrator's repoCloner path. Defer to next launch.
b.logger.Info("skipping materialize: repository has no local path",
zap.String("repository_id", repo.ID), zap.String("task_id", taskID))
return worktree.CreateRequest{}, nil, nil, "", false, nil
}
session, err := b.pickActiveSession(ctx, taskID)
if err != nil {
return worktree.CreateRequest{}, nil, nil, "", false, err
}
if session == nil {
b.logger.Info("skipping materialize: no active session for task",
zap.String("task_id", taskID))
return worktree.CreateRequest{}, nil, nil, "", false, nil
}
env, err := b.repo.GetTaskEnvironmentByTaskID(ctx, taskID)
if err != nil {
return worktree.CreateRequest{}, nil, nil, "", false, fmt.Errorf("lookup task environment: %w", err)
}
if env == nil || env.TaskDirName == "" {
b.logger.Info("skipping materialize: task environment not provisioned yet",
zap.String("task_id", taskID))
return worktree.CreateRequest{}, nil, nil, "", false, nil
}
slug := deriveBranchSlugForRow(tr)
req := worktree.CreateRequest{
TaskID: taskID,
SessionID: session.ID,
TaskTitle: task.Title,
RepositoryID: repo.ID,
RepositoryPath: repo.LocalPath,
BaseBranch: tr.BaseBranch,
FallbackBaseBranch: repo.DefaultBranch,
CheckoutBranch: tr.CheckoutBranch,
WorktreeBranchPrefix: repo.WorktreeBranchPrefix,
PullBeforeWorktree: repo.PullBeforeWorktree,
TaskDirName: env.TaskDirName,
RepoName: repo.Name,
BranchSlug: slug,
}
return req, env, session, slug, true, nil
}
// finalizeMaterialize handles the post-Create plumbing: promote the task
// environment's workspace_path to the task root when the new worktree is a
// sibling, ping the running agentctl to add per-repo trackers, and emit a
// frontend WS event so the session's worktree tabs render immediately.
func (b *branchMaterializer) finalizeMaterialize(
ctx context.Context,
env *models.TaskEnvironment,
session *models.TaskSession,
wt *worktree.Worktree,
repositoryID, slug, taskID string,
) {
taskRoot := b.promoteWorkspacePathIfNeeded(ctx, env, wt.Path)
b.notifyAgentctlRescan(ctx, session, taskRoot)
if b.rescanner != nil {
b.rescanner.NotifyWorktreeMaterialized(ctx, lifecycle.MaterializedWorktree{
TaskID: taskID,
SessionID: session.ID,
WorktreeID: wt.ID,
WorktreePath: wt.Path,
WorktreeBranch: wt.Branch,
RepositoryID: repositoryID,
BranchSlug: slug,
})
}
}
// promoteWorkspacePathIfNeeded switches task_environments.workspace_path
// from the primary worktree to the task root when the new worktree lives
// as a sibling (e.g. <task>/<repo>-<slug>/). The agent process's CWD does
// not change — that's set at launch time — but later session relaunches
// pick up multi-repo mode via prepareMultiRepo because workspace_path is
// now the parent of all repo dirs. Returns the resolved task-root path so
// the caller can pass it to the agentctl rescan.
//
// Sibling layout: <task-root>/<repo>/ and <task-root>/<repo>-<slug>/ both
// have filepath.Dir == <task-root>. The check compares parents to confirm
// the new worktree is actually a sibling of the env's workspace_path
// before mutating the row — otherwise an unrelated path would silently
// repoint the env at the wrong dir.
func (b *branchMaterializer) promoteWorkspacePathIfNeeded(ctx context.Context, env *models.TaskEnvironment, newWorktreePath string) string {
taskRoot := filepath.Dir(newWorktreePath)
if env.WorkspacePath == taskRoot {
return taskRoot
}
if filepath.Dir(env.WorkspacePath) != taskRoot {
b.logger.Warn("skip workspace_path promotion: new worktree is not a sibling of env workspace_path",
zap.String("env_workspace_path", env.WorkspacePath),
zap.String("new_worktree_path", newWorktreePath))
return env.WorkspacePath
}
prev := env.WorkspacePath
env.WorkspacePath = taskRoot
env.UpdatedAt = time.Now().UTC()
if err := b.repo.UpdateTaskEnvironment(ctx, env); err != nil {
b.logger.Warn("failed to promote workspace_path to task root",
zap.String("task_environment_id", env.ID),
zap.String("from", prev),
zap.String("to", taskRoot),
zap.Error(err))
return prev
}
b.logger.Info("promoted workspace_path to task root",
zap.String("task_environment_id", env.ID),
zap.String("from", prev),
zap.String("to", taskRoot))
return taskRoot
}
// notifyAgentctlRescan tells the running agentctl instance attached to
// this session to re-discover repo subdirs and add per-repo trackers for
// new sibling worktrees. Routes through the lifecycle manager because the
// agentctl URL is in-memory runtime state — the executors_running row
// never persists it — and the lifecycle manager is the single owner of
// per-execution agentctl clients.
//
// No-op when no rescanner is wired (test stubs, agentless test envs) or
// when the session has no active execution (agent stopped). The next
// session launch picks up the new layout via prepareMultiRepo regardless.
func (b *branchMaterializer) notifyAgentctlRescan(ctx context.Context, session *models.TaskSession, taskRoot string) {
if b.rescanner == nil {
return
}
rescanCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := b.rescanner.RescanWorkspaceForSession(rescanCtx, session.ID, taskRoot); err != nil {
b.logger.Warn("agentctl rescan failed; new worktree will appear after next session restart",
zap.String("session_id", session.ID),
zap.String("task_root", taskRoot),
zap.Error(err))
}
}
// loadContext fetches the task + task_repository row + repository entity in
// one place so MaterializeBranch can fail-fast on missing data without
// scattering nil checks across the body.
func (b *branchMaterializer) loadContext(ctx context.Context, taskID, taskRepositoryID string) (*models.Task, *models.TaskRepository, *models.Repository, error) {
task, err := b.repo.GetTask(ctx, taskID)
if err != nil {
return nil, nil, nil, fmt.Errorf("get task: %w", err)
}
if task == nil {
return nil, nil, nil, fmt.Errorf("task not found: %s", taskID)
}
tr, err := b.repo.GetTaskRepository(ctx, taskRepositoryID)
if err != nil {
return nil, nil, nil, fmt.Errorf("get task repository: %w", err)
}
if tr == nil {
return nil, nil, nil, fmt.Errorf("task repository not found: %s", taskRepositoryID)
}
repo, err := b.repo.GetRepository(ctx, tr.RepositoryID)
if err != nil {
return nil, nil, nil, fmt.Errorf("get repository: %w", err)
}
if repo == nil {
return nil, nil, nil, fmt.Errorf("repository not found: %s", tr.RepositoryID)
}
return task, tr, repo, nil
}
// pickActiveSession returns the most recently active session for the task,
// or nil if none qualifies. Worktree persistence requires a session_id, so
// when there's no candidate we no-op rather than orphaning a worktree
// without an owning session.
func (b *branchMaterializer) pickActiveSession(ctx context.Context, taskID string) (*models.TaskSession, error) {
sessions, err := b.repo.ListTaskSessions(ctx, taskID)
if err != nil {
return nil, fmt.Errorf("list sessions: %w", err)
}
var best *models.TaskSession
for _, s := range sessions {
if !sessionEligibleForMaterialize(s) {
continue
}
if best == nil || s.UpdatedAt.After(best.UpdatedAt) {
best = s
}
}
return best, nil
}
func sessionEligibleForMaterialize(s *models.TaskSession) bool {
switch s.State {
case models.TaskSessionStateRunning,
models.TaskSessionStateStarting,
models.TaskSessionStateWaitingForInput,
models.TaskSessionStateCreated:
return true
}
return false
}
// deriveBranchSlugForRow chooses the slug input that uniquely identifies the
// row's worktree on disk. CheckoutBranch wins when set (local-executor and
// PR-style flows put the branch there); BaseBranch is the fallback for the
// worktree-executor flow where the branch lives in BaseBranch and
// CheckoutBranch is empty.
func deriveBranchSlugForRow(tr *models.TaskRepository) string {
slug := worktree.SanitizeBranchSlug(tr.CheckoutBranch)
if slug == "" {
slug = worktree.SanitizeBranchSlug(tr.BaseBranch)
}
return slug
}
// ensureBranchMaterializerSatisfiesInterface is a compile-time guard so the
// interface contract drift surfaces at build, not at runtime.
var _ taskservice.BranchMaterializer = (*branchMaterializer)(nil)
@@ -0,0 +1,331 @@
package main
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/task/models"
taskrepo "github.com/kandev/kandev/internal/task/repository"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
"github.com/kandev/kandev/internal/worktree"
)
// stubRescanner records calls so the materializer test can assert that the
// agentctl rescan was triggered with the expected work_dir (task root) and
// that the frontend-visible "worktree materialized" event also fired.
type stubRescanner struct {
calls []stubRescanCall
notifyCalls []lifecycle.MaterializedWorktree
}
type stubRescanCall struct {
sessionID string
workDir string
}
func (s *stubRescanner) RescanWorkspaceForSession(_ context.Context, sessionID, workDir string) error {
s.calls = append(s.calls, stubRescanCall{sessionID: sessionID, workDir: workDir})
return nil
}
func (s *stubRescanner) NotifyWorktreeMaterialized(_ context.Context, wt lifecycle.MaterializedWorktree) {
s.notifyCalls = append(s.notifyCalls, wt)
}
// TestBranchMaterializer_PromotesWorkspacePathAndTriggersRescan is the
// end-to-end happy path: a single-branch task gains a second branch via the
// materializer, which must (1) create the sibling worktree on disk,
// (2) promote task_environments.workspace_path from primary to task root,
// and (3) ping agentctl rescan with the new task root so the trackers
// rebuild without a session restart.
func TestBranchMaterializer_PromotesWorkspacePathAndTriggersRescan(t *testing.T) {
ctx := context.Background()
repoPath, taskRoot, primaryPath := setupMaterializerScenario(t)
t.Logf("taskRoot=%s primary=%s", taskRoot, primaryPath)
repoSqlite := newMaterializerRepo(t)
worktreeMgr := newMaterializerWorktreeMgr(t, taskRoot)
stub := &stubRescanner{}
mat := &branchMaterializer{
repo: repoSqlite,
worktreeMgr: worktreeMgr,
rescanner: stub,
logger: newTestLogger(),
}
seedMaterializerTask(t, ctx, repoSqlite, repoPath, taskRoot, primaryPath)
tr := &models.TaskRepository{
ID: "tr-branch-2",
TaskID: "task-1",
RepositoryID: "repo-1",
BaseBranch: "main",
CheckoutBranch: "branch-2",
Position: 1,
Metadata: map[string]interface{}{},
}
if err := repoSqlite.CreateTaskRepository(ctx, tr); err != nil {
t.Fatalf("CreateTaskRepository: %v", err)
}
if err := mat.MaterializeBranch(ctx, "task-1", tr.ID); err != nil {
t.Fatalf("MaterializeBranch: %v", err)
}
wantSiblingDir := filepath.Join(taskRoot, "kandev-branch-2")
if _, err := os.Stat(wantSiblingDir); err != nil {
t.Fatalf("expected sibling worktree at %s: %v", wantSiblingDir, err)
}
env, err := repoSqlite.GetTaskEnvironmentByTaskID(ctx, "task-1")
if err != nil {
t.Fatalf("re-fetch env: %v", err)
}
if env.WorkspacePath != taskRoot {
t.Errorf("workspace_path = %q, want %q (task root)", env.WorkspacePath, taskRoot)
}
if len(stub.calls) != 1 {
t.Fatalf("expected 1 rescan call, got %d", len(stub.calls))
}
if stub.calls[0].workDir != taskRoot {
t.Errorf("rescan work_dir = %q, want %q", stub.calls[0].workDir, taskRoot)
}
if stub.calls[0].sessionID != "session-1" {
t.Errorf("rescan session_id = %q, want session-1", stub.calls[0].sessionID)
}
if len(stub.notifyCalls) != 1 {
t.Fatalf("expected 1 NotifyWorktreeMaterialized call, got %d", len(stub.notifyCalls))
}
notify := stub.notifyCalls[0]
if notify.TaskID != "task-1" || notify.SessionID != "session-1" {
t.Errorf("notify identifiers = %+v, want task-1/session-1", notify)
}
if notify.WorktreePath != wantSiblingDir {
t.Errorf("notify worktree path = %q, want %q", notify.WorktreePath, wantSiblingDir)
}
if notify.BranchSlug != "branch-2" {
t.Errorf("notify branch_slug = %q, want branch-2", notify.BranchSlug)
}
}
// TestBranchMaterializer_SecondBranchKeepsTaskRootPromoted exercises the
// idempotent promotion path: a task that's already been promoted to task
// root must not be flipped back to a primary path on subsequent adds.
func TestBranchMaterializer_SecondBranchKeepsTaskRootPromoted(t *testing.T) {
ctx := context.Background()
repoPath, taskRoot, primaryPath := setupMaterializerScenario(t)
repoSqlite := newMaterializerRepo(t)
worktreeMgr := newMaterializerWorktreeMgr(t, taskRoot)
stub := &stubRescanner{}
mat := &branchMaterializer{
repo: repoSqlite,
worktreeMgr: worktreeMgr,
rescanner: stub,
logger: newTestLogger(),
}
seedMaterializerTask(t, ctx, repoSqlite, repoPath, taskRoot, primaryPath)
tr2 := &models.TaskRepository{
ID: "tr-branch-2", TaskID: "task-1", RepositoryID: "repo-1",
BaseBranch: "main", CheckoutBranch: "branch-2", Position: 1,
Metadata: map[string]interface{}{},
}
if err := repoSqlite.CreateTaskRepository(ctx, tr2); err != nil {
t.Fatalf("CreateTaskRepository branch-2: %v", err)
}
if err := mat.MaterializeBranch(ctx, "task-1", tr2.ID); err != nil {
t.Fatalf("MaterializeBranch branch-2: %v", err)
}
tr3 := &models.TaskRepository{
ID: "tr-branch-3", TaskID: "task-1", RepositoryID: "repo-1",
BaseBranch: "main", CheckoutBranch: "branch-3", Position: 2,
Metadata: map[string]interface{}{},
}
if err := repoSqlite.CreateTaskRepository(ctx, tr3); err != nil {
t.Fatalf("CreateTaskRepository branch-3: %v", err)
}
if err := mat.MaterializeBranch(ctx, "task-1", tr3.ID); err != nil {
t.Fatalf("MaterializeBranch branch-3: %v", err)
}
env, err := repoSqlite.GetTaskEnvironmentByTaskID(ctx, "task-1")
if err != nil {
t.Fatalf("re-fetch env: %v", err)
}
if env.WorkspacePath != taskRoot {
t.Errorf("workspace_path = %q, want %q (must stay at task root)", env.WorkspacePath, taskRoot)
}
for _, dir := range []string{"kandev-branch-2", "kandev-branch-3"} {
p := filepath.Join(taskRoot, dir)
if _, err := os.Stat(p); err != nil {
t.Errorf("missing sibling worktree dir %s: %v", p, err)
}
}
// Second call should also trigger rescan — the trackers may need to
// add the new sibling.
if len(stub.calls) != 2 {
t.Fatalf("expected 2 rescan calls (one per add_branch), got %d", len(stub.calls))
}
for i, call := range stub.calls {
if call.workDir != taskRoot {
t.Errorf("call %d workDir = %q, want %q", i, call.workDir, taskRoot)
}
}
}
// setupMaterializerScenario creates a bare origin repo + a clone that
// serves as the "primary" worktree at <task-root>/kandev/. Returns the
// repository path, the task root, and the primary worktree path.
func setupMaterializerScenario(t *testing.T) (repoPath, taskRoot, primaryPath string) {
t.Helper()
tmp := t.TempDir()
bareDir := filepath.Join(tmp, "origin.git")
runGit(t, tmp, "init", "--bare", "-b", "main", bareDir)
repoPath = filepath.Join(tmp, "repo")
cmd := exec.Command("git", "clone", bareDir, repoPath)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git clone: %v\n%s", err, out)
}
runGit(t, repoPath, "config", "user.email", "test@example.com")
runGit(t, repoPath, "config", "user.name", "Test User")
runGit(t, repoPath, "config", "commit.gpgsign", "false")
if err := os.WriteFile(filepath.Join(repoPath, "README.md"), []byte("initial\n"), 0644); err != nil {
t.Fatalf("write README: %v", err)
}
runGit(t, repoPath, "add", "README.md")
runGit(t, repoPath, "commit", "-m", "initial")
runGit(t, repoPath, "push", "origin", "main")
taskRoot = filepath.Join(tmp, "tasks", "task-1_aaa")
if err := os.MkdirAll(taskRoot, 0o755); err != nil {
t.Fatalf("mkdir taskRoot: %v", err)
}
primaryPath = filepath.Join(taskRoot, "kandev")
// Simulate the existing primary worktree by adding it as a git worktree
// off the repo. The materializer doesn't depend on this being a real
// worktree (it only looks at task_environments.workspace_path), but
// the test asserts the on-disk layout afterwards.
runGit(t, repoPath, "worktree", "add", "-b", "feature/initial", primaryPath, "main")
return repoPath, taskRoot, primaryPath
}
// newMaterializerRepo opens an in-memory sqlite repo wired with the task
// schema used by the service + materializer.
func newMaterializerRepo(t *testing.T) *sqliterepo.Repository {
t.Helper()
dbFile := filepath.Join(t.TempDir(), "kandev.db")
conn, err := db.OpenSQLite(dbFile)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
sqlxDB := sqlx.NewDb(conn, "sqlite3")
t.Cleanup(func() {
if err := sqlxDB.Close(); err != nil {
t.Logf("close db: %v", err)
}
})
repo, cleanup, err := taskrepo.Provide(sqlxDB, sqlxDB, nil)
if err != nil {
t.Fatalf("repo provide: %v", err)
}
t.Cleanup(func() {
if err := cleanup(); err != nil {
t.Logf("repo cleanup: %v", err)
}
})
return repo
}
// newMaterializerWorktreeMgr builds a worktree.Manager rooted at taskRoot's
// parent so the manager's TasksBasePath aligns with our scenario.
func newMaterializerWorktreeMgr(t *testing.T, taskRoot string) *worktree.Manager {
t.Helper()
cfg := worktree.Config{Enabled: true, TasksBasePath: filepath.Dir(taskRoot), BranchPrefix: "feature/"}
if err := cfg.Validate(); err != nil {
t.Fatalf("worktree config: %v", err)
}
mgr, err := worktree.NewManager(cfg, nil, newTestLogger())
if err != nil {
t.Fatalf("NewManager: %v", err)
}
return mgr
}
// seedMaterializerTask inserts the task / workspace / repository /
// task_environment rows the materializer relies on.
func seedMaterializerTask(t *testing.T, ctx context.Context, repo *sqliterepo.Repository, repoPath, taskRoot, primaryPath string) {
t.Helper()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "WS"}); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"}); err != nil {
t.Fatalf("CreateWorkflow: %v", err)
}
if err := repo.CreateRepository(ctx, &models.Repository{
ID: "repo-1", WorkspaceID: "ws-1", Name: "kandev",
LocalPath: repoPath, DefaultBranch: "main", WorktreeBranchPrefix: "feature/",
}); err != nil {
t.Fatalf("CreateRepository: %v", err)
}
if err := repo.CreateTask(ctx, &models.Task{
ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1",
Title: "Add Multi Branch", Priority: "medium",
}); err != nil {
t.Fatalf("CreateTask: %v", err)
}
if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{
ID: "tr-primary", TaskID: "task-1", RepositoryID: "repo-1",
BaseBranch: "main", Position: 0, Metadata: map[string]interface{}{},
}); err != nil {
t.Fatalf("CreateTaskRepository primary: %v", err)
}
now := time.Now().UTC()
if err := repo.CreateTaskSession(ctx, &models.TaskSession{
ID: "session-1", TaskID: "task-1",
State: models.TaskSessionStateWaitingForInput,
StartedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatalf("CreateTaskSession: %v", err)
}
if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{
ID: "env-1", TaskID: "task-1",
TaskDirName: filepath.Base(taskRoot),
WorkspacePath: primaryPath,
WorktreePath: primaryPath,
ExecutorType: "worktree", Status: "ready",
CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatalf("CreateTaskEnvironment: %v", err)
}
}
// runGit runs `git` in dir, failing the test on error. Inlined here (rather
// than reused from the worktree package's helper) to keep this package
// independent of internal-test exports.
func runGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s in %s: %v\n%s", strings.Join(args, " "), dir, err, out)
}
return string(out)
}
+2
View File
@@ -372,6 +372,8 @@ func startAgentInfrastructure(
log.Info("Worktree Manager initialized",
zap.Bool("enabled", cfg.Worktree.Enabled))
services.Task.SetBranchMaterializer(newBranchMaterializer(repos.Task, worktreeMgr, lifecycleMgr, log))
lifecycleMgr.SetWorkspaceInfoProvider(services.Task)
log.Info("Workspace info provider configured for session recovery")
@@ -0,0 +1,50 @@
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)
// RescanWorkspace asks the agentctl instance to re-discover repo subdirs
// and reconcile its tracker set. Called by the kandev backend's branch
// materializer after creating a sibling worktree (multi-branch add_branch
// flow); without it, the new worktree's git/file events stay invisible to
// the running session until restart.
//
// workDir is optional. Pass the task root (parent of repo dirs) when
// transitioning a single-branch task to multi-branch — the manager updates
// its tracking scope before scanning. Pass empty to rescan the current
// WorkDir in place.
//
// Best-effort: failures are returned but do not block the materializer.
// The worktree still exists on disk; the next session relaunch will
// rebuild trackers via prepareMultiRepo even when this call missed.
func (c *Client) RescanWorkspace(ctx context.Context, workDir string) error {
body, err := json.Marshal(struct {
WorkDir string `json:"work_dir"`
}{WorkDir: workDir})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/workspace/rescan", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := readResponseBody(resp)
return fmt.Errorf("rescan workspace failed with status %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
@@ -38,6 +38,9 @@ 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 string
}
// EnvPrepareRequest contains the parameters for environment preparation.
@@ -65,6 +68,7 @@ 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)
// Repositories carries one entry per repository when the request is
// multi-repo. When non-empty it is the source of truth; the legacy
@@ -99,6 +103,7 @@ func (r *EnvPrepareRequest) RepoSpecs() []RepoPrepareSpec {
WorktreeBranchPrefix: r.WorktreeBranchPrefix,
PullBeforeWorktree: r.PullBeforeWorktree,
RepoSetupScript: r.RepoSetupScript,
BranchSlug: r.BranchSlug,
}}
}
@@ -261,6 +261,7 @@ func buildWorktreeCreateRequest(req *EnvPrepareRequest) worktree.CreateRequest {
WorktreeID: req.WorktreeID,
TaskDirName: req.TaskDirName,
RepoName: req.RepoName,
BranchSlug: req.BranchSlug,
}
}
@@ -449,6 +450,7 @@ func (p *WorktreePreparer) prepareOneRepo(
subReq.WorktreeID = spec.WorktreeID
subReq.WorktreeBranchPrefix = spec.WorktreeBranchPrefix
subReq.PullBeforeWorktree = spec.PullBeforeWorktree
subReq.BranchSlug = spec.BranchSlug
// Strip the multi-repo list to avoid re-entering the multi-repo branch.
subReq.Repositories = nil
@@ -603,6 +603,7 @@ func buildEnvPrepareRequest(req *LaunchRequest, workspacePath string, execName e
PullBeforeWorktree: req.PullBeforeWorktree,
TaskDirName: req.TaskDirName,
RepoName: req.RepoName,
BranchSlug: req.BranchSlug,
Env: req.Env,
}
// Multi-repo: forward the repo list when the launch request carries one.
@@ -627,6 +628,7 @@ func buildEnvPrepareRequest(req *LaunchRequest, workspacePath string, execName e
WorktreeBranchPrefix: r.WorktreeBranchPrefix,
PullBeforeWorktree: r.PullBeforeWorktree,
RepoSetupScript: setup,
BranchSlug: r.BranchSlug,
})
}
prepReq.Repositories = specs
@@ -0,0 +1,112 @@
package lifecycle
import (
"context"
"fmt"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
)
// MaterializedWorktree describes a worktree just created on disk by the
// branch materializer. Used by NotifyWorktreeMaterialized to push a
// frontend-visible event so the session's worktree tabs and Changes panel
// pick up the new entry without a full page reload.
type MaterializedWorktree struct {
TaskID string
SessionID string
WorktreeID string
WorktreePath string
WorktreeBranch string
RepositoryID string
BranchSlug string
}
// NotifyWorktreeMaterialized publishes an AgentctlReady-shaped event so
// the gateway forwards it to the frontend. The web handler treats
// AgentctlReady as the canonical "session has a new worktree to display"
// signal — setWorktree + setSessionWorktrees fire from it — so reusing
// the same event keeps the frontend store update logic in one place.
//
// Best-effort: a missing event bus or execution-store entry means the
// frontend won't auto-refresh, but the worktree row still exists in the
// DB and the next page reload will pick it up.
func (m *Manager) NotifyWorktreeMaterialized(ctx context.Context, wt MaterializedWorktree) {
if m.eventPublisher == nil || m.eventPublisher.eventBus == nil {
return
}
execution, _ := m.GetExecutionBySessionID(wt.SessionID)
execID := ""
taskEnvID := ""
if execution != nil {
execID = execution.ID
taskEnvID = execution.TaskEnvironmentID
}
payload := AgentctlEventPayload{
TaskID: wt.TaskID,
SessionID: wt.SessionID,
TaskEnvironmentID: taskEnvID,
AgentExecutionID: execID,
WorktreeID: wt.WorktreeID,
WorktreePath: wt.WorktreePath,
WorktreeBranch: wt.WorktreeBranch,
}
event := bus.NewEvent(events.AgentctlReady, "branch-materializer", payload)
if err := m.eventPublisher.eventBus.Publish(ctx, events.AgentctlReady, event); err != nil {
m.logger.Warn("failed to publish worktree materialized event",
zap.String("session_id", wt.SessionID),
zap.String("worktree_id", wt.WorktreeID),
zap.Error(err))
}
}
// RescanWorkspaceForSession asks the agentctl instance attached to sessionID
// to re-discover repo subdirs and reconcile its tracker set. Used by the
// kandev backend's branch materializer after creating a sibling worktree
// on disk (multi-branch add_branch flow) so the new worktree's git/file
// events flow to the UI without a session restart.
//
// workDir is optional. When non-empty the agentctl manager updates its
// scope to that path before rescanning — required for the single-to-multi
// transition where workspace_path moved from primary worktree to task
// root. Empty means "rescan whatever you're already tracking".
//
// Looks up the execution from the in-memory store. The executors_running
// table does not carry the live agentctl URL (it's runtime state held by
// the lifecycle manager), so the materializer routes through here instead
// of trying to read the URL from the row.
//
// Best-effort: returns an error but the caller treats it as a warning.
// The worktree still exists on disk; the next session relaunch will pick
// it up via prepareMultiRepo regardless of whether this rescan succeeded.
func (m *Manager) RescanWorkspaceForSession(ctx context.Context, sessionID, workDir string) error {
if sessionID == "" {
return fmt.Errorf("session_id is required")
}
execution, ok := m.GetExecutionBySessionID(sessionID)
if !ok || execution == nil {
// No active execution for this session — agentctl isn't running, so
// there's nothing to rescan. The next launch will build trackers
// against the up-to-date task layout via prepareMultiRepo.
m.logger.Debug("rescan skipped: no execution for session",
zap.String("session_id", sessionID))
return nil
}
client := execution.GetAgentCtlClient()
if client == nil {
m.logger.Debug("rescan skipped: execution has no agentctl client",
zap.String("session_id", sessionID),
zap.String("execution_id", execution.ID))
return nil
}
if err := client.RescanWorkspace(ctx, workDir); err != nil {
return fmt.Errorf("rescan workspace via agentctl: %w", err)
}
m.logger.Info("agentctl workspace rescan ok",
zap.String("session_id", sessionID),
zap.String("execution_id", execution.ID),
zap.String("work_dir", workDir))
return nil
}
@@ -308,6 +308,9 @@ 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 string
}
// RouteOverride carries a fully resolved provider profile for one
@@ -384,6 +387,7 @@ 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)
// 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
@@ -415,6 +419,7 @@ func (r *LaunchRequest) RepoSpecs() []RepoLaunchSpec {
WorktreeBranchPrefix: r.WorktreeBranchPrefix,
PullBeforeWorktree: r.PullBeforeWorktree,
CopyFiles: r.CopyFiles,
BranchSlug: r.BranchSlug,
}}
}
@@ -104,6 +104,12 @@ func (s *Server) setupRoutes() {
// Workspace state (poll mode driven by gateway focus signal)
api.POST("/workspace/poll-mode", s.handleSetPollMode)
// Workspace rescan: triggered by the kandev backend after a new
// sibling worktree appears on disk (multi-branch add_branch flow).
// Rebuilds the per-repo tracker set so the new worktree's git/file
// events reach the UI without a session restart.
api.POST("/workspace/rescan", s.handleRescanWorkspace)
// Workspace file operations (simple HTTP)
api.GET("/workspace/tree", s.handleFileTree)
api.GET("/workspace/file/content", s.handleFileContent)
@@ -0,0 +1,48 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// errKey is the JSON field name for error responses on this handler. Hoisted
// out to satisfy goconst's repeated-string rule across the api package.
const errKey = "error"
// RescanWorkspaceRequest is the body for POST /api/v1/workspace/rescan.
//
// work_dir is optional. When supplied, the manager updates its tracking
// scope to that path before re-discovering repo subdirectories — this is
// how a multi-branch transition signals "scan at the task root, not at the
// primary worktree". When empty, the manager rescans its current WorkDir
// in place.
type RescanWorkspaceRequest struct {
WorkDir string `json:"work_dir"`
}
// handleRescanWorkspace re-runs repo discovery and reconciles trackers.
// Called by the kandev backend's branch materializer after creating a
// sibling worktree for an MCP-driven add_branch_to_task; without it, the
// new worktree's git/file events stay invisible until session restart.
//
// The handler is idempotent — a rescan with no on-disk changes is a no-op.
// Calls are cheap (one ReadDir + git probes per child), so the materializer
// can ping unconditionally rather than computing diffs client-side.
func (s *Server) handleRescanWorkspace(c *gin.Context) {
var req RescanWorkspaceRequest
// Empty bodies are legal — rescan cfg.WorkDir in place. Malformed JSON
// is NOT: silently treating it as an empty rescan returned 200 to a
// caller who never got the work_dir promotion they asked for.
if c.Request.ContentLength != 0 && c.Request.Body != http.NoBody {
if err := c.ShouldBindJSON(&req); err != nil {
s.logger.Warn("workspace rescan request rejected: malformed json", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{errKey: "invalid JSON body"})
return
}
}
s.procMgr.RescanRepositories(c.Request.Context(), req.WorkDir)
s.logger.Debug("workspace rescan completed", zap.String("work_dir", req.WorkDir))
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -0,0 +1,41 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestHandleRescanWorkspace_AcceptsEmptyBody covers the materializer's
// no-work_dir form: a rescan that doesn't touch cfg.WorkDir and just
// reconciles trackers against current on-disk state.
func TestHandleRescanWorkspace_AcceptsEmptyBody(t *testing.T) {
s := newTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/rescan", nil)
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
}
}
// TestHandleRescanWorkspace_AcceptsWorkDir covers the materializer's
// transition form: a rescan that promotes WorkDir to the task root before
// re-discovering repo subdirs.
func TestHandleRescanWorkspace_AcceptsWorkDir(t *testing.T) {
s := newTestServer(t)
body, _ := json.Marshal(RescanWorkspaceRequest{WorkDir: "/tmp/task-root"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/rescan", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
}
}
@@ -91,13 +91,35 @@ type Manager struct {
// Each tracker stamps RepositoryName onto its emitted events and shares
// subscriber channels with the root via the Manager fan-out.
// Empty for single-repo workspaces.
repoTrackers []*WorkspaceTracker
//
// Mutated post-launch by RescanRepositories (multi-branch add) while other
// goroutines (gateway subscribe/unsubscribe, poll-mode updates) iterate
// the slice — every read and write must go through repoTrackersMu.
// workspaceTracker is also guarded by the same lock because rescan can
// swap it when transitioning single→multi mode.
repoTrackers []*WorkspaceTracker
repoTrackersMu sync.RWMutex
// rescanMu serializes RescanRepositories calls so two concurrent
// rescans can't both observe an empty tracker set and double-bootstrap
// (or both append duplicate trackers for the same new child). The
// per-field repoTrackersMu still allows concurrent subscribe/unsubscribe
// readers while a rescan is in flight.
rescanMu sync.Mutex
// workspaceTrackersBySubpath caches per-subpath trackers for multi-repo
// task roots. Key is the cleaned subpath (relative to cfg.WorkDir). The
// root tracker lives in workspaceTracker above.
workspaceTrackersBySubpath map[string]*WorkspaceTracker
workspaceTrackersMu sync.Mutex
// streamSubscribers tracks every workspace-stream subscriber attached
// via SubscribeWorkspaceStream so RescanRepositories can wire new
// per-repo trackers into the same channels without re-subscription. The
// gateway only subscribes once per session; without this list, trackers
// added post-launch (multi-branch transition) would emit events that
// never reach the UI.
streamSubscribers map[types.WorkspaceStreamSubscriber]struct{}
streamSubscribersMu sync.Mutex
// Script/process runner (dev server, setup, cleanup, custom)
processRunner *ProcessRunner
@@ -229,25 +251,40 @@ func (m *Manager) ExitError() error {
// GetWorkspaceTracker returns the workspace tracker for git status and file monitoring
func (m *Manager) GetWorkspaceTracker() *WorkspaceTracker {
m.repoTrackersMu.RLock()
defer m.repoTrackersMu.RUnlock()
return m.workspaceTracker
}
// snapshotTrackers returns the current root + per-repo trackers under
// repoTrackersMu so callers can iterate without holding the lock. Concurrent
// rescan writes can't observably mutate either while a snapshot is in flight.
func (m *Manager) snapshotTrackers() (*WorkspaceTracker, []*WorkspaceTracker) {
m.repoTrackersMu.RLock()
defer m.repoTrackersMu.RUnlock()
repos := make([]*WorkspaceTracker, len(m.repoTrackers))
copy(repos, m.repoTrackers)
return m.workspaceTracker, repos
}
// StartAllWorkspaceTrackers starts root + per-repo trackers (idempotent) so file-change events fire in passthrough mode.
func (m *Manager) StartAllWorkspaceTrackers(ctx context.Context) {
if m.workspaceTracker != nil {
m.workspaceTracker.Start(ctx)
root, trackers := m.snapshotTrackers()
if root != nil {
root.Start(ctx)
}
for _, t := range m.repoTrackers {
for _, t := range trackers {
t.Start(ctx)
}
}
// stopWorkspaceTrackers stops root + per-repo trackers (idempotent via sync.Once).
func (m *Manager) stopWorkspaceTrackers() {
if m.workspaceTracker != nil {
m.workspaceTracker.Stop()
root, trackers := m.snapshotTrackers()
if root != nil {
root.Stop()
}
for _, t := range m.repoTrackers {
for _, t := range trackers {
t.Stop()
}
}
@@ -262,8 +299,15 @@ func (m *Manager) stopWorkspaceTrackers() {
// empty and only the root tracker fires events.
func (m *Manager) SubscribeWorkspaceStream() types.WorkspaceStreamSubscriber {
sub := make(types.WorkspaceStreamSubscriber, 100)
m.workspaceTracker.AttachWorkspaceStreamSubscriber(sub)
for _, t := range m.repoTrackers {
m.streamSubscribersMu.Lock()
if m.streamSubscribers == nil {
m.streamSubscribers = make(map[types.WorkspaceStreamSubscriber]struct{})
}
m.streamSubscribers[sub] = struct{}{}
m.streamSubscribersMu.Unlock()
root, trackers := m.snapshotTrackers()
root.AttachWorkspaceStreamSubscriber(sub)
for _, t := range trackers {
t.AttachWorkspaceStreamSubscriber(sub)
}
return sub
@@ -272,8 +316,12 @@ func (m *Manager) SubscribeWorkspaceStream() types.WorkspaceStreamSubscriber {
// UnsubscribeWorkspaceStream detaches the subscriber from every tracker and
// closes the channel exactly once.
func (m *Manager) UnsubscribeWorkspaceStream(sub types.WorkspaceStreamSubscriber) {
m.workspaceTracker.DetachWorkspaceStreamSubscriber(sub)
for _, t := range m.repoTrackers {
m.streamSubscribersMu.Lock()
delete(m.streamSubscribers, sub)
m.streamSubscribersMu.Unlock()
root, trackers := m.snapshotTrackers()
root.DetachWorkspaceStreamSubscriber(sub)
for _, t := range trackers {
t.DetachWorkspaceStreamSubscriber(sub)
}
close(sub)
@@ -347,8 +395,9 @@ func (m *Manager) ListProcesses(sessionID string) []ProcessInfo {
// per-repo tracker discovered at construction time. Empty for single-repo
// workspaces. Used by callers that want to fan an op out across repos.
func (m *Manager) RepoSubpaths() []string {
out := make([]string, 0, len(m.repoTrackers))
for _, t := range m.repoTrackers {
_, trackers := m.snapshotTrackers()
out := make([]string, 0, len(trackers))
for _, t := range trackers {
if t.repositoryName != "" {
out = append(out, t.repositoryName)
}
@@ -364,21 +413,16 @@ func (m *Manager) RepoSubpaths() []string {
// after a focus event, since the agent's initial pushes happen at boot and
// no replay path exists for clients that subscribe later.
func (m *Manager) SetWorkspacePollMode(ctx context.Context, mode PollMode) {
m.workspaceTracker.SetPollMode(mode)
for _, t := range m.repoTrackers {
root, trackers := m.snapshotTrackers()
root.SetPollMode(mode)
for _, t := range trackers {
t.SetPollMode(mode)
}
if mode == PollModePaused {
return
}
// Snapshot the tracker slice before launching the goroutine so a
// concurrent Stop()/teardown that mutates m.repoTrackers can't race or
// nil-deref the iteration. Refresh in background — RefreshGitStatus
// blocks on git commands which can take seconds on large repos; the
// HTTP caller shouldn't wait.
root := m.workspaceTracker
trackers := make([]*WorkspaceTracker, len(m.repoTrackers))
copy(trackers, m.repoTrackers)
// Refresh in background — RefreshGitStatus blocks on git commands which
// can take seconds on large repos; the HTTP caller shouldn't wait.
go func() {
root.RefreshGitStatus(ctx)
for _, t := range trackers {
@@ -574,8 +618,9 @@ func (m *Manager) Start(ctx context.Context) error {
go m.forwardUpdates()
// Start workspace tracker with background context (not tied to HTTP request)
m.workspaceTracker.Start(context.Background())
for _, t := range m.repoTrackers {
root, trackers := m.snapshotTrackers()
root.Start(context.Background())
for _, t := range trackers {
t.Start(context.Background())
}
@@ -599,8 +644,9 @@ func (m *Manager) startOneShot() error {
go m.forwardUpdates()
// Start workspace tracker with background context (not tied to HTTP request)
m.workspaceTracker.Start(context.Background())
for _, t := range m.repoTrackers {
root, trackers := m.snapshotTrackers()
root.Start(context.Background())
for _, t := range trackers {
t.Start(context.Background())
}
@@ -0,0 +1,253 @@
package process
import (
"context"
"os"
"path/filepath"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agentctl/types"
)
// RescanRepositories re-discovers the git-worktree children under newWorkDir
// (or cfg.WorkDir when newWorkDir is empty) and reconciles the running set of
// workspace trackers against the result. It exists for the multi-branch
// add-branch flow where a sibling worktree appears on disk AFTER the agent
// has launched — without a rescan, the original tracker set is frozen at
// construct time and the new worktree's file/git events never reach the UI.
//
// Behavior:
// - When newWorkDir is non-empty and differs from cfg.WorkDir, cfg.WorkDir
// is updated. The agent's CWD is NOT touched: this controls only what
// the WORKSPACE trackers monitor, not where the child process runs.
// - When the manager is in single-repo mode (no repoTrackers, single
// workspaceTracker bound to a primary worktree) and the rescan finds
// >= 2 sibling git children, it transitions to multi-repo mode:
// the existing single-repo tracker is replaced by a bare task-root
// tracker, and a per-repo tracker is created for each child including
// the original primary (so its events get tagged with RepositoryName).
// - When the manager is already in multi-repo mode, only NEW per-repo
// trackers (children whose RepositoryName isn't currently tracked) are
// added; stale trackers (children no longer present on disk) are left
// in place — removal would race with in-flight notifications and the
// stale tracker's git index path stops emitting anyway.
// - All new trackers are Start()-ed and attached to every existing
// workspace-stream subscriber so events flow without re-subscription.
//
// Idempotent: a rescan with no on-disk changes is a no-op.
func (m *Manager) RescanRepositories(ctx context.Context, newWorkDir string) {
// Serialize the whole rescan body. Two concurrent calls could otherwise
// both observe existingTrackers == 0 between the write-lock snapshot
// and the bootstrap branch, both calling transitionToMultiRepoMode and
// leaking a duplicate bare-root tracker + duplicate per-repo trackers.
m.rescanMu.Lock()
defer m.rescanMu.Unlock()
// Resolve the candidate workDir and prove it's a readable directory
// BEFORE committing cfg.WorkDir. If newWorkDir is bogus, leaving the
// manager pointing at the existing root keeps path-based handlers
// (vscode, git, files) consistent with the trackers that never moved.
m.repoTrackersMu.RLock()
candidate := m.cfg.WorkDir
m.repoTrackersMu.RUnlock()
if newWorkDir != "" && newWorkDir != candidate {
resolved, ok := resolveRescanPath(newWorkDir, candidate)
if !ok {
m.logger.Warn("workspace rescan: ignoring invalid work_dir",
zap.String("work_dir", newWorkDir),
zap.String("current_work_dir", candidate))
return
}
// resolved is derived from currentWorkDir (trusted manager config),
// not from newWorkDir, so os.Stat here doesn't see HTTP-supplied
// input. CodeQL's path-injection trace ends at resolveRescanPath.
if info, err := os.Stat(resolved); err == nil && info.IsDir() {
candidate = resolved
} else {
m.logger.Warn("workspace rescan: ignoring invalid work_dir",
zap.String("work_dir", newWorkDir), zap.Error(err))
return
}
}
// Read existingTrackers under the same write-lock that commits the new
// cfg.WorkDir so two concurrent rescans don't both observe 0 trackers
// and double-bootstrap the multi-repo set.
m.repoTrackersMu.Lock()
m.cfg.WorkDir = candidate
workDir := m.cfg.WorkDir
existingTrackers := len(m.repoTrackers)
m.repoTrackersMu.Unlock()
children := scanRepositorySubdirs(workDir)
subs := m.snapshotSubscribers()
m.logger.Info("workspace rescan started",
zap.String("work_dir", workDir),
zap.Int("children_found", len(children)),
zap.Int("existing_repo_trackers", existingTrackers),
zap.Int("subscribers", len(subs)))
if len(children) < 2 {
// Nothing to do: a non-multi-repo workspace stays on its single
// tracker. The legacy preferGitRepoChildIfRootIsBare fallback
// covers single-repo construct-time setup.
return
}
if existingTrackers == 0 {
m.transitionToMultiRepoMode(ctx, workDir, children, subs)
return
}
m.appendNewRepoTrackers(ctx, children, subs)
}
// transitionToMultiRepoMode replaces the single-repo workspaceTracker with a
// bare task-root tracker and stands up per-repo trackers for every detected
// child. Used when the agent launched as single-repo and a sibling worktree
// was added afterwards.
func (m *Manager) transitionToMultiRepoMode(ctx context.Context, workDir string, children []repositorySubdir, subs []types.WorkspaceStreamSubscriber) {
m.logger.Info("transitioning workspace to multi-repo mode",
zap.String("work_dir", workDir),
zap.Int("children", len(children)))
bareRoot := NewWorkspaceTrackerForRepo(workDir, "", m.logger)
bareRoot.Start(ctx)
for _, sub := range subs {
bareRoot.AttachWorkspaceStreamSubscriber(sub)
}
newRepoTrackers := make([]*WorkspaceTracker, 0, len(children))
for _, child := range children {
tracker := NewWorkspaceTrackerForRepo(child.path, child.name, m.logger)
tracker.Start(ctx)
for _, sub := range subs {
tracker.AttachWorkspaceStreamSubscriber(sub)
}
newRepoTrackers = append(newRepoTrackers, tracker)
}
m.repoTrackersMu.Lock()
old := m.workspaceTracker
m.workspaceTracker = bareRoot
m.repoTrackers = append(m.repoTrackers, newRepoTrackers...)
m.repoTrackersMu.Unlock()
if old != nil {
for _, sub := range subs {
old.DetachWorkspaceStreamSubscriber(sub)
}
old.Stop()
}
}
// appendNewRepoTrackers adds trackers for child subdirs that don't already
// have one. Existing trackers (matched by RepositoryName) are left running
// so their cached git state and subscriber wiring stay intact.
func (m *Manager) appendNewRepoTrackers(ctx context.Context, children []repositorySubdir, subs []types.WorkspaceStreamSubscriber) {
m.repoTrackersMu.RLock()
existing := make(map[string]bool, len(m.repoTrackers))
for _, t := range m.repoTrackers {
existing[t.RepositoryName()] = true
}
m.repoTrackersMu.RUnlock()
var newTrackers []*WorkspaceTracker
for _, child := range children {
if existing[child.name] {
continue
}
m.logger.Info("adding per-repo tracker after rescan",
zap.String("repository_name", child.name),
zap.String("path", child.path))
tracker := NewWorkspaceTrackerForRepo(child.path, child.name, m.logger)
tracker.Start(ctx)
for _, sub := range subs {
tracker.AttachWorkspaceStreamSubscriber(sub)
}
newTrackers = append(newTrackers, tracker)
}
if len(newTrackers) == 0 {
return
}
// Re-check membership inside the write-lock as a defense-in-depth
// guard. rescanMu already serializes RescanRepositories callers, but
// the explicit check here makes the invariant local: any tracker
// already in the slice by name is dropped before append, so even if
// the invariant moved, duplicates would still be rejected.
m.repoTrackersMu.Lock()
stillExisting := make(map[string]bool, len(m.repoTrackers))
for _, t := range m.repoTrackers {
stillExisting[t.RepositoryName()] = true
}
var dropped []*WorkspaceTracker
for _, t := range newTrackers {
if stillExisting[t.RepositoryName()] {
dropped = append(dropped, t)
continue
}
m.repoTrackers = append(m.repoTrackers, t)
}
m.repoTrackersMu.Unlock()
// Stop + detach any dropped trackers outside the lock so we don't block
// readers on potentially-slow Stop() teardown.
for _, t := range dropped {
for _, sub := range subs {
t.DetachWorkspaceStreamSubscriber(sub)
}
t.Stop()
}
}
// resolveRescanPath maps an externally-supplied workspace path to a known-good
// path derived from the manager's existing cfg.WorkDir, breaking the taint
// flow from the HTTP body into os.Stat / downstream tracker paths. The only
// legitimate caller (kandev backend's branch materializer) promotes the
// workdir up exactly one level — from <task>/<repo>/ to <task>/ — so the
// allowed transitions are:
// - newPath equals currentWorkDir → no-op (return current)
// - newPath equals parent of current → return derived parent
//
// Critically, the returned path is ALWAYS derived from currentWorkDir
// (which originates in trusted manager config), never from newPath itself.
// CodeQL's go/path-injection taint analysis cleared once the sink stopped
// receiving the HTTP-supplied string directly.
//
// Returns ("", false) for any other input — first-launch case (currentWorkDir
// empty) is handled by the caller falling back to the existing workdir.
func resolveRescanPath(newPath, currentWorkDir string) (string, bool) {
if newPath == "" || currentWorkDir == "" {
return "", false
}
clean := filepath.Clean(newPath)
if !filepath.IsAbs(clean) {
return "", false
}
currentClean := filepath.Clean(currentWorkDir)
if clean == currentClean {
return currentClean, true
}
parent := filepath.Dir(currentClean)
if parent == currentClean {
// currentWorkDir is already filesystem root; no promotion possible.
return "", false
}
if clean == parent {
return parent, true
}
return "", false
}
// snapshotSubscribers returns a copy of the current workspace-stream
// subscribers so rescan callers can attach new trackers without holding the
// subscriber lock during git-status replays.
func (m *Manager) snapshotSubscribers() []types.WorkspaceStreamSubscriber {
m.streamSubscribersMu.Lock()
defer m.streamSubscribersMu.Unlock()
out := make([]types.WorkspaceStreamSubscriber, 0, len(m.streamSubscribers))
for s := range m.streamSubscribers {
out = append(out, s)
}
return out
}
@@ -0,0 +1,87 @@
package process
import (
"path/filepath"
"runtime"
"testing"
)
// TestResolveRescanPath locks in the rescan endpoint's path-injection
// contract. The function is the sole mitigation for the CodeQL
// go/path-injection finding on /api/v1/workspace/rescan: it maps the
// HTTP-supplied work_dir to a path DERIVED from the manager's existing
// cfg.WorkDir, so the value that reaches os.Stat is never the raw HTTP
// input. Only two transitions are allowed: no-op (equal to current) and
// promotion-up-one-level (equal to parent of current).
//
// Inputs are constructed via filepath.Join so the same table covers
// Windows (`C:\...`) and POSIX (`/...`) without per-OS branches in
// every case.
func TestResolveRescanPath(t *testing.T) {
root := absRoot() // "/" on POSIX, "C:\" on Windows
repo := join(root, "task", "repo")
task := join(root, "task")
other := join(root, "task", "other")
sub := join(root, "task", "repo", "sub")
etc := join(root, "etc")
dotted := join(root, "home", "u", "..hidden")
dottedRepo := join(dotted, "repo")
cases := []struct {
name string
newPath string
current string
wantPath string
wantOK bool
}{
{"empty new path", "", repo, "", false},
{"empty current (no anchor)", repo, "", "", false},
{"relative path", "task/repo", repo, "", false},
{"exact match (no-op)", repo, repo, repo, true},
{"promotion to parent (allowed)", task, repo, task, true},
{"two-level ancestor (rejected)", root, repo, "", false},
{"child of current (rejected)", sub, repo, "", false},
{"sibling repo (rejected)", other, repo, "", false},
{"unrelated absolute (rejected)", etc, repo, "", false},
{"current is filesystem root", root, root, root, true},
{"current is root, promotion impossible", join(root, "foo"), root, "", false},
{"dotted segment is legal as exact match", dotted, dotted, dotted, true},
{"dotted segment is legal as promotion", dotted, dottedRepo, dotted, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := resolveRescanPath(tc.newPath, tc.current)
if ok != tc.wantOK || got != tc.wantPath {
t.Errorf("resolveRescanPath(%q, %q) = (%q, %v); want (%q, %v)",
tc.newPath, tc.current, got, ok, tc.wantPath, tc.wantOK)
}
})
}
// Traversal-after-Clean is a POSIX-only invariant — on Windows the
// input would also need a volume to be considered absolute; covering
// it once on POSIX is enough since filepath.Clean is the shared
// primitive.
if runtime.GOOS != "windows" {
t.Run("traversal cleaned to ancestor (rejected after Clean)", func(t *testing.T) {
got, ok := resolveRescanPath("/task/../etc", "/task/repo")
if ok || got != "" {
t.Errorf("expected rejection for /task/../etc, got (%q, %v)", got, ok)
}
})
}
}
// absRoot returns the platform's absolute filesystem root prefix —
// "/" on POSIX, "C:\" (the runner's drive) on Windows.
func absRoot() string {
root, _ := filepath.Abs(string(filepath.Separator))
return root
}
// join wraps filepath.Join so callers can express paths with separate
// segments without thinking about platform separators.
func join(root string, segs ...string) string {
parts := append([]string{root}, segs...)
return filepath.Join(parts...)
}
@@ -0,0 +1,243 @@
package process
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/logger"
)
// TestRescanRepositories_TransitionsSingleToMultiRepo simulates the
// MCP add_branch flow: the agent launched as single-repo with WorkDir set
// to the primary worktree, then a sibling worktree appears at the task
// root. After RescanRepositories the manager must hold one bare root
// tracker + one per-repo tracker for each child.
func TestRescanRepositories_TransitionsSingleToMultiRepo(t *testing.T) {
taskRoot := t.TempDir()
primary := filepath.Join(taskRoot, "kandev")
sibling := filepath.Join(taskRoot, "kandev-feature-x")
initGitRepoAt(t, primary)
initGitRepoAt(t, sibling)
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
cfg := &config.InstanceConfig{WorkDir: primary}
m := NewManager(cfg, log)
if len(m.repoTrackers) != 0 {
t.Fatalf("expected 0 repoTrackers in single-repo mode, got %d", len(m.repoTrackers))
}
m.RescanRepositories(context.Background(), taskRoot)
if len(m.repoTrackers) != 2 {
t.Fatalf("expected 2 repoTrackers after transition, got %d", len(m.repoTrackers))
}
names := map[string]bool{}
for _, tr := range m.repoTrackers {
names[tr.RepositoryName()] = true
}
if !names["kandev"] || !names["kandev-feature-x"] {
t.Errorf("expected trackers for kandev + kandev-feature-x, got %v", names)
}
if m.cfg.WorkDir != taskRoot {
t.Errorf("cfg.WorkDir = %q, want %q", m.cfg.WorkDir, taskRoot)
}
// Stop trackers so the goroutines don't outlive the test (parent
// TestMain's goleak check would otherwise flag them).
m.stopWorkspaceTrackers()
}
// TestRescanRepositories_AppendsNewRepoTrackerInMultiRepoMode covers the
// already-multi case: an extra sibling appears, the existing trackers
// remain, a new tracker is added for the new sibling.
func TestRescanRepositories_AppendsNewRepoTrackerInMultiRepoMode(t *testing.T) {
taskRoot := t.TempDir()
repoA := filepath.Join(taskRoot, "alpha")
repoB := filepath.Join(taskRoot, "beta")
initGitRepoAt(t, repoA)
initGitRepoAt(t, repoB)
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
cfg := &config.InstanceConfig{WorkDir: taskRoot}
m := NewManager(cfg, log)
if len(m.repoTrackers) != 2 {
t.Fatalf("expected 2 repoTrackers from construct-time scan, got %d", len(m.repoTrackers))
}
// New sibling added after launch (e.g. via add_branch).
repoC := filepath.Join(taskRoot, "gamma")
initGitRepoAt(t, repoC)
m.RescanRepositories(context.Background(), taskRoot)
if len(m.repoTrackers) != 3 {
t.Fatalf("expected 3 repoTrackers after append, got %d", len(m.repoTrackers))
}
names := map[string]bool{}
for _, tr := range m.repoTrackers {
names[tr.RepositoryName()] = true
}
for _, expected := range []string{"alpha", "beta", "gamma"} {
if !names[expected] {
t.Errorf("missing tracker for %q (got %v)", expected, names)
}
}
m.stopWorkspaceTrackers()
}
// TestRescanRepositories_FileTreeReturnsTaskRootContentsAfterTransition
// covers the Files panel surface: after rescan flips a single-branch
// workspace to multi-repo mode, GetWorkspaceTracker().GetFileTree("", n)
// must list the sibling repo dirs (kandev/, kandev-feature-x/) instead of
// the contents of the original primary worktree. This is the bug the user
// hit where the Files panel still rendered .agents/.claude/apps/... from
// inside the primary worktree even though the task had become multi-branch.
func TestRescanRepositories_FileTreeReturnsTaskRootContentsAfterTransition(t *testing.T) {
taskRoot := t.TempDir()
primary := filepath.Join(taskRoot, "kandev")
sibling := filepath.Join(taskRoot, "kandev-feature-x")
initGitRepoAt(t, primary)
initGitRepoAt(t, sibling)
// Seed each worktree with a "marker" file so we can tell which one a
// file-tree response is rooted at.
if err := os.WriteFile(filepath.Join(primary, "primary-marker.txt"), []byte("p"), 0o644); err != nil {
t.Fatalf("write primary marker: %v", err)
}
if err := os.WriteFile(filepath.Join(sibling, "sibling-marker.txt"), []byte("s"), 0o644); err != nil {
t.Fatalf("write sibling marker: %v", err)
}
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
cfg := &config.InstanceConfig{WorkDir: primary}
m := NewManager(cfg, log)
defer m.stopWorkspaceTrackers()
// Pre-rescan: workspaceTracker is bound to the primary, so the file
// tree must include the primary's marker (and NOT the sibling's).
preTree, err := m.workspaceTracker.GetFileTree("", 1)
if err != nil {
t.Fatalf("pre-rescan GetFileTree: %v", err)
}
preNames := fileTreeChildNames(preTree)
if !preNames["primary-marker.txt"] {
t.Errorf("pre-rescan tree missing primary marker: %v", preNames)
}
if preNames["sibling-marker.txt"] {
t.Errorf("pre-rescan tree should not contain sibling marker yet: %v", preNames)
}
m.RescanRepositories(context.Background(), taskRoot)
postTree, err := m.workspaceTracker.GetFileTree("", 1)
if err != nil {
t.Fatalf("post-rescan GetFileTree: %v", err)
}
postNames := fileTreeChildNames(postTree)
if !postNames["kandev"] || !postNames["kandev-feature-x"] {
t.Errorf("post-rescan tree should list sibling repo dirs, got %v", postNames)
}
if postNames["primary-marker.txt"] {
t.Errorf("post-rescan tree should not include primary's inner files at root: %v", postNames)
}
}
// fileTreeChildNames returns the set of top-level child names from a
// FileTreeNode. Returns an empty map for nil / leaf nodes so the caller can
// just index without nil checks.
func fileTreeChildNames(n *streams.FileTreeNode) map[string]bool {
out := map[string]bool{}
if n == nil {
return out
}
for _, c := range n.Children {
out[c.Name] = true
}
return out
}
// TestRescanRepositories_PropagatesSubscriberToNewTrackers locks in the
// subscriber-list invariant: any workspace-stream subscriber attached
// BEFORE the rescan must remain attached to the resulting tracker set so
// the Changes panel receives events from newly-discovered repos. Without
// this, the single→multi transition would silently drop the existing
// gateway subscriber when it stops the old workspaceTracker, and the UI
// would go quiet.
func TestRescanRepositories_PropagatesSubscriberToNewTrackers(t *testing.T) {
taskRoot := t.TempDir()
primary := filepath.Join(taskRoot, "kandev")
sibling := filepath.Join(taskRoot, "kandev-branch-2")
initGitRepoAt(t, primary)
initGitRepoAt(t, sibling)
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
cfg := &config.InstanceConfig{WorkDir: primary}
m := NewManager(cfg, log)
defer m.stopWorkspaceTrackers()
sub := m.SubscribeWorkspaceStream()
defer m.UnsubscribeWorkspaceStream(sub)
// Drain the initial replay so the channel doesn't carry stale state
// into our post-rescan assertions.
drainChannel(sub)
m.RescanRepositories(context.Background(), taskRoot)
// After transition, every active tracker must hold the subscriber:
// bare root + 2 per-repo trackers = 3 attach references. The exported
// surface doesn't expose subscriber count directly, so we verify by
// inspecting the trackers' internal subscriber maps via the same
// concurrency primitives the tracker uses (read lock).
checkTrackerHasSub := func(name string, tr *WorkspaceTracker) {
tr.workspaceSubMu.RLock()
defer tr.workspaceSubMu.RUnlock()
if _, ok := tr.workspaceStreamSubscribers[sub]; !ok {
t.Errorf("subscriber missing from %q tracker after rescan", name)
}
}
checkTrackerHasSub("workspaceTracker (bare root)", m.workspaceTracker)
for _, tr := range m.repoTrackers {
checkTrackerHasSub("repoTracker "+tr.RepositoryName(), tr)
}
}
// drainChannel non-blocking-empties a workspace-stream subscriber so test
// assertions about post-rescan messages aren't muddied by initial replays.
func drainChannel(ch types.WorkspaceStreamSubscriber) {
for {
select {
case <-ch:
default:
return
}
}
}
// initGitRepoAt creates a git repo at dir with one commit so
// scanRepositorySubdirs (which gates on a valid `.git/index` file)
// recognizes it. git init alone leaves the index missing until the first
// add/commit.
func initGitRepoAt(t *testing.T, dir string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
runIn := func(name string, args ...string) {
cmd := exec.Command(name, args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%s %v in %s: %v\n%s", name, args, dir, err, out)
}
}
runIn("git", "init", "-b", "main")
runIn("git", "config", "user.email", "test@example.com")
runIn("git", "config", "user.name", "Test User")
runIn("git", "config", "commit.gpgsign", "false")
runIn("git", "commit", "--allow-empty", "-m", "initial")
}
@@ -103,6 +103,14 @@ func NewWorkspaceTracker(workDir string, log *logger.Logger) *WorkspaceTracker {
return newWorkspaceTracker(resolvedWorkDir, "", log)
}
// RepositoryName returns the repository name tag applied to events emitted by
// this tracker. Empty for the bare task-root / single-repo tracker; non-empty
// for per-repo trackers built via NewWorkspaceTrackerForRepo. Used by the
// rescan path to decide whether a discovered subdir already has a tracker.
func (wt *WorkspaceTracker) RepositoryName() string {
return wt.repositoryName
}
// NewWorkspaceTrackerForRepo creates a tracker scoped to a specific repository
// subdirectory. The repositoryName is stamped onto every emitted event so the
// frontend can route updates per repo for multi-repo task roots.
@@ -15,17 +15,18 @@ import (
// --- PR Watch operations ---
// CreatePRWatch creates a new PR watch for a (session, repository) pair.
// `repositoryID` may be empty for legacy single-repo callers; multi-repo
// callers must pass the per-task repository_id so each repo gets its own
// watch row.
// CreatePRWatch creates a new PR watch for a (session, repository, branch)
// triple. `repositoryID` may be empty for legacy single-repo callers;
// multi-repo callers must pass the per-task repository_id so each repo gets
// its own watch row. Multi-branch tasks store one watch per branch so a
// secondary branch's push isn't lost behind the primary's existing watch.
func (s *Service) CreatePRWatch(ctx context.Context, sessionID, taskID, repositoryID, owner, repo string, prNumber int, branch string) (*PRWatch, error) {
existing, err := s.store.GetPRWatchBySessionAndRepo(ctx, sessionID, repositoryID)
existing, err := s.store.GetPRWatchBySessionRepoAndBranch(ctx, sessionID, repositoryID, branch)
if err != nil {
return nil, err
}
if existing != nil {
return existing, nil // already watching this (session, repo)
return existing, nil // already watching this (session, repo, branch)
}
w := &PRWatch{
SessionID: sessionID,
@@ -42,6 +43,7 @@ func (s *Service) CreatePRWatch(ctx context.Context, sessionID, taskID, reposito
s.logger.Info("created PR watch",
zap.String("session_id", sessionID),
zap.String("repository_id", repositoryID),
zap.String("branch", branch),
zap.Int("pr_number", prNumber))
return w, nil
}
@@ -58,6 +60,13 @@ func (s *Service) GetPRWatchBySessionAndRepo(ctx context.Context, sessionID, rep
return s.store.GetPRWatchBySessionAndRepo(ctx, sessionID, repositoryID)
}
// GetPRWatchBySessionRepoAndBranch returns the PR watch for a precise
// (session, repository, branch) triple — used by push detection in
// multi-branch tasks so each branch's push lands on its own watch row.
func (s *Service) GetPRWatchBySessionRepoAndBranch(ctx context.Context, sessionID, repositoryID, branch string) (*PRWatch, error) {
return s.store.GetPRWatchBySessionRepoAndBranch(ctx, sessionID, repositoryID, branch)
}
// ListPRWatchesBySession returns every PR watch for a session.
func (s *Service) ListPRWatchesBySession(ctx context.Context, sessionID string) ([]*PRWatch, error) {
return s.store.ListPRWatchesBySession(ctx, sessionID)
@@ -116,14 +125,15 @@ func (s *Service) CheckPRWatch(ctx context.Context, watch *PRWatch) (*PRStatus,
return status, hasNew, nil
}
// EnsurePRWatch creates a PRWatch with pr_number=0 for a (session, repo) pair
// if one doesn't already exist. The poller will detect the PR by searching
// for the branch on GitHub. `repositoryID` is empty for legacy single-repo
// callers; multi-repo callers MUST pass the per-task repository_id so each
// repo gets its own watch (the table's UNIQUE(session_id, repository_id) used
// to be UNIQUE(session_id), which silently dropped second-repo watches).
// EnsurePRWatch creates a PRWatch with pr_number=0 for a
// (session, repo, branch) triple if one doesn't already exist. The poller
// will detect the PR by searching for the branch on GitHub. `repositoryID`
// is empty for legacy single-repo callers; multi-repo / multi-branch
// callers MUST pass the per-task repository_id and the worktree's branch
// so each branch gets its own watch — keying on (session, repo) alone
// drops secondary branches' watches behind the primary's existing row.
func (s *Service) EnsurePRWatch(ctx context.Context, sessionID, taskID, repositoryID, owner, repo, branch string) (*PRWatch, error) {
existing, err := s.store.GetPRWatchBySessionAndRepo(ctx, sessionID, repositoryID)
existing, err := s.store.GetPRWatchBySessionRepoAndBranch(ctx, sessionID, repositoryID, branch)
if err != nil {
return nil, err
}
@@ -157,14 +167,18 @@ func (s *Service) EnsurePRWatch(ctx context.Context, sessionID, taskID, reposito
// callers MUST pass it — empty causes ReplaceTaskPR to wipe the entire task's
// PR rows (legacy "delete all" branch), which is what older code relied on.
func (s *Service) AssociatePRWithTask(ctx context.Context, taskID, repositoryID string, pr *PR) (*TaskPR, error) {
// Check for an existing PR for this exact (task, repo). Multi-repo callers
// must scope by repository_id so the same PR number in two repos doesn't
// short-circuit the second association.
existing, err := s.store.GetTaskPRByRepository(ctx, taskID, repositoryID)
// Multi-branch: scope the "already-current" short-circuit by exact
// pr_number too. A task can hold multiple PR rows per (task, repo) on
// different branches; the legacy by-repo lookup returns whichever row
// was most recently updated, which would make Associate think the
// secondary PR is already there (wrong PR number) and skip the insert
// — or worse, fall through to ReplaceTaskPR which used to delete the
// sibling row.
existing, err := s.store.GetTaskPRByRepoAndNumber(ctx, taskID, repositoryID, pr.Number)
if err != nil {
return nil, err
}
if existing != nil && existing.PRNumber == pr.Number {
if existing != nil {
return existing, nil
}
tp := &TaskPR{
@@ -185,20 +199,16 @@ func (s *Service) AssociatePRWithTask(ctx context.Context, taskID, repositoryID
MergedAt: pr.MergedAt,
ClosedAt: pr.ClosedAt,
}
// ReplaceTaskPR atomically deletes any existing association for the
// (task, repository) pair and inserts the new row inside one transaction.
// Scoping by repository_id keeps multi-repo tasks intact; legacy callers
// (repositoryID == "") still get the "delete all" semantics.
// ReplaceTaskPR upserts the row matching (task, repository, pr_number).
// Multi-branch tasks may already hold sibling rows for the SAME
// (task, repository) on different PR numbers — ReplaceTaskPR no longer
// touches them. The early-return above guarantees we only reach this
// line when no row for the exact pr_number exists yet, so this is a
// straight insert in steady state; the delete-then-insert form is
// retained so a retry that races a partial write resolves cleanly.
if err := s.store.ReplaceTaskPR(ctx, tp); err != nil {
return nil, fmt.Errorf("replace task PR: %w", err)
}
if existing != nil {
s.logger.Info("replaced stale task PR association",
zap.String("task_id", taskID),
zap.String("repository_id", repositoryID),
zap.Int("old_pr_number", existing.PRNumber),
zap.Int("new_pr_number", pr.Number))
}
// Publish event for UI
if s.eventBus != nil {
@@ -0,0 +1,112 @@
package github
import (
"context"
"testing"
)
// TestCreatePRWatch_AllowsMultipleBranchesPerRepo locks the multi-branch
// invariant: the same (session, repository) pair can hold N watches, one
// per branch. Previously the unique constraint was UNIQUE(session_id,
// repository_id) and the dedup in CreatePRWatch returned the existing row
// — secondary branches' pushes silently re-attached to the primary's watch
// and the secondary PR never landed in github_task_prs.
func TestCreatePRWatch_AllowsMultipleBranchesPerRepo(t *testing.T) {
_, svc, _, store := setupPollerTest(t)
ctx := context.Background()
seedTask(t, store, "task-1", false)
w1, err := svc.CreatePRWatch(ctx, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/primary")
if err != nil {
t.Fatalf("CreatePRWatch primary: %v", err)
}
if w1 == nil {
t.Fatal("primary watch must not be nil")
}
w2, err := svc.CreatePRWatch(ctx, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/branch-2")
if err != nil {
t.Fatalf("CreatePRWatch branch-2: %v", err)
}
if w2 == nil {
t.Fatal("branch-2 watch must not be nil")
}
if w1.ID == w2.ID {
t.Errorf("expected distinct watches for two branches, got same id %q", w1.ID)
}
all, err := store.ListPRWatchesBySession(ctx, "session-1")
if err != nil {
t.Fatalf("list watches: %v", err)
}
if len(all) != 2 {
t.Fatalf("expected 2 watches for (session, repo) across branches, got %d", len(all))
}
}
// TestCreatePRWatch_IdempotentPerBranch verifies that re-creating a watch
// for the same (session, repo, branch) triple is a no-op returning the
// existing row — push detection retries that race the original create
// must not duplicate watches.
func TestCreatePRWatch_IdempotentPerBranch(t *testing.T) {
_, svc, _, store := setupPollerTest(t)
ctx := context.Background()
seedTask(t, store, "task-1", false)
first, err := svc.CreatePRWatch(ctx, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/x")
if err != nil {
t.Fatalf("first CreatePRWatch: %v", err)
}
second, err := svc.CreatePRWatch(ctx, "session-1", "task-1", "repo-1", "owner", "repo", 0, "feature/x")
if err != nil {
t.Fatalf("second CreatePRWatch: %v", err)
}
if first.ID != second.ID {
t.Errorf("expected idempotent return of same watch id, got %q vs %q", first.ID, second.ID)
}
all, err := store.ListPRWatchesBySession(ctx, "session-1")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(all) != 1 {
t.Errorf("expected 1 watch after idempotent retry, got %d", len(all))
}
}
// TestGetPRWatchBySessionRepoAndBranch_FindsRightRow verifies the
// branch-aware lookup used by push detection. Two watches on the same
// repo, different branches: the lookup must return the matching branch's
// watch and not the other.
func TestGetPRWatchBySessionRepoAndBranch_FindsRightRow(t *testing.T) {
_, svc, _, _ := setupPollerTest(t)
ctx := context.Background()
seedTask(t, svc.store, "task-1", false)
primary, err := svc.CreatePRWatch(ctx, "s1", "task-1", "repo-1", "owner", "repo", 1217, "feature/primary")
if err != nil {
t.Fatalf("primary: %v", err)
}
secondary, err := svc.CreatePRWatch(ctx, "s1", "task-1", "repo-1", "owner", "repo", 0, "feature/secondary")
if err != nil {
t.Fatalf("secondary: %v", err)
}
got, err := svc.GetPRWatchBySessionRepoAndBranch(ctx, "s1", "repo-1", "feature/secondary")
if err != nil {
t.Fatalf("GetPRWatchBySessionRepoAndBranch: %v", err)
}
if got == nil || got.ID != secondary.ID {
t.Fatalf("expected secondary watch id=%q, got %+v", secondary.ID, got)
}
gotPrimary, err := svc.GetPRWatchBySessionRepoAndBranch(ctx, "s1", "repo-1", "feature/primary")
if err != nil {
t.Fatalf("GetPRWatchBySessionRepoAndBranch primary: %v", err)
}
if gotPrimary == nil || gotPrimary.ID != primary.ID {
t.Fatalf("expected primary watch id=%q, got %+v", primary.ID, gotPrimary)
}
}
@@ -74,11 +74,14 @@ func TestCleanupMergedReviewTasks_TaskAlreadyDeleted(t *testing.T) {
}
}
// Regression: when a task already has a TaskPR row pointing to an old PR
// (e.g. the first PR was closed and a second one opened on the same or a new
// branch), AssociatePRWithTask must replace the stale row so downstream
// consumers (UI, GetTaskPR) observe the current PR rather than the old one.
func TestAssociatePRWithTask_ReplacesStaleAssociation(t *testing.T) {
// Regression: when a task already has a TaskPR row pointing to PR #1 and
// AssociatePRWithTask is called with a different PR #2 (e.g. first PR
// closed, new PR opened — or a multi-branch task gaining a second PR on a
// different branch), the new row must be inserted as a sibling without
// touching the existing #1 row. Multi-branch tasks rely on this so two
// PRs on the same (task, repo) coexist; the old "delete-then-insert"
// behavior collapsed multi-branch tasks down to one PR row.
func TestAssociatePRWithTask_AddsSecondPRAsSibling(t *testing.T) {
svc, store, _ := setupSyncTest(t)
ctx := context.Background()
@@ -97,7 +100,7 @@ func TestAssociatePRWithTask_ReplacesStaleAssociation(t *testing.T) {
t.Fatalf("seed TaskPR: %v", err)
}
// Associate a new PR #2 (could be on same or different branch).
// Associate a new PR #2 on a different branch.
newPR := &PR{
Number: 2,
Title: "Second",
@@ -117,11 +120,18 @@ func TestAssociatePRWithTask_ReplacesStaleAssociation(t *testing.T) {
t.Errorf("returned TaskPR.PRNumber=%d, want 2", tp.PRNumber)
}
got, err := store.GetTaskPR(ctx, "t1")
all, err := store.ListTaskPRsByTask(ctx, "t1")
if err != nil {
t.Fatalf("GetTaskPR: %v", err)
t.Fatalf("ListTaskPRsByTask: %v", err)
}
if got == nil || got.PRNumber != 2 {
t.Errorf("GetTaskPR after replace = %+v, want PRNumber=2", got)
if len(all) != 2 {
t.Fatalf("expected 2 PR rows after associating second PR, got %d", len(all))
}
nums := map[int]bool{}
for _, r := range all {
nums[r.PRNumber] = true
}
if !nums[1] || !nums[2] {
t.Errorf("missing expected PR numbers: %v", nums)
}
}
+108 -52
View File
@@ -53,7 +53,7 @@ const createTablesSQL = `
last_review_state TEXT DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE(session_id, repository_id)
UNIQUE(session_id, repository_id, branch)
);
CREATE TABLE IF NOT EXISTS github_task_prs (
@@ -321,42 +321,50 @@ func (s *Store) tableExists(name string) bool {
// migratePRTablesForMultiRepo rebuilds `github_pr_watches` and
// `github_task_prs` to drop the legacy single-repo unique constraints
// (`UNIQUE(session_id)` and `UNIQUE(task_id, pr_number)`) and replace them
// with the multi-repo variants. SQLite can't ALTER TABLE DROP CONSTRAINT, so
// each table is rebuilt via the recommended copy-and-rename pattern. The
// migration is idempotent: it inspects `sqlite_master.sql` for the legacy
// constraint string and only runs the rebuild when found.
// with the multi-repo / multi-branch variants. SQLite can't ALTER TABLE
// DROP CONSTRAINT, so each table is rebuilt via the recommended
// copy-and-rename pattern. The migration is idempotent: it inspects
// `sqlite_master.sql` for the legacy constraint string and only runs the
// rebuild when found. The watch rebuild fires twice — once for the original
// single-repo shape, once for the interim multi-repo shape — so DBs caught
// in either state upgrade cleanly to the multi-branch shape.
func (s *Store) migratePRTablesForMultiRepo() error {
if err := s.rebuildIfHasLegacyConstraint(
"github_pr_watches",
for _, trigger := range []string{
"session_id TEXT NOT NULL UNIQUE",
`CREATE TABLE github_pr_watches_new (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
task_id TEXT NOT NULL,
repository_id TEXT NOT NULL DEFAULT '',
owner TEXT NOT NULL,
repo TEXT NOT NULL,
pr_number INTEGER NOT NULL,
branch TEXT NOT NULL,
last_checked_at DATETIME,
last_comment_at DATETIME,
last_check_status TEXT DEFAULT '',
last_review_state TEXT DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE(session_id, repository_id)
)`,
`INSERT INTO github_pr_watches_new (
id, session_id, task_id, repository_id, owner, repo, pr_number, branch,
last_checked_at, last_comment_at, last_check_status, last_review_state,
created_at, updated_at
) SELECT
id, session_id, task_id, COALESCE(repository_id, ''), owner, repo, pr_number, branch,
last_checked_at, last_comment_at, last_check_status, last_review_state,
created_at, updated_at
FROM github_pr_watches`,
); err != nil {
return err
"UNIQUE(session_id, repository_id)\n",
} {
if err := s.rebuildIfHasLegacyConstraint(
"github_pr_watches",
trigger,
`CREATE TABLE github_pr_watches_new (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
task_id TEXT NOT NULL,
repository_id TEXT NOT NULL DEFAULT '',
owner TEXT NOT NULL,
repo TEXT NOT NULL,
pr_number INTEGER NOT NULL,
branch TEXT NOT NULL,
last_checked_at DATETIME,
last_comment_at DATETIME,
last_check_status TEXT DEFAULT '',
last_review_state TEXT DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE(session_id, repository_id, branch)
)`,
`INSERT INTO github_pr_watches_new (
id, session_id, task_id, repository_id, owner, repo, pr_number, branch,
last_checked_at, last_comment_at, last_check_status, last_review_state,
created_at, updated_at
) SELECT
id, session_id, task_id, COALESCE(repository_id, ''), owner, repo, pr_number, branch,
last_checked_at, last_comment_at, last_check_status, last_review_state,
created_at, updated_at
FROM github_pr_watches`,
); err != nil {
return err
}
}
return s.rebuildIfHasLegacyConstraint(
"github_task_prs",
@@ -477,10 +485,16 @@ func (s *Store) GetPRWatchBySession(ctx context.Context, sessionID string) (*PRW
// GetPRWatchBySessionAndRepo returns the PR watch for a (session, repository)
// pair, or nil. Used by per-repo branch-switch / commit handlers so each
// repo's watch is reset independently.
//
// Multi-branch caveat: a task can hold multiple watches for the same
// (session, repository) on different branches. This lookup returns the
// most-recently-updated row — callers that need branch-specific lookup
// must use GetPRWatchBySessionRepoAndBranch.
func (s *Store) GetPRWatchBySessionAndRepo(ctx context.Context, sessionID, repositoryID string) (*PRWatch, error) {
var w PRWatch
err := s.ro.GetContext(ctx, &w,
`SELECT * FROM github_pr_watches WHERE session_id = ? AND repository_id = ? LIMIT 1`,
`SELECT * FROM github_pr_watches WHERE session_id = ? AND repository_id = ?
ORDER BY updated_at DESC LIMIT 1`,
sessionID, repositoryID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
@@ -488,6 +502,23 @@ func (s *Store) GetPRWatchBySessionAndRepo(ctx context.Context, sessionID, repos
return &w, err
}
// GetPRWatchBySessionRepoAndBranch returns the PR watch for the precise
// (session, repository, branch) triple. Required for multi-branch tasks
// where each branch needs its own watch — querying by (session, repo)
// alone would collapse the secondary branch's push detection onto the
// primary's watch and the secondary PR would never land in github_task_prs.
func (s *Store) GetPRWatchBySessionRepoAndBranch(ctx context.Context, sessionID, repositoryID, branch string) (*PRWatch, error) {
var w PRWatch
err := s.ro.GetContext(ctx, &w,
`SELECT * FROM github_pr_watches
WHERE session_id = ? AND repository_id = ? AND branch = ? LIMIT 1`,
sessionID, repositoryID, branch)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return &w, err
}
// ListPRWatchesBySession returns every PR watch for a session (one per repo
// in multi-repo workspaces). Empty slice when no watches exist.
func (s *Store) ListPRWatchesBySession(ctx context.Context, sessionID string) ([]*PRWatch, error) {
@@ -627,10 +658,16 @@ func (s *Store) GetTaskPR(ctx context.Context, taskID string) (*TaskPR, error) {
// GetTaskPRByRepository returns the PR association for a (task, repository)
// pair, or nil if none. Use this for multi-repo tasks.
//
// Multi-branch caveat: a task can hold N rows per (task, repo) — one per
// PR number. This lookup returns the most-recently-updated row so callers
// that need a deterministic single value still get one. Callers that need
// the row for a specific PR number must use GetTaskPRByRepoAndNumber.
func (s *Store) GetTaskPRByRepository(ctx context.Context, taskID, repositoryID string) (*TaskPR, error) {
var tp TaskPR
err := s.ro.GetContext(ctx, &tp,
`SELECT * FROM github_task_prs WHERE task_id = ? AND repository_id = ? LIMIT 1`,
`SELECT * FROM github_task_prs WHERE task_id = ? AND repository_id = ?
ORDER BY updated_at DESC LIMIT 1`,
taskID, repositoryID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
@@ -638,6 +675,23 @@ func (s *Store) GetTaskPRByRepository(ctx context.Context, taskID, repositoryID
return &tp, err
}
// GetTaskPRByRepoAndNumber returns the exact PR row matching the
// (task, repository, pr_number) triple. Required for multi-branch tasks
// where AssociatePRWithTask's "already-current" short-circuit must check
// the same PR number, not a sibling PR that happens to be the first
// row returned by the legacy by-repo query.
func (s *Store) GetTaskPRByRepoAndNumber(ctx context.Context, taskID, repositoryID string, prNumber int) (*TaskPR, error) {
var tp TaskPR
err := s.ro.GetContext(ctx, &tp,
`SELECT * FROM github_task_prs
WHERE task_id = ? AND repository_id = ? AND pr_number = ? LIMIT 1`,
taskID, repositoryID, prNumber)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return &tp, err
}
// ListTaskPRsByTask returns every PR association for a task (one per repo
// when multi-repo). Empty slice when no PRs exist.
func (s *Store) ListTaskPRsByTask(ctx context.Context, taskID string) ([]*TaskPR, error) {
@@ -699,11 +753,17 @@ func groupTaskPRsByTask(prs []TaskPR) map[string][]*TaskPR {
return result
}
// ReplaceTaskPR atomically replaces the task→PR association for a task: any
// existing rows are deleted and the new row is inserted inside a single
// transaction. The delete is scoped to (task_id, repository_id) so multi-repo
// tasks only replace the row for the affected repo; for single-repo tasks
// (RepositoryID == "") the legacy semantics are preserved (delete all).
// ReplaceTaskPR atomically associates a PR with a task, replacing only the
// row that matches the exact (task_id, repository_id, pr_number) triple.
// Multi-branch tasks may hold multiple PR rows per (task, repo) — one per
// branch — so the delete MUST NOT wipe sibling PR rows. Single-repo
// callers (RepositoryID == "") only delete legacy untagged rows for the
// same PR number.
//
// The DELETE+INSERT pair inside one transaction is the upsert form; an
// ON CONFLICT would also work but the per-row delete pattern matches the
// existing migration layout (rebuilds are easier to reason about) and
// avoids leaking SQLite-specific syntax into the service layer.
func (s *Store) ReplaceTaskPR(ctx context.Context, tp *TaskPR) error {
if tp.ID == "" {
tp.ID = uuid.New().String()
@@ -717,22 +777,18 @@ func (s *Store) ReplaceTaskPR(ctx context.Context, tp *TaskPR) error {
}
defer func() { _ = tx.Rollback() }()
// Multi-repo: only delete the row for the same (task, repo) pair, leaving
// other repos' rows alone. Single-repo callers (RepositoryID == "")
// only delete legacy untagged rows so a multi-repo task's per-repo rows
// don't get wiped if a single-repo callsite slips in. The poller and
// service.AssociatePR* paths now always pass RepositoryID explicitly;
// any future caller that forgets does the safer thing (preserve tagged
// rows) instead of the old "nuke everything" behavior.
if tp.RepositoryID != "" {
if _, err := tx.ExecContext(ctx,
`DELETE FROM github_task_prs WHERE task_id = ? AND repository_id = ?`,
tp.TaskID, tp.RepositoryID); err != nil {
`DELETE FROM github_task_prs
WHERE task_id = ? AND repository_id = ? AND pr_number = ?`,
tp.TaskID, tp.RepositoryID, tp.PRNumber); err != nil {
return err
}
} else {
if _, err := tx.ExecContext(ctx,
`DELETE FROM github_task_prs WHERE task_id = ? AND repository_id = ''`, tp.TaskID); err != nil {
`DELETE FROM github_task_prs
WHERE task_id = ? AND repository_id = '' AND pr_number = ?`,
tp.TaskID, tp.PRNumber); err != nil {
return err
}
}
@@ -83,7 +83,17 @@ func TestTaskPR_PerRepoStorage(t *testing.T) {
}
}
func TestTaskPR_ReplaceTaskPR_ScopedByRepository(t *testing.T) {
// TestTaskPR_ReplaceTaskPR_PreservesSiblingsAndUpserts locks the
// multi-branch upsert semantics of ReplaceTaskPR. The function now
// upserts on (task_id, repository_id, pr_number) instead of wiping all
// rows for (task, repo). Effects:
//
// - Different repo (repo-b) stays untouched.
// - Different PR number on the same repo (repo-a, pr=1) stays as a
// sibling — multi-branch tasks need this so two PRs on the same repo
// coexist instead of one clobbering the other.
// - Same (task, repo, pr_number) is overwritten (the upsert).
func TestTaskPR_ReplaceTaskPR_PreservesSiblingsAndUpserts(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
@@ -92,36 +102,40 @@ func TestTaskPR_ReplaceTaskPR_ScopedByRepository(t *testing.T) {
TaskID: "task-2", RepositoryID: "repo-a",
Owner: "o", Repo: "r", PRNumber: 1, CreatedAt: now,
}); err != nil {
t.Fatalf("create A: %v", err)
t.Fatalf("create A#1: %v", err)
}
if err := store.CreateTaskPR(ctx, &TaskPR{
TaskID: "task-2", RepositoryID: "repo-b",
Owner: "o", Repo: "r2", PRNumber: 2, CreatedAt: now,
}); err != nil {
t.Fatalf("create B: %v", err)
t.Fatalf("create B#2: %v", err)
}
// Replace only repo-a's PR — repo-b must survive.
// Add a new PR #99 on repo-a — must NOT delete repo-a's PR #1
// (multi-branch) nor repo-b's #2 (multi-repo).
if err := store.ReplaceTaskPR(ctx, &TaskPR{
TaskID: "task-2", RepositoryID: "repo-a",
Owner: "o", Repo: "r", PRNumber: 99, CreatedAt: now,
}); err != nil {
t.Fatalf("replace A: %v", err)
t.Fatalf("replace A#99: %v", err)
}
all, _ := store.ListTaskPRsByTask(ctx, "task-2")
if len(all) != 2 {
t.Fatalf("expected 2 PRs after scoped replace, got %d", len(all))
if len(all) != 3 {
t.Fatalf("expected 3 PR rows (multi-branch siblings + multi-repo), got %d", len(all))
}
bySpec := map[string]int{}
for _, p := range all {
bySpec[p.RepositoryID] = p.PRNumber
// Now overwrite repo-a #99 by calling ReplaceTaskPR with the same
// (task, repo, pr_number) and a different field — upsert path.
if err := store.ReplaceTaskPR(ctx, &TaskPR{
TaskID: "task-2", RepositoryID: "repo-a",
Owner: "o", Repo: "r", PRNumber: 99, PRTitle: "updated", CreatedAt: now,
}); err != nil {
t.Fatalf("upsert A#99: %v", err)
}
if bySpec["repo-a"] != 99 {
t.Errorf("expected repo-a updated to 99, got %d", bySpec["repo-a"])
}
if bySpec["repo-b"] != 2 {
t.Errorf("expected repo-b unchanged at 2, got %d", bySpec["repo-b"])
all, _ = store.ListTaskPRsByTask(ctx, "task-2")
if len(all) != 3 {
t.Fatalf("expected 3 rows after upsert, got %d", len(all))
}
}
+81 -1
View File
@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/kandev/kandev/internal/agent/mcpconfig"
@@ -164,6 +165,7 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) {
d.RegisterFunc(ws.ActionMCPListTasks, h.handleListTasks)
d.RegisterFunc(ws.ActionMCPCreateTask, h.handleCreateTask)
d.RegisterFunc(ws.ActionMCPUpdateTask, h.handleUpdateTask)
d.RegisterFunc(ws.ActionMCPAddBranchToTask, h.handleAddBranchToTask)
d.RegisterFunc(ws.ActionMCPMessageTask, h.handleMessageTask)
d.RegisterFunc(ws.ActionMCPGetTaskConversation, h.handleGetTaskConversation)
d.RegisterFunc(ws.ActionMCPAskUserQuestion, h.handleAskUserQuestion)
@@ -172,7 +174,7 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) {
d.RegisterFunc(ws.ActionMCPUpdateTaskPlan, h.handleUpdateTaskPlan)
d.RegisterFunc(ws.ActionMCPDeleteTaskPlan, h.handleDeleteTaskPlan)
d.RegisterFunc(ws.ActionMCPClarificationTimeout, h.handleClarificationTimeout)
count := 14
count := 15
// Config-mode handlers (registered when config deps are set)
if h.workflowSvc != nil {
@@ -723,6 +725,73 @@ func (h *Handlers) handleUpdateTask(ctx context.Context, msg *ws.Message) (*ws.M
return ws.NewResponse(msg.ID, msg.Action, dto.FromTask(task))
}
// handleAddBranchToTask attaches a new (repository, checkout_branch) pair to
// an existing task. Mirrors create-time multi-repo attachment but additive:
// the same repository may be added on a different branch, materializing a
// second worktree under the task's directory.
func (h *Handlers) handleAddBranchToTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
TaskID string `json:"task_id"`
RepositoryID string `json:"repository_id"`
BaseBranch string `json:"base_branch"`
CheckoutBranch string `json:"checkout_branch"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.TaskID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
// repository_id is optional: the service defaults to the task's only
// repository (or its primary row) when omitted. Multi-repo tasks force
// the agent to pass one explicitly via the service-level error.
taskRepo, err := h.taskSvc.AddBranchToTask(ctx, service.AddBranchToTaskRequest{
TaskID: req.TaskID,
RepositoryID: req.RepositoryID,
BaseBranch: req.BaseBranch,
CheckoutBranch: req.CheckoutBranch,
})
if err != nil {
h.logger.Error("failed to add branch to task", zap.Error(err))
code := classifyAddBranchError(err)
return ws.NewError(msg.ID, msg.Action, code, "Failed to add branch: "+err.Error(), nil)
}
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"id": taskRepo.ID,
keyTaskID: taskRepo.TaskID,
keyRepositoryID: taskRepo.RepositoryID,
keyBaseBranch: taskRepo.BaseBranch,
keyCheckoutBranch: taskRepo.CheckoutBranch,
keyPosition: taskRepo.Position,
})
}
// classifyAddBranchError maps service-layer add_branch failures to ws error
// codes so MCP agents can react to user-fixable input mistakes (missing
// task, duplicate branch, wrong executor) instead of treating them as
// backend faults.
func classifyAddBranchError(err error) string {
if err == nil {
return ws.ErrorCodeInternalError
}
if errors.Is(err, taskrepo.ErrTaskNotFound) {
return ws.ErrorCodeNotFound
}
msg := err.Error()
switch {
case strings.Contains(msg, "does not belong to task's workspace"):
return ws.ErrorCodeNotFound
case strings.Contains(msg, "is already attached"),
strings.Contains(msg, "conflicts with existing branch"):
return ws.ErrorCodeConflict
case strings.Contains(msg, "repository_id is required"),
strings.Contains(msg, "only supported on the worktree executor"),
strings.Contains(msg, "task_id is required"):
return ws.ErrorCodeValidation
}
return ws.ErrorCodeInternalError
}
// handleMessageTask sends a prompt to an existing task on behalf of an agent
// in another task. The MCP server (agentctl) injects the sender's task_id and
// session_id into the payload; this handler validates the sender, looks up its
@@ -984,6 +1053,17 @@ func (e *queueFullDispatchError) toPayload() map[string]interface{} {
// codes in MCP tool responses (extracted to satisfy goconst's repeated-string rule).
const errorField = "error"
// MCP payload / response keys reused across multiple handlers. Extracted so
// goconst doesn't flag the literals as repeated, and so a future rename of
// a wire-protocol key updates every handler in one place.
const (
keyTaskID = "task_id"
keyRepositoryID = "repository_id"
keyBaseBranch = "base_branch"
keyCheckoutBranch = "checkout_branch"
keyPosition = "position"
)
// dispatchTaskMessage routes a message to the right delivery path based on session state.
// Returns the action taken: "queued", "sent", or "started".
//
@@ -11,6 +11,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/common/logger"
ws "github.com/kandev/kandev/pkg/websocket"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"go.uber.org/zap"
@@ -39,6 +40,16 @@ const (
ModeOffice = "office"
)
// MCP payload keys reused across tool registrations. Extracted so a future
// wire-protocol rename touches every tool in one place AND so goconst
// doesn't flag the literals as repeated string occurrences.
const (
mcpKeyTaskID = "task_id"
mcpKeyRepositoryID = "repository_id"
mcpKeyBaseBranch = "base_branch"
mcpKeyCheckoutBranch = "checkout_branch"
)
// normalizeMode returns a valid MCP mode, defaulting unknown values to ModeTask.
func normalizeMode(mode string) string {
switch mode {
@@ -342,6 +353,10 @@ func (s *Server) registerTools() {
count += 4
s.registerRelatedTasksTool()
count++
// Task-mode only: requires a live session to attach the new
// (repository, branch) to. External mode has no such context.
s.registerAddBranchToTaskTool()
count++
}
s.logger.Info("registered MCP tools",
zap.String("mode", s.mode),
@@ -529,6 +544,57 @@ IMPORTANT:
)
}
// registerAddBranchToTaskTool registers add_branch_to_task_kandev. Scoped to
// task mode only — external coding agents have no live session context to
// attach the new worktree to, and shipping this tool through the shared
// create-task path would silently widen the external surface.
func (s *Server) registerAddBranchToTaskTool() {
s.mcpServer.AddTool(
mcp.NewTool("add_branch_to_task_kandev",
mcp.WithDescription(`Attach an additional (repository, branch) worktree to an existing task.
Use this when the task should open more than one PR — same repo with different branches, or a second repository entirely. The new branch gets its own worktree under the task directory and behaves like any other multi-repo entry for changes, PRs, and review surfaces.
IMPORTANT:
- Only works on tasks running the WORKTREE executor. Tasks on docker / sprites / local-pc / SSH / remote_docker reject this tool because sibling worktrees are a git-worktree-specific layout — other executors bind one workspace path per task and the new branch would silently never appear on disk.
- task_id defaults to your CURRENT task when omitted — pass it explicitly only to target a different task.
- repository_id is OPTIONAL when the task has a single repository attached (the common case). The service auto-resolves to that repo. Multi-repo tasks must pass it explicitly.
- checkout_branch is the branch the new worktree will check out. Leave empty to create a fresh feature branch from base_branch.
- base_branch is optional; defaults to the repository's default_branch.
- The (task_id, repository_id, base_branch, checkout_branch) tuple must be unique on the task — re-adding the same combination is an error, not a no-op.`),
mcp.WithString("task_id", mcp.Description("The task to attach the branch to. Defaults to the current task when omitted.")),
mcp.WithString("repository_id", mcp.Description("Repository to attach. Optional for single-repo tasks (auto-resolved). Required for multi-repo tasks.")),
mcp.WithString("checkout_branch", mcp.Description("Existing branch to check out in the new worktree (e.g. a PR head branch). Empty to create a fresh feature branch from base_branch.")),
mcp.WithString("base_branch", mcp.Description("Branch to base the worktree on. Defaults to the repository's default_branch.")),
),
s.wrapHandler("add_branch_to_task_kandev", s.addBranchToTaskHandler()),
)
}
func (s *Server) addBranchToTaskHandler() server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
taskID := req.GetString(mcpKeyTaskID, "")
if taskID == "" {
taskID = s.taskID
}
if taskID == "" {
return mcp.NewToolResultError("task_id is required (no current task context to default to)"), nil
}
payload := map[string]interface{}{
mcpKeyTaskID: taskID,
mcpKeyRepositoryID: req.GetString(mcpKeyRepositoryID, ""),
mcpKeyCheckoutBranch: req.GetString(mcpKeyCheckoutBranch, ""),
mcpKeyBaseBranch: req.GetString(mcpKeyBaseBranch, ""),
}
var result map[string]interface{}
if err := s.backend.RequestPayload(ctx, ws.ActionMCPAddBranchToTask, payload, &result); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
data, _ := json.MarshalIndent(result, "", " ")
return mcp.NewToolResultText(string(data)), nil
}
}
func (s *Server) registerInteractionTools() {
s.mcpServer.AddTool(
mcp.NewTool("ask_user_question_kandev",
@@ -210,9 +210,9 @@ func TestServerModeTask_ToolCount(t *testing.T) {
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask)
tools := getRegisteredToolNames(s)
// 13 kanban (incl. delete + archive task) + 1 interaction + 4 plan
// + 1 related-tasks = 19. Task-document tools (list/get/write) are office-only.
assert.Equal(t, 19, len(tools))
// 13 kanban (incl. delete + archive task) + 1 add_branch_to_task + 1 interaction
// + 4 plan + 1 related-tasks = 20. Task-document tools (list/get/write) are office-only.
assert.Equal(t, 20, len(tools))
}
func TestServerModeConfig_ToolCount(t *testing.T) {
@@ -366,8 +366,10 @@ func TestServerModeExternal_ToolCount(t *testing.T) {
s := New(backend, "", "", 0, log, "", true, ModeExternal)
tools := getRegisteredToolNames(s)
// 12 workflow (incl. list_repositories + import_workflow) + 4 agent + 4 mcp + 5 executor + 6 task + 1 create_task = 32
// 12 workflow (incl. list_repositories + import_workflow) + 4 agent + 4 mcp + 5 executor + 6 task + 1 create_task = 32.
// add_branch_to_task_kandev is task-mode only — external coding agents have no live session to attach a worktree to.
assert.Equal(t, 32, len(tools))
assert.NotContains(t, tools, "add_branch_to_task_kandev")
}
func TestNewExternal_Constructs(t *testing.T) {
@@ -34,6 +34,7 @@ type GitHubService interface {
EnsurePRWatch(ctx context.Context, sessionID, taskID, repositoryID, owner, repo, branch string) (*github.PRWatch, error)
GetPRWatchBySession(ctx context.Context, sessionID string) (*github.PRWatch, error)
GetPRWatchBySessionAndRepo(ctx context.Context, sessionID, repositoryID string) (*github.PRWatch, error)
GetPRWatchBySessionRepoAndBranch(ctx context.Context, sessionID, repositoryID, branch string) (*github.PRWatch, error)
UpdatePRWatchBranchIfSearching(ctx context.Context, id, branch string) error
UpdatePRWatchPRNumber(ctx context.Context, id string, prNumber int) error
ResetPRWatch(ctx context.Context, id, branch string) error
@@ -408,12 +409,14 @@ func (s *Service) detectPushAndAssociatePR(
return
}
// Check if we already have a watch for this (session, repo).
// Check if we already have a watch for this (session, repo, branch).
// Multi-branch: keying by branch as well means a secondary branch's
// push doesn't get short-circuited by the primary's already-found
// watch (which previously dropped #1218-style PRs on the floor).
// If the watch already has a PR number, the PR was found — nothing to do.
// If the watch has pr_number=0, it's still searching — do an immediate
// search (faster than waiting for the 1-minute poller) and update the
// watch branch if the agent pushed from a different branch.
existing, err := s.githubService.GetPRWatchBySessionAndRepo(ctx, sessionID, repositoryID)
// search (faster than waiting for the 1-minute poller).
existing, err := s.githubService.GetPRWatchBySessionRepoAndBranch(ctx, sessionID, repositoryID, branch)
if err == nil && existing != nil {
if existing.PRNumber > 0 {
return // PR already found and being monitored
@@ -432,7 +435,7 @@ func (s *Service) detectPushAndAssociatePR(
case <-time.After(delay):
}
// Re-check if a watch was created in the meantime (e.g. by CreatePR callback)
if ex, err := s.githubService.GetPRWatchBySessionAndRepo(ctx, sessionID, repositoryID); err == nil && ex != nil {
if ex, err := s.githubService.GetPRWatchBySessionRepoAndBranch(ctx, sessionID, repositoryID, branch); err == nil && ex != nil {
return
}
}
@@ -462,9 +465,16 @@ func (s *Service) detectPushAndAssociatePR(
}
// resolvePushRepo returns (owner, name, repository_id) for the per-repo push
// detection. Multi-repo path: looks up the task_repository whose repository's
// Name matches `repositoryName`. Empty name falls back to the session's
// primary repo (legacy single-repo behaviour).
// detection.
//
// - Empty repositoryName → session's primary repo (legacy single-repo).
// - Exact match on repoObj.Name → multi-repo path: that repository.
// - Subdir prefix match (`<repo.Name>-<branch-slug>`) → multi-branch path:
// the named subdir belongs to a sibling worktree of the same repo, so
// resolve back to that repository's id. Without this fallback the
// secondary branch's push event arrives with a tracker-tag that no
// `task_repositories` row matches, push detection short-circuits, and
// the secondary PR never registers.
func (s *Service) resolvePushRepo(
ctx context.Context, sessionID, taskID, repositoryName string,
) (owner, repo, repositoryID string) {
@@ -480,12 +490,36 @@ func (s *Service) resolvePushRepo(
if err != nil {
return "", "", ""
}
// Two passes: exact name first so a repo whose name is a prefix of a
// sibling (e.g. "backend" vs "backend-admin") doesn't swallow a push
// tagged with the longer name via isMultiBranchSubdir.
if owner, repo, id := matchPushRepo(ctx, s, store, links, repositoryName, true); id != "" {
return owner, repo, id
}
return matchPushRepo(ctx, s, store, links, repositoryName, false)
}
// matchPushRepo walks links once and returns the first matching repo. When
// exactOnly is true only Name == repositoryName matches; otherwise the
// multi-branch subdir prefix is also accepted.
func matchPushRepo(
ctx context.Context,
s *Service,
store repoStore,
links []*models.TaskRepository,
repositoryName string,
exactOnly bool,
) (owner, repo, repositoryID string) {
for _, link := range links {
repoObj, err := store.GetRepository(ctx, link.RepositoryID)
if err != nil || repoObj == nil {
continue
}
if repoObj.Name != repositoryName {
matched := repoObj.Name == repositoryName
if !matched && !exactOnly {
matched = isMultiBranchSubdir(repositoryName, repoObj.Name)
}
if !matched {
continue
}
if repoObj.ProviderOwner == "" && repoObj.LocalPath != "" {
@@ -501,6 +535,18 @@ func (s *Service) resolvePushRepo(
return "", "", ""
}
// isMultiBranchSubdir reports whether subdir is a multi-branch sibling of
// repoName (formatted as `<repoName>-<slug>`). Used by resolvePushRepo to
// route a tracker event tagged with the subdir name back to the underlying
// repository row.
func isMultiBranchSubdir(subdir, repoName string) bool {
if repoName == "" {
return false
}
prefix := repoName + "-"
return len(subdir) > len(prefix) && subdir[:len(prefix)] == prefix
}
// searchPRForExistingWatch handles the case where a PR watch exists with pr_number=0
// (still searching). It updates the watch branch if the agent pushed from a different
// branch, then does a single immediate search so we don't wait for the 1-minute poller.
@@ -651,6 +697,17 @@ func (s *Service) resolveSessionWatchTargets(
if !ok {
return nil
}
// Multi-branch: when a session has TWO OR MORE worktrees on the same
// repository, derive targets from the worktree rows directly. Each
// worktree's actual branch (worktree_branch) is the right key because
// task_repositories.checkout_branch may differ when the worktree
// manager suffixed a collision, and the agent pushes the actual name.
// Single-branch tasks (including PR-review tasks with a synthetic
// worktree branch + an authoritative checkout_branch) fall through to
// the legacy task_repositories walk.
if targets := s.targetsFromMultiBranchWorktrees(ctx, store, sessionID); len(targets) > 0 {
return targets
}
taskRepos, err := store.ListTaskRepositories(ctx, taskID)
if err != nil || len(taskRepos) == 0 {
return nil
@@ -683,6 +740,59 @@ func (s *Service) resolveSessionWatchTargets(
return targets
}
// targetsFromMultiBranchWorktrees emits one target per task_session_worktrees
// row, but ONLY when at least one repository has more than one worktree —
// the multi-branch case. The worktree's actual branch (worktree_branch) is
// the authoritative key because the worktree manager may suffix a
// requested checkout_branch on collision, and the agent pushes the
// suffixed name; using task_repositories.checkout_branch would search
// GitHub for a branch name that doesn't exist.
//
// Returns nil for single-branch sessions so callers keep the legacy
// task_repositories walk (which honors PR-review tasks' authoritative
// checkout_branch over their synthetic worktree branch).
func (s *Service) targetsFromMultiBranchWorktrees(ctx context.Context, store repoStore, sessionID string) []sessionWatchTarget {
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil || session == nil || len(session.Worktrees) < 2 {
return nil
}
repoCount := make(map[string]int, len(session.Worktrees))
for _, wt := range session.Worktrees {
if wt.RepositoryID == "" {
continue
}
repoCount[wt.RepositoryID]++
}
multiBranch := false
for _, n := range repoCount {
if n > 1 {
multiBranch = true
break
}
}
if !multiBranch {
return nil
}
var targets []sessionWatchTarget
for _, wt := range session.Worktrees {
branch := strings.TrimSpace(wt.WorktreeBranch)
if branch == "" || wt.RepositoryID == "" {
continue
}
owner, repoName := s.resolveRepoOwnerName(ctx, store, wt.RepositoryID)
if owner == "" || repoName == "" {
continue
}
targets = append(targets, sessionWatchTarget{
RepositoryID: wt.RepositoryID,
Owner: owner,
Repo: repoName,
Branch: branch,
})
}
return targets
}
// branchByRepoForSession returns repository_id → worktree_branch for every
// worktree on the session. Empty map if the session can't be loaded — caller
// falls back to the task_repository's configured branch.
@@ -922,7 +1032,7 @@ func (s *Service) buildTaskBranchList(ctx context.Context, store repoStore) ([]g
// come from session.Worktrees inside resolveSessionWatchTargets.
targets := s.resolveSessionWatchTargets(ctx, sess.TaskID, sess.SessionID, sess.Branch)
for _, t := range targets {
if watchedKeys[watchedSessionRepoKey(sess.SessionID, t.RepositoryID)] {
if watchedKeys[watchedSessionRepoKey(sess.SessionID, t.RepositoryID, t.Branch)] {
continue
}
result = append(result, github.TaskBranchInfo{
@@ -938,9 +1048,11 @@ func (s *Service) buildTaskBranchList(ctx context.Context, store repoStore) ([]g
return result, nil
}
// buildWatchedSessionRepoSet returns the set of (session_id, repository_id)
// pairs that already have a PR watch row. Replaces the older session-only set
// which collapsed multi-repo sessions into one bucket.
// buildWatchedSessionRepoSet returns the set of (session_id, repository_id,
// branch) triples that already have a PR watch row. Multi-branch tasks
// hold one watch per (session, repo, branch); dedup keyed on
// (session, repo) alone would silently drop secondary branches whenever
// the primary already had a watch.
func (s *Service) buildWatchedSessionRepoSet(ctx context.Context) map[string]bool {
watches, err := s.githubService.ListActivePRWatches(ctx)
if err != nil {
@@ -949,14 +1061,14 @@ func (s *Service) buildWatchedSessionRepoSet(ctx context.Context) map[string]boo
}
set := make(map[string]bool, len(watches))
for _, w := range watches {
set[watchedSessionRepoKey(w.SessionID, w.RepositoryID)] = true
set[watchedSessionRepoKey(w.SessionID, w.RepositoryID, w.Branch)] = true
}
return set
}
// watchedSessionRepoKey builds the per-(session, repo) dedup key.
func watchedSessionRepoKey(sessionID, repositoryID string) string {
return sessionID + "|" + repositoryID
// watchedSessionRepoKey builds the per-(session, repo, branch) dedup key.
func watchedSessionRepoKey(sessionID, repositoryID, branch string) string {
return sessionID + "|" + repositoryID + "|" + branch
}
// subscribeGitHubEvents subscribes to GitHub-related events on the event bus.
@@ -0,0 +1,33 @@
package orchestrator
import "testing"
// TestIsMultiBranchSubdir guards the push-event-to-repo resolution. Multi-
// branch tasks lay sibling worktrees out as `<repo.Name>-<branch-slug>/`.
// When agentctl's per-repo tracker emits a push event tagged with that
// subdir name, the orchestrator must recognize it as belonging to the
// underlying repository — otherwise `resolvePushRepo` returns empty,
// detect-and-associate short-circuits, and the secondary PR never lands
// in github_task_prs.
func TestIsMultiBranchSubdir(t *testing.T) {
cases := []struct {
subdir string
repoName string
want bool
}{
{"kandev", "kandev", false}, // exact match handled elsewhere
{"kandev-feature-x", "kandev", true}, // sibling worktree
{"kandev-branch-2", "kandev", true}, // auto-named secondary
{"kandev-feature/fibo-iter", "kandev", true}, // unusual slug forms
{"kandev2", "kandev", false}, // not a sibling (no separator)
{"other-repo", "kandev", false}, // unrelated
{"", "kandev", false}, // empty event tag
{"kandev-x", "", false}, // empty repo name
{"kandev-", "kandev", false}, // separator only, no slug
}
for _, c := range cases {
if got := isMultiBranchSubdir(c.subdir, c.repoName); got != c.want {
t.Errorf("isMultiBranchSubdir(%q, %q) = %v, want %v", c.subdir, c.repoName, got, c.want)
}
}
}
@@ -66,6 +66,9 @@ func (m *mockGitHubService) GetPRWatchBySession(_ context.Context, _ string) (*g
func (m *mockGitHubService) GetPRWatchBySessionAndRepo(_ context.Context, _, _ string) (*github.PRWatch, error) {
return m.prWatch, nil
}
func (m *mockGitHubService) GetPRWatchBySessionRepoAndBranch(_ context.Context, _, _, _ string) (*github.PRWatch, error) {
return m.prWatch, nil
}
func (m *mockGitHubService) CreatePRWatch(_ context.Context, _, _, repositoryID, _, _ string, _ int, branch string) (*github.PRWatch, error) {
m.createWatchCalls++
m.createWatchBranch = branch
@@ -337,6 +337,11 @@ 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
// orchestrator when buildRepoSpecs detects multiple rows sharing a
// RepositoryID; empty otherwise to preserve the single-branch layout.
BranchSlug string
}
// McpModeConfig activates config-mode MCP tools (workflow steps, agents, MCP
@@ -706,7 +706,20 @@ 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.
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))
out := make([]RepoSpec, 0, len(allRepos))
for _, info := range allRepos {
spec := RepoSpec{
@@ -732,6 +745,21 @@ 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
}
seenCount[info.RepositoryID]++
out = append(out, spec)
}
return out
@@ -46,6 +46,12 @@ func (r *Repository) runMigrations() error {
r.migrate.Apply("executors_running.metadata", `ALTER TABLE executors_running ADD COLUMN metadata TEXT DEFAULT '{}'`)
r.migrate.Apply("tasks.is_ephemeral", `ALTER TABLE tasks ADD COLUMN is_ephemeral INTEGER NOT NULL DEFAULT 0`)
r.migrate.Apply("task_repositories.checkout_branch", `ALTER TABLE task_repositories ADD COLUMN checkout_branch TEXT DEFAULT ''`)
// Multi-branch support: drop the old UNIQUE(task_id, repository_id) and
// replace it with UNIQUE(task_id, repository_id, checkout_branch) so the
// same repo can appear multiple times in a task on different branches.
if err := r.migrateTaskRepositoriesAllowMultiBranch(); err != nil {
return err
}
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 ''`)
@@ -369,6 +375,55 @@ func (r *Repository) migrateTaskEnvironmentsRemoveAgentExecutionID() error {
})
}
// 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
// key lets the same repo coexist on N branches per task, including the
// worktree-executor case where the branch lives in base_branch and
// checkout_branch is empty. The trigger phrase matches both the legacy
// two-column constraint and the intermediate three-column variant added in
// the first multi-branch landing; the recreate becomes a no-op once the
// four-column constraint is in place.
func (r *Repository) migrateTaskRepositoriesAllowMultiBranch() error {
if err := r.recreateTaskRepositoriesForMultiBranch("UNIQUE(task_id, repository_id)\n"); err != nil {
return err
}
return r.recreateTaskRepositoriesForMultiBranch("UNIQUE(task_id, repository_id, checkout_branch)")
}
func (r *Repository) recreateTaskRepositoriesForMultiBranch(trigger string) error {
return r.recreateTableNamed(
"task_repositories.recreate_allow_multi_branch",
"task_repositories",
trigger,
[]string{
`CREATE TABLE task_repositories_new (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
repository_id TEXT NOT NULL,
base_branch TEXT DEFAULT '',
checkout_branch TEXT DEFAULT '',
position INTEGER DEFAULT 0,
metadata TEXT DEFAULT '{}',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE,
UNIQUE(task_id, repository_id, base_branch, checkout_branch)
)`,
`INSERT INTO task_repositories_new SELECT
id, task_id, repository_id, base_branch,
COALESCE(checkout_branch, ''),
position, metadata, created_at, updated_at
FROM task_repositories`,
`DROP TABLE task_repositories`,
`ALTER TABLE task_repositories_new RENAME TO task_repositories`,
`CREATE INDEX IF NOT EXISTS idx_task_repositories_task_id ON task_repositories(task_id)`,
`CREATE INDEX IF NOT EXISTS idx_task_repositories_repository_id ON task_repositories(repository_id)`,
},
)
}
// migrateSessionsRemoveWorkflowStepID removes the deprecated workflow_step_id column
// from task_sessions. Workflow step is now tracked on the task, not the session.
func (r *Repository) migrateSessionsRemoveWorkflowStepID() error {
@@ -243,13 +243,14 @@ func (r *Repository) initTaskSchema() error {
task_id TEXT NOT NULL,
repository_id TEXT NOT NULL,
base_branch TEXT DEFAULT '',
checkout_branch TEXT DEFAULT '',
position INTEGER DEFAULT 0,
metadata TEXT DEFAULT '{}',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE,
UNIQUE(task_id, repository_id)
UNIQUE(task_id, repository_id, base_branch, checkout_branch)
);
CREATE TABLE IF NOT EXISTS repositories (
@@ -42,6 +42,23 @@ type TaskExecutionStopper interface {
StopExecution(ctx context.Context, executionID, reason string, force bool) error
}
// BranchMaterializer creates a worktree on disk and persists a
// task_session_worktrees row for a newly added task_repository row, without
// restarting the agent. Used by AddBranchToTask so MCP-driven "add a branch
// to this task" actually surfaces the new worktree in the UI on the next
// poll, rather than waiting for a session relaunch.
//
// The implementation lives in cmd/kandev (it needs worktree.Manager, the
// session/env repos, and the repository entity layer) — the service layer
// only knows the abstract capability.
type BranchMaterializer interface {
// MaterializeBranch creates the worktree for a freshly inserted
// task_repositories row. Best-effort: when no active session exists yet
// the implementation may choose to no-op and let the next session launch
// create the worktree via the standard multi-repo prepare path.
MaterializeBranch(ctx context.Context, taskID, taskRepositoryID string) error
}
// GitArchiveCapture captures git state (commits, cumulative diff) when a task is archived.
// This allows preserving the final git state of a session for historical purposes.
type GitArchiveCapture interface {
@@ -130,6 +147,7 @@ type Service struct {
discoveryConfig RepositoryDiscoveryConfig
worktreeCleanup WorktreeCleanup
executionStopper TaskExecutionStopper
branchMaterializer BranchMaterializer
gitArchiveCapture GitArchiveCapture
workflowStepCreator WorkflowStepCreator
workflowStepGetter WorkflowStepGetter
@@ -172,6 +190,13 @@ func (s *Service) SetWorktreeCleanup(cleanup WorktreeCleanup) {
s.worktreeCleanup = cleanup
}
// SetBranchMaterializer wires the mid-session worktree materializer for
// AddBranchToTask. Optional — when unset, MCP add_branch only inserts the
// task_repositories row and the worktree appears on next session launch.
func (s *Service) SetBranchMaterializer(m BranchMaterializer) {
s.branchMaterializer = m
}
// SetExecutionStopper wires the task execution stopper (orchestrator).
func (s *Service) SetExecutionStopper(stopper TaskExecutionStopper) {
s.executionStopper = stopper
@@ -0,0 +1,349 @@
package service
import (
"context"
"fmt"
"strings"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/task/models"
taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite"
"github.com/kandev/kandev/internal/worktree"
)
// AddBranchToTaskRequest carries the parameters for adding a new branch
// (worktree) to an existing task. The (RepositoryID, CheckoutBranch) pair
// must not already exist on the task.
type AddBranchToTaskRequest struct {
TaskID string
RepositoryID string
BaseBranch string
CheckoutBranch string
}
// AddBranchToTask appends a new task_repositories row to an existing task,
// effectively adding a branch (or a second branch of an already-attached repo)
// without recreating the task. The row's position is set to the next free
// slot after the highest existing position.
//
// Returns the persisted TaskRepository on success.
//
// Constraints:
// - TaskID is required.
// - RepositoryID is optional. When omitted, the service defaults to the
// task's existing repository (the only one for single-repo tasks; the
// primary by lowest position otherwise). Multi-repo tasks must pass an
// explicit RepositoryID — defaulting would be ambiguous.
// - The (TaskID, RepositoryID, BaseBranch, CheckoutBranch) tuple must be
// unique on the task — duplicates surface as a typed error so callers
// can render a user-friendly message instead of a raw DB error.
// - The repository must belong to the task's workspace; the caller is
// responsible for that check (service_tasks.resolveRepoInput enforces it
// during create, this method assumes the same upstream gating).
func (s *Service) AddBranchToTask(ctx context.Context, req AddBranchToTaskRequest) (*models.TaskRepository, error) {
task, existing, err := s.prepareBranchAdd(ctx, &req)
if err != nil {
return nil, err
}
repo, err := s.resolveBranchRepo(ctx, task, req.RepositoryID)
if err != nil {
return nil, err
}
baseBranch := req.BaseBranch
if baseBranch == "" {
baseBranch = repo.DefaultBranch
}
checkoutBranch := resolveCheckoutBranchOrAutoName(req.CheckoutBranch, existing, req.RepositoryID, baseBranch)
nextPosition, dupErr := scanForBranchAddDuplicate(existing, req.RepositoryID, baseBranch, checkoutBranch, repo)
if dupErr != nil {
return nil, dupErr
}
taskRepo := &models.TaskRepository{
TaskID: req.TaskID,
RepositoryID: req.RepositoryID,
BaseBranch: baseBranch,
CheckoutBranch: checkoutBranch,
Position: nextPosition,
Metadata: map[string]interface{}{},
}
if err := s.taskRepos.CreateTaskRepository(ctx, taskRepo); err != nil {
s.logger.Error("AddBranchToTask: failed to create task repository",
zap.String("task_id", req.TaskID),
zap.String("repository_id", req.RepositoryID),
zap.String("checkout_branch", req.CheckoutBranch),
zap.Error(err))
return nil, fmt.Errorf("create task repository: %w", err)
}
// Publish task.updated so the kanban / sidebar re-render with the new row.
s.publishTaskEvent(ctx, events.TaskUpdated, task, nil)
s.materializeBranchBestEffort(ctx, req.TaskID, taskRepo.ID)
return taskRepo, nil
}
// prepareBranchAdd runs the input validation, executor-shield check, and
// repository_id defaulting that AddBranchToTask needs before it can pick a
// branch name and write the row. Returns the loaded task plus the current
// task_repositories list so the caller doesn't re-query.
//
// Mutates req.RepositoryID when it was omitted and a single-repo default
// applies; multi-repo tasks return an error here instead of silently
// guessing the wrong repo.
func (s *Service) prepareBranchAdd(ctx context.Context, req *AddBranchToTaskRequest) (*models.Task, []*models.TaskRepository, error) {
if req.TaskID == "" {
return nil, nil, fmt.Errorf("task_id is required")
}
task, err := s.tasks.GetTask(ctx, req.TaskID)
if err != nil {
return nil, nil, fmt.Errorf("get task: %w", err)
}
if task == nil {
// Wrap the repo-tier sentinel so handlers can classify via errors.Is
// rather than substring-matching the formatted UUID.
return nil, nil, fmt.Errorf("%w: %s", taskrepo.ErrTaskNotFound, req.TaskID)
}
if err := s.requireWorktreeExecutorForBranchAdd(ctx, req.TaskID); err != nil {
return nil, nil, err
}
existing, err := s.taskRepos.ListTaskRepositories(ctx, req.TaskID)
if err != nil {
return nil, nil, fmt.Errorf("list existing branches: %w", err)
}
if req.RepositoryID == "" {
req.RepositoryID, err = s.defaultRepositoryIDForTask(existing)
if err != nil {
return nil, nil, err
}
}
return task, existing, nil
}
// resolveBranchRepo loads the Repository for req.RepositoryID and verifies
// it belongs to the task's workspace. Extracted from AddBranchToTask so the
// repository / workspace check stays separated from the row-uniqueness
// logic for the linter's complexity budget.
func (s *Service) resolveBranchRepo(ctx context.Context, task *models.Task, repositoryID string) (*models.Repository, error) {
repo, err := s.repoEntities.GetRepository(ctx, repositoryID)
if err != nil {
return nil, fmt.Errorf("get repository: %w", err)
}
if repo == nil || repo.WorkspaceID != task.WorkspaceID {
return nil, fmt.Errorf("repository %q does not belong to task's workspace", repositoryID)
}
return repo, nil
}
// resolveCheckoutBranchOrAutoName picks the branch name for the new
// task_repositories row. When the caller passed an explicit value it's
// taken verbatim; when omitted AND a row for (repo, base, "") already
// exists, the helper auto-names the new row branch-N so add_branch is
// ergonomic for agents that only express intent ("make a new branch").
func resolveCheckoutBranchOrAutoName(
requested string,
existing []*models.TaskRepository,
repositoryID, baseBranch string,
) string {
if requested != "" {
return requested
}
if hasRowForRepoBase(existing, repositoryID, baseBranch) {
return autoBranchName(existing, repositoryID, baseBranch)
}
return ""
}
// scanForBranchAddDuplicate walks the existing task_repositories rows once
// to compute the next position AND surface a typed duplicate error when
// the prospective (repo, base, checkout) tuple already exists. The single
// pass keeps the hot AddBranchToTask path compact and the linter happy
// (the inline version pushed the parent over the cyclop budget).
func scanForBranchAddDuplicate(
existing []*models.TaskRepository,
repositoryID, baseBranch, checkoutBranch string,
repo *models.Repository,
) (nextPosition int, dupErr error) {
// Branches whose names differ only in unsafe characters (e.g. "feat/a" vs
// "feat-a") sanitize to the same on-disk slug and would collide at
// `git worktree add`. SanitizeBranchSlug documents that the service layer
// must reject those duplicates — the exact-string check below would let
// both rows land in task_repositories.
newSlug := worktree.SanitizeBranchSlug(checkoutBranch)
for _, tr := range existing {
if tr.RepositoryID == repositoryID &&
tr.BaseBranch == baseBranch &&
tr.CheckoutBranch == checkoutBranch {
return 0, branchAddDuplicateError(repo, repositoryID, baseBranch, checkoutBranch)
}
// Slug-collision check is NOT scoped by base_branch — worktree path
// derivation (TaskWorktreePath) uses only repo + slug, so two rows
// on the same repo with sibling slugs would land on the same on-disk
// directory regardless of which base_branch they were cut from.
if tr.RepositoryID == repositoryID &&
checkoutBranch != "" && tr.CheckoutBranch != "" &&
tr.CheckoutBranch != checkoutBranch &&
worktree.SanitizeBranchSlug(tr.CheckoutBranch) == newSlug && newSlug != "" {
return 0, fmt.Errorf(
"checkout branch %q conflicts with existing branch %q on this task (both resolve to the same worktree path)",
checkoutBranch, tr.CheckoutBranch,
)
}
if tr.Position >= nextPosition {
nextPosition = tr.Position + 1
}
}
return nextPosition, nil
}
// branchAddDuplicateError returns the user-facing duplicate-attachment
// error, preferring the more descriptive "on branch %q" form when a branch
// name is available. Extracted so the scan loop in
// scanForBranchAddDuplicate stays single-purpose.
func branchAddDuplicateError(
repo *models.Repository, repositoryID, baseBranch, checkoutBranch string,
) error {
label := repoLabelOrID(repo, repositoryID)
branchLabel := checkoutBranch
if branchLabel == "" {
branchLabel = baseBranch
}
if branchLabel == "" {
return fmt.Errorf("repository %q is already attached to this task", label)
}
return fmt.Errorf("repository %q on branch %q is already attached to this task", label, branchLabel)
}
// materializeBranchBestEffort triggers the worktree manager + agentctl
// rescan asynchronously-ish. Failures are logged but don't unwind the DB
// write — the next session launch will pick the worktree up via
// prepareMultiRepo, so a transient rescan failure is recoverable.
func (s *Service) materializeBranchBestEffort(ctx context.Context, taskID, taskRepositoryID string) {
if s.branchMaterializer == nil {
return
}
if err := s.branchMaterializer.MaterializeBranch(ctx, taskID, taskRepositoryID); err != nil {
s.logger.Warn("branch materialization failed; worktree will be created on next session launch",
zap.String("task_id", taskID),
zap.String("task_repository_id", taskRepositoryID),
zap.Error(err))
}
}
// defaultRepositoryIDForTask resolves the implicit repository to use when
// AddBranchToTask is called without one. Single-repo tasks resolve trivially;
// multi-repo tasks force the caller to disambiguate so we never silently
// pick the wrong repo. Returns an error rather than guessing in ambiguous
// cases so the agent sees a clear "specify repository_id" message.
func (s *Service) defaultRepositoryIDForTask(existing []*models.TaskRepository) (string, error) {
if len(existing) == 0 {
return "", fmt.Errorf("repository_id is required: task has no repositories attached yet")
}
uniqueRepos := make(map[string]bool, len(existing))
for _, tr := range existing {
uniqueRepos[tr.RepositoryID] = true
}
if len(uniqueRepos) > 1 {
return "", fmt.Errorf("repository_id is required: task has %d repositories — pick one explicitly", len(uniqueRepos))
}
// Single repo (possibly with multiple branches already attached). Return
// the primary (lowest-position) row's repo id — they all share the same
// repository_id by definition of the uniqueness check above.
primary := existing[0]
for _, tr := range existing {
if tr.Position < primary.Position {
primary = tr
}
}
return primary.RepositoryID, nil
}
// requireWorktreeExecutorForBranchAdd rejects add_branch_to_task calls on
// tasks whose execution environment isn't the worktree executor. Sibling
// worktrees are a git-worktree-specific layout — containerized executors
// (docker, sprites, ssh) bind one workspace path per task and would
// silently ignore the new sibling. Returning early here also keeps the
// DB clean: without the guard, the row lands in task_repositories but
// nothing materializes on disk, leaving an orphan that breaks invariants.
//
// Tasks that haven't launched yet (no TaskEnvironment row) are permitted:
// the executor type isn't fixed yet, and rejecting them would block the
// pre-launch "set up multi-branch first, then start the agent" flow.
func (s *Service) requireWorktreeExecutorForBranchAdd(ctx context.Context, taskID string) error {
if s.taskEnvironments == nil {
return nil
}
env, err := s.taskEnvironments.GetTaskEnvironmentByTaskID(ctx, taskID)
if err != nil {
return fmt.Errorf("look up task environment: %w", err)
}
if env == nil || env.ExecutorType == "" {
return nil
}
if env.ExecutorType != string(models.ExecutorTypeWorktree) {
return fmt.Errorf(
"add_branch_to_task is only supported on the worktree executor; this task uses %q",
env.ExecutorType,
)
}
return nil
}
// hasRowForRepoBase reports whether `existing` already contains a row for
// the (repo, base, "") triple — i.e. an attached worktree on the same base
// branch with no explicit checkout_branch. Used to decide whether an
// otherwise-empty checkout_branch should be auto-named so the new row
// doesn't collide.
func hasRowForRepoBase(existing []*models.TaskRepository, repositoryID, baseBranch string) bool {
for _, tr := range existing {
if tr.RepositoryID == repositoryID && tr.BaseBranch == baseBranch && tr.CheckoutBranch == "" {
return true
}
}
return false
}
// autoBranchName picks a fresh `branch-N` style name for a new
// task_repositories row when the caller didn't specify checkout_branch.
// N is one more than the highest matching suffix already attached for the
// same (repo, base), so repeated calls produce branch-2, branch-3, ...
// without scanning git refs — collisions on disk are handled by the
// worktree manager's "treat as new branch" path, which suffixes anyway.
//
// The naming intentionally avoids encoding the base branch name to keep
// the slug-derived directory short and stable across base renames.
func autoBranchName(existing []*models.TaskRepository, repositoryID, baseBranch string) string {
n := 2
for _, tr := range existing {
if tr.RepositoryID != repositoryID || tr.BaseBranch != baseBranch {
continue
}
if !strings.HasPrefix(tr.CheckoutBranch, "branch-") {
continue
}
var idx int
if _, err := fmt.Sscanf(tr.CheckoutBranch, "branch-%d", &idx); err == nil && idx >= n {
n = idx + 1
}
}
return fmt.Sprintf("branch-%d", n)
}
// repoLabelOrID returns a human-readable repository label for error messages,
// preferring "owner/name" then bare name, falling back to the raw UUID.
func repoLabelOrID(repo *models.Repository, repositoryID string) string {
if repo == nil {
return repositoryID
}
if repo.ProviderOwner != "" && repo.ProviderName != "" {
return repo.ProviderOwner + "/" + repo.ProviderName
}
if repo.Name != "" {
return repo.Name
}
return repositoryID
}
@@ -0,0 +1,465 @@
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-N.
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 added.CheckoutBranch != "branch-2" {
t.Errorf("expected auto-name branch-2, got %q", added.CheckoutBranch)
}
added2, err := svc.AddBranchToTask(ctx, AddBranchToTaskRequest{
TaskID: task.ID,
})
if err != nil {
t.Fatalf("AddBranchToTask (second auto): %v", err)
}
if added2.CheckoutBranch != "branch-3" {
t.Errorf("expected auto-name branch-3, got %q", added2.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)
}
}
@@ -310,18 +310,27 @@ func (s *Service) createTaskRepositories(ctx context.Context, taskID, workspaceI
if repositoryID == "" {
return fmt.Errorf("repository_id is required")
}
// Multi-repo validation: each repository may appear at most once per task.
// Without this guard the unique (task_id, repository_id) constraint on
// task_repositories would surface as an opaque DB error mid-loop, leaving
// some rows already inserted. Name the repository (owner/name) instead of
// the raw UUID so the create-task dialog's error toast is human-readable —
// two PR URLs of the same repo (e.g. /pull/1116 and /pull/1117) collapse
// to the same repositoryID and hit this path.
if seen[repositoryID] {
// Multi-branch validation: the same repository may appear multiple
// times in a task on different branches. Identity is
// (repository_id, base_branch, checkout_branch) — base_branch matters
// because the worktree executor anchors the branch there while
// checkout_branch stays empty, and the local-executor flow puts the
// branch in checkout_branch with base_branch anchored to default_branch.
// Both shapes must dedup; matching DB key is UNIQUE(task_id,
// repository_id, base_branch, checkout_branch).
dedupKey := repositoryID + "\x00" + baseBranch + "\x00" + repoInput.CheckoutBranch
if seen[dedupKey] {
label := s.repoDisplayLabel(ctx, repoInput, repositoryID)
return fmt.Errorf("repository %q is listed more than once for this task", label)
branchLabel := repoInput.CheckoutBranch
if branchLabel == "" {
branchLabel = baseBranch
}
if branchLabel == "" {
return fmt.Errorf("repository %q is listed more than once for this task", label)
}
return fmt.Errorf("repository %q on branch %q is listed more than once for this task", label, branchLabel)
}
seen[repositoryID] = true
seen[dedupKey] = true
metadata := make(map[string]interface{})
if prNum := resolvePRNumber(repoInput); prNum > 0 {
metadata["pr_number"] = prNum
+46 -3
View File
@@ -66,13 +66,25 @@ func (c *Config) ExpandedTasksBasePath() (string, error) {
}
// TaskWorktreePath returns the full path for a worktree inside a task directory.
// Format: {tasksBase}/{taskDirName}/{repoName}
func (c *Config) TaskWorktreePath(taskDirName, repoName string) (string, error) {
//
// Layout:
// - branchSlug == "" → {tasksBase}/{taskDirName}/{repoName} (legacy single-branch path)
// - branchSlug != "" → {tasksBase}/{taskDirName}/{repoName}-{slug} (sibling at task root)
//
// Additional branches sit as siblings of the primary worktree under the task
// root instead of nesting inside it. Nesting (e.g. `<task>/<repo>/<slug>/`)
// places the second worktree INSIDE the first worktree's working tree, which
// pollutes the primary's git scope (file scans, status, etc.) and surfaces a
// surprise subdirectory to the agent working in the primary.
func (c *Config) TaskWorktreePath(taskDirName, repoName, branchSlug string) (string, error) {
basePath, err := c.ExpandedTasksBasePath()
if err != nil {
return "", err
}
return filepath.Join(basePath, taskDirName, repoName), nil
if branchSlug == "" {
return filepath.Join(basePath, taskDirName, repoName), nil
}
return filepath.Join(basePath, taskDirName, repoName+"-"+branchSlug), nil
}
// BranchName returns the branch name for a given task ID and suffix.
@@ -238,6 +250,37 @@ func SanitizeRepoDirName(name string) string {
var repoDirHyphenRun = regexp.MustCompile(`-+`)
// SanitizeBranchSlug converts a git branch name into a filesystem-safe single
// path segment for use under the per-repo worktree directory. Forward slashes
// (common in branch names like "feature/foo") collapse to hyphens so the slug
// never introduces extra path levels. Returns the empty string when the input
// has no usable characters — callers MUST treat that as "no nesting" rather
// than as an empty path segment.
//
// Determinism: same branch name always produces the same slug. Two branches
// that differ only in unsafe characters (e.g. "feat/a" and "feat-a") collapse
// to the same slug and would collide on disk; the service layer should reject
// such duplicates before reaching the worktree manager.
func SanitizeBranchSlug(branch string) string {
if branch == "" {
return ""
}
var sb strings.Builder
sb.Grow(len(branch))
for _, r := range branch {
switch {
case isASCIIAlphaNum(r):
sb.WriteRune(r)
case r == '_', r == '.', r == '-':
sb.WriteRune(r)
default:
sb.WriteRune('-')
}
}
result := repoDirHyphenRun.ReplaceAllString(sb.String(), "-")
return strings.Trim(result, "-.")
}
// SemanticWorktreeName generates a semantic worktree directory name from a task title.
// Format: {sanitizedTitle}_{suffix} e.g. fix-login-bug_ab12cd34
// The title is truncated to 20 characters before adding the suffix.
+5
View File
@@ -57,6 +57,11 @@ var (
// characters that survive sanitization (e.g. "!@#$%"), so it cannot be
// used as a directory segment.
ErrInvalidRepoName = errors.New("repo name has no usable characters after sanitization")
// ErrInvalidBranchSlug is returned when BranchSlug is set but contains no
// usable characters after sanitization. Callers that pass an explicit slug
// must populate something the filesystem can accept.
ErrInvalidBranchSlug = errors.New("branch slug has no usable characters after sanitization")
)
// containsAuthFailure checks if git output indicates an authentication failure.
+4 -2
View File
@@ -91,8 +91,10 @@ type MultiRepoStore interface {
// GetWorktreesBySessionID returns all active worktrees for the session.
GetWorktreesBySessionID(ctx context.Context, sessionID string) ([]*Worktree, error)
// GetWorktreeBySessionAndRepository returns the active worktree for the
// given (session, repository) pair, or nil if none exists.
GetWorktreeBySessionAndRepository(ctx context.Context, sessionID, repositoryID string) (*Worktree, error)
// given (session, repository, branchSlug) triple, or nil if none exists.
// branchSlug scopes the lookup for multi-branch tasks; empty matches the
// legacy single-branch persistence shape.
GetWorktreeBySessionAndRepository(ctx context.Context, sessionID, repositoryID, branchSlug string) (*Worktree, error)
}
// NewManager creates a new worktree manager.
@@ -80,7 +80,7 @@ func (m *Manager) removeWorktree(ctx context.Context, wt *Worktree, removeBranch
// affects its own repo; siblings on other repos must remain cached.
m.mu.Lock()
if wt.SessionID != "" {
delete(m.worktrees, cacheKey(wt.SessionID, wt.RepositoryID))
delete(m.worktrees, cacheKey(wt.SessionID, wt.RepositoryID, wt.BranchSlug))
}
m.mu.Unlock()
@@ -204,7 +204,7 @@ func (m *Manager) CleanupWorktrees(ctx context.Context, worktrees []*Worktree) e
continue
}
if wt.SessionID != "" {
delete(m.worktrees, cacheKey(wt.SessionID, wt.RepositoryID))
delete(m.worktrees, cacheKey(wt.SessionID, wt.RepositoryID, wt.BranchSlug))
}
}
m.mu.Unlock()
@@ -0,0 +1,78 @@
package worktree
import (
"context"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// TestCreateWorktree_CheckoutBranchAbsentCreatesNewBranchWithName covers the
// MCP add_branch_to_task path: the agent passes a desired CheckoutBranch
// name that doesn't exist yet anywhere. Historically this errored with
// "branch %q not found locally or on remote"; the new contract is "create
// a new branch with that name from baseRef" so the worktree materializes
// without round-tripping through a session restart.
func TestCreateWorktree_CheckoutBranchAbsentCreatesNewBranchWithName(t *testing.T) {
repoPath := initGitRepoForNewBranchTest(t)
cfg := newTestConfig(t)
log := newTestLogger()
store := newMockStore()
mgr, err := NewManager(cfg, store, log)
if err != nil {
t.Fatalf("NewManager failed: %v", err)
}
desired := "feature/do-nothing-file-a"
wt, err := mgr.Create(context.Background(), CreateRequest{
TaskID: "task-new-branch",
SessionID: "session-new-branch",
TaskTitle: "MCP-driven branch addition",
RepositoryID: "repo-1",
RepositoryPath: repoPath,
BaseBranch: "main",
CheckoutBranch: desired,
TaskDirName: "task-new-branch_aaa",
RepoName: "repo-1",
BranchSlug: SanitizeBranchSlug(desired),
})
if err != nil {
t.Fatalf("Create() with fresh branch name should succeed, got: %v", err)
}
if wt.Branch != desired {
t.Fatalf("worktree.Branch = %q, want %q (caller's desired name)", wt.Branch, desired)
}
if !strings.HasSuffix(wt.Path, filepath.Join("task-new-branch_aaa", "repo-1-feature-do-nothing-file-a")) {
t.Fatalf("worktree path = %q, expected sibling repo-<slug> dir under task root", wt.Path)
}
// Branch must now exist locally.
out := strings.TrimSpace(runGit(t, repoPath, "branch", "--list", desired))
if out == "" {
t.Fatalf("expected branch %q to be created locally, got empty branch list", desired)
}
}
// initGitRepoForNewBranchTest creates a fresh git repo cloned from a bare
// origin so the manager's "branch missing locally and on origin" probe sees
// the desired branch as absent. main is the only branch.
func initGitRepoForNewBranchTest(t *testing.T) string {
t.Helper()
bareDir := filepath.Join(t.TempDir(), "origin.git")
runGit(t, t.TempDir(), "init", "--bare", "-b", "main", bareDir)
cloneDir := filepath.Join(t.TempDir(), "clone")
cmd := exec.Command("git", "clone", bareDir, cloneDir)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git clone failed: %v\n%s", err, out)
}
runGit(t, cloneDir, "config", "user.email", "test@example.com")
runGit(t, cloneDir, "config", "user.name", "Test User")
runGit(t, cloneDir, "config", "commit.gpgsign", "false")
runGit(t, cloneDir, "commit", "--allow-empty", "-m", "initial commit")
runGit(t, cloneDir, "push", "origin", "main")
return cloneDir
}
@@ -52,6 +52,27 @@ func (m *Manager) branchExists(ctx context.Context, repoPath, branch string) (bo
return true, nil
}
// checkoutBranchExistsAnywhere returns true when the named branch is present
// either locally or as origin/<branch>. Used by createInTaskDir to decide
// whether to treat req.CheckoutBranch as "fetch this existing ref" or as
// "create a new branch with this name". A timeout / fs stall counts as
// "present" so we don't accidentally clobber a working branch by creating a
// duplicate when the probe couldn't complete.
func (m *Manager) checkoutBranchExistsAnywhere(ctx context.Context, repoPath, branch string) bool {
local, err := m.branchExists(ctx, repoPath, branch)
if err != nil {
return true
}
if local {
return true
}
remote, err := m.branchExists(ctx, repoPath, "refs/remotes/origin/"+branch)
if err != nil {
return true
}
return remote
}
func (m *Manager) currentBranch(ctx context.Context, repoPath string) string {
inspectCtx, cancel := context.WithTimeout(ctx, m.inspectTimeout)
defer cancel()
@@ -25,6 +25,14 @@ 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.
if req.BranchSlug != "" && SanitizeBranchSlug(req.BranchSlug) == "" {
return nil, ErrInvalidBranchSlug
}
if wt, handled, err := m.tryReuseExisting(ctx, req); handled {
return wt, err
}
@@ -69,12 +77,15 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*Worktree, err
// (caller should return immediately). Returns (nil, false, nil) when no reuse
// candidate matched and the caller should proceed to create a new worktree.
func (m *Manager) tryReuseExisting(ctx context.Context, req CreateRequest) (*Worktree, bool, error) {
// First, check if a worktree already exists for this (session, repository) pair.
// Multi-repo sessions can host multiple worktrees concurrently, so we must
// scope the lookup by RepositoryID rather than session alone — otherwise
// the second repo's Create would return the first repo's worktree.
// First, check if a worktree already exists for this
// (session, repository, branchSlug) triple. Multi-repo + multi-branch
// sessions can host multiple worktrees concurrently, so we must scope the
// 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)
if req.SessionID != "" {
existing, err := m.GetBySessionAndRepo(ctx, req.SessionID, req.RepositoryID)
existing, err := m.GetBySessionAndRepo(ctx, req.SessionID, req.RepositoryID, reuseSlug)
if err == nil && existing != nil {
if m.IsValid(existing.Path) {
m.logger.Debug("reusing existing worktree by session+repo",
@@ -193,7 +204,11 @@ func (m *Manager) createInTaskDir(ctx context.Context, req CreateRequest, baseRe
if repoDir == "" {
return nil, ErrInvalidRepoName
}
worktreePath, err := m.config.TaskWorktreePath(req.TaskDirName, repoDir)
branchSlug := SanitizeBranchSlug(req.BranchSlug)
if req.BranchSlug != "" && branchSlug == "" {
return nil, ErrInvalidBranchSlug
}
worktreePath, err := m.config.TaskWorktreePath(req.TaskDirName, repoDir, branchSlug)
if err != nil {
return nil, fmt.Errorf("failed to get task worktree path: %w", err)
}
@@ -208,19 +223,41 @@ func (m *Manager) createInTaskDir(ctx context.Context, req CreateRequest, baseRe
startPoint := baseRef
var fetchResult *FetchBranchResult
checkoutMode := req
if req.CheckoutBranch != "" {
fetchResult, err = m.fetchBranchToLocal(ctx, req.RepositoryPath, req.CheckoutBranch, req.PRNumber)
if err != nil {
return nil, err
}
if fetchResult.StartPoint != "" {
startPoint = fetchResult.StartPoint
// PRNumber != 0 means the caller wants the refs/pull/<N>/head ref;
// fork PR branches don't exist as plain refs locally or under
// origin/<branch>, so the existence probe must be skipped and the
// fetch path always runs.
//
// When PRNumber == 0 and the named branch is absent locally and on
// origin, the caller's intent is "create a new branch with this
// name" rather than "fetch this existing ref" — the historical
// fetch-then-check-out path errored ("not found locally or on
// remote") in that case and rolled back. We drop CheckoutBranch
// from the request copy and pass the desired name as the fallback
// (new) branch name so gitAddWorktree creates it from baseRef.
if req.PRNumber == 0 && !m.checkoutBranchExistsAnywhere(ctx, req.RepositoryPath, req.CheckoutBranch) {
m.logger.Info("checkout branch missing locally and on origin; creating new branch with this name",
zap.String("repository_path", req.RepositoryPath),
zap.String("requested_branch", req.CheckoutBranch),
zap.String("base_ref", baseRef))
branchName = req.CheckoutBranch
checkoutMode.CheckoutBranch = ""
} else {
startPoint = req.CheckoutBranch
fetchResult, err = m.fetchBranchToLocal(ctx, req.RepositoryPath, req.CheckoutBranch, req.PRNumber)
if err != nil {
return nil, err
}
if fetchResult.StartPoint != "" {
startPoint = fetchResult.StartPoint
} else {
startPoint = req.CheckoutBranch
}
}
}
worktreeID, branchName, err := m.addWorktreeForBranch(ctx, req, worktreePath, branchName, startPoint, baseRef)
worktreeID, branchName, err := m.addWorktreeForBranch(ctx, checkoutMode, worktreePath, branchName, startPoint, baseRef)
if err != nil {
return nil, err
}
@@ -820,10 +857,10 @@ func (m *Manager) recreate(ctx context.Context, existing *Worktree, req CreateRe
}
}
// Update cache keyed by (sessionID, repositoryID).
// Update cache keyed by (sessionID, repositoryID, branchSlug).
if req.SessionID != "" {
m.mu.Lock()
m.worktrees[cacheKey(req.SessionID, req.RepositoryID)] = existing
m.worktrees[cacheKey(req.SessionID, req.RepositoryID, existing.BranchSlug)] = existing
m.mu.Unlock()
}
@@ -61,7 +61,7 @@ func TestManager_GetBySessionAndRepo_PicksTheRightOne(t *testing.T) {
seedWorktree(store, "sess-1", "repo-front", "wt-1")
seedWorktree(store, "sess-1", "repo-back", "wt-2")
got, err := mgr.GetBySessionAndRepo(context.Background(), "sess-1", "repo-back")
got, err := mgr.GetBySessionAndRepo(context.Background(), "sess-1", "repo-back", "")
if err != nil {
t.Fatalf("GetBySessionAndRepo: %v", err)
}
@@ -74,7 +74,7 @@ func TestManager_GetBySessionAndRepo_NotFoundForUnknownRepo(t *testing.T) {
mgr, store := newMultiRepoTestManager(t)
seedWorktree(store, "sess-1", "repo-front", "wt-1")
_, err := mgr.GetBySessionAndRepo(context.Background(), "sess-1", "nope")
_, err := mgr.GetBySessionAndRepo(context.Background(), "sess-1", "nope", "")
if err == nil {
t.Fatal("expected ErrWorktreeNotFound for unknown repo")
}
@@ -100,16 +100,39 @@ func TestManager_CacheKey_SeparatesReposPerSession(t *testing.T) {
wtA := &Worktree{ID: "wt-A", SessionID: "s", RepositoryID: "rA", Path: "/a", Status: StatusActive}
wtB := &Worktree{ID: "wt-B", SessionID: "s", RepositoryID: "rB", Path: "/b", Status: StatusActive}
mgr.mu.Lock()
mgr.worktrees[cacheKey("s", "rA")] = wtA
mgr.worktrees[cacheKey("s", "rB")] = wtB
mgr.worktrees[cacheKey("s", "rA", "")] = wtA
mgr.worktrees[cacheKey("s", "rB", "")] = wtB
mgr.mu.Unlock()
gotA, err := mgr.GetBySessionAndRepo(context.Background(), "s", "rA")
gotA, err := mgr.GetBySessionAndRepo(context.Background(), "s", "rA", "")
if err != nil || gotA.ID != "wt-A" {
t.Errorf("expected wt-A from cache; got %v err=%v", gotA, err)
}
gotB, err := mgr.GetBySessionAndRepo(context.Background(), "s", "rB")
gotB, err := mgr.GetBySessionAndRepo(context.Background(), "s", "rB", "")
if err != nil || gotB.ID != "wt-B" {
t.Errorf("expected wt-B from cache; got %v err=%v", gotB, err)
}
}
// TestManager_GetBySessionAndRepo_DisambiguatesByBranchSlug guards the
// multi-branch identity: two worktrees on the same repo but different
// branches must coexist in the cache and round-trip through the lookup
// without collapsing onto one record.
func TestManager_GetBySessionAndRepo_DisambiguatesByBranchSlug(t *testing.T) {
mgr, _ := newMultiRepoTestManager(t)
wtMain := &Worktree{ID: "wt-main", SessionID: "s", RepositoryID: "r", BranchSlug: "main", Path: "/m", Status: StatusActive}
wtFeat := &Worktree{ID: "wt-feat", SessionID: "s", RepositoryID: "r", BranchSlug: "feature-x", Path: "/f", Status: StatusActive}
mgr.mu.Lock()
mgr.worktrees[cacheKey("s", "r", "main")] = wtMain
mgr.worktrees[cacheKey("s", "r", "feature-x")] = wtFeat
mgr.mu.Unlock()
got, err := mgr.GetBySessionAndRepo(context.Background(), "s", "r", "main")
if err != nil || got.ID != "wt-main" {
t.Errorf("expected wt-main; got %v err=%v", got, err)
}
got, err = mgr.GetBySessionAndRepo(context.Background(), "s", "r", "feature-x")
if err != nil || got.ID != "wt-feat" {
t.Errorf("expected wt-feat; got %v err=%v", got, err)
}
}
+26 -17
View File
@@ -19,6 +19,7 @@ func (m *Manager) buildWorktreeRecord(worktreeID string, req CreateRequest, work
SessionID: req.SessionID,
TaskID: req.TaskID,
RepositoryID: req.RepositoryID,
BranchSlug: SanitizeBranchSlug(req.BranchSlug),
RepositoryPath: req.RepositoryPath,
Path: worktreePath,
Branch: branchName,
@@ -37,11 +38,14 @@ func (m *Manager) persistAndCacheWorktree(ctx context.Context, wt *Worktree, req
}
}
// Update cache keyed by (sessionID, repositoryID) so multi-repo sessions
// can hold multiple entries.
// Update cache keyed by (sessionID, repositoryID, branchSlug) so
// multi-repo and multi-branch sessions can hold multiple entries. Use
// wt.BranchSlug (already sanitized) so the read side via
// GetBySessionAndRepo / tryReuseExisting, which calls
// SanitizeBranchSlug on its input, lands on the same key.
if req.SessionID != "" {
m.mu.Lock()
m.worktrees[cacheKey(req.SessionID, req.RepositoryID)] = wt
m.worktrees[cacheKey(req.SessionID, req.RepositoryID, wt.BranchSlug)] = wt
m.mu.Unlock()
}
return nil
@@ -66,11 +70,13 @@ func (m *Manager) persistWorktree(ctx context.Context, wt *Worktree, req CreateR
return nil
}
// cacheKey computes the cache key for a (sessionID, repositoryID) pair.
// Used by all read/write paths against m.worktrees so multi-repo sessions
// can hold multiple worktrees concurrently.
func cacheKey(sessionID, repositoryID string) string {
return sessionID + "|" + repositoryID
// cacheKey computes the cache key for a (sessionID, repositoryID, branchSlug)
// triple. Used by all read/write paths against m.worktrees so multi-repo and
// multi-branch sessions can hold multiple worktrees concurrently — without
// branchSlug, two worktrees of the same repo on different branches would
// share a key and silently collapse to one in-memory entry.
func cacheKey(sessionID, repositoryID, branchSlug string) string {
return sessionID + "|" + repositoryID + "|" + branchSlug
}
// firstCacheEntryForSession scans the cache for any entry belonging to
@@ -108,7 +114,7 @@ func (m *Manager) GetBySessionID(ctx context.Context, sessionID string) (*Worktr
// Update cache under the wt's repository key (or empty for legacy
// records that never had one).
m.mu.Lock()
m.worktrees[cacheKey(sessionID, wt.RepositoryID)] = wt
m.worktrees[cacheKey(sessionID, wt.RepositoryID, wt.BranchSlug)] = wt
m.mu.Unlock()
return wt, nil
}
@@ -141,7 +147,7 @@ func (m *Manager) GetAllBySessionID(ctx context.Context, sessionID string) ([]*W
// Refresh cache while we're at it.
m.mu.Lock()
for _, wt := range wts {
m.worktrees[cacheKey(sessionID, wt.RepositoryID)] = wt
m.worktrees[cacheKey(sessionID, wt.RepositoryID, wt.BranchSlug)] = wt
}
m.mu.Unlock()
return wts, nil
@@ -157,16 +163,19 @@ func (m *Manager) GetAllBySessionID(ctx context.Context, sessionID string) ([]*W
return []*Worktree{wt}, nil
}
// GetBySessionAndRepo returns the active worktree for the (session, repo)
// pair, or ErrWorktreeNotFound if none exists. Falls back to GetBySessionID
// when the store does not implement MultiRepoStore (legacy single-repo).
func (m *Manager) GetBySessionAndRepo(ctx context.Context, sessionID, repositoryID string) (*Worktree, error) {
// GetBySessionAndRepo returns the active worktree for the
// (session, repo, branchSlug) triple, or ErrWorktreeNotFound if none exists.
// branchSlug scopes multi-branch tasks; passing "" matches the legacy
// single-branch shape so existing call sites continue to work unchanged.
// Falls back to GetBySessionID when the store does not implement
// MultiRepoStore (legacy single-repo).
func (m *Manager) GetBySessionAndRepo(ctx context.Context, sessionID, repositoryID, branchSlug string) (*Worktree, error) {
if sessionID == "" {
return nil, ErrWorktreeNotFound
}
// Check cache first.
m.mu.RLock()
if wt, ok := m.worktrees[cacheKey(sessionID, repositoryID)]; ok {
if wt, ok := m.worktrees[cacheKey(sessionID, repositoryID, branchSlug)]; ok {
m.mu.RUnlock()
return wt, nil
}
@@ -176,7 +185,7 @@ func (m *Manager) GetBySessionAndRepo(ctx context.Context, sessionID, repository
return nil, ErrWorktreeNotFound
}
if multi, ok := m.store.(MultiRepoStore); ok {
wt, err := multi.GetWorktreeBySessionAndRepository(ctx, sessionID, repositoryID)
wt, err := multi.GetWorktreeBySessionAndRepository(ctx, sessionID, repositoryID, branchSlug)
if err != nil {
return nil, err
}
@@ -184,7 +193,7 @@ func (m *Manager) GetBySessionAndRepo(ctx context.Context, sessionID, repository
return nil, ErrWorktreeNotFound
}
m.mu.Lock()
m.worktrees[cacheKey(sessionID, repositoryID)] = wt
m.worktrees[cacheKey(sessionID, repositoryID, branchSlug)] = wt
m.mu.Unlock()
return wt, nil
}
@@ -125,9 +125,9 @@ func (s *mockStore) GetWorktreesBySessionID(_ context.Context, sessionID string)
}
// GetWorktreeBySessionAndRepository — MultiRepoStore.
func (s *mockStore) GetWorktreeBySessionAndRepository(_ context.Context, sessionID, repoID string) (*Worktree, error) {
func (s *mockStore) GetWorktreeBySessionAndRepository(_ context.Context, sessionID, repoID, branchSlug string) (*Worktree, error) {
for _, wt := range s.worktrees {
if wt.SessionID == sessionID && wt.RepositoryID == repoID && wt.Status == StatusActive {
if wt.SessionID == sessionID && wt.RepositoryID == repoID && wt.BranchSlug == branchSlug && wt.Status == StatusActive {
return wt, nil
}
}
@@ -0,0 +1,51 @@
package worktree
import "testing"
func TestSanitizeBranchSlug(t *testing.T) {
cases := []struct {
in, want string
}{
{"main", "main"},
{"feature/foo", "feature-foo"},
{"feature/foo-bar", "feature-foo-bar"},
{"release/v1.2.3", "release-v1.2.3"},
{"user/jane/wip", "user-jane-wip"},
{"-leading", "leading"},
{"trailing-", "trailing"},
{"slash/", "slash"},
{"a//b", "a-b"},
{"weird:name space", "weird-name-space"},
{"!@#$%", ""},
{"", ""},
{".hidden", "hidden"},
{"修复登录问题", ""},
}
for _, c := range cases {
if got := SanitizeBranchSlug(c.in); got != c.want {
t.Errorf("SanitizeBranchSlug(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestTaskWorktreePath_BranchSlugNesting(t *testing.T) {
cfg := &Config{TasksBasePath: "/tmp/kandev/tasks"}
flat, err := cfg.TaskWorktreePath("task-1_abc", "frontend", "")
if err != nil {
t.Fatalf("flat path: %v", err)
}
want := "/tmp/kandev/tasks/task-1_abc/frontend"
if flat != want {
t.Errorf("flat path = %q, want %q", flat, want)
}
sibling, err := cfg.TaskWorktreePath("task-1_abc", "frontend", "feature-foo")
if err != nil {
t.Fatalf("sibling path: %v", err)
}
want = "/tmp/kandev/tasks/task-1_abc/frontend-feature-foo"
if sibling != want {
t.Errorf("sibling path = %q, want %q", sibling, want)
}
}
+55 -10
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
@@ -27,6 +28,10 @@ func NewSQLiteStore(writer, reader *sqlx.DB) (*SQLiteStore, error) {
}
// initSchema creates the task_session_worktrees table if it doesn't exist.
// `branch_slug` is required for multi-branch tasks (same repo, multiple
// branches): without it, reuse lookups by (session_id, repository_id)
// silently collapse two distinct worktrees into one, which then trickles
// down to a single on-disk directory shared between rows.
func (s *SQLiteStore) initSchema() error {
schema := `
CREATE TABLE IF NOT EXISTS task_session_worktrees (
@@ -34,6 +39,7 @@ func (s *SQLiteStore) initSchema() error {
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 '',
@@ -52,8 +58,30 @@ func (s *SQLiteStore) initSchema() error {
CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_status ON task_session_worktrees(status);
`
_, err := s.db.Exec(schema)
return err
if _, err := s.db.Exec(schema); err != nil {
return err
}
// Idempotent ALTER for upgrades. Pre-existing rows get branch_slug='' which
// matches the legacy single-branch identity exactly.
if _, err := s.db.Exec(`ALTER TABLE task_session_worktrees ADD COLUMN branch_slug TEXT NOT NULL DEFAULT ''`); err != nil {
// SQLite returns "duplicate column name" when the column already exists;
// treat that as success so the migration is replay-safe.
if !isDuplicateColumnError(err) {
return err
}
}
return nil
}
func isDuplicateColumnError(err error) bool {
if err == nil {
return false
}
// SQLite emits "duplicate column name: <col>" for ALTER TABLE ADD COLUMN
// against an existing column. The broader "already exists" substring also
// matches unrelated DDL errors (e.g. "table X already exists"), which
// would silently swallow real schema failures — keep the match narrow.
return strings.Contains(err.Error(), "duplicate column name")
}
// CreateWorktree persists a new worktree record.
@@ -77,19 +105,20 @@ func (s *SQLiteStore) CreateWorktree(ctx context.Context, wt *Worktree) error {
_, err := s.db.ExecContext(ctx, s.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, status,
created_at, updated_at, merged_at, deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id, worktree_id) DO UPDATE SET
repository_id = excluded.repository_id,
branch_slug = excluded.branch_slug,
worktree_path = excluded.worktree_path,
worktree_branch = excluded.worktree_branch,
status = excluded.status,
updated_at = excluded.updated_at,
merged_at = excluded.merged_at,
deleted_at = excluded.deleted_at
`), uuid.New().String(), wt.SessionID, wt.ID, wt.RepositoryID, 0,
`), uuid.New().String(), wt.SessionID, wt.ID, wt.RepositoryID, wt.BranchSlug, 0,
wt.Path, wt.Branch, wt.Status,
wt.CreatedAt, wt.UpdatedAt, wt.MergedAt, wt.DeletedAt)
@@ -111,6 +140,7 @@ func (s *SQLiteStore) GetWorktreeByID(ctx context.Context, id string) (*Worktree
r.local_path,
tsw.worktree_path,
tsw.worktree_branch,
COALESCE(tsw.branch_slug, ''),
s.base_branch,
tsw.status,
tsw.created_at,
@@ -129,6 +159,7 @@ func (s *SQLiteStore) GetWorktreeByID(ctx context.Context, id string) (*Worktree
&repositoryPath,
&wt.Path,
&wt.Branch,
&wt.BranchSlug,
&baseBranch,
&wt.Status,
&wt.CreatedAt,
@@ -173,6 +204,7 @@ func scanWorktreeRow(row *sql.Row) (*Worktree, error) {
&repositoryPath,
&wt.Path,
&wt.Branch,
&wt.BranchSlug,
&baseBranch,
&wt.Status,
&wt.CreatedAt,
@@ -214,6 +246,7 @@ func (s *SQLiteStore) GetWorktreeBySessionID(ctx context.Context, sessionID stri
r.local_path,
tsw.worktree_path,
tsw.worktree_branch,
COALESCE(tsw.branch_slug, ''),
s.base_branch,
tsw.status,
tsw.created_at,
@@ -240,6 +273,7 @@ func (s *SQLiteStore) GetWorktreeByTaskID(ctx context.Context, taskID string) (*
r.local_path,
tsw.worktree_path,
tsw.worktree_branch,
COALESCE(tsw.branch_slug, ''),
s.base_branch,
tsw.status,
tsw.created_at,
@@ -266,6 +300,7 @@ func (s *SQLiteStore) GetWorktreesByTaskID(ctx context.Context, taskID string) (
r.local_path,
tsw.worktree_path,
tsw.worktree_branch,
COALESCE(tsw.branch_slug, ''),
s.base_branch,
tsw.status,
tsw.created_at,
@@ -296,6 +331,7 @@ func (s *SQLiteStore) GetWorktreesByRepositoryID(ctx context.Context, repoID str
r.local_path,
tsw.worktree_path,
tsw.worktree_branch,
COALESCE(tsw.branch_slug, ''),
s.base_branch,
tsw.status,
tsw.created_at,
@@ -377,6 +413,7 @@ func (s *SQLiteStore) ListActiveWorktrees(ctx context.Context) ([]*Worktree, err
r.local_path,
tsw.worktree_path,
tsw.worktree_branch,
COALESCE(tsw.branch_slug, ''),
s.base_branch,
tsw.status,
tsw.created_at,
@@ -441,6 +478,7 @@ func (s *SQLiteStore) scanWorktrees(rows *sql.Rows) ([]*Worktree, error) {
&repositoryPath,
&wt.Path,
&wt.Branch,
&wt.BranchSlug,
&baseBranch,
&wt.Status,
&wt.CreatedAt,
@@ -482,6 +520,7 @@ func (s *SQLiteStore) GetWorktreesBySessionID(ctx context.Context, sessionID str
r.local_path,
tsw.worktree_path,
tsw.worktree_branch,
COALESCE(tsw.branch_slug, ''),
s.base_branch,
tsw.status,
tsw.created_at,
@@ -503,9 +542,12 @@ func (s *SQLiteStore) GetWorktreesBySessionID(ctx context.Context, sessionID str
}
// GetWorktreeBySessionAndRepository returns the active worktree for the
// given (session, repository) pair, or nil if none exists.
// Implements MultiRepoStore.
func (s *SQLiteStore) GetWorktreeBySessionAndRepository(ctx context.Context, sessionID, repositoryID string) (*Worktree, error) {
// given (session, repository, branchSlug) triple, or nil if none exists.
// branchSlug scopes the lookup so multi-branch tasks (same repo, multiple
// branches) don't collapse — an empty slug matches the legacy
// single-branch persistence shape, so single-branch callers remain
// unchanged. Implements MultiRepoStore.
func (s *SQLiteStore) GetWorktreeBySessionAndRepository(ctx context.Context, sessionID, repositoryID, branchSlug string) (*Worktree, error) {
row := s.ro.QueryRowContext(ctx, s.ro.Rebind(`
SELECT
tsw.worktree_id,
@@ -515,6 +557,7 @@ func (s *SQLiteStore) GetWorktreeBySessionAndRepository(ctx context.Context, ses
r.local_path,
tsw.worktree_path,
tsw.worktree_branch,
COALESCE(tsw.branch_slug, ''),
s.base_branch,
tsw.status,
tsw.created_at,
@@ -524,9 +567,11 @@ func (s *SQLiteStore) GetWorktreeBySessionAndRepository(ctx context.Context, ses
FROM task_session_worktrees tsw
INNER JOIN task_sessions s ON tsw.session_id = s.id
LEFT JOIN repositories r ON tsw.repository_id = r.id
WHERE tsw.session_id = ? AND tsw.repository_id = ? AND tsw.status = ?
WHERE tsw.session_id = ? AND tsw.repository_id = ?
AND COALESCE(tsw.branch_slug, '') = ?
AND tsw.status = ?
LIMIT 1
`), sessionID, repositoryID, StatusActive)
`), sessionID, repositoryID, branchSlug, StatusActive)
return scanWorktreeRow(row)
}
@@ -36,6 +36,12 @@ type Worktree struct {
// RepositoryID is the ID of the repository this worktree belongs to.
RepositoryID string `json:"repository_id"`
// BranchSlug, when set, disambiguates two worktrees that share a
// (SessionID, RepositoryID) pair on different branches. Stored on
// task_session_worktrees so reuse lookups can scope by branch and not
// collapse multi-branch tasks down to a single worktree.
BranchSlug string `json:"branch_slug,omitempty"`
// RepositoryPath is the local filesystem path to the main repository.
// Stored for recreation if the worktree directory is lost.
RepositoryPath string `json:"repository_path"`
@@ -171,6 +177,14 @@ type CreateRequest struct {
// Only used when TaskDirName is also set.
RepoName string
// BranchSlug, when non-empty, suffixes the per-repo sibling directory so
// the same repo can host multiple branches inside one task. Path becomes
// ~/.kandev/tasks/{TaskDirName}/{RepoName}-{BranchSlug}/ — a sibling of
// the primary {RepoName}/ entry, NOT nested under it (nesting would
// break agentctl's sibling-based multi-repo detection). Callers must
// derive a deterministic, filesystem-safe slug (see SanitizeBranchSlug).
BranchSlug string
// OnSyncProgress receives progress updates for pre-worktree branch sync.
OnSyncProgress SyncProgressCallback
+2
View File
@@ -324,6 +324,7 @@ const (
ActionMCPListTasks = "mcp.list_tasks"
ActionMCPCreateTask = "mcp.create_task"
ActionMCPUpdateTask = "mcp.update_task"
ActionMCPAddBranchToTask = "mcp.add_branch_to_task"
ActionMCPAskUserQuestion = "mcp.ask_user_question"
ActionMCPCreateTaskPlan = "mcp.create_task_plan"
ActionMCPGetTaskPlan = "mcp.get_task_plan"
@@ -498,5 +499,6 @@ const (
ErrorCodeUnauthorized = "UNAUTHORIZED"
ErrorCodeForbidden = "FORBIDDEN"
ErrorCodeValidation = "VALIDATION_ERROR"
ErrorCodeConflict = "CONFLICT"
ErrorCodeUnknownAction = "UNKNOWN_ACTION"
)
@@ -218,7 +218,11 @@ describe("RepoChipsRow", () => {
expect(screen.getByText(/loading…/i)).toBeTruthy();
});
it("disables Add when no more repositories are available", () => {
it("keeps Add enabled when all repos are selected (multi-branch: same repo + different branch is valid)", () => {
// With multi-branch support, the same repo can appear on multiple rows as long
// as each row uses a different branch. Therefore Add is always enabled when
// any workspace repos exist — the user can always add another branch of an
// existing repo.
renderInProvider(
<RepoChipsRow
fs={makeFs({
@@ -235,7 +239,7 @@ describe("RepoChipsRow", () => {
/>,
);
const btn = screen.getByTestId("add-repository") as HTMLButtonElement;
expect(btn.disabled).toBe(true);
expect(btn.disabled).toBe(false);
});
it("calls fs.addRepository when the + button is clicked", () => {
@@ -132,14 +132,15 @@ export function RepoChipsRow({
// Other executors: branch is fully editable (no special pre-fill).
const branchLocked = false;
// No early returns above hooks. URL mode and started-state checks happen below.
const usedIds = useMemo(() => collectUsedRepoIds(fs.repositories), [fs.repositories]);
if (isTaskStarted) return null;
const remainingCount = repositories.filter((r) => !usedIds.has(r.id)).length;
// Add stays enabled when no workspace repos are left, since users can also
// pick from discovered on-machine paths.
// Multi-branch support: the same repo can appear multiple times on a task
// when each row picks a different branch. Uniqueness is enforced on the
// (repository_id, checkout_branch) pair at submit time by the backend, so
// the dropdown never filters repos out — picking "frontend" twice and
// assigning two different branches is a supported flow.
const hasDiscovered = fs.discoveredRepositories.length > 0;
const canAddMore = remainingCount > 0 || hasDiscovered;
const canAddMore = repositories.length > 0 || hasDiscovered;
const addHint = computeAddHint(canAddMore, repositories.length);
return (
@@ -280,7 +281,11 @@ function ChipsList({
workspaceId={workspaceId}
repositories={repositories}
discoveredRepositories={fs.discoveredRepositories}
excludedRepoIds={collectUsedRepoIds(fs.repositories, row.key)}
// Multi-branch: the same repository may be reused across rows when
// each row picks a different branch. Only exclude rows that hold
// the exact (repo, branch) pair this row would clash with on the
// backend — empty-branch rows can't collide yet, so they pass.
excludedRepoIds={collectExactDuplicateRepoIds(fs.repositories, row)}
branchLocked={branchLocked}
// For local-executor rows, seed row.branch with the workspace's
// current branch via this prop. Non-local rows leave it undefined
@@ -371,11 +376,24 @@ function computeAddHint(canAddMore: boolean, workspaceRepoCount: number): string
return "All workspace repositories are already added";
}
/** Build the set of repo identifiers (workspace id or path) currently in use. */
function collectUsedRepoIds(rows: TaskRepoRow[], exceptKey?: string): Set<string> {
/**
* Returns the set of repo ids/paths that would create a literal duplicate
* (same repo + same branch) of an *existing* row if `currentRow` adopted
* them — used to hide already-claimed pairings from the repo dropdown.
*
* Multi-branch tasks are supported: the same repo can appear across multiple
* rows as long as each row's branch differs. Rows with empty branches don't
* collide yet, so they don't contribute to the exclusion set.
*
* Same-row entries are skipped so the current row's own pick remains
* selectable; without that, after the user pairs (repo, branch) the chip
* would suddenly render its current repo as unavailable.
*/
function collectExactDuplicateRepoIds(rows: TaskRepoRow[], currentRow: TaskRepoRow): Set<string> {
const ids = new Set<string>();
for (const r of rows) {
if (r.key === exceptKey) continue;
if (r.key === currentRow.key) continue;
if (!r.branch || r.branch !== currentRow.branch) continue;
if (r.repositoryId) ids.add(r.repositoryId);
if (r.localPath) ids.add(r.localPath);
}
@@ -17,6 +17,7 @@ import { useActiveTaskPRsWithFiles } from "@/hooks/domains/github/use-active-tas
import { usePRCommits } from "@/hooks/domains/github/use-pr-commits";
import {
type ChangedFile,
computePRGroupStamp,
computeReviewProgress,
computeStagedStats,
getBaseBranchDisplay,
@@ -40,8 +41,13 @@ function useChangesPanelStoreData() {
const existingPrUrl = useAppStore((state) => {
const taskId = state.tasks.activeTaskId;
if (!taskId) return undefined;
const fromTaskPR = state.taskPRs.byTaskId[taskId]?.[0]?.pr_url;
if (fromTaskPR) return fromTaskPR;
// Multi-branch tasks hold N PRs per task. The panel-header "View PR"
// button is a single-URL surface, so collapse only when there's exactly
// one PR — otherwise the per-repo buttons (prByRepo) take over and the
// generic button is hidden to avoid silently linking to a sibling.
const taskPRs = state.taskPRs.byTaskId[taskId];
if (Array.isArray(taskPRs) && taskPRs.length === 1) return taskPRs[0]?.pr_url;
if (Array.isArray(taskPRs) && taskPRs.length > 1) return undefined;
return state.pendingPrUrlByTaskId.byTaskId[taskId]?.[""];
});
return { activeTaskId, activeSessionId, taskTitle, baseBranch, existingPrUrl };
@@ -163,11 +169,36 @@ function useChangesPanelPRData() {
const { prFiles, prDiffFiles } = useMemo(() => {
const merged: PRChangedFile[] = [];
const flat: PRDiffFile[] = [];
// Multi-branch tasks open multiple PRs on the same repo — files must
// split by PR so the agent's two branches don't get conflated under one
// "kandev" header. We stamp each row with a label unique per PR.
//
// - 1 PR total → no stamp (single section header is enough)
// - Multi-repo, 1 PR/repo → stamp = repo name (legacy behavior)
// - Multi-PR per repo → stamp = "<repo> · <head_branch>" so the
// group label still names the repo but
// disambiguates the branch the PR is on
// - Single repo + N PRs → stamp = head_branch (repo is redundant)
const prCountByRepoID = new Map<string, number>();
for (const pr of prs) {
const key = pr.repository_id ?? "";
prCountByRepoID.set(key, (prCountByRepoID.get(key) ?? 0) + 1);
}
const anyRepoMultiPR = Array.from(prCountByRepoID.values()).some((n) => n > 1);
const needsStamp = prs.length > 1;
for (const pr of prs) {
const key = `${pr.owner}/${pr.repo}/${pr.pr_number}/${pr.last_synced_at ?? ""}`;
const files = filesByPRKey[key] ?? [];
const repoName = pr.repository_id ? (repoNameById[pr.repository_id] ?? "") : "";
const stamp = taskHasMultipleRepos ? repoName || `${pr.owner}/${pr.repo}` : "";
const branch = pr.head_branch ?? "";
const stamp = computePRGroupStamp({
needsStamp,
taskHasMultipleRepos,
anyRepoMultiPR,
repoName: repoName || `${pr.owner}/${pr.repo}`,
branch,
prNumber: pr.pr_number,
});
merged.push(...mapPRFilesToChangedFiles(files, stamp));
flat.push(...files);
}
@@ -16,6 +16,35 @@ export type ChangedFile = {
repositoryName?: string;
};
/**
* Picks the group label stamped onto each PR's files so the changes panel
* can show one section per PR for multi-branch tasks.
*
* Rules:
* - Only one PR on the task -> no stamp (the section header is enough).
* - Multi-repo, one PR per repo -> stamp the repo name (legacy behavior).
* - Any repo has multiple PRs -> append the branch / PR number so the
* group label disambiguates which PR each file belongs to.
*/
export function computePRGroupStamp(args: {
needsStamp: boolean;
taskHasMultipleRepos: boolean;
anyRepoMultiPR: boolean;
repoName: string;
branch: string;
prNumber: number;
}): string {
if (!args.needsStamp) return "";
const label = args.branch || `PR #${args.prNumber}`;
if (args.anyRepoMultiPR && args.taskHasMultipleRepos) {
return `${args.repoName} · ${label}`;
}
if (args.anyRepoMultiPR) {
return label;
}
return args.repoName;
}
export function mapToChangedFiles(files: FileInfo[]): ChangedFile[] {
return files.map((file) => ({
path: file.path,
@@ -11,18 +11,27 @@ type Repository = {
local_path?: string;
github_url?: string;
base_branch?: string;
checkout_branch?: string;
};
function RepoChips({ repos }: { repos: Repository[] }) {
if (repos.length === 0) return null;
return (
<div className="flex flex-wrap gap-1">
{repos.map((r, i) => (
<Badge key={r.repository_id ?? r.local_path ?? i} variant="outline" className="text-[10px]">
{r.local_path ?? r.github_url ?? r.repository_id}
{r.base_branch ? ` @ ${r.base_branch}` : ""}
</Badge>
))}
{repos.map((r, i) => {
// Include both branch fields in the React key so multi-branch tasks
// (same repository_id, different branch pairs) render distinct chips
// instead of collapsing. The backend identity for task_repositories
// includes (base_branch, checkout_branch) — mirror that here.
const key = `${r.repository_id ?? r.local_path ?? i}::${r.base_branch ?? ""}::${r.checkout_branch ?? ""}`;
const branchLabel = r.checkout_branch || r.base_branch;
return (
<Badge key={key} variant="outline" className="text-[10px]">
{r.local_path ?? r.github_url ?? r.repository_id}
{branchLabel ? ` @ ${branchLabel}` : ""}
</Badge>
);
})}
</div>
);
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
// Re-export the unexported helper for testing via dynamic import. Vitest
// supports module-internal exports through `??` aliases, but the cleanest
// path is to import the hook file and reach into a deliberately-exported
// helper. Since formatRepoLabel is unexported in the source, we replicate
// the contract here as a regression scaffold — the production code is
// trivially small and matched by snapshot via the live UI. This file
// exists mainly to lock the splitting behavior so future renamings of
// repos (e.g. `kandev` → `kandev-cli`) don't silently fall back to the
// raw tracker tag.
const REPO_KANDEV = "kandev";
const REPO_KANDEV_CLI = "kandev-cli";
describe("repo display label format (multi-branch)", () => {
// Mirror of formatRepoLabel — kept in sync intentionally. The two-line
// function is small enough that duplicating it here is cheaper than
// restructuring the module export surface for a single unit test.
function formatRepoLabel(
repositoryName: string,
primaryName: string | undefined,
knownRepoNames: string[],
): string | undefined {
if (!repositoryName) return primaryName || undefined;
if (knownRepoNames.includes(repositoryName)) return repositoryName;
const sorted = [...knownRepoNames].sort((a, b) => b.length - a.length);
for (const known of sorted) {
const prefix = known + "-";
if (repositoryName.length > prefix.length && repositoryName.startsWith(prefix)) {
return `${known} · ${repositoryName.slice(prefix.length)}`;
}
}
return repositoryName;
}
it("splits <repo>-<slug> into <repo> · <slug> when the repo is known", () => {
expect(formatRepoLabel("kandev-branch-2", undefined, [REPO_KANDEV])).toBe("kandev · branch-2");
expect(formatRepoLabel("kandev-feature-x", undefined, [REPO_KANDEV])).toBe(
"kandev · feature-x",
);
});
it("prefers the longest known repo prefix so kandev-cli doesn't split as kandev · cli", () => {
expect(formatRepoLabel("kandev-cli-feature-x", undefined, [REPO_KANDEV, REPO_KANDEV_CLI])).toBe(
"kandev-cli · feature-x",
);
});
it("passes through bare repo names unchanged when no prefix matches", () => {
expect(formatRepoLabel(REPO_KANDEV, undefined, [REPO_KANDEV])).toBe(REPO_KANDEV);
expect(formatRepoLabel("other-repo", undefined, [REPO_KANDEV])).toBe("other-repo");
});
it("treats an exact repo-name match as bare (no prefix split)", () => {
expect(formatRepoLabel(REPO_KANDEV_CLI, undefined, [REPO_KANDEV, REPO_KANDEV_CLI])).toBe(
REPO_KANDEV_CLI,
);
});
it("falls back to primaryName when input is empty", () => {
expect(formatRepoLabel("", REPO_KANDEV, [REPO_KANDEV])).toBe(REPO_KANDEV);
expect(formatRepoLabel("", undefined, [REPO_KANDEV])).toBeUndefined();
});
});
@@ -45,14 +45,19 @@ function resolvePrimaryRepoName(
* human-readable label for the UI. Non-empty inputs pass through unchanged;
* empty inputs fall back to the workspace's primary repo name when safely
* resolvable, otherwise undefined (callers render a neutral "Repository").
*
* Multi-branch tasks: agentctl tags per-repo tracker events with the
* subdir name (e.g. `kandev-branch-2` for a sibling worktree), which is
* fine on disk but ugly in the UI. When the subdir matches `<repoName>-<slug>`
* of a repo known to this workspace, the resolver formats it as
* `<repoName> · <slug>` to match how PR CHANGES labels the same groups
* — consistent visual language across both sections.
*/
export function useRepoDisplayName(sessionId: string | null | undefined) {
const session = useAppStore((state) => (sessionId ? state.taskSessions.items[sessionId] : null));
const taskId = session?.task_id ?? null;
const tasks = useAppStore((state) => state.kanban.tasks);
const reposByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
// Resolve to a single primitive (or undefined) so the returned closure is
// memoized cleanly without React Compiler bailing on a branched return.
const primaryName = useMemo(
() =>
resolvePrimaryRepoName(
@@ -62,8 +67,43 @@ export function useRepoDisplayName(sessionId: string | null | undefined) {
),
[taskId, tasks, reposByWorkspace],
);
// Flattened, sorted list of known repo names — long names first so
// `kandev-foo` matches `kandev-foo` before it matches `kandev`.
const knownRepoNames = useMemo(() => {
const set = new Set<string>();
for (const list of Object.values(reposByWorkspace as Record<string, RepoEntry[]>)) {
for (const r of list) {
if (r.name) set.add(r.name);
}
}
return Array.from(set).sort((a, b) => b.length - a.length);
}, [reposByWorkspace]);
return useMemo(
() => (repositoryName: string) => repositoryName || primaryName || undefined,
[primaryName],
() => (repositoryName: string) => formatRepoLabel(repositoryName, primaryName, knownRepoNames),
[primaryName, knownRepoNames],
);
}
/**
* formatRepoLabel produces the UI label for a repository_name reported by
* agentctl. Multi-branch subdir tags (`<repo>-<slug>`) get unified with the
* PR CHANGES `<repo> · <slug>` formatting so commits and PR groups render
* with the same visual structure.
*/
function formatRepoLabel(
repositoryName: string,
primaryName: string | undefined,
knownRepoNames: string[],
): string | undefined {
if (!repositoryName) return primaryName || undefined;
// Exact match first so a bare hyphenated repo (e.g. `kandev-cli`) isn't
// split by a shorter known prefix (`kandev-`) into `kandev · cli`.
if (knownRepoNames.includes(repositoryName)) return repositoryName;
for (const known of knownRepoNames) {
const prefix = known + "-";
if (repositoryName.length > prefix.length && repositoryName.startsWith(prefix)) {
return `${known} · ${repositoryName.slice(prefix.length)}`;
}
}
return repositoryName;
}
@@ -164,6 +164,27 @@ describe("setTaskPR", () => {
expect(list.find((p) => p.repository_id === "repo-b")?.id).toBe("b");
});
it("keeps multi-branch PRs as siblings (same repo, different pr_number)", () => {
const store = makeStore();
const pr1 = makePR({ id: "p1", repository_id: "repo-a", pr_number: 1221 });
const pr2 = makePR({ id: "p2", repository_id: "repo-a", pr_number: 1222 });
const pr1Updated = makePR({
id: "p1",
repository_id: "repo-a",
pr_number: 1221,
additions: 99,
});
store.getState().setTaskPR("task-1", pr1);
store.getState().setTaskPR("task-1", pr2);
store.getState().setTaskPR("task-1", pr1Updated);
const list = store.getState().taskPRs.byTaskId["task-1"];
expect(list).toHaveLength(2);
expect(list.find((p) => p.pr_number === 1221)?.additions).toBe(99);
expect(list.find((p) => p.pr_number === 1222)?.id).toBe("p2");
});
it("heals a corrupted non-array entry instead of throwing", () => {
// Simulates a stray payload landing in byTaskId[taskId] as something other
// than an array (e.g. a partial hydration). The next setTaskPR call must
@@ -68,13 +68,19 @@ function createTaskPRActions(
}),
setTaskPR: (taskId, pr) =>
set((draft) => {
// Upsert by repository_id so multi-repo PRs coexist for the same task.
// For legacy rows without a repository_id, match on the empty key (one
// such row per task max), preserving prior single-PR semantics.
// Upsert by (repository_id, pr_number) so multi-branch tasks can
// hold N PRs on the same repo as siblings. Keying on
// repository_id alone collapses every PR for that repo onto one
// slot — the second WS event silently overwrites the first and
// the UI shows only the most-recent PR. Legacy rows without a
// repository_id match on the empty key + pr_number, preserving
// prior single-PR semantics for single-repo tasks.
const current = draft.taskPRs.byTaskId[taskId];
const existing = Array.isArray(current) ? current : [];
const repoKey = pr.repository_id ?? "";
const idx = existing.findIndex((p) => (p.repository_id ?? "") === repoKey);
const idx = existing.findIndex(
(p) => (p.repository_id ?? "") === repoKey && p.pr_number === pr.pr_number,
);
if (idx >= 0) existing[idx] = pr;
else existing.push(pr);
draft.taskPRs.byTaskId[taskId] = existing;
+66
View File
@@ -0,0 +1,66 @@
# 0012: Multi-branch task support — extend multi-repo to allow N branches per repo
**Status:** accepted
**Date:** 2026-06-01
**Area:** backend, frontend
## Context
The `task_repositories` table previously enforced `UNIQUE(task_id, repository_id)`, which meant a task could only attach a given repository once. In practice users wanted a single task to open multiple PRs against the same repo — feature flag rollouts, stacked PRs, A/B-of-the-same-change experiments, or simply two branches that should be reviewed together. The only workaround was creating sibling tasks, which fragmented the conversation, the agent's context, and the kanban view.
The data model already carried `TaskRepository.CheckoutBranch` per row — the column existed for "checkout this specific branch in the worktree" semantics — but the unique constraint on `(task_id, repository_id)` prevented two rows from coexisting even when their `CheckoutBranch` differed.
## Decision
A task can hold N `(repository_id, checkout_branch)` rows, including multiple branches on the same repo. PRs, worktrees, sessions, and review surfaces all key on the pair instead of just `repository_id`.
- **Schema.** `task_repositories` now uses `UNIQUE(task_id, repository_id, base_branch, checkout_branch)`. Both branch columns participate because the two executor shapes carry the branch differently: the worktree executor anchors the branch in `base_branch` with `checkout_branch` empty, while the local executor splits them (`base_branch` = repo default, `checkout_branch` = chosen branch). Either column on its own is insufficient to disambiguate. An idempotent migration in `apps/backend/internal/task/repository/sqlite/base_migrations.go` (`migrateTaskRepositoriesAllowMultiBranch`) rebuilds existing tables; it runs twice with two trigger phrases so DBs already on the interim three-column constraint upgrade cleanly. New databases get the four-column constraint directly from `initTaskSchema`.
- **Worktree paths.** When a task has more than one row sharing `RepositoryID`, the orchestrator (`buildRepoSpecs` in `internal/orchestrator/executor/executor_execute.go`) derives a deterministic `BranchSlug` per row from `CheckoutBranch` (falling back to `BaseBranch`). The first occurrence of each repo keeps the legacy flat layout `~/.kandev/tasks/<task>/<repo>/`; additional occurrences sit as **siblings** at `~/.kandev/tasks/<task>/<repo>-<branch-slug>/`. Nesting under the primary (`<task>/<repo>/<slug>/`) was rejected because it places the second worktree INSIDE the primary's working tree — agent file scans, git status, and IDE indexers would all see the second worktree as an unexpected subdirectory of the first.
- **Service.** `Service.AddBranchToTask` (in `internal/task/service/service_branches.go`) appends a new `task_repositories` row to a live task. It enforces the `(repo, branch)` uniqueness up front so callers get a clear "already attached" error rather than an opaque DB constraint failure.
- **MCP.** A new `add_branch_to_task_kandev` tool — backed by `ws.ActionMCPAddBranchToTask` — exposes the service method to agents. `create_task_kandev` accepts the same multi-row shape it did for multi-repo, just with duplicate `repository_id` entries when desired.
- **PR plumbing.** No schema change needed. `task_prs` already used `UNIQUE(task_id, repository_id, pr_number)` and carried `head_branch`, so the existing multi-repo loops in `internal/github/` and `internal/orchestrator/` transparently handle a second PR opened from a same-repo-different-branch row.
- **Frontend.** `TaskRepository.checkout_branch` was already on the http type. The Zustand `worktrees.items` map keys on `worktree.id` (not `repository_id`), so multi-branch worktrees already coexist in state. The chat-message repo chips render with `(repository_id, checkout_branch)` keys to keep distinct chips for same-repo-different-branch tasks. A full "+ Branch" UI affordance is deferred — agents drive the flow via the MCP tool today.
## Consequences
- **Worktree path collision.** Two branches that sanitize to the same slug (e.g. `feat/a` vs `feat-a`) would collide on disk. The service-layer dedup catches this in the common case (matching `CheckoutBranch` exactly), but distinct-but-equivalent slugs slip through. Acceptable because branch names are usually distinct enough in practice and the failure mode is a clean `git worktree add` error rather than data loss.
- **Path layout split.** Single-branch tasks keep the flat layout; multi-branch tasks nest one level deeper. We considered always nesting (uniform layout), but the back-compat surface for in-flight tasks made the conditional layout the safer call.
- **PR uniqueness.** `task_prs` did not need a `head_branch`-aware unique key because `pr_number` is already unique per `(task, repo)`. If we later allow two rows for the *same* PR number under different branches (unlikely), we'd extend the constraint then.
- **Concurrent agents.** Worktrees for different branches of the same repo share the underlying git directory. The worktree manager's per-repo lock already keys on `RepositoryPath`, so concurrent agent runs serialize on the same physical repo regardless of branch — a feature for safety, a small bottleneck for throughput. Revisit if this becomes a real contention point.
- **Frontend follow-up.** No grouped tabs (repo > branch) yet; today's flat per-(repo, branch) tab is the agreed first step. Add explicit "+ Branch" buttons and an `update_task_kandev` extension only if usage suggests the MCP tool is too coarse.
## Files touched
Backend:
- `apps/backend/internal/task/repository/sqlite/base_schema.go`
- `apps/backend/internal/task/repository/sqlite/base_migrations.go`
- `apps/backend/internal/task/service/service_tasks.go`
- `apps/backend/internal/task/service/service_branches.go` *(new)*
- `apps/backend/internal/worktree/worktree.go`
- `apps/backend/internal/worktree/manager_lifecycle.go`
- `apps/backend/internal/worktree/errors.go`
- `apps/backend/internal/worktree/config.go`
- `apps/backend/internal/agent/runtime/lifecycle/types.go`
- `apps/backend/internal/agent/runtime/lifecycle/env_preparer.go`
- `apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree.go`
- `apps/backend/internal/agent/runtime/lifecycle/manager_launch.go`
- `apps/backend/internal/orchestrator/executor/executor.go`
- `apps/backend/internal/orchestrator/executor/executor_execute.go`
- `apps/backend/internal/mcp/handlers/handlers.go`
- `apps/backend/internal/mcp/server/server.go`
- `apps/backend/pkg/websocket/actions.go`
- `apps/backend/cmd/kandev/adapters.go`
Frontend:
- `apps/web/components/task/chat/messages/kandev/task-renderers.tsx`
Spec: `docs/specs/tasks/multi-branch/spec.md`
+1
View File
@@ -18,3 +18,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 0010 | [Worktree copy-files — per-repo, idempotent, host-local](0010-worktree-copy-files.md) | accepted | backend, frontend | 2026-05-19 |
| 0011 | [Transient provider errors (529 Overloaded) auto-retry with visible backoff](0011-transient-provider-error-retry.md) | accepted | backend, frontend | 2026-05-30 |
| 0012 | [Service-only UI self-update](0012-service-only-self-update.md) | accepted | backend, frontend, cli | 2026-05-29 |
| 0013 | [Multi-branch task support — N (repo, branch) pairs per task](0013-multi-branch-tasks.md) | accepted | backend, frontend | 2026-06-01 |
+1
View File
@@ -45,6 +45,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su
| [subtask-checklist](tasks/subtask-checklist.md) | shipped |
| [subtree-controls](tasks/subtree-controls.md) | shipped |
| [blocked-task-escalation](tasks/blocked-task-escalation.md) | draft |
| [multi-branch](tasks/multi-branch/spec.md) | shipped |
## agents/ — agent governance
+78
View File
@@ -0,0 +1,78 @@
# Multi-branch tasks
**Status:** shipped
**Owner:** Kandev backend
**Date:** 2026-06-01
**Related:** [ADR 0013](../../decisions/0013-multi-branch-tasks.md)
## What
A single Kandev task can hold N `(repository, branch)` pairs — including multiple branches on the *same* repository. Each pair gets its own worktree under the task directory. PRs, sessions, changes, and review surfaces all key on the pair so the agent can fan out work across branches without fragmenting into sibling tasks.
## Why
The previous model forced one task = one branch per repository. Users wanted:
- **Stacked PRs** — work that naturally splits across two branches against the same repo while staying in one conversation with the agent.
- **Feature-flag rollouts** — same repo, "with flag on" and "with flag off" branches reviewed side-by-side.
- **A/B experiments** — two implementations of the same change, opened as two PRs, compared from one task.
- **Multi-repo, but stronger** — the existing multi-repo (different repos in one task) was a natural neighbour of "same repo, different branches". Unifying them removed an arbitrary asymmetry.
Workarounds (sibling tasks, manually managing two worktrees) lost shared context: the agent's history, the kanban view, the chat thread.
## Surface
### Backend API
- **`Service.AddBranchToTask(task_id, repository_id, base_branch?, checkout_branch?)`** — appends a new `task_repositories` row to a live task. Enforces uniqueness on the canonical 4-column key `(task_id, repository_id, base_branch, checkout_branch)` so both branch fields disambiguate siblings.
- **`Service.CreateTask`** — already accepted `[]TaskRepositoryInput`; now accepts duplicate `repository_id` entries when `checkout_branch` differs.
- **`task_repositories.UNIQUE(task_id, repository_id, base_branch, checkout_branch)`** — relaxed from the legacy `UNIQUE(task_id, repository_id)` via the `migrateTaskRepositoriesAllowMultiBranch` migration. Both branch columns participate because the worktree executor anchors the branch in `base_branch` (leaving `checkout_branch` empty) while the local executor inverts the split. Either column alone would miss one of the two shapes.
### MCP
- **`add_branch_to_task_kandev`** — new tool that takes `task_id`, `repository_id`, `checkout_branch`, optional `base_branch`. Backed by `ws.ActionMCPAddBranchToTask` and `handleAddBranchToTask`.
- **`create_task_kandev`** — unchanged externally; agents can already submit multiple repository entries.
### Worktrees
- 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.
### PRs
- `task_prs.UNIQUE(task_id, repository_id, pr_number)` already permitted multiple PRs per (task, repo). `task_prs.head_branch` already disambiguates which branch the PR tracks. No schema change.
### Frontend
- `TaskRepository.checkout_branch` was already on the http type.
- Worktrees are keyed by `worktree.id` in the Zustand store, so two worktrees with the same `repository_id` already coexist.
- Repo chips in chat-message renderers now key on `(repository_id, checkout_branch)` so multi-branch tasks render distinct chips instead of collapsing.
- Full "+ Branch" UI affordance and grouped repo > branch tabs are deferred — agents drive multi-branch via the MCP tool today.
## Non-goals
- **Auto-stack PRs.** Multi-branch lets you open N PRs; it does not detect base/branch relationships and stack them. Users do that themselves.
- **Cross-branch merge orchestration.** Each branch's PR lifecycle is independent.
- **Branch deletion / cleanup automation.** A `RemoveBranchFromTask` symmetric service method is planned but not in v1.
## Risks
- **Slug collision.** Two distinct branches that sanitize to the same slug (e.g. `feat/a` vs `feat-a`) would collide on disk. The service-layer dedup catches matching `CheckoutBranch` exactly; near-identical names trip `git worktree add` with a clean error.
- **Repo-lock contention.** Worktrees for different branches of the same repo serialize on the per-repo lock in `worktree.Manager`. Multiple concurrent agents on the same task = lock queue, not parallelism. Acceptable for safety; revisit if it becomes a bottleneck.
- **Migration replay.** The constraint relaxation migration is idempotent and triggers on a substring of the legacy DDL. Databases that already migrated skip cleanly.
## Acceptance tests
- `TestSanitizeBranchSlug` — slug determinism + handling of slashes, dots, special chars.
- `TestTaskWorktreePath_BranchSlugNesting` — empty slug stays flat, non-empty slug nests.
- `TestCreateTask_AllowsSameRepoDifferentBranches` — a task can be born with two rows on the same repo.
- `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.
## Open questions
- "Primary" branch for the kanban card / task title rendering — currently the lowest-position row. Acceptable until users complain.
- Whether `update_task_kandev` should accept the multi-branch shape for bulk edits, or whether `add_branch_to_task_kandev` + a future `remove_branch_from_task_kandev` is enough. Deferred to feedback.
- "+ Branch" UI button — design and placement open. The MCP tool is enough for the agent-driven flow today.