From 3a775cf0bc5f53f0f6dd046926cc8d90980cdae7 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 28 Apr 2026 07:12:30 -0500 Subject: [PATCH] Auto-clean and auto-restore worktrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DefaultWorktreeCleanupMaxAge 7d → 24h - Stale-cleanup pass 1h → 10min - setupWorktree auto-unarchives saved state when no live worktree exists, so closed tasks transparently come back when retried/queued Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/task/main.go | 4 ++-- internal/executor/cleanup_test.go | 13 +++++++++++ internal/executor/executor.go | 36 +++++++++++++++++++++++++++---- 3 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 internal/executor/cleanup_test.go diff --git a/cmd/task/main.go b/cmd/task/main.go index f90659a3..f781a9a9 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -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: @@ -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) diff --git a/internal/executor/cleanup_test.go b/internal/executor/cleanup_test.go new file mode 100644 index 00000000..8f88457b --- /dev/null +++ b/internal/executor/cleanup_test.go @@ -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) + } +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 2e01495f..5371ec29 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -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" @@ -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 { @@ -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")