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:
@@ -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`
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user