From 6534bfe5642b6ed96fcdffa964cde35da688bd6f Mon Sep 17 00:00:00 2001 From: skippy Date: Thu, 2 Apr 2026 18:52:03 -0500 Subject: [PATCH] feat: extend daemon refresh loop to workspace and persistent agents (Story 81.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refresh loop previously only synced worker worktrees. Now it also: - Refreshes workspace worktrees (full stash/rebase/restore, same as workers) - Updates refs for persistent agents sharing the main checkout (via the per-repo fetch that already runs — no rebase needed for shared checkouts) - Skips agents whose WorktreePath equals the repo dir (shared checkout guard) Provenance: L3 --- internal/daemon/daemon.go | 30 ++++-- internal/daemon/worktree_test.go | 161 +++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 6 deletions(-) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index c755412..5f0027e 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -516,9 +516,10 @@ func (d *Daemon) worktreeRefreshLoop() { } } -// refreshWorktrees syncs worker worktrees that are behind main +// refreshWorktrees syncs worker and workspace worktrees with main branch, +// and updates refs for persistent agents sharing the main checkout. func (d *Daemon) refreshWorktrees() { - d.logger.Debug("Checking worker worktrees for refresh") + d.logger.Debug("Checking worktrees for refresh") repos := d.state.GetAllRepos() for repoName, repo := range repos { @@ -544,16 +545,27 @@ func (d *Daemon) refreshWorktrees() { continue } - // Fetch from remote to have latest state + // Fetch from remote to have latest state. + // This also satisfies persistent agents sharing the main checkout — + // they only need updated refs, not working tree changes. if err := wt.FetchRemote(remote); err != nil { d.logger.Debug("Could not fetch from remote for %s: %v", repoName, err) continue } - // Check each worker agent's worktree + // Log that persistent agents got their refs updated via the fetch above for agentName, agent := range repo.Agents { - // Only refresh worker worktrees - if agent.Type != state.AgentTypeWorker { + if agent.Type == state.AgentTypeMergeQueue || + agent.Type == state.AgentTypePRShepherd || + agent.Type == state.AgentTypeGenericPersistent { + d.logger.Debug("Refs updated for persistent agent %s/%s via fetch", repoName, agentName) + } + } + + // Refresh worktrees for workers and workspace agents + for agentName, agent := range repo.Agents { + // Only refresh agents that have their own worktrees (workers + workspace) + if agent.Type != state.AgentTypeWorker && agent.Type != state.AgentTypeWorkspace { continue } @@ -562,6 +574,12 @@ func (d *Daemon) refreshWorktrees() { continue } + // Skip if worktree path is the repo dir itself (shared checkout, not a separate worktree) + if agent.WorktreePath == repoPath { + d.logger.Debug("Skipping refresh for %s/%s: shares main checkout", repoName, agentName) + continue + } + // Check if worktree exists if _, err := os.Stat(agent.WorktreePath); os.IsNotExist(err) { continue diff --git a/internal/daemon/worktree_test.go b/internal/daemon/worktree_test.go index 426f885..ba0867e 100644 --- a/internal/daemon/worktree_test.go +++ b/internal/daemon/worktree_test.go @@ -454,6 +454,167 @@ func TestRefreshWorktrees_MultipleWorkers(t *testing.T) { d.refreshWorktrees() } +func TestRefreshWorktrees_WorkspaceAgent(t *testing.T) { + d, repoDir, cleanup := setupTestDaemonWithGitRepo(t) + defer cleanup() + + // Add repo + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + } + if err := d.state.AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + // Create a workspace worktree + wtDir := d.paths.WorktreeDir("test-repo") + if err := os.MkdirAll(wtDir, 0755); err != nil { + t.Fatalf("Failed to create worktree dir: %v", err) + } + wtPath := filepath.Join(wtDir, "workspace") + cmd := exec.Command("git", "worktree", "add", "-b", "workspace/default", wtPath, "main") + cmd.Dir = repoDir + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to create workspace worktree: %v", err) + } + + workspaceAgent := state.Agent{ + Type: state.AgentTypeWorkspace, + WorktreePath: wtPath, + TmuxWindow: "workspace", + SessionID: "workspace-session", + CreatedAt: time.Now(), + } + if err := d.state.AddAgent("test-repo", "workspace", workspaceAgent); err != nil { + t.Fatalf("Failed to add workspace agent: %v", err) + } + + // Should not panic - workspace agents are refreshed like workers + d.refreshWorktrees() +} + +func TestRefreshWorktrees_PersistentAgentSharedCheckout(t *testing.T) { + d, repoDir, cleanup := setupTestDaemonWithGitRepo(t) + defer cleanup() + + // Add repo + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + } + if err := d.state.AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + // Persistent agents share the repo dir — they should be skipped for rebase + // but their refs get updated by the per-repo fetch + for _, tc := range []struct { + name string + agentType state.AgentType + }{ + {"merge-queue", state.AgentTypeMergeQueue}, + {"pr-shepherd", state.AgentTypePRShepherd}, + {"arch-watchdog", state.AgentTypeGenericPersistent}, + } { + agent := state.Agent{ + Type: tc.agentType, + WorktreePath: repoDir, // Shares main checkout + TmuxWindow: tc.name, + SessionID: tc.name + "-session", + CreatedAt: time.Now(), + } + if err := d.state.AddAgent("test-repo", tc.name, agent); err != nil { + t.Fatalf("Failed to add agent %s: %v", tc.name, err) + } + } + + // Should not panic - persistent agents sharing main checkout are skipped for rebase + d.refreshWorktrees() +} + +func TestRefreshWorktrees_MixedAgentTypes(t *testing.T) { + d, repoDir, cleanup := setupTestDaemonWithGitRepo(t) + defer cleanup() + + // Add repo + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + } + if err := d.state.AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + // Create worktree dirs + wtDir := d.paths.WorktreeDir("test-repo") + if err := os.MkdirAll(wtDir, 0755); err != nil { + t.Fatalf("Failed to create worktree dir: %v", err) + } + + // Worker with own worktree + workerPath := filepath.Join(wtDir, "worker1") + cmd := exec.Command("git", "worktree", "add", "-b", "work/worker1", workerPath, "main") + cmd.Dir = repoDir + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to create worker worktree: %v", err) + } + if err := d.state.AddAgent("test-repo", "worker1", state.Agent{ + Type: state.AgentTypeWorker, + WorktreePath: workerPath, + TmuxWindow: "worker1", + SessionID: "worker1-session", + CreatedAt: time.Now(), + }); err != nil { + t.Fatalf("Failed to add worker: %v", err) + } + + // Workspace with own worktree + wsPath := filepath.Join(wtDir, "workspace") + cmd = exec.Command("git", "worktree", "add", "-b", "workspace/default", wsPath, "main") + cmd.Dir = repoDir + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to create workspace worktree: %v", err) + } + if err := d.state.AddAgent("test-repo", "workspace", state.Agent{ + Type: state.AgentTypeWorkspace, + WorktreePath: wsPath, + TmuxWindow: "workspace", + SessionID: "workspace-session", + CreatedAt: time.Now(), + }); err != nil { + t.Fatalf("Failed to add workspace: %v", err) + } + + // Supervisor sharing main checkout + if err := d.state.AddAgent("test-repo", "supervisor", state.Agent{ + Type: state.AgentTypeSupervisor, + WorktreePath: repoDir, + TmuxWindow: "supervisor", + SessionID: "supervisor-session", + CreatedAt: time.Now(), + }); err != nil { + t.Fatalf("Failed to add supervisor: %v", err) + } + + // Persistent agent sharing main checkout + if err := d.state.AddAgent("test-repo", "merge-queue", state.Agent{ + Type: state.AgentTypeMergeQueue, + WorktreePath: repoDir, + TmuxWindow: "merge-queue", + SessionID: "mq-session", + CreatedAt: time.Now(), + }); err != nil { + t.Fatalf("Failed to add merge-queue: %v", err) + } + + // Should not panic - handles all agent types correctly + d.refreshWorktrees() +} + func TestCleanupOrphanedWorktrees_WithActiveWorktrees(t *testing.T) { d, repoDir, cleanup := setupTestDaemonWithGitRepo(t) defer cleanup()