Files
gastown/internal/deacon/stale_hooks.go
max bf16f7894b fix(deacon): use session package for hq- session names (gt-r38pj)
stale_hooks.go was using hardcoded 'gt-deacon' and 'gt-mayor' instead of
session.DeaconSessionName() and session.MayorSessionName() which return
'hq-deacon' and 'hq-mayor'. This caused incorrect session lookups.

Also fixes duplicate WorktreeAddFromRef method from merge conflict.
2026-01-06 23:42:03 -08:00

196 lines
5.1 KiB
Go

// Package deacon provides the Deacon agent infrastructure.
package deacon
import (
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
"github.com/steveyegge/gastown/internal/session"
"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 session.DeaconSessionName()
case "mayor":
return session.MayorSessionName()
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()
}