feat(deacon): add stale hooked bead cleanup (gt-2yls3)
Add `gt deacon stale-hooks` command to find and unhook stale beads. Problem: Beads can get stuck in 'hooked' status when agents die or abandon work without properly unhooking. Solution: - New command scans for hooked beads older than threshold (default 1h) - Checks if assignee agent is still alive (tmux session exists) - Unhooks beads with dead agents (sets status back to 'open') - Supports --dry-run to preview without making changes Also adds "stale-hook-check" step to Deacon patrol formula. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
ac63b10aa8
commit
74409dc32b
194
internal/deacon/stale_hooks.go
Normal file
194
internal/deacon/stale_hooks.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Package deacon provides the Deacon agent infrastructure.
|
||||
package deacon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
)
|
||||
|
||||
// StaleHookConfig holds configurable parameters for stale hook detection.
|
||||
type StaleHookConfig struct {
|
||||
// MaxAge is how long a bead can be hooked before being considered stale.
|
||||
MaxAge time.Duration `json:"max_age"`
|
||||
// DryRun if true, only reports what would be done without making changes.
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
// DefaultStaleHookConfig returns the default stale hook config.
|
||||
func DefaultStaleHookConfig() *StaleHookConfig {
|
||||
return &StaleHookConfig{
|
||||
MaxAge: 1 * time.Hour,
|
||||
DryRun: false,
|
||||
}
|
||||
}
|
||||
|
||||
// HookedBead represents a bead in hooked status from bd list output.
|
||||
type HookedBead struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Assignee string `json:"assignee"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// StaleHookResult represents the result of processing a stale hooked bead.
|
||||
type StaleHookResult struct {
|
||||
BeadID string `json:"bead_id"`
|
||||
Title string `json:"title"`
|
||||
Assignee string `json:"assignee"`
|
||||
Age string `json:"age"`
|
||||
AgentAlive bool `json:"agent_alive"`
|
||||
Unhooked bool `json:"unhooked"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// StaleHookScanResult contains the full results of a stale hook scan.
|
||||
type StaleHookScanResult struct {
|
||||
ScannedAt time.Time `json:"scanned_at"`
|
||||
TotalHooked int `json:"total_hooked"`
|
||||
StaleCount int `json:"stale_count"`
|
||||
Unhooked int `json:"unhooked"`
|
||||
Results []*StaleHookResult `json:"results"`
|
||||
}
|
||||
|
||||
// ScanStaleHooks finds hooked beads older than the threshold and optionally unhooks them.
|
||||
func ScanStaleHooks(townRoot string, cfg *StaleHookConfig) (*StaleHookScanResult, error) {
|
||||
if cfg == nil {
|
||||
cfg = DefaultStaleHookConfig()
|
||||
}
|
||||
|
||||
result := &StaleHookScanResult{
|
||||
ScannedAt: time.Now().UTC(),
|
||||
Results: make([]*StaleHookResult, 0),
|
||||
}
|
||||
|
||||
// Get all hooked beads
|
||||
hookedBeads, err := listHookedBeads(townRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing hooked beads: %w", err)
|
||||
}
|
||||
|
||||
result.TotalHooked = len(hookedBeads)
|
||||
|
||||
// Filter to stale ones (older than threshold)
|
||||
threshold := time.Now().Add(-cfg.MaxAge)
|
||||
t := tmux.NewTmux()
|
||||
|
||||
for _, bead := range hookedBeads {
|
||||
// Skip if updated recently (not stale)
|
||||
if bead.UpdatedAt.After(threshold) {
|
||||
continue
|
||||
}
|
||||
|
||||
result.StaleCount++
|
||||
|
||||
hookResult := &StaleHookResult{
|
||||
BeadID: bead.ID,
|
||||
Title: bead.Title,
|
||||
Assignee: bead.Assignee,
|
||||
Age: time.Since(bead.UpdatedAt).Round(time.Minute).String(),
|
||||
}
|
||||
|
||||
// Check if assignee agent is still alive
|
||||
if bead.Assignee != "" {
|
||||
sessionName := assigneeToSessionName(bead.Assignee)
|
||||
if sessionName != "" {
|
||||
alive, _ := t.HasSession(sessionName)
|
||||
hookResult.AgentAlive = alive
|
||||
}
|
||||
}
|
||||
|
||||
// If agent is dead/gone and not dry run, unhook the bead
|
||||
if !hookResult.AgentAlive && !cfg.DryRun {
|
||||
if err := unhookBead(townRoot, bead.ID); err != nil {
|
||||
hookResult.Error = err.Error()
|
||||
} else {
|
||||
hookResult.Unhooked = true
|
||||
result.Unhooked++
|
||||
}
|
||||
}
|
||||
|
||||
result.Results = append(result.Results, hookResult)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// listHookedBeads returns all beads with status=hooked.
|
||||
func listHookedBeads(townRoot string) ([]*HookedBead, error) {
|
||||
cmd := exec.Command("bd", "list", "--status=hooked", "--json", "--limit=0")
|
||||
cmd.Dir = townRoot
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// No hooked beads is not an error
|
||||
if strings.Contains(string(output), "no issues found") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(output) == 0 || string(output) == "[]" || string(output) == "null\n" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var beads []*HookedBead
|
||||
if err := json.Unmarshal(output, &beads); err != nil {
|
||||
return nil, fmt.Errorf("parsing hooked beads: %w", err)
|
||||
}
|
||||
|
||||
return beads, nil
|
||||
}
|
||||
|
||||
// assigneeToSessionName converts an assignee address to a tmux session name.
|
||||
// Supports formats like "gastown/polecats/max", "gastown/crew/joe", etc.
|
||||
func assigneeToSessionName(assignee string) string {
|
||||
parts := strings.Split(assignee, "/")
|
||||
|
||||
switch len(parts) {
|
||||
case 1:
|
||||
// Simple names like "deacon", "mayor"
|
||||
switch assignee {
|
||||
case "deacon":
|
||||
return "gt-deacon"
|
||||
case "mayor":
|
||||
return "gt-mayor"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
case 2:
|
||||
// rig/role: "gastown/witness", "gastown/refinery"
|
||||
rig, role := parts[0], parts[1]
|
||||
switch role {
|
||||
case "witness", "refinery":
|
||||
return fmt.Sprintf("gt-%s-%s", rig, role)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
case 3:
|
||||
// rig/type/name: "gastown/polecats/max", "gastown/crew/joe"
|
||||
rig, agentType, name := parts[0], parts[1], parts[2]
|
||||
switch agentType {
|
||||
case "polecats":
|
||||
return fmt.Sprintf("gt-%s-%s", rig, name)
|
||||
case "crew":
|
||||
return fmt.Sprintf("gt-%s-crew-%s", rig, name)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// unhookBead sets a bead's status back to 'open'.
|
||||
func unhookBead(townRoot, beadID string) error {
|
||||
cmd := exec.Command("bd", "update", beadID, "--status=open")
|
||||
cmd.Dir = townRoot
|
||||
return cmd.Run()
|
||||
}
|
||||
Reference in New Issue
Block a user