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
4 changes: 2 additions & 2 deletions cmd/task/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2004,7 +2004,7 @@ Examples:
Worktrees are archived first (preserving uncommitted changes in git refs),
then the directory is removed. Archived tasks can be restored with 'unarchive'.

The default max age is 7 days. Use --max-age to override.
The default max age is 24 hours. Use --max-age to override.
Use --dry-run to preview what would be cleaned up.

Examples:
Expand Down Expand Up @@ -2072,7 +2072,7 @@ Examples:
},
}
worktreesCleanupCmd.Flags().Bool("dry-run", false, "Show what would be removed without making changes")
worktreesCleanupCmd.Flags().String("max-age", "", "Maximum age before cleanup (e.g., 168h, 72h, 0 for all). Default: 168h (7 days)")
worktreesCleanupCmd.Flags().String("max-age", "", "Maximum age before cleanup (e.g., 24h, 72h, 0 for all). Default: 24h (1 day)")
worktreesCmd.AddCommand(worktreesCleanupCmd)
rootCmd.AddCommand(worktreesCmd)

Expand Down
13 changes: 13 additions & 0 deletions internal/executor/cleanup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package executor

import "testing"

func TestDefaultWorktreeCleanupMaxAgeIsShort(t *testing.T) {
// Regression test: the default must be short enough that heavy task batches
// can't accumulate stale worktrees for a week. 7 days was the prior value
// and caused a disk-fill incident.
hours := int(DefaultWorktreeCleanupMaxAge.Hours())
if hours > 48 {
t.Errorf("DefaultWorktreeCleanupMaxAge is %d hours - must be <= 48h to keep cleanup prompt", hours)
}
}
36 changes: 32 additions & 4 deletions internal/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ const DoneTaskCleanupTimeout = 30 * time.Minute

// DefaultWorktreeCleanupMaxAge is the default time after completion before a done/archived
// task's worktree is automatically archived and removed to reclaim disk space.
const DefaultWorktreeCleanupMaxAge = 7 * 24 * time.Hour // 7 days
// Each worktree can hold hundreds of megabytes (node_modules, build artifacts, binary
// outputs like MP4s), so an aggressive default is important to prevent disk fill.
const DefaultWorktreeCleanupMaxAge = 24 * time.Hour // 1 day

const (
defaultExecutorSlug = "claude"
Expand Down Expand Up @@ -727,11 +729,11 @@ func (e *Executor) worker(ctx context.Context) {
// Check for idle blocked tasks to suspend every 60 seconds (30 ticks)
// Check for due scheduled tasks every 10 seconds (5 ticks)
// Check for inactive done tasks to cleanup every 5 minutes (150 ticks)
// Check for stale worktrees to archive every 1 hour (1800 ticks)
// Check for stale worktrees to archive every 10 minutes (300 ticks)
tickCount := 0
const suspendCheckInterval = 30
const doneCleanupInterval = 150 // 5 minutes at 2 second ticks
const staleWorktreeInterval = 1800 // 1 hour at 2 second ticks
const doneCleanupInterval = 150 // 5 minutes at 2 second ticks
const staleWorktreeInterval = 300 // 10 minutes at 2 second ticks

for {
select {
Expand Down Expand Up @@ -3482,6 +3484,32 @@ func (e *Executor) setupWorktree(task *db.Task) (string, error) {
task.BranchName = ""
}

// Auto-restore from saved state if the task was previously closed and its
// worktree was saved (committed + uncommitted + tracked + untracked + any
// declared artifacts). This makes the worktree lifecycle invisible: when a
// done/archived task is queued for retry or moved back into an active state,
// its worktree (and via the existing --resume path, its executor session)
// transparently come back.
if task.HasArchiveState() {
e.logger.Info("Restoring saved worktree for task", "task", task.ID)
if err := e.UnarchiveWorktree(task); err != nil {
// Don't hard-fail; fall through to fresh-worktree creation so the
// task can still run. Log loudly so users notice.
e.logger.Warn("failed to restore saved worktree, creating fresh",
"task", task.ID, "error", err)
e.logLine(task.ID, "system", fmt.Sprintf("Could not restore prior worktree (%v); starting fresh", err))
} else if task.WorktreePath != "" {
// UnarchiveWorktree wrote the new path back to the DB. Re-run the
// path-exists branch above to wire env files / symlinks.
trustMiseConfig(task.WorktreePath)
e.writeWorktreeEnvFile(projectDir, task.WorktreePath, task, paths.configDir)
symlinkClaudeConfig(projectDir, task.WorktreePath)
symlinkMCPConfig(projectDir, task.WorktreePath)
copyMCPConfig(paths.configFile, projectDir, task.WorktreePath)
return task.WorktreePath, nil
}
}

// Create worktree directory inside the project
// This allows Claude to inherit the project's MCP config and settings
worktreesDir := filepath.Join(projectDir, ".task-worktrees")
Expand Down
Loading