feat(agentic): archive stale completed plans

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 02:24:40 +00:00
parent 886461ca28
commit 09aa19afde
3 changed files with 99 additions and 10 deletions

View file

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

View file

@ -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

View file

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