From 09aa19afde7ed9c0e33799fd705e6044b91553e5 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:24:40 +0000 Subject: [PATCH] feat(agentic): archive stale completed plans Co-Authored-By: Virgil --- pkg/agentic/commands.go | 4 +- pkg/agentic/plan_retention.go | 71 ++++++++++++++++++++++++++---- pkg/agentic/plan_retention_test.go | 34 ++++++++++++++ 3 files changed, 99 insertions(+), 10 deletions(-) diff --git a/pkg/agentic/commands.go b/pkg/agentic/commands.go index e0c3abd..6cada3f 100644 --- a/pkg/agentic/commands.go +++ b/pkg/agentic/commands.go @@ -57,8 +57,8 @@ func (s *PrepSubsystem) registerCommands(ctx context.Context) { c.Command("lang/list", core.Command{Description: "List supported language identifiers", Action: s.cmdLangList}) c.Command("epic", core.Command{Description: "Create sub-issues from an epic plan", Action: s.cmdEpic}) c.Command("agentic:epic", core.Command{Description: "Create sub-issues from an epic plan", Action: s.cmdEpic}) - c.Command("plan-cleanup", core.Command{Description: "Permanently delete archived plans past the retention period", Action: s.cmdPlanCleanup}) - c.Command("agentic:plan-cleanup", core.Command{Description: "Permanently delete archived plans past the retention period", Action: s.cmdPlanCleanup}) + c.Command("plan-cleanup", core.Command{Description: "Archive old completed plans and delete stale archives past the retention period", Action: s.cmdPlanCleanup}) + c.Command("agentic:plan-cleanup", core.Command{Description: "Archive old completed plans and delete stale archives past the retention period", Action: s.cmdPlanCleanup}) c.Command("pr-manage", core.Command{Description: "Manage open PRs (merge, close, review)", Action: s.cmdPRManage}) c.Command("agentic:pr-manage", core.Command{Description: "Manage open PRs (merge, close, review)", Action: s.cmdPRManage}) c.Command("review-queue", core.Command{Description: "Process the CodeRabbit review queue", Action: s.cmdReviewQueue}) diff --git a/pkg/agentic/plan_retention.go b/pkg/agentic/plan_retention.go index b64f7fa..b3d7e7d 100644 --- a/pkg/agentic/plan_retention.go +++ b/pkg/agentic/plan_retention.go @@ -17,6 +17,7 @@ type PlanCleanupOutput struct { Success bool `json:"success"` Disabled bool `json:"disabled,omitempty"` DryRun bool `json:"dry_run,omitempty"` + Archived int `json:"archived,omitempty"` Deleted int `json:"deleted,omitempty"` Matched int `json:"matched,omitempty"` Cutoff string `json:"cutoff,omitempty"` @@ -44,16 +45,25 @@ func (s *PrepSubsystem) cmdPlanCleanup(options core.Options) core.Result { } if output.Matched == 0 { - core.Print(nil, "No archived plans found past the retention period.") + core.Print(nil, "No plans found past the retention period.") return core.Result{Value: output, OK: true} } if output.DryRun { - core.Print(nil, "DRY RUN: %d archived plan(s) would be permanently deleted (archived before %s).", output.Matched, output.Cutoff) + core.Print(nil, "DRY RUN: %d plan(s) would be archived or deleted (cutoff %s).", output.Matched, output.Cutoff) return core.Result{Value: output, OK: true} } - core.Print(nil, "Permanently deleted %d archived plan(s) archived before %s.", output.Deleted, output.Cutoff) + if output.Archived > 0 && output.Deleted > 0 { + core.Print(nil, "Archived %d plan(s) and permanently deleted %d stale archive(s) before %s.", output.Archived, output.Deleted, output.Cutoff) + return core.Result{Value: output, OK: true} + } + if output.Archived > 0 { + core.Print(nil, "Archived %d plan(s) before %s.", output.Archived, output.Cutoff) + return core.Result{Value: output, OK: true} + } + + core.Print(nil, "Permanently deleted %d stale archive(s) before %s.", output.Deleted, output.Cutoff) return core.Result{Value: output, OK: true} } @@ -97,12 +107,31 @@ func (s *PrepSubsystem) planCleanup(options core.Options) core.Result { return core.Result{Value: output, OK: true} } + archived := 0 if output.DryRun { + for _, candidate := range candidates { + if planRetentionShouldArchive(candidate.plan.Status) { + archived++ + } + } + output.Archived = archived return core.Result{Value: output, OK: true} } deleted := 0 for _, candidate := range candidates { + if planRetentionShouldArchive(candidate.plan.Status) { + _, archiveErr := archivePlanResult(PlanDeleteInput{ + ID: candidate.plan.ID, + Reason: "plan retention cleanup", + }, "id is required", "planCleanup") + if archiveErr != nil { + return core.Result{Value: archiveErr, OK: false} + } + archived++ + continue + } + if r := fs.Delete(candidate.path); !r.OK { err, _ := r.Value.(error) if err == nil { @@ -113,13 +142,15 @@ func (s *PrepSubsystem) planCleanup(options core.Options) core.Result { deleted++ } + output.Archived = archived output.Deleted = deleted return core.Result{Value: output, OK: true} } type planRetentionCandidate struct { path string - archivedAt time.Time + plan *Plan + retainedAt time.Time } func planRetentionCandidates(dir string, cutoff time.Time) []planRetentionCandidate { @@ -138,24 +169,48 @@ func planRetentionCandidates(dir string, cutoff time.Time) []planRetentionCandid if !ok || plan == nil { continue } - if plan.Status != "archived" { + if !planRetentionShouldArchive(plan.Status) && plan.Status != "archived" { continue } - archivedAt := planArchivedAt(path, plan) - if archivedAt.IsZero() || !archivedAt.Before(cutoff) { + retainedAt := planRetentionAt(path, plan) + if retainedAt.IsZero() || !retainedAt.Before(cutoff) { continue } candidates = append(candidates, planRetentionCandidate{ path: path, - archivedAt: archivedAt, + plan: plan, + retainedAt: retainedAt, }) } return candidates } +func planRetentionShouldArchive(status string) bool { + switch status { + case "approved", "completed": + return true + default: + return false + } +} + +func planRetentionAt(path string, plan *Plan) time.Time { + if plan == nil { + return time.Time{} + } + + if !plan.ArchivedAt.IsZero() { + return plan.ArchivedAt + } + if !plan.UpdatedAt.IsZero() { + return plan.UpdatedAt + } + return planArchivedAt(path, plan) +} + func planArchivedAt(path string, plan *Plan) time.Time { if plan != nil && !plan.ArchivedAt.IsZero() { return plan.ArchivedAt diff --git a/pkg/agentic/plan_retention_test.go b/pkg/agentic/plan_retention_test.go index bb6cfa9..e27f0ce 100644 --- a/pkg/agentic/plan_retention_test.go +++ b/pkg/agentic/plan_retention_test.go @@ -59,6 +59,40 @@ func TestPlanRetention_PlanCleanup_Good_DeletesExpiredArchivedPlans(t *testing.T assert.True(t, fs.Exists(core.JoinPath(PlansRoot(), "active-plan-abc123.json"))) } +func TestPlanRetention_PlanCleanup_Good_ArchivesExpiredCompletedPlans(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + + plan := &Plan{ + ID: "completed-plan-abc123", + Title: "Completed Plan", + Status: "approved", + Objective: "Archive me", + UpdatedAt: time.Now().AddDate(0, 0, -100), + } + + _, err := writePlan(PlansRoot(), plan) + require.NoError(t, err) + + result := s.planCleanup(core.NewOptions(core.Option{Key: "days", Value: 90})) + require.True(t, result.OK) + + output, ok := result.Value.(PlanCleanupOutput) + require.True(t, ok) + assert.True(t, output.Success) + assert.Equal(t, 1, output.Archived) + assert.Equal(t, 0, output.Deleted) + assert.Equal(t, 1, output.Matched) + + updated, err := readPlan(PlansRoot(), plan.ID) + require.NoError(t, err) + assert.Equal(t, "archived", updated.Status) + assert.False(t, updated.ArchivedAt.IsZero()) + assert.True(t, fs.Exists(core.JoinPath(PlansRoot(), "completed-plan-abc123.json"))) +} + func TestPlanRetention_PlanCleanup_Bad_DryRunKeepsFiles(t *testing.T) { dir := t.TempDir() t.Setenv("CORE_WORKSPACE", dir)