Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}

Expand All @@ -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
Expand Down
161 changes: 161 additions & 0 deletions internal/daemon/worktree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down