Skip to content

Commit 97d2caf

Browse files
authored
Merge pull request #495 from bborn/task/1965-fix-daemon-terminating-claude-for-projec
Fix daemon terminating Claude for project-based tasks
2 parents eadc69c + de37f31 commit 97d2caf

2 files changed

Lines changed: 135 additions & 1 deletion

File tree

internal/executor/executor.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1035,6 +1035,11 @@ func (e *Executor) executeTask(ctx context.Context, task *db.Task) {
10351035
e.logger.Error("Failed to update status", "error", err)
10361036
return
10371037
}
1038+
// Keep the task struct in sync with the DB so that subsequent UpdateTask() calls
1039+
// (e.g. in setupWorktree/setupSharedWorkDir) don't accidentally reset the status
1040+
// back to the original value (e.g. "queued"), which would cause pollTmuxSession
1041+
// to immediately return {Interrupted: true} and create an infinite retry loop.
1042+
task.Status = db.StatusProcessing
10381043

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

4321+
// Skip worktree removal for non-worktree tasks where WorktreePath is the
4322+
// project root itself. Running "git worktree remove" on the main working
4323+
// tree would fail with "fatal: is a main working tree".
4324+
isWorktree := task.WorktreePath != projectDir &&
4325+
strings.Contains(task.WorktreePath, string(filepath.Separator)+".task-worktrees"+string(filepath.Separator))
4326+
4327+
if !isWorktree {
4328+
// For non-worktree tasks, just clean up the .envrc and hooks files
4329+
// that were written to the project directory.
4330+
os.Remove(filepath.Join(task.WorktreePath, ".envrc"))
4331+
settingsPath := filepath.Join(task.WorktreePath, ".claude", "settings.local.json")
4332+
os.Remove(settingsPath)
4333+
return nil
4334+
}
4335+
43134336
// Run teardown script before removing the worktree
43144337
e.runWorktreeTeardownScript(projectDir, task.WorktreePath, task)
43154338

internal/executor/executor_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1822,3 +1822,114 @@ func TestTriggerProcessing(t *testing.T) {
18221822
}
18231823
})
18241824
}
1825+
1826+
// TestUpdateTaskDoesNotResetStatus verifies that UpdateTask() calls in
1827+
// setupWorktree/setupSharedWorkDir don't accidentally reset the task status
1828+
// back to "queued" after executeTask has set it to "processing".
1829+
// This was the root cause of the daemon terminating Claude immediately
1830+
// for project-based (non-worktree) tasks.
1831+
func TestUpdateTaskDoesNotResetStatus(t *testing.T) {
1832+
tmpDir := t.TempDir()
1833+
dbPath := filepath.Join(tmpDir, "test.db")
1834+
database, err := db.Open(dbPath)
1835+
if err != nil {
1836+
t.Fatalf("failed to open database: %v", err)
1837+
}
1838+
defer database.Close()
1839+
1840+
// Create a project
1841+
if err := database.CreateProject(&db.Project{Name: "testproj", Path: tmpDir}); err != nil {
1842+
t.Fatal(err)
1843+
}
1844+
1845+
// Create a task in queued status (as processNextTask would see it)
1846+
task := &db.Task{
1847+
Title: "Say hello",
1848+
Status: db.StatusQueued,
1849+
Project: "testproj",
1850+
}
1851+
if err := database.CreateTask(task); err != nil {
1852+
t.Fatal(err)
1853+
}
1854+
1855+
// Simulate what executeTask does:
1856+
// 1. Update DB status to processing
1857+
if err := database.UpdateTaskStatus(task.ID, db.StatusProcessing); err != nil {
1858+
t.Fatal(err)
1859+
}
1860+
// 2. Keep the struct in sync (this is the fix)
1861+
task.Status = db.StatusProcessing
1862+
1863+
// 3. Simulate setupSharedWorkDir which calls UpdateTask
1864+
task.WorktreePath = tmpDir
1865+
task.BranchName = ""
1866+
if err := database.UpdateTask(task); err != nil {
1867+
t.Fatal(err)
1868+
}
1869+
1870+
// Verify the status is still "processing" (not reset to "queued")
1871+
updatedTask, err := database.GetTask(task.ID)
1872+
if err != nil {
1873+
t.Fatal(err)
1874+
}
1875+
if updatedTask.Status != db.StatusProcessing {
1876+
t.Errorf("expected status %q after UpdateTask, got %q (status was reset!)",
1877+
db.StatusProcessing, updatedTask.Status)
1878+
}
1879+
}
1880+
1881+
// TestCleanupWorktreeNonWorktreeTask verifies that CleanupWorktree handles
1882+
// non-worktree tasks correctly (where WorktreePath is the project root).
1883+
// It should NOT attempt "git worktree remove" on the main working tree.
1884+
func TestCleanupWorktreeNonWorktreeTask(t *testing.T) {
1885+
tmpDir := t.TempDir()
1886+
dbPath := filepath.Join(tmpDir, "test.db")
1887+
database, err := db.Open(dbPath)
1888+
if err != nil {
1889+
t.Fatalf("failed to open database: %v", err)
1890+
}
1891+
defer database.Close()
1892+
1893+
projectDir := filepath.Join(tmpDir, "myproject")
1894+
os.MkdirAll(projectDir, 0755)
1895+
1896+
// Create project
1897+
if err := database.CreateProject(&db.Project{Name: "testproj", Path: projectDir}); err != nil {
1898+
t.Fatal(err)
1899+
}
1900+
1901+
cfg := config.New(database)
1902+
exec := New(database, cfg)
1903+
1904+
// Non-worktree task: WorktreePath == projectDir
1905+
task := &db.Task{
1906+
Title: "Test task",
1907+
Status: db.StatusBlocked,
1908+
Project: "testproj",
1909+
WorktreePath: projectDir,
1910+
}
1911+
if err := database.CreateTask(task); err != nil {
1912+
t.Fatal(err)
1913+
}
1914+
1915+
// Write files that CleanupWorktree should remove
1916+
os.MkdirAll(filepath.Join(projectDir, ".claude"), 0755)
1917+
os.WriteFile(filepath.Join(projectDir, ".envrc"), []byte("export WORKTREE_TASK_ID=1"), 0644)
1918+
os.WriteFile(filepath.Join(projectDir, ".claude", "settings.local.json"), []byte("{}"), 0644)
1919+
1920+
// CleanupWorktree should NOT error (no git worktree remove on main tree)
1921+
err = exec.CleanupWorktree(task)
1922+
if err != nil {
1923+
t.Errorf("CleanupWorktree should not error for non-worktree tasks, got: %v", err)
1924+
}
1925+
1926+
// Verify .envrc was cleaned up
1927+
if _, err := os.Stat(filepath.Join(projectDir, ".envrc")); !os.IsNotExist(err) {
1928+
t.Error("expected .envrc to be removed")
1929+
}
1930+
1931+
// Verify settings.local.json was cleaned up
1932+
if _, err := os.Stat(filepath.Join(projectDir, ".claude", "settings.local.json")); !os.IsNotExist(err) {
1933+
t.Error("expected settings.local.json to be removed")
1934+
}
1935+
}

0 commit comments

Comments
 (0)