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:
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user