Files
kandev/apps/backend/internal/agentctl/server/process/manager_rescan.go
T
2026-09-14 10:32:21 +00:00

390 lines
15 KiB
Go

package process
import (
"context"
"fmt"
"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) error {
return m.RescanRepositoriesWithSourceRoots(ctx, newWorkDir, nil)
}
// RescanRepositoriesWithSourceRoots discovers repository children and updates
// their tracker graph using proposed source roots as one serialized operation.
// A nil roots slice retains the existing policy for compatibility callers.
func (m *Manager) RescanRepositoriesWithSourceRoots(ctx context.Context, newWorkDir string, roots []string) error {
release, err := m.admitStart()
if err != nil {
m.logger.Debug("workspace rescan rejected during teardown")
return fmt.Errorf("workspace rescan rejected during teardown: %w", err)
}
defer release()
// 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()
candidate, scopeChanged, err := m.rescanCandidateWorkDir(newWorkDir)
if err != nil {
return err
}
if roots == nil {
roots = m.currentWorkspaceSourceRoots()
} else {
roots = canonicalWorkspaceSourceRoots(roots)
}
// A remote workspace may launch with a plain task root and no repository,
// then receive its first repository after launch. Preserve that root as the
// file-tree scope when it is rescanned; otherwise NewWorkspaceTracker's
// single-repo compatibility fallback would replace it with the new child
// and hide files that remain at the task root.
forceBareRoot := scopeChanged || m.hasBareMultiRepoTrackerGraph() || m.hasBareTaskRootTracker(candidate)
return m.reconcileWorkspaceTrackerGraph(ctx, candidate, roots, forceBareRoot)
}
// rescanCandidateWorkDir resolves and validates a proposed tracking root
// without committing it, so a failed rescan retains the active graph.
func (m *Manager) rescanCandidateWorkDir(newWorkDir string) (string, bool, error) {
m.repoTrackersMu.RLock()
candidate := m.cfg.WorkDir
m.repoTrackersMu.RUnlock()
if newWorkDir == "" || newWorkDir == candidate {
return candidate, false, nil
}
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 "", false, fmt.Errorf("invalid workspace work_dir: %s", newWorkDir)
}
// resolveRescanPath returns only the configured workspace root or its
// direct parent, so this Stat never receives an arbitrary request path.
if info, err := os.Stat(resolved); err == nil && info.IsDir() {
return resolved, true, nil
} else {
m.logger.Warn("workspace rescan: ignoring invalid work_dir",
zap.String("work_dir", newWorkDir), zap.Error(err))
return "", false, fmt.Errorf("workspace work_dir is inaccessible: %s", newWorkDir)
}
}
func (m *Manager) commitRescanWorkspaceState(workDir string, roots []string) {
m.repoTrackersMu.Lock()
m.cfg.WorkDir = workDir
m.workspaceSourceRoots = append([]string(nil), roots...)
m.cfg.WorkspaceSourceRoots = append([]string(nil), roots...)
m.repoTrackersMu.Unlock()
}
// ReconcileRepositories makes the current-root repository tracker set exact.
// It is reserved for rollback after a materialized checkout has been removed:
// unlike RescanRepositories, it stops and removes trackers whose repository
// directories no longer exist. Existing trackers remain in place so their
// workspace-stream subscriptions and cached state are preserved.
func (m *Manager) ReconcileRepositories(ctx context.Context) error {
release, err := m.admitStart()
if err != nil {
return fmt.Errorf("workspace reconcile rejected during teardown: %w", err)
}
defer release()
m.rescanMu.Lock()
defer m.rescanMu.Unlock()
m.repoTrackersMu.RLock()
workDir := m.cfg.WorkDir
m.repoTrackersMu.RUnlock()
if workDir == "" {
return fmt.Errorf("workspace work_dir is required")
}
info, err := os.Stat(workDir)
if err != nil || !info.IsDir() {
return fmt.Errorf("workspace work_dir is inaccessible: %s", workDir)
}
roots := m.currentWorkspaceSourceRoots()
return m.reconcileWorkspaceTrackerGraph(ctx, workDir, roots, true)
}
// RebindWorkspace replaces the complete workspace tracker graph after the
// owning host process has been stopped. Unlike RescanRepositories it never
// retains a tracker from the previous root: retaining one would keep file and
// git events scoped to a workspace the agent no longer executes in. Calling it
// again with the previous root is the rollback operation used by lifecycle.
func (m *Manager) RebindWorkspace(ctx context.Context, workDir string) error {
return m.RebindWorkspaceWithSourceRoots(ctx, workDir, nil)
}
// RebindWorkspaceWithSourceRoots replaces the complete workspace tracker graph
// and installs proposed source roots before discovering linked repositories.
// A nil roots slice retains the current policy for compatibility callers.
func (m *Manager) RebindWorkspaceWithSourceRoots(ctx context.Context, workDir string, roots []string) error {
if workDir == "" || !filepath.IsAbs(workDir) {
return fmt.Errorf("workspace work_dir must be an absolute path")
}
resolved := filepath.Clean(workDir)
// codeql[go/path-injection] Rebind accepts an authenticated absolute workspace root and rejects inaccessible directories.
info, err := os.Stat(resolved)
if err != nil || !info.IsDir() {
return fmt.Errorf("workspace work_dir is inaccessible: %s", workDir)
}
release, err := m.admitStart()
if err != nil {
return fmt.Errorf("workspace rebind rejected during teardown: %w", err)
}
defer release()
m.rescanMu.Lock()
defer m.rescanMu.Unlock()
if roots == nil {
roots = m.currentWorkspaceSourceRoots()
} else {
roots = canonicalWorkspaceSourceRoots(roots)
}
return m.replaceWorkspaceTrackerGraph(ctx, resolved, roots)
}
// 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, roots []string, subs []types.WorkspaceStreamSubscriber) {
m.logger.Info("transitioning workspace to multi-repo mode",
zap.String("work_dir", workDir),
zap.Int("children", len(children)))
bareRoot := m.newTrackerForRepo(workDir, "")
m.configureTracker(bareRoot, "", roots)
m.prepareTrackerComparisonTarget(bareRoot, false)
bareRoot.Start(ctx)
for _, sub := range subs {
bareRoot.AttachWorkspaceStreamSubscriber(sub)
}
newRepoTrackers := make([]*WorkspaceTracker, 0, len(children))
for _, child := range children {
tracker := m.newTrackerForRepo(child.path, child.name)
m.configureTracker(tracker, child.name, roots)
m.prepareTrackerComparisonTarget(tracker, false)
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.applyWorkspacePollModeLocked(append([]*WorkspaceTracker{bareRoot}, newRepoTrackers...)...)
m.cfg.WorkDir = workDir
m.workspaceSourceRoots = append([]string(nil), roots...)
m.cfg.WorkspaceSourceRoots = append([]string(nil), roots...)
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, workDir string, children []repositorySubdir, roots []string, 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 := m.newTrackerForRepo(child.path, child.name)
m.configureTracker(tracker, child.name, roots)
m.prepareTrackerComparisonTarget(tracker, false)
tracker.Start(ctx)
for _, sub := range subs {
tracker.AttachWorkspaceStreamSubscriber(sub)
}
newTrackers = append(newTrackers, tracker)
}
// 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.applyWorkspacePollModeLocked(t)
}
m.cfg.WorkDir = workDir
m.workspaceSourceRoots = append([]string(nil), roots...)
m.cfg.WorkspaceSourceRoots = append([]string(nil), roots...)
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()
}
}
func (m *Manager) reconcileRepoTrackers(ctx context.Context, workDir string, children []repositorySubdir, roots []string, subs []types.WorkspaceStreamSubscriber) {
wanted := make(map[repositoryTrackerKey]struct{}, len(children))
for _, child := range children {
wanted[repositoryTrackerIdentity(child.name, child.path)] = struct{}{}
}
m.repoTrackersMu.Lock()
retained := make([]*WorkspaceTracker, 0, len(m.repoTrackers))
removed := make([]*WorkspaceTracker, 0)
for _, tracker := range m.repoTrackers {
identity := repositoryTrackerIdentity(tracker.RepositoryName(), tracker.workDir)
if _, ok := wanted[identity]; ok {
retained = append(retained, tracker)
delete(wanted, identity)
continue
}
removed = append(removed, tracker)
}
m.repoTrackersMu.Unlock()
newTrackers := make([]*WorkspaceTracker, 0, len(wanted))
for _, child := range children {
if _, needed := wanted[repositoryTrackerIdentity(child.name, child.path)]; !needed {
continue
}
tracker := m.newTrackerForRepo(child.path, child.name)
m.configureTracker(tracker, child.name, roots)
m.prepareTrackerComparisonTarget(tracker, false)
tracker.Start(ctx)
for _, sub := range subs {
tracker.AttachWorkspaceStreamSubscriber(sub)
}
newTrackers = append(newTrackers, tracker)
}
m.repoTrackersMu.Lock()
retained = append(retained, newTrackers...)
m.repoTrackers = retained
m.applyWorkspacePollModeLocked(newTrackers...)
m.cfg.WorkDir = workDir
m.workspaceSourceRoots = append([]string(nil), roots...)
m.cfg.WorkspaceSourceRoots = append([]string(nil), roots...)
m.repoTrackersMu.Unlock()
for _, tracker := range removed {
for _, sub := range subs {
tracker.DetachWorkspaceStreamSubscriber(sub)
}
tracker.Stop()
}
}
type repositoryTrackerKey struct {
name string
path string
}
func repositoryTrackerIdentity(name, path string) repositoryTrackerKey {
return repositoryTrackerKey{name: name, path: filepath.Clean(path)}
}
// 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
//
// 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
}
}
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
}