fix(agentctl): workspace tracker falls back to first repo subdir for multi-repo task roots

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>
This commit is contained in:
Kandev Agent
2026-04-26 09:28:27 +01:00
parent ffef4da34a
commit a69d4ef700
2 changed files with 114 additions and 1 deletions
@@ -84,7 +84,15 @@ type WorkspaceTracker struct {
// NewWorkspaceTracker creates a new workspace tracker
func NewWorkspaceTracker(workDir string, log *logger.Logger) *WorkspaceTracker {
resolvedWorkDir := resolveExistingWorkDir(workDir, log.WithFields(zap.String("component", "workspace-tracker")))
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)
@@ -117,6 +125,38 @@ func NewWorkspaceTracker(workDir string, log *logger.Logger) *WorkspaceTracker {
}
}
// 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.
@@ -0,0 +1,73 @@
package process
import (
"context"
"os"
"path/filepath"
"testing"
)
// TestWorkspaceTracker_FallsBackToRepoSubdir verifies the fix for the
// "changes panel empty on multi-repo task" issue. When the agent's workspace
// path is the multi-repo task root (a plain holder directory), the workspace
// tracker should fall back to the first git-repo child so it can still emit
// git status updates for at least one repo.
func TestWorkspaceTracker_FallsBackToRepoSubdir(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
// Build a synthetic task root that holds the test repo as a real
// sub-directory, matching what env_preparer creates for multi-repo tasks
// (worktrees placed under ~/.kandev/tasks/{TaskDirName}/{RepoName}/).
// We rename rather than symlink because the production layout uses real
// directories — and os.ReadDir's IsDir() returns false for symlinks.
taskRoot := t.TempDir()
repoSubdir := filepath.Join(taskRoot, "frontend")
if err := os.Rename(repoDir, repoSubdir); err != nil {
t.Fatalf("rename: %v", err)
}
wt := NewWorkspaceTracker(taskRoot, newTestLogger(t))
// The tracker must have resolved its workDir to the repo subdir, otherwise
// getGitStatus would fail with "not a git repository".
if wt.workDir == taskRoot {
t.Fatalf("expected workDir to fall back to %q, got the bare task root %q", repoSubdir, taskRoot)
}
status, err := wt.getGitStatus(context.Background())
if err != nil {
t.Fatalf("getGitStatus on resolved subdir: %v", err)
}
if status.Branch == "" {
t.Errorf("expected non-empty branch from the fallback subdir; got status=%+v", status)
}
}
// TestWorkspaceTracker_NoFallbackForRealRepo verifies the fallback only kicks
// in when the work dir itself is non-git. Single-repo tasks must keep their
// previous behavior verbatim.
func TestWorkspaceTracker_NoFallbackForRealRepo(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
wt := NewWorkspaceTracker(repoDir, newTestLogger(t))
if wt.workDir != repoDir {
t.Errorf("real git repo workDir should not be rewritten; got %q want %q", wt.workDir, repoDir)
}
}
// TestWorkspaceTracker_NoFallbackWhenNoGitChild verifies that a truly empty
// task root (no git children) doesn't try to fall back to anything — the
// tracker keeps the original workDir and just emits no git data.
func TestWorkspaceTracker_NoFallbackWhenNoGitChild(t *testing.T) {
tmp := t.TempDir()
if err := os.MkdirAll(filepath.Join(tmp, "not-a-repo"), 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
wt := NewWorkspaceTracker(tmp, newTestLogger(t))
if wt.workDir != tmp {
t.Errorf("expected workDir unchanged when no git child exists; got %q", wt.workDir)
}
}