Skip to content

Commit dc17941

Browse files
steveyeggeclaude
andcommitted
fix: reaper fast-tracks plugin receipt cleanup (1h instead of 7d)
Deacon dogs create plugin-run receipt beads that accumulate in the issues table because AutoClose requires 7 days of staleness. Add ClosePluginReceipts to the reaper that closes type:plugin-run labeled issues after 1 hour. Also make RecordRun close receipts immediately after creation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b02e0b1 commit dc17941

3 files changed

Lines changed: 130 additions & 2 deletions

File tree

internal/daemon/wisp_reaper.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,33 @@ func (d *Daemon) reapWispsInline(config *WispReaperConfig, maxAge, deleteAge tim
223223
mol.closeStep("purge")
224224
}
225225

226+
// Step 3b: Close plugin receipts (fast-track — 1h instead of 7d stale age)
227+
pluginReceiptAge := 1 * time.Hour
228+
var totalPluginClosed int
229+
for _, dbName := range databases {
230+
if err := reaper.ValidateDBName(dbName); err != nil {
231+
continue
232+
}
233+
db, err := reaper.OpenDB("127.0.0.1", port, dbName, 10*time.Second, 10*time.Second)
234+
if err != nil {
235+
continue
236+
}
237+
if ok, _ := reaper.HasReaperSchema(db); !ok {
238+
db.Close()
239+
continue
240+
}
241+
result, err := reaper.ClosePluginReceipts(db, dbName, pluginReceiptAge, dryRun)
242+
db.Close()
243+
if err != nil {
244+
d.logger.Printf("wisp_reaper: %s: plugin receipt close error: %v", dbName, err)
245+
continue
246+
}
247+
totalPluginClosed += result.Closed
248+
if result.Closed > 0 {
249+
d.logger.Printf("wisp_reaper: %s: closed %d plugin receipts", dbName, result.Closed)
250+
}
251+
}
252+
226253
// Step 4: Auto-close
227254
autoCloseErrors := 0
228255
for _, dbName := range databases {
@@ -260,8 +287,8 @@ func (d *Daemon) reapWispsInline(config *WispReaperConfig, maxAge, deleteAge tim
260287
d.logger.Printf("wisp_reaper: WARNING: %d open wisps exceed threshold %d — investigate wisp lifecycle",
261288
totalOpen, wispAlertThreshold)
262289
}
263-
d.logger.Printf("wisp_reaper: cycle complete — reaped=%d purged=%d mail_purged=%d auto_closed=%d open=%d databases=%d dryRun=%v",
264-
totalReaped, totalPurged, totalMailPurged, totalAutoClosed, totalOpen, len(databases), dryRun)
290+
d.logger.Printf("wisp_reaper: cycle complete — reaped=%d purged=%d mail_purged=%d plugin_closed=%d auto_closed=%d open=%d databases=%d dryRun=%v",
291+
totalReaped, totalPurged, totalMailPurged, totalPluginClosed, totalAutoClosed, totalOpen, len(databases), dryRun)
265292
mol.closeStep("report")
266293
}
267294

internal/plugin/recording.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,15 @@ func (r *Recorder) RecordRun(record PluginRunRecord) (string, error) {
102102
return "", fmt.Errorf("parsing bd create output: %w", err)
103103
}
104104

105+
// Close the receipt immediately — it exists for audit/cooldown-gate queries
106+
// (which use --all to include closed beads) but should not stay open.
107+
closeCtx, closeCancel := context.WithTimeout(context.Background(), constants.BdCommandTimeout)
108+
defer closeCancel()
109+
closeCmd := exec.CommandContext(closeCtx, "bd", "close", result.ID, "--reason", "plugin run recorded") //nolint:gosec // G204: bd is a trusted internal tool
110+
closeCmd.Dir = r.townRoot
111+
closeCmd.Env = append(os.Environ(), "BEADS_DIR="+beads.ResolveBeadsDir(r.townRoot))
112+
_ = closeCmd.Run() // Best-effort — reaper will catch it if this fails
113+
105114
return result.ID, nil
106115
}
107116

internal/reaper/reaper.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,98 @@ func batchDeleteRows(ctx context.Context, db *sql.DB, idQuery string, cutoffArg
724724
return totalDeleted, nil
725725
}
726726

727+
// ClosePluginReceiptResult holds the results of closing plugin run receipts.
728+
type ClosePluginReceiptResult struct {
729+
Database string `json:"database"`
730+
Closed int `json:"closed"`
731+
DryRun bool `json:"dry_run,omitempty"`
732+
Anomalies []Anomaly `json:"anomalies,omitempty"`
733+
}
734+
735+
// ClosePluginReceipts closes open issues labeled "type:plugin-run" that are
736+
// older than maxAge. These are transient run receipts created by deacon dog
737+
// plugins; they should be closed shortly after creation since they exist only
738+
// for audit/cooldown-gate purposes. The standard AutoClose path requires 7 days
739+
// of staleness, which lets plugin receipts accumulate into the hundreds.
740+
func ClosePluginReceipts(db *sql.DB, dbName string, maxAge time.Duration, dryRun bool) (*ClosePluginReceiptResult, error) {
741+
ctx, cancel := context.WithTimeout(context.Background(), DefaultQueryTimeout)
742+
defer cancel()
743+
744+
cutoff := time.Now().UTC().Add(-maxAge)
745+
result := &ClosePluginReceiptResult{Database: dbName, DryRun: dryRun}
746+
747+
// Find open issues with the "type:plugin-run" label older than maxAge.
748+
selectQuery := fmt.Sprintf(`
749+
SELECT i.id FROM `+"`%s`"+`.issues i
750+
INNER JOIN `+"`%s`"+`.labels l ON i.id = l.issue_id
751+
WHERE i.status IN ('open', 'in_progress')
752+
AND l.label = 'type:plugin-run'
753+
AND i.created_at < ?`, dbName, dbName)
754+
755+
rows, err := db.QueryContext(ctx, selectQuery, cutoff)
756+
if err != nil {
757+
if isTableNotFound(err) {
758+
return result, nil
759+
}
760+
return nil, fmt.Errorf("select plugin receipts: %w", err)
761+
}
762+
var ids []string
763+
for rows.Next() {
764+
var id string
765+
if err := rows.Scan(&id); err != nil {
766+
rows.Close()
767+
return nil, fmt.Errorf("scan plugin receipt id: %w", err)
768+
}
769+
ids = append(ids, id)
770+
}
771+
rows.Close()
772+
773+
result.Closed = len(ids)
774+
if len(ids) == 0 || dryRun {
775+
return result, nil
776+
}
777+
778+
if _, err := db.ExecContext(ctx, "SET @@autocommit = 0"); err != nil {
779+
return nil, fmt.Errorf("disable autocommit: %w", err)
780+
}
781+
defer func() {
782+
_, _ = db.ExecContext(context.Background(), "SET @@autocommit = 1")
783+
}()
784+
785+
placeholders := make([]string, len(ids))
786+
args := make([]interface{}, len(ids))
787+
for i, id := range ids {
788+
placeholders[i] = "?"
789+
args[i] = id
790+
}
791+
updateQuery := fmt.Sprintf(
792+
"UPDATE `%s`.issues SET status = 'closed', closed_at = NOW() WHERE id IN (%s)",
793+
dbName, strings.Join(placeholders, ","))
794+
if _, err := db.ExecContext(ctx, updateQuery, args...); err != nil {
795+
return nil, fmt.Errorf("close plugin receipts: %w", err)
796+
}
797+
798+
// Flush and commit.
799+
if _, err := db.ExecContext(ctx, "COMMIT"); err != nil {
800+
result.Anomalies = append(result.Anomalies, Anomaly{
801+
Type: "sql_commit_failed",
802+
Message: fmt.Sprintf("sql commit after plugin receipt close failed: %v", err),
803+
})
804+
return result, nil
805+
}
806+
commitMsg := fmt.Sprintf("reaper: close %d plugin receipts in %s", len(ids), dbName)
807+
if _, err := db.ExecContext(ctx, fmt.Sprintf("CALL DOLT_COMMIT('-Am', '%s')", commitMsg)); err != nil { //nolint:gosec // G201: commitMsg from safe values
808+
if !isNothingToCommit(err) {
809+
result.Anomalies = append(result.Anomalies, Anomaly{
810+
Type: "dolt_commit_failed",
811+
Message: fmt.Sprintf("dolt commit after plugin receipt close failed: %v", err),
812+
})
813+
}
814+
}
815+
816+
return result, nil
817+
}
818+
727819
// FormatJSON marshals any value to indented JSON.
728820
func FormatJSON(v interface{}) string {
729821
data, err := json.MarshalIndent(v, "", " ")

0 commit comments

Comments
 (0)