Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions internal/reaper/reaper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down Expand Up @@ -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)
Expand Down
26 changes: 24 additions & 2 deletions internal/reaper/reaper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package reaper

import (
"fmt"
"os"
"strings"
"testing"
)
Expand Down Expand Up @@ -212,15 +213,36 @@ 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) {
sourcePath := "reaper.go"
data, err := os.ReadFile(sourcePath)
if err != nil {
t.Fatalf("read %s: %v", sourcePath, err)
}
source := string(data)
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)
}
}