Files
kandev/apps/backend/internal/worktree/manager_state.go
T
João Salavisa fd79d6170f 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>
2026-06-01 19:36:20 +01:00

257 lines
7.7 KiB
Go

package worktree
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"go.uber.org/zap"
)
// buildWorktreeRecord constructs an in-memory Worktree value from a completed git worktree add.
func (m *Manager) buildWorktreeRecord(worktreeID string, req CreateRequest, worktreePath, branchName string) *Worktree {
now := time.Now()
return &Worktree{
ID: worktreeID,
SessionID: req.SessionID,
TaskID: req.TaskID,
RepositoryID: req.RepositoryID,
BranchSlug: SanitizeBranchSlug(req.BranchSlug),
RepositoryPath: req.RepositoryPath,
Path: worktreePath,
Branch: branchName,
BaseBranch: req.BaseBranch,
Status: StatusActive,
CreatedAt: now,
UpdatedAt: now,
}
}
// persistAndCacheWorktree saves the worktree to the store and updates the in-memory cache.
func (m *Manager) persistAndCacheWorktree(ctx context.Context, wt *Worktree, req CreateRequest, worktreePath string) error {
if m.store != nil {
if err := m.persistWorktree(ctx, wt, req, worktreePath); err != nil {
return err
}
}
// 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.BranchSlug)] = wt
m.mu.Unlock()
}
return nil
}
// persistWorktree writes the worktree to persistent storage, logging a warning when
// session_id is missing and cleaning up the git worktree directory on failure.
func (m *Manager) persistWorktree(ctx context.Context, wt *Worktree, req CreateRequest, worktreePath string) error {
if req.SessionID == "" {
m.logger.Warn("skipping worktree persistence: missing session_id",
zap.String("task_id", req.TaskID),
zap.String("worktree_id", wt.ID))
return nil
}
if err := m.store.CreateWorktree(ctx, wt); err != nil {
// Cleanup git worktree on store failure
if cleanupErr := m.removeWorktreeDir(ctx, worktreePath, req.RepositoryPath); cleanupErr != nil {
m.logger.Warn("failed to cleanup worktree after persist failure", zap.Error(cleanupErr))
}
return fmt.Errorf("failed to persist worktree: %w", err)
}
return nil
}
// 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
// sessionID. Returns the first match or nil. Caller must hold m.mu.
func (m *Manager) firstCacheEntryForSession(sessionID string) *Worktree {
prefix := sessionID + "|"
for k, wt := range m.worktrees {
if strings.HasPrefix(k, prefix) {
return wt
}
}
return nil
}
// GetBySessionID returns one worktree for the session. For multi-repo sessions
// it returns whichever worktree happens to come first; callers that need a
// specific repo's worktree should use GetBySessionAndRepo, and callers that
// need them all should use GetAllBySessionID.
func (m *Manager) GetBySessionID(ctx context.Context, sessionID string) (*Worktree, error) {
// Check cache first
m.mu.RLock()
if wt := m.firstCacheEntryForSession(sessionID); wt != nil {
m.mu.RUnlock()
return wt, nil
}
m.mu.RUnlock()
// Check store
if m.store != nil {
wt, err := m.store.GetWorktreeBySessionID(ctx, sessionID)
if err != nil {
return nil, err
}
if wt != nil {
// 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.BranchSlug)] = wt
m.mu.Unlock()
return wt, nil
}
}
return nil, ErrWorktreeNotFound
}
// GetAllBySessionID returns all active worktrees for a session. Empty slice
// (not error) if the session has none.
func (m *Manager) GetAllBySessionID(ctx context.Context, sessionID string) ([]*Worktree, error) {
if m.store == nil {
// Cache-only path: scan our in-memory map.
m.mu.RLock()
defer m.mu.RUnlock()
prefix := sessionID + "|"
var out []*Worktree
for k, wt := range m.worktrees {
if strings.HasPrefix(k, prefix) {
out = append(out, wt)
}
}
return out, nil
}
if multi, ok := m.store.(MultiRepoStore); ok {
wts, err := multi.GetWorktreesBySessionID(ctx, sessionID)
if err != nil {
return nil, err
}
// Refresh cache while we're at it.
m.mu.Lock()
for _, wt := range wts {
m.worktrees[cacheKey(sessionID, wt.RepositoryID, wt.BranchSlug)] = wt
}
m.mu.Unlock()
return wts, nil
}
// Single-repo store fallback: at most one worktree per session.
wt, err := m.store.GetWorktreeBySessionID(ctx, sessionID)
if err != nil {
return nil, err
}
if wt == nil {
return nil, nil
}
return []*Worktree{wt}, nil
}
// 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, branchSlug)]; ok {
m.mu.RUnlock()
return wt, nil
}
m.mu.RUnlock()
if m.store == nil {
return nil, ErrWorktreeNotFound
}
if multi, ok := m.store.(MultiRepoStore); ok {
wt, err := multi.GetWorktreeBySessionAndRepository(ctx, sessionID, repositoryID, branchSlug)
if err != nil {
return nil, err
}
if wt == nil {
return nil, ErrWorktreeNotFound
}
m.mu.Lock()
m.worktrees[cacheKey(sessionID, repositoryID, branchSlug)] = wt
m.mu.Unlock()
return wt, nil
}
// Legacy single-repo store: best we can do is the session lookup.
wt, err := m.store.GetWorktreeBySessionID(ctx, sessionID)
if err != nil {
return nil, err
}
if wt == nil || (repositoryID != "" && wt.RepositoryID != repositoryID) {
return nil, ErrWorktreeNotFound
}
return wt, nil
}
// GetByID returns a worktree by its unique ID.
func (m *Manager) GetByID(ctx context.Context, worktreeID string) (*Worktree, error) {
if m.store == nil {
return nil, ErrWorktreeNotFound
}
wt, err := m.store.GetWorktreeByID(ctx, worktreeID)
if err != nil {
return nil, err
}
if wt == nil {
return nil, ErrWorktreeNotFound
}
return wt, nil
}
// GetAllByTaskID returns all worktrees for a task.
func (m *Manager) GetAllByTaskID(ctx context.Context, taskID string) ([]*Worktree, error) {
if m.store == nil {
return nil, nil
}
return m.store.GetWorktreesByTaskID(ctx, taskID)
}
// IsValid checks if a worktree directory is valid and usable.
func (m *Manager) IsValid(path string) bool {
// Check directory exists
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
return false
}
// Check .git file exists (worktrees have .git file, not directory)
gitFile := filepath.Join(path, ".git")
content, err := os.ReadFile(gitFile)
if err != nil {
return false
}
// .git file should contain "gitdir: <path>"
if !strings.HasPrefix(string(content), "gitdir:") {
return false
}
return true
}