You've already forked agentic-coding-workflow
feat(jobs/runners): worktree prune two-phase + workspace prune queries/repo methods
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package runners_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/jobs/runners"
|
||||
)
|
||||
|
||||
// fakeWorktreeRepo is a fresh fake distinct from fakeWorkspaceFetchRepo.
|
||||
type fakeWorktreeRepo struct {
|
||||
warnList []runners.WorktreeRow
|
||||
pruneList []runners.WorktreeRow
|
||||
warnListErr error
|
||||
pruneListErr error
|
||||
|
||||
markedWarning []uuid.UUID
|
||||
markWarningErr error
|
||||
|
||||
tryStartResult runners.WorktreeRow
|
||||
tryStartErr error
|
||||
tryStartCalled map[uuid.UUID]bool
|
||||
|
||||
finalized map[uuid.UUID]string // "ok" | "rollback"
|
||||
finishSuccessErr error
|
||||
finishRollbackErr error
|
||||
}
|
||||
|
||||
func (f *fakeWorktreeRepo) ListWorktreesNeedingPruneWarning(_ context.Context, _ time.Time) ([]runners.WorktreeRow, error) {
|
||||
return f.warnList, f.warnListErr
|
||||
}
|
||||
|
||||
func (f *fakeWorktreeRepo) ListWorktreesNeedingPrune(_ context.Context, _ time.Time) ([]runners.WorktreeRow, error) {
|
||||
return f.pruneList, f.pruneListErr
|
||||
}
|
||||
|
||||
func (f *fakeWorktreeRepo) MarkPruneWarning(_ context.Context, id uuid.UUID) error {
|
||||
f.markedWarning = append(f.markedWarning, id)
|
||||
return f.markWarningErr
|
||||
}
|
||||
|
||||
func (f *fakeWorktreeRepo) TryStartPrune(_ context.Context, id uuid.UUID) (runners.WorktreeRow, error) {
|
||||
if f.tryStartCalled == nil {
|
||||
f.tryStartCalled = map[uuid.UUID]bool{}
|
||||
}
|
||||
f.tryStartCalled[id] = true
|
||||
if f.tryStartErr != nil {
|
||||
return runners.WorktreeRow{}, f.tryStartErr
|
||||
}
|
||||
return f.tryStartResult, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorktreeRepo) FinishPruneSuccess(_ context.Context, id uuid.UUID) error {
|
||||
if f.finalized == nil {
|
||||
f.finalized = map[uuid.UUID]string{}
|
||||
}
|
||||
f.finalized[id] = "ok"
|
||||
return f.finishSuccessErr
|
||||
}
|
||||
|
||||
func (f *fakeWorktreeRepo) FinishPruneRollback(_ context.Context, id uuid.UUID) error {
|
||||
if f.finalized == nil {
|
||||
f.finalized = map[uuid.UUID]string{}
|
||||
}
|
||||
f.finalized[id] = "rollback"
|
||||
return f.finishRollbackErr
|
||||
}
|
||||
|
||||
// fakePruner records WorktreeRemove calls.
|
||||
type fakePruner struct {
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakePruner) WorktreeRemove(_ context.Context, _, _ string) error {
|
||||
f.calls++
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestWorktreePrune_WarningPhase(t *testing.T) {
|
||||
t.Parallel()
|
||||
id := uuid.New()
|
||||
ownerID := uuid.New()
|
||||
row := runners.WorktreeRow{ID: id, OwnerID: ownerID, Branch: "feat-x", MainPath: "/main", Path: "/main/.worktrees/feat-x"}
|
||||
|
||||
repo := &fakeWorktreeRepo{warnList: []runners.WorktreeRow{row}}
|
||||
disp := &recordingDispatcher{}
|
||||
gitr := &fakePruner{}
|
||||
cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}
|
||||
|
||||
r := runners.NewWorktreePrune(repo, gitr, disp, cfg, discardLogger())
|
||||
r.Run(context.Background())
|
||||
|
||||
require.Len(t, repo.markedWarning, 1)
|
||||
assert.Equal(t, id, repo.markedWarning[0])
|
||||
|
||||
require.Len(t, disp.msgs, 1)
|
||||
msg := disp.msgs[0]
|
||||
assert.Equal(t, ownerID, msg.UserID)
|
||||
assert.Equal(t, "worktree.prune_pending", msg.Topic)
|
||||
assert.Equal(t, notify.SeverityWarning, msg.Severity)
|
||||
|
||||
// execution phase: pruneList is empty, so no prune calls.
|
||||
assert.Zero(t, gitr.calls)
|
||||
}
|
||||
|
||||
func TestWorktreePrune_ExecutionPhaseSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
id := uuid.New()
|
||||
ownerID := uuid.New()
|
||||
row := runners.WorktreeRow{ID: id, OwnerID: ownerID, Branch: "feat-y", MainPath: "/main", Path: "/main/.worktrees/feat-y"}
|
||||
|
||||
repo := &fakeWorktreeRepo{
|
||||
pruneList: []runners.WorktreeRow{row},
|
||||
tryStartResult: row,
|
||||
}
|
||||
gitr := &fakePruner{}
|
||||
cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}
|
||||
|
||||
r := runners.NewWorktreePrune(repo, gitr, &recordingDispatcher{}, cfg, discardLogger())
|
||||
r.Run(context.Background())
|
||||
|
||||
assert.True(t, repo.tryStartCalled[id])
|
||||
assert.Equal(t, 1, gitr.calls)
|
||||
require.NotNil(t, repo.finalized)
|
||||
assert.Equal(t, "ok", repo.finalized[id])
|
||||
}
|
||||
|
||||
func TestWorktreePrune_ExecutionFailureRollsBack(t *testing.T) {
|
||||
t.Parallel()
|
||||
id := uuid.New()
|
||||
ownerID := uuid.New()
|
||||
row := runners.WorktreeRow{ID: id, OwnerID: ownerID, Branch: "feat-z", MainPath: "/main", Path: "/main/.worktrees/feat-z"}
|
||||
|
||||
repo := &fakeWorktreeRepo{
|
||||
pruneList: []runners.WorktreeRow{row},
|
||||
tryStartResult: row,
|
||||
}
|
||||
gitr := &fakePruner{err: errors.New("git worktree remove failed")}
|
||||
cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}
|
||||
|
||||
r := runners.NewWorktreePrune(repo, gitr, &recordingDispatcher{}, cfg, discardLogger())
|
||||
r.Run(context.Background())
|
||||
|
||||
assert.True(t, repo.tryStartCalled[id])
|
||||
assert.Equal(t, 1, gitr.calls)
|
||||
require.NotNil(t, repo.finalized)
|
||||
assert.Equal(t, "rollback", repo.finalized[id])
|
||||
}
|
||||
|
||||
func TestWorktreePrune_CASLostSkipsSilently(t *testing.T) {
|
||||
t.Parallel()
|
||||
id := uuid.New()
|
||||
ownerID := uuid.New()
|
||||
row := runners.WorktreeRow{ID: id, OwnerID: ownerID, Branch: "feat-cas", MainPath: "/main", Path: "/main/.worktrees/feat-cas"}
|
||||
|
||||
repo := &fakeWorktreeRepo{
|
||||
pruneList: []runners.WorktreeRow{row},
|
||||
tryStartErr: errors.New("worktree no longer idle"),
|
||||
}
|
||||
gitr := &fakePruner{}
|
||||
cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}
|
||||
|
||||
r := runners.NewWorktreePrune(repo, gitr, &recordingDispatcher{}, cfg, discardLogger())
|
||||
r.Run(context.Background())
|
||||
|
||||
// TryStartPrune was called but failed.
|
||||
assert.True(t, repo.tryStartCalled[id])
|
||||
// No git call.
|
||||
assert.Zero(t, gitr.calls)
|
||||
// No finalize for that ID.
|
||||
assert.Empty(t, repo.finalized)
|
||||
}
|
||||
Reference in New Issue
Block a user