a69d4ef700
The "Changes" panel was empty for multi-repo tasks because the agent's workspace path is the task root — a plain holder directory that holds one git worktree per repository as siblings. The workspace tracker tried to run git commands against that bare root and silently failed, so no git status updates ever reached the UI. The tracker now detects this at construction: when the configured workDir isn't itself a git repo, it falls back to the first immediate child directory that is. The user gets the primary repo's changes in the panel; cross-repo aggregation is a follow-up. - preferGitRepoChildIfRootIsBare scans os.ReadDir entries (skipping hidden / non-directory entries) and returns the first that has a valid git index. Logs an info line when the rewrite happens so it's visible in the agentctl log. - Repo-less tasks (e.g. quick chat) and single-repo tasks keep their previous behavior verbatim — the fallback only fires when the root itself fails resolveGitIndexPath. - Tests cover: fallback when the workdir is a multi-repo holder, no-fallback for a real git repo, no-fallback when no git child exists. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
243 lines
9.2 KiB
Go
243 lines
9.2 KiB
Go
package process
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/agentctl/types"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// DefaultGitPollInterval is the default interval for polling git status
|
|
const DefaultGitPollInterval = 3 * time.Second
|
|
|
|
// fileStatus constants for FileInfo.Status values.
|
|
const (
|
|
fileStatusDeleted = "deleted"
|
|
fileStatusModified = "modified"
|
|
fileStatusUntracked = "untracked"
|
|
)
|
|
|
|
// WorkspaceTracker monitors workspace changes and provides real-time updates.
|
|
// It uses git status polling instead of fsnotify to avoid file descriptor exhaustion
|
|
// on macOS (where kqueue opens a file descriptor for every watched file).
|
|
type WorkspaceTracker struct {
|
|
workDir string
|
|
gitIndexPath string // Cached, validated path to git index file (works with worktrees)
|
|
logger *logger.Logger
|
|
|
|
// Current state
|
|
currentStatus types.GitStatusUpdate
|
|
currentFiles types.FileListUpdate
|
|
mu sync.RWMutex
|
|
|
|
// Cached git state for detecting manual operations
|
|
cachedHeadSHA string
|
|
cachedBranchName string // Current branch name for detecting branch switches
|
|
cachedIndexHash string // Hash of git status porcelain output to detect staging changes
|
|
gitStateMu sync.RWMutex
|
|
|
|
// Unified workspace stream subscribers
|
|
workspaceStreamSubscribers map[types.WorkspaceStreamSubscriber]struct{}
|
|
workspaceSubMu sync.RWMutex
|
|
|
|
// Polling intervals (used in PollModeFast)
|
|
filePollInterval time.Duration
|
|
gitPollInterval time.Duration
|
|
|
|
// pollMode is the current rate at which the polling loops scan git state.
|
|
// Default at construction is PollModeSlow — safe fallback before the gateway
|
|
// pushes a focus signal. Mutate via SetPollMode (never directly) so loops
|
|
// receive the wake-up notification on transitions.
|
|
pollMode PollMode
|
|
pollModeMu sync.RWMutex
|
|
// One channel per loop because we need both loops to wake up on a mode
|
|
// change. A single shared channel would let the first reader steal the
|
|
// signal so only one loop wakes.
|
|
monitorModeChanged chan struct{} // buffered(1); read by monitorLoop
|
|
gitPollModeChanged chan struct{} // buffered(1); read by pollGitChanges
|
|
|
|
// Overlap guards: prevent tick pile-up when git commands take longer than the poll interval.
|
|
monitorRunning int32 // atomic; 1 if monitorLoop tick is in progress
|
|
gitPollRunning int32 // atomic; 1 if pollGitChanges tick is in progress
|
|
|
|
// updateMu prevents concurrent updateGitStatus calls from the two polling loops.
|
|
// Polling loops use TryLock (skip if busy); RefreshGitStatus uses Lock (always completes).
|
|
updateMu sync.Mutex
|
|
|
|
// Control
|
|
stopCh chan struct{}
|
|
wg sync.WaitGroup
|
|
started bool
|
|
stopOnce sync.Once
|
|
initialScanDone chan struct{} // closed after monitorLoop's first getWorkspaceState; tests wait on it
|
|
tickDone chan struct{} // buffered(1); signalled after each monitorTick completes; tests wait on it
|
|
cancelCtx context.Context // Cancellable context for killing in-flight git commands on Stop
|
|
cancelFunc context.CancelFunc // Cancel function called during Stop
|
|
}
|
|
|
|
// NewWorkspaceTracker creates a new workspace tracker
|
|
func NewWorkspaceTracker(workDir string, log *logger.Logger) *WorkspaceTracker {
|
|
tlog := log.WithFields(zap.String("component", "workspace-tracker"))
|
|
resolvedWorkDir := resolveExistingWorkDir(workDir, tlog)
|
|
// Multi-repo task roots are plain directories that hold one git worktree
|
|
// per repository as siblings. The git poller can't monitor a non-git path,
|
|
// so when the configured workDir isn't a git repo, fall back to the first
|
|
// child subdirectory that is. The user sees that repo's changes in the
|
|
// uncommitted-changes panel; the per-repo aggregation across all sibling
|
|
// worktrees is a follow-up.
|
|
resolvedWorkDir = preferGitRepoChildIfRootIsBare(resolvedWorkDir, tlog)
|
|
|
|
// Cache validated git index path (works with worktrees where .git is a file)
|
|
gitIndexPath := resolveGitIndexPath(resolvedWorkDir)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
return &WorkspaceTracker{
|
|
workDir: resolvedWorkDir,
|
|
gitIndexPath: gitIndexPath,
|
|
logger: log.WithFields(zap.String("component", "workspace-tracker")),
|
|
workspaceStreamSubscribers: make(map[types.WorkspaceStreamSubscriber]struct{}),
|
|
filePollInterval: DefaultFilePollInterval,
|
|
gitPollInterval: DefaultGitPollInterval,
|
|
// Default to fast polling — matches pre-PR behavior so newly-created
|
|
// agentctl instances don't have a startup window where changes go
|
|
// undetected for up to 30s. The gateway pushes slow/paused once it
|
|
// knows no client is actively watching this workspace; until then,
|
|
// fast is the safe default (a freshly-spawned instance was always
|
|
// about to be used by someone, historically). Retained-task CPU
|
|
// savings still apply because those instances eventually receive a
|
|
// slow or paused mode push.
|
|
pollMode: PollModeFast,
|
|
monitorModeChanged: make(chan struct{}, 1),
|
|
gitPollModeChanged: make(chan struct{}, 1),
|
|
stopCh: make(chan struct{}),
|
|
initialScanDone: make(chan struct{}),
|
|
tickDone: make(chan struct{}, 1),
|
|
cancelCtx: ctx,
|
|
cancelFunc: cancel,
|
|
}
|
|
}
|
|
|
|
// preferGitRepoChildIfRootIsBare returns workDir unchanged when it's already a
|
|
// git repo, otherwise returns the path of the first immediate child directory
|
|
// that is a git repo (or worktree). Used to make the workspace tracker work
|
|
// for multi-repo task roots, which are plain holder directories with each
|
|
// repo's worktree as a child.
|
|
//
|
|
// Returns workDir unchanged when no git child is found — the tracker will
|
|
// then run with no git data, matching the pre-multi-repo behavior for
|
|
// repo-less tasks (e.g. quick chat).
|
|
func preferGitRepoChildIfRootIsBare(workDir string, log *logger.Logger) string {
|
|
if workDir == "" || resolveGitIndexPath(workDir) != "" {
|
|
return workDir
|
|
}
|
|
entries, err := os.ReadDir(workDir)
|
|
if err != nil {
|
|
return workDir
|
|
}
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
|
continue
|
|
}
|
|
candidate := filepath.Join(workDir, entry.Name())
|
|
if resolveGitIndexPath(candidate) != "" {
|
|
log.Info("workspace tracker falling back to repo subdirectory (multi-repo task root is not itself a git repo)",
|
|
zap.String("task_root", workDir),
|
|
zap.String("repo_subdir", candidate))
|
|
return candidate
|
|
}
|
|
}
|
|
return workDir
|
|
}
|
|
|
|
// resolveGitIndexPath returns the validated path to the git index file.
|
|
// Returns empty string if the path cannot be resolved or validated.
|
|
// This handles worktrees where .git is a file pointing elsewhere.
|
|
func resolveGitIndexPath(workDir string) string {
|
|
cmd := exec.Command("git", "rev-parse", "--git-dir")
|
|
cmd.Dir = workDir
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
gitDir := strings.TrimSpace(string(out))
|
|
if !filepath.IsAbs(gitDir) {
|
|
gitDir = filepath.Join(workDir, gitDir)
|
|
}
|
|
// Clean and construct the index path
|
|
gitDir = filepath.Clean(gitDir)
|
|
indexPath := filepath.Join(gitDir, "index")
|
|
// Validate the index file exists (this proves gitDir is a valid git directory)
|
|
info, err := os.Stat(indexPath)
|
|
if err != nil || info.IsDir() {
|
|
return ""
|
|
}
|
|
return indexPath
|
|
}
|
|
|
|
// workDirExists checks whether the workspace directory still exists on disk.
|
|
// Used to detect deleted worktrees and stop polling loops gracefully.
|
|
// The workDir is validated at construction time via resolveExistingWorkDir,
|
|
// so this only checks for subsequent deletion (e.g., worktree cleanup).
|
|
func (wt *WorkspaceTracker) workDirExists() bool {
|
|
_, err := os.Stat(wt.workDir) //nolint:gosec // workDir is validated at construction via resolveExistingWorkDir
|
|
return !os.IsNotExist(err)
|
|
}
|
|
|
|
// Start begins monitoring the workspace using polling (no fsnotify).
|
|
// File changes are detected via git status polling, which is efficient and
|
|
// doesn't consume file descriptors like fsnotify/kqueue does on macOS.
|
|
// The passed context is ignored — the tracker uses its own cancellable context
|
|
// so that Stop() can kill in-flight git commands immediately.
|
|
func (wt *WorkspaceTracker) Start(_ context.Context) {
|
|
wt.mu.Lock()
|
|
if wt.started {
|
|
wt.mu.Unlock()
|
|
wt.logger.Debug("workspace tracker already started, skipping")
|
|
return
|
|
}
|
|
wt.started = true
|
|
wt.mu.Unlock()
|
|
|
|
// Start file change monitoring (uses git status polling)
|
|
wt.wg.Add(1)
|
|
go wt.monitorLoop(wt.cancelCtx)
|
|
|
|
// Start git polling for detecting manual git operations (commits, resets, etc.)
|
|
wt.wg.Add(1)
|
|
go wt.pollGitChanges(wt.cancelCtx)
|
|
}
|
|
|
|
// stopTimeout is the maximum time Stop() will wait for goroutines to exit.
|
|
const stopTimeout = 5 * time.Second
|
|
|
|
// Stop stops the workspace tracker. It cancels in-flight git commands and waits
|
|
// up to 5 seconds for goroutines to exit before proceeding.
|
|
func (wt *WorkspaceTracker) Stop() {
|
|
wt.stopOnce.Do(func() {
|
|
wt.cancelFunc() // Kill in-flight git commands immediately
|
|
close(wt.stopCh)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
wt.wg.Wait()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
// Clean shutdown
|
|
case <-time.After(stopTimeout):
|
|
wt.logger.Warn("workspace tracker stop timed out, proceeding anyway",
|
|
zap.Duration("timeout", stopTimeout))
|
|
}
|
|
wt.logger.Info("workspace tracker stopped")
|
|
})
|
|
}
|