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) }