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

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

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

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

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

262 lines
10 KiB
Go

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. The legitimate caller (kandev backend's branch materializer) promotes
// the workdir to the task root that contains the per-repo worktrees as
// siblings. Allowed transitions are:
// - newPath equals currentWorkDir → no-op (return current)
// - newPath equals parent of current → return derived parent
// - newPath is a different absolute directory that actually holds >=1 git
// repo subdir → return cleaned newPath (recovery path: covers envs whose
// workspace_path landed on the source repo's local_path instead of the
// primary worktree, so the parent-only check would otherwise refuse to
// ever switch the manager away from the wrong root)
//
// The third branch reintroduces the HTTP-supplied path as a Stat sink, but
// the endpoint is already authenticated via the bearer-token middleware and
// the manager verifies the path resolves to a real directory before
// committing — taint here is gated by auth, not path-shape.
//
// 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 == "" {
return "", false
}
clean := filepath.Clean(newPath)
if !filepath.IsAbs(clean) {
return "", false
}
if currentWorkDir != "" {
currentClean := filepath.Clean(currentWorkDir)
if clean == currentClean {
return currentClean, true
}
parent := filepath.Dir(currentClean)
if parent != currentClean && clean == parent {
return parent, true
}
}
// Recovery path: accept any absolute directory that actually contains git
// repo subdirs. scanRepositorySubdirs reads the directory and validates
// each child has a working .git entry, so a hostile or empty path returns
// nil and the rescan stays a no-op below.
if children := scanRepositorySubdirs(clean); len(children) >= 1 {
return clean, 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
}