package runners import ( "context" "log/slog" "time" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/infra/notify" ) // WorktreeRow projects the worktree fields needed for prune. OwnerID and // MainPath come from a JOIN performed in the production adapter (workspace // + project), not from the worktrees table directly. type WorktreeRow struct { ID uuid.UUID OwnerID uuid.UUID Branch string MainPath string Path string } // WorktreePruneRepo is the minimal repository surface used by this runner. type WorktreePruneRepo interface { ListWorktreesNeedingPruneWarning(ctx context.Context, lastUsedBefore time.Time) ([]WorktreeRow, error) ListWorktreesNeedingPrune(ctx context.Context, lastUsedBefore time.Time) ([]WorktreeRow, error) MarkPruneWarning(ctx context.Context, id uuid.UUID) error TryStartPrune(ctx context.Context, id uuid.UUID) (WorktreeRow, error) FinishPruneSuccess(ctx context.Context, id uuid.UUID) error FinishPruneRollback(ctx context.Context, id uuid.UUID) error } // WorktreePruner abstracts the git CLI surface needed. type WorktreePruner interface { WorktreeRemove(ctx context.Context, mainPath, worktreePath string) error } // WorktreePruneConfig matches jobs.WorktreePruneConfig minus enabled/interval // (managed by scheduler). type WorktreePruneConfig struct { IdleThreshold time.Duration WarningLead time.Duration } // WorktreePrune is the B runner. type WorktreePrune struct { repo WorktreePruneRepo gitr WorktreePruner disp Dispatcher cfg WorktreePruneConfig log *slog.Logger } // NewWorktreePrune constructs the runner. func NewWorktreePrune(repo WorktreePruneRepo, gitr WorktreePruner, disp Dispatcher, cfg WorktreePruneConfig, log *slog.Logger) *WorktreePrune { if log == nil { log = slog.Default() } return &WorktreePrune{repo: repo, gitr: gitr, disp: disp, cfg: cfg, log: log} } // Run executes warning phase then execution phase. func (r *WorktreePrune) Run(ctx context.Context) { r.warningPhase(ctx) r.executionPhase(ctx) } func (r *WorktreePrune) warningPhase(ctx context.Context) { cutoff := time.Now().Add(-(r.cfg.IdleThreshold - r.cfg.WarningLead)) rows, err := r.repo.ListWorktreesNeedingPruneWarning(ctx, cutoff) if err != nil { r.log.Error("worktree_prune.list_warning", "err", err.Error()) return } for _, w := range rows { if err := r.repo.MarkPruneWarning(ctx, w.ID); err != nil { r.log.Error("worktree_prune.mark_warning", "id", w.ID, "err", err.Error()) continue } if derr := r.disp.Dispatch(ctx, notify.Message{ UserID: w.OwnerID, Topic: "worktree.prune_pending", Severity: notify.SeverityWarning, Title: "Worktree will be pruned soon", Body: "Branch " + w.Branch + " has been idle and will be removed in 3 days unless used.", Metadata: map[string]any{ "worktree_id": w.ID.String(), "branch": w.Branch, }, CreatedAt: time.Now().UTC(), }); derr != nil { r.log.Error("worktree_prune.dispatch", "id", w.ID, "err", derr.Error()) } } } func (r *WorktreePrune) executionPhase(ctx context.Context) { cutoff := time.Now().Add(-r.cfg.IdleThreshold) rows, err := r.repo.ListWorktreesNeedingPrune(ctx, cutoff) if err != nil { r.log.Error("worktree_prune.list_prune", "err", err.Error()) return } for _, w := range rows { started, err := r.repo.TryStartPrune(ctx, w.ID) if err != nil { // CAS lost (idle → other status), skip continue } if rmErr := r.gitr.WorktreeRemove(ctx, started.MainPath, started.Path); rmErr != nil { r.log.Error("worktree_prune.remove_failed", "id", w.ID, "err", rmErr.Error()) if rbErr := r.repo.FinishPruneRollback(ctx, w.ID); rbErr != nil { r.log.Error("worktree_prune.rollback", "id", w.ID, "err", rbErr.Error()) } continue } if err := r.repo.FinishPruneSuccess(ctx, w.ID); err != nil { r.log.Error("worktree_prune.finish_success", "id", w.ID, "err", err.Error()) } } }