diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_poll.go b/apps/backend/internal/agentctl/server/process/workspace_git_poll.go index a389bc83c3..e0be429f58 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_git_poll.go +++ b/apps/backend/internal/agentctl/server/process/workspace_git_poll.go @@ -39,6 +39,12 @@ func (wt *WorkspaceTracker) pollGitChanges(ctx context.Context) { case <-wt.stopCh: return case <-ticker.C: + // Stop polling if the work directory was deleted (worktree removed) + if !wt.workDirExists() { + wt.logger.Warn("work directory no longer exists, stopping git polling", + zap.String("workDir", wt.workDir)) + return + } wt.checkGitChanges(ctx) } } diff --git a/apps/backend/internal/agentctl/server/process/workspace_monitor.go b/apps/backend/internal/agentctl/server/process/workspace_monitor.go index 984ff508af..c3dd707ce1 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_monitor.go +++ b/apps/backend/internal/agentctl/server/process/workspace_monitor.go @@ -49,7 +49,16 @@ func (wt *WorkspaceTracker) monitorLoop(ctx context.Context) { // Quick state check using mtime + diff-files currentState, err := wt.getWorkspaceState(ctx) if err != nil { - // Git command failed - skip this cycle and retry on next tick. + // Check if the work directory has been deleted (e.g., worktree removed + // after task was archived or PR branch was deleted). If so, stop the + // tracker to avoid spamming warnings every poll cycle. + if !wt.workDirExists() { + wt.logger.Warn("work directory no longer exists, stopping workspace tracker", + zap.String("workDir", wt.workDir)) + return + } + + // Git command failed for another reason - skip this cycle and retry on next tick. // Don't update lastState so the change will be detected when git recovers. wt.logger.Warn("failed to get workspace state, will retry next cycle", zap.Error(err)) diff --git a/apps/backend/internal/agentctl/server/process/workspace_monitor_test.go b/apps/backend/internal/agentctl/server/process/workspace_monitor_test.go new file mode 100644 index 0000000000..a3b024c966 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/workspace_monitor_test.go @@ -0,0 +1,43 @@ +package process + +import ( + "context" + "os" + "testing" + "time" +) + +func TestWorkspaceTracker_StopsWhenWorkDirDeleted(t *testing.T) { + isolateTestGitEnv(t) + + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + log := newTestLogger(t) + wt := NewWorkspaceTracker(repoDir, log) + wt.gitPollInterval = 100 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + wt.Start(ctx) + + // Delete the work directory to simulate worktree removal + if err := os.RemoveAll(repoDir); err != nil { + t.Fatalf("failed to remove workdir: %v", err) + } + + // Both monitorLoop and pollGitChanges should exit within a few poll cycles + done := make(chan struct{}) + go func() { + wt.wg.Wait() + close(done) + }() + + select { + case <-done: + // Both goroutines exited — success + case <-time.After(5 * time.Second): + t.Fatal("workspace tracker goroutines did not stop after workdir was deleted") + } +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker.go b/apps/backend/internal/agentctl/server/process/workspace_tracker.go index 29f955f925..cae8fcfa94 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker.go @@ -104,6 +104,15 @@ func resolveGitIndexPath(workDir string) string { 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.