You've already forked agentic-coding-workflow
f6089ac5fb
Cross-cutting review polish: - worker.go: id/type → job_id/job_type; one stray attempts → attempt for consistency with the other 4 sites in the same file. - runners/workspace_fetch.go: ws_id → workspace_id (matches the dispatched notify.Message metadata key). - runners/worktree_prune.go: id → worktree_id (same reason). - runners/worktree_prune_test.go: add TestWorktreePrune_NoRowsIsNoOp for symmetry with the no-op tests in the other 4 runner test files. - migrations/0005_jobs.down.sql: comment that DROP TABLE jobs cascades to drop the three idx_jobs_* indexes automatically.
123 lines
3.9 KiB
Go
123 lines
3.9 KiB
Go
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", "worktree_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", "worktree_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", "worktree_id", w.ID, "err", rmErr.Error())
|
|
if rbErr := r.repo.FinishPruneRollback(ctx, w.ID); rbErr != nil {
|
|
r.log.Error("worktree_prune.rollback", "worktree_id", w.ID, "err", rbErr.Error())
|
|
}
|
|
continue
|
|
}
|
|
if err := r.repo.FinishPruneSuccess(ctx, w.ID); err != nil {
|
|
r.log.Error("worktree_prune.finish_success", "worktree_id", w.ID, "err", err.Error())
|
|
}
|
|
}
|
|
}
|