Skip to content
Merged
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
25 changes: 24 additions & 1 deletion internal/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -1035,6 +1035,11 @@ func (e *Executor) executeTask(ctx context.Context, task *db.Task) {
e.logger.Error("Failed to update status", "error", err)
return
}
// Keep the task struct in sync with the DB so that subsequent UpdateTask() calls
// (e.g. in setupWorktree/setupSharedWorkDir) don't accidentally reset the status
// back to the original value (e.g. "queued"), which would cause pollTmuxSession
// to immediately return {Interrupted: true} and create an infinite retry loop.
task.Status = db.StatusProcessing

// Log start and trigger hook
startMsg := fmt.Sprintf("Starting task #%d: %s", task.ID, task.Title)
Expand Down Expand Up @@ -1120,7 +1125,10 @@ func (e *Executor) executeTask(ctx context.Context, task *db.Task) {
// Update final status and trigger hooks
// Respect status set by hooks - don't override blocked with done
if result.Interrupted {
// Status already set by Interrupt(), just run hook
// Explicitly set to backlog - don't assume Interrupt() already did it,
// as the interruption may have come from pollTmuxSession detecting a
// stale status or context cancellation.
e.updateStatus(task.ID, db.StatusBacklog)
e.hooks.OnStatusChange(task, db.StatusBacklog, "Task interrupted by user")
// Kill executor process to free memory when task is interrupted
taskExecutor.Kill(task.ID)
Expand Down Expand Up @@ -4310,6 +4318,21 @@ func (e *Executor) CleanupWorktree(task *db.Task) error {
}
paths := e.claudePathsForProject(task.Project)

// Skip worktree removal for non-worktree tasks where WorktreePath is the
// project root itself. Running "git worktree remove" on the main working
// tree would fail with "fatal: is a main working tree".
isWorktree := task.WorktreePath != projectDir &&
strings.Contains(task.WorktreePath, string(filepath.Separator)+".task-worktrees"+string(filepath.Separator))

if !isWorktree {
// For non-worktree tasks, just clean up the .envrc and hooks files
// that were written to the project directory.
os.Remove(filepath.Join(task.WorktreePath, ".envrc"))
settingsPath := filepath.Join(task.WorktreePath, ".claude", "settings.local.json")
os.Remove(settingsPath)
return nil
}

// Run teardown script before removing the worktree
e.runWorktreeTeardownScript(projectDir, task.WorktreePath, task)

Expand Down
111 changes: 111 additions & 0 deletions internal/executor/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1822,3 +1822,114 @@ func TestTriggerProcessing(t *testing.T) {
}
})
}

// TestUpdateTaskDoesNotResetStatus verifies that UpdateTask() calls in
// setupWorktree/setupSharedWorkDir don't accidentally reset the task status
// back to "queued" after executeTask has set it to "processing".
// This was the root cause of the daemon terminating Claude immediately
// for project-based (non-worktree) tasks.
func TestUpdateTaskDoesNotResetStatus(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
database, err := db.Open(dbPath)
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
defer database.Close()

// Create a project
if err := database.CreateProject(&db.Project{Name: "testproj", Path: tmpDir}); err != nil {
t.Fatal(err)
}

// Create a task in queued status (as processNextTask would see it)
task := &db.Task{
Title: "Say hello",
Status: db.StatusQueued,
Project: "testproj",
}
if err := database.CreateTask(task); err != nil {
t.Fatal(err)
}

// Simulate what executeTask does:
// 1. Update DB status to processing
if err := database.UpdateTaskStatus(task.ID, db.StatusProcessing); err != nil {
t.Fatal(err)
}
// 2. Keep the struct in sync (this is the fix)
task.Status = db.StatusProcessing

// 3. Simulate setupSharedWorkDir which calls UpdateTask
task.WorktreePath = tmpDir
task.BranchName = ""
if err := database.UpdateTask(task); err != nil {
t.Fatal(err)
}

// Verify the status is still "processing" (not reset to "queued")
updatedTask, err := database.GetTask(task.ID)
if err != nil {
t.Fatal(err)
}
if updatedTask.Status != db.StatusProcessing {
t.Errorf("expected status %q after UpdateTask, got %q (status was reset!)",
db.StatusProcessing, updatedTask.Status)
}
}

// TestCleanupWorktreeNonWorktreeTask verifies that CleanupWorktree handles
// non-worktree tasks correctly (where WorktreePath is the project root).
// It should NOT attempt "git worktree remove" on the main working tree.
func TestCleanupWorktreeNonWorktreeTask(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
database, err := db.Open(dbPath)
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
defer database.Close()

projectDir := filepath.Join(tmpDir, "myproject")
os.MkdirAll(projectDir, 0755)

// Create project
if err := database.CreateProject(&db.Project{Name: "testproj", Path: projectDir}); err != nil {
t.Fatal(err)
}

cfg := config.New(database)
exec := New(database, cfg)

// Non-worktree task: WorktreePath == projectDir
task := &db.Task{
Title: "Test task",
Status: db.StatusBlocked,
Project: "testproj",
WorktreePath: projectDir,
}
if err := database.CreateTask(task); err != nil {
t.Fatal(err)
}

// Write files that CleanupWorktree should remove
os.MkdirAll(filepath.Join(projectDir, ".claude"), 0755)
os.WriteFile(filepath.Join(projectDir, ".envrc"), []byte("export WORKTREE_TASK_ID=1"), 0644)
os.WriteFile(filepath.Join(projectDir, ".claude", "settings.local.json"), []byte("{}"), 0644)

// CleanupWorktree should NOT error (no git worktree remove on main tree)
err = exec.CleanupWorktree(task)
if err != nil {
t.Errorf("CleanupWorktree should not error for non-worktree tasks, got: %v", err)
}

// Verify .envrc was cleaned up
if _, err := os.Stat(filepath.Join(projectDir, ".envrc")); !os.IsNotExist(err) {
t.Error("expected .envrc to be removed")
}

// Verify settings.local.json was cleaned up
if _, err := os.Stat(filepath.Join(projectDir, ".claude", "settings.local.json")); !os.IsNotExist(err) {
t.Error("expected settings.local.json to be removed")
}
}