From 37a438b8bd5632c5e218874b6d12b52fba41e2a5 Mon Sep 17 00:00:00 2001 From: mayor Date: Wed, 1 Apr 2026 02:53:58 +0000 Subject: [PATCH 1/3] fix: align reaper scan with reap eligibility --- internal/reaper/reaper.go | 10 ++++++---- internal/reaper/reaper_test.go | 12 ++++++++++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/internal/reaper/reaper.go b/internal/reaper/reaper.go index 98f9ae496d..9a602c31c6 100644 --- a/internal/reaper/reaper.go +++ b/internal/reaper/reaper.go @@ -128,9 +128,9 @@ type PurgeResult struct { // AutoCloseResult holds the results of an auto-close operation. type AutoCloseResult struct { - Database string `json:"database"` - Closed int `json:"closed"` - DryRun bool `json:"dry_run,omitempty"` + Database string `json:"database"` + Closed int `json:"closed"` + DryRun bool `json:"dry_run,omitempty"` Anomalies []Anomaly `json:"anomalies,omitempty"` } @@ -225,9 +225,11 @@ func Scan(db *sql.DB, dbName string, maxAge, purgeAge, mailDeleteAge, staleIssue parentJoin, parentWhere := parentExcludeJoin(dbName) // Count reap candidates: open wisps past max_age with eligible parent status. + // Must match Reap() eligibility semantics exactly, including the exclusion of + // agent beads, otherwise scan can report candidates that reap will never close. // Uses LEFT JOIN anti-pattern instead of correlated EXISTS to avoid O(n*m) cost (gt-jd1z). reapQuery := fmt.Sprintf( - "SELECT COUNT(*) FROM wisps w %s WHERE w.status IN ('open', 'hooked', 'in_progress') AND w.created_at < ? AND %s", + "SELECT COUNT(*) FROM wisps w %s WHERE w.status IN ('open', 'hooked', 'in_progress') AND w.created_at < ? AND w.issue_type != 'agent' AND %s", parentJoin, parentWhere) if err := db.QueryRowContext(ctx, reapQuery, now.Add(-maxAge)).Scan(&result.ReapCandidates); err != nil { return nil, fmt.Errorf("count reap candidates: %w", err) diff --git a/internal/reaper/reaper_test.go b/internal/reaper/reaper_test.go index f7ac0d7055..548e9c3415 100644 --- a/internal/reaper/reaper_test.go +++ b/internal/reaper/reaper_test.go @@ -212,15 +212,23 @@ func TestReapExcludesAgentBeads(t *testing.T) { // by checking the source code pattern. // This is a compile-time guard — if the exclusion is removed, this test // will fail when the query pattern doesn't match. - + // The whereClause in Reap() should contain: // "w.issue_type != 'agent'" // This test documents the expected behavior; actual exclusion is tested // in integration tests with a real database. - + // Integration test would require spinning up a Dolt server, which is // beyond the scope of this unit test. The exclusion is verified manually // by checking that agent beads are not closed by the wisp_reaper patrol. t.Log("Agent beads (issue_type='agent') are excluded from wisp reaping") t.Log("This prevents hq-mayor, hq-deacon, witness, refinery, etc. from being closed") } + +// TestScanExcludesAgentBeads documents that Scan() must use the same eligibility +// predicate as Reap() for stale open wisps. If Scan counts agent beads but Reap +// excludes them, the operator sees scan>0 and reap=0 for the same cutoff. +func TestScanExcludesAgentBeads(t *testing.T) { + t.Log("Agent beads (issue_type='agent') are excluded from stale-wisp scan counts") + t.Log("This keeps Scan() and Reap() aligned so scan-reported candidates are actually reapable") +} From 19b0bbafcf11fe30b94c48318840380eaea026f7 Mon Sep 17 00:00:00 2001 From: mayor Date: Wed, 1 Apr 2026 03:08:11 +0000 Subject: [PATCH 2/3] test: assert scan excludes agent wisps --- internal/reaper/reaper_test.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/internal/reaper/reaper_test.go b/internal/reaper/reaper_test.go index 548e9c3415..118a1c17fd 100644 --- a/internal/reaper/reaper_test.go +++ b/internal/reaper/reaper_test.go @@ -2,6 +2,7 @@ package reaper import ( "fmt" + "os" "strings" "testing" ) @@ -229,6 +230,13 @@ func TestReapExcludesAgentBeads(t *testing.T) { // predicate as Reap() for stale open wisps. If Scan counts agent beads but Reap // excludes them, the operator sees scan>0 and reap=0 for the same cutoff. func TestScanExcludesAgentBeads(t *testing.T) { - t.Log("Agent beads (issue_type='agent') are excluded from stale-wisp scan counts") - t.Log("This keeps Scan() and Reap() aligned so scan-reported candidates are actually reapable") + sourcePath := "reaper.go" + data, err := os.ReadFile(sourcePath) + if err != nil { + t.Fatalf("read %s: %v", sourcePath, err) + } + source := string(data) + if !strings.Contains(source, "w.issue_type != 'agent'") { + t.Fatalf("expected Scan/Reap eligibility to exclude agent beads, source missing predicate:\n%s", source) + } } From 5a795f613aa672e3c6e2dda89a20fd74eaf652d5 Mon Sep 17 00:00:00 2001 From: mayor Date: Wed, 1 Apr 2026 03:43:07 +0000 Subject: [PATCH 3/3] test: scope scan exclusion assertion to Scan --- internal/reaper/reaper_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/reaper/reaper_test.go b/internal/reaper/reaper_test.go index 118a1c17fd..8b68a9a88c 100644 --- a/internal/reaper/reaper_test.go +++ b/internal/reaper/reaper_test.go @@ -236,7 +236,13 @@ func TestScanExcludesAgentBeads(t *testing.T) { t.Fatalf("read %s: %v", sourcePath, err) } source := string(data) - if !strings.Contains(source, "w.issue_type != 'agent'") { - t.Fatalf("expected Scan/Reap eligibility to exclude agent beads, source missing predicate:\n%s", source) + scanStart := strings.Index(source, "func Scan(") + reapStart := strings.Index(source, "func Reap(") + if scanStart == -1 || reapStart == -1 || reapStart <= scanStart { + t.Fatalf("could not isolate Scan() body in %s", sourcePath) + } + scanBody := source[scanStart:reapStart] + if !strings.Contains(scanBody, "w.issue_type != 'agent'") { + t.Fatalf("expected Scan() eligibility to exclude agent beads, scan body was:\n%s", scanBody) } }