Add wisp-gc doctor check, integrate into gt doctor --fix (gt-psj76.2)

- 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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-25 21:44:27 -08:00
parent fc4bddd694
commit 0de7b980f7
3 changed files with 148 additions and 13 deletions

View File

@@ -36,7 +36,7 @@
"id": "session-gc", "id": "session-gc",
"title": "Clean dead sessions", "title": "Clean dead sessions",
"needs": ["orphan-check"], "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", "id": "context-check",

View File

@@ -24,6 +24,11 @@ var doctorCmd = &cobra.Command{
Doctor checks for common configuration issues, missing files, Doctor checks for common configuration issues, missing files,
and other problems that could affect workspace operation. 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 checks:
- patrol-molecules-exist Verify patrol molecules exist - patrol-molecules-exist Verify patrol molecules exist
- patrol-hooks-wired Verify daemon triggers patrols - 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.NewBeadsDatabaseCheck())
d.Register(doctor.NewOrphanSessionCheck()) d.Register(doctor.NewOrphanSessionCheck())
d.Register(doctor.NewOrphanProcessCheck()) d.Register(doctor.NewOrphanProcessCheck())
d.Register(doctor.NewWispGCCheck())
d.Register(doctor.NewBranchCheck()) d.Register(doctor.NewBranchCheck())
d.Register(doctor.NewBeadsSyncOrphanCheck()) d.Register(doctor.NewBeadsSyncOrphanCheck())
d.Register(doctor.NewIdentityCollisionCheck()) d.Register(doctor.NewIdentityCollisionCheck())

View File

@@ -1,14 +1,143 @@
package doctor package doctor
// Legacy wisp directory checks removed. import (
// Wisps are now just a flag on regular beads issues (Wisp: true). "bufio"
// Hook files are stored in .beads/ alongside other beads data. "encoding/json"
// "fmt"
// These checks were for the old .beads-wisp/ directory infrastructure: "os"
// - WispExistsCheck: checked if .beads-wisp/ exists "os/exec"
// - WispGitCheck: checked if .beads-wisp/ is a git repo "path/filepath"
// - WispOrphansCheck: checked for old wisps "time"
// - WispSizeCheck: checked size of .beads-wisp/ )
// - WispStaleCheck: checked for inactive wisps
// // WispGCCheck detects and cleans orphaned wisps that are older than a threshold.
// All removed as of the wisp simplification (gt-5klh, bd-bkul). // 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
}