From 0de7b980f7f8b38e4fffca7c74a444787f07cf97 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 25 Dec 2025 21:44:27 -0800 Subject: [PATCH] Add wisp-gc doctor check, integrate into gt doctor --fix (gt-psj76.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement WispGCCheck in internal/doctor/wisp_check.go - Scans rigs for wisps older than 1 hour threshold - Fix runs `bd --no-daemon wisp gc` in each affected rig - Register wisp-gc check in gt doctor - Update help text to document cleanup checks - Simplify Deacon patrol session-gc step to just use gt doctor --fix Now `gt doctor --fix` handles all cleanup: - orphan-sessions: Kill orphaned tmux sessions - orphan-processes: Kill orphaned Claude processes - wisp-gc: Garbage collect abandoned wisps (>1h) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../formulas/mol-deacon-patrol.formula.json | 2 +- internal/cmd/doctor.go | 6 + internal/doctor/wisp_check.go | 153 ++++++++++++++++-- 3 files changed, 148 insertions(+), 13 deletions(-) diff --git a/.beads/formulas/mol-deacon-patrol.formula.json b/.beads/formulas/mol-deacon-patrol.formula.json index 9d599127..50edb1c2 100644 --- a/.beads/formulas/mol-deacon-patrol.formula.json +++ b/.beads/formulas/mol-deacon-patrol.formula.json @@ -36,7 +36,7 @@ "id": "session-gc", "title": "Clean dead sessions", "needs": ["orphan-check"], - "description": "Clean dead sessions and orphaned state.\n\nGarbage collect terminated sessions and orphaned artifacts:\n\n## 1. Clean orphaned sessions and processes\n\n```bash\n# Check for orphans (dry run)\ngt doctor -v\n\n# Fix orphaned sessions and processes\ngt doctor --fix\n```\n\nThis handles:\n- Orphaned tmux sessions (gt-* not matching valid rig/role patterns)\n- Orphaned Claude processes (no tmux parent)\n\n## 2. Clean orphaned wisps\n\n```bash\n# Clean wisps older than 1 hour (default threshold)\nbd --no-daemon wisp gc\n\n# Or with custom age threshold\nbd --no-daemon wisp gc --age 2h\n\n# Preview what would be cleaned\nbd --no-daemon wisp gc --dry-run\n```\n\nThe `--no-daemon` flag is required when the bd daemon is running, since wisp gc needs direct database access.\n\n## Notes\n\n- Preserve audit trail - only clean confirmed dead sessions\n- Old logs and temp files: not yet implemented\n- Completed molecule archival: handled by bd mol squash/burn" + "description": "Clean dead sessions and orphaned state.\n\nRun `gt doctor --fix` to handle all cleanup:\n\n```bash\n# Preview what needs cleaning\ngt doctor -v\n\n# Fix everything\ngt doctor --fix\n```\n\nThis handles:\n- **orphan-sessions**: Kill orphaned tmux sessions (gt-* not matching valid patterns)\n- **orphan-processes**: Kill orphaned Claude processes (no tmux parent)\n- **wisp-gc**: Garbage collect abandoned wisps (>1h old)\n\nAll cleanup is handled by doctor checks - no need to run separate commands." }, { "id": "context-check", diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index a12e1898..6cdc23cf 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -24,6 +24,11 @@ var doctorCmd = &cobra.Command{ Doctor checks for common configuration issues, missing files, and other problems that could affect workspace operation. +Cleanup checks (fixable): + - orphan-sessions Detect orphaned tmux sessions + - orphan-processes Detect orphaned Claude processes + - wisp-gc Detect and clean abandoned wisps (>1h) + Patrol checks: - patrol-molecules-exist Verify patrol molecules exist - patrol-hooks-wired Verify daemon triggers patrols @@ -66,6 +71,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { d.Register(doctor.NewBeadsDatabaseCheck()) d.Register(doctor.NewOrphanSessionCheck()) d.Register(doctor.NewOrphanProcessCheck()) + d.Register(doctor.NewWispGCCheck()) d.Register(doctor.NewBranchCheck()) d.Register(doctor.NewBeadsSyncOrphanCheck()) d.Register(doctor.NewIdentityCollisionCheck()) diff --git a/internal/doctor/wisp_check.go b/internal/doctor/wisp_check.go index 663e34f8..95e7d750 100644 --- a/internal/doctor/wisp_check.go +++ b/internal/doctor/wisp_check.go @@ -1,14 +1,143 @@ package doctor -// Legacy wisp directory checks removed. -// Wisps are now just a flag on regular beads issues (Wisp: true). -// Hook files are stored in .beads/ alongside other beads data. -// -// These checks were for the old .beads-wisp/ directory infrastructure: -// - WispExistsCheck: checked if .beads-wisp/ exists -// - WispGitCheck: checked if .beads-wisp/ is a git repo -// - WispOrphansCheck: checked for old wisps -// - WispSizeCheck: checked size of .beads-wisp/ -// - WispStaleCheck: checked for inactive wisps -// -// All removed as of the wisp simplification (gt-5klh, bd-bkul). +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" +) + +// WispGCCheck detects and cleans orphaned wisps that are older than a threshold. +// Wisps are ephemeral issues (Wisp: true flag) used for patrol cycles and +// operational workflows that shouldn't accumulate. +type WispGCCheck struct { + FixableCheck + threshold time.Duration + abandonedRigs map[string]int // rig -> count of abandoned wisps +} + +// NewWispGCCheck creates a new wisp GC check with 1 hour threshold. +func NewWispGCCheck() *WispGCCheck { + return &WispGCCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "wisp-gc", + CheckDescription: "Detect and clean orphaned wisps (>1h old)", + }, + }, + threshold: 1 * time.Hour, + abandonedRigs: make(map[string]int), + } +} + +// Run checks for abandoned wisps in each rig. +func (c *WispGCCheck) Run(ctx *CheckContext) *CheckResult { + c.abandonedRigs = make(map[string]int) + + rigs, err := discoverRigs(ctx.TownRoot) + if err != nil { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "Failed to discover rigs", + Details: []string{err.Error()}, + } + } + + if len(rigs) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No rigs configured", + } + } + + var details []string + totalAbandoned := 0 + + for _, rigName := range rigs { + rigPath := filepath.Join(ctx.TownRoot, rigName) + count := c.countAbandonedWisps(rigPath) + if count > 0 { + c.abandonedRigs[rigName] = count + totalAbandoned += count + details = append(details, fmt.Sprintf("%s: %d abandoned wisp(s)", rigName, count)) + } + } + + if totalAbandoned > 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("%d abandoned wisp(s) found (>1h old)", totalAbandoned), + Details: details, + FixHint: "Run 'gt doctor --fix' to garbage collect orphaned wisps", + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No abandoned wisps found", + } +} + +// countAbandonedWisps counts wisps older than the threshold in a rig. +func (c *WispGCCheck) countAbandonedWisps(rigPath string) int { + // Check the beads database for wisps + issuesPath := filepath.Join(rigPath, ".beads", "issues.jsonl") + file, err := os.Open(issuesPath) + if err != nil { + return 0 // No issues file + } + defer file.Close() + + cutoff := time.Now().Add(-c.threshold) + count := 0 + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + var issue struct { + ID string `json:"id"` + Status string `json:"status"` + Wisp bool `json:"wisp"` + UpdatedAt time.Time `json:"updated_at"` + } + if err := json.Unmarshal([]byte(line), &issue); err != nil { + continue + } + + // Count wisps that are not closed and older than threshold + if issue.Wisp && issue.Status != "closed" && !issue.UpdatedAt.IsZero() && issue.UpdatedAt.Before(cutoff) { + count++ + } + } + + return count +} + +// Fix runs bd wisp gc in each rig with abandoned wisps. +func (c *WispGCCheck) Fix(ctx *CheckContext) error { + var lastErr error + + for rigName := range c.abandonedRigs { + rigPath := filepath.Join(ctx.TownRoot, rigName) + + // Run bd --no-daemon wisp gc + cmd := exec.Command("bd", "--no-daemon", "wisp", "gc") + cmd.Dir = rigPath + if output, err := cmd.CombinedOutput(); err != nil { + lastErr = fmt.Errorf("%s: %v (%s)", rigName, err, string(output)) + } + } + + return lastErr +}