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.
196 lines
5.1 KiB
Go
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()
|
|
}
|