Files
gastown/internal/doctor/agent_beads_check.go
Jacob aaee7fed40 fix(doctor): use route path directly for agent beads check (#36)
The agent-beads-exist check was hardcoding '/mayor/rig' suffix when
constructing the beads path, but routes.jsonl can contain paths like
'my-saas' without this suffix.

This caused the check to look for beads in the wrong location:
- Expected: <town>/<route-path>/.beads (e.g., ~/gt/my-saas/.beads)
- Actual: <town>/<rig>/mayor/rig/.beads (e.g., ~/gt/my-saas/mayor/rig/.beads)

The fix stores the full route path and uses it directly when creating
the beads client, instead of reconstructing an assumed path structure.

Fixes agent beads not being found when routes use simple rig names.
2026-01-02 18:19:58 -08:00

302 lines
8.7 KiB
Go

package doctor
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/steveyegge/gastown/internal/beads"
)
// AgentBeadsCheck verifies that agent beads exist for all agents.
// This includes:
// - Global agents (deacon, mayor) - stored in first rig's beads
// - Per-rig agents (witness, refinery) - stored in each rig's beads
// - Crew workers - stored in each rig's beads
//
// Agent beads are created by gt rig add (see gt-h3hak, gt-pinkq) and gt crew add.
// Each rig uses its configured prefix (e.g., "gt-" for gastown, "bd-" for beads).
type AgentBeadsCheck struct {
FixableCheck
}
// NewAgentBeadsCheck creates a new agent beads check.
func NewAgentBeadsCheck() *AgentBeadsCheck {
return &AgentBeadsCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "agent-beads-exist",
CheckDescription: "Verify agent beads exist for all agents",
},
},
}
}
// rigInfo holds the rig name and its beads path from routes.
type rigInfo struct {
name string // rig name (first component of path)
beadsPath string // full path to beads directory relative to town root
}
// Run checks if agent beads exist for all expected agents.
func (c *AgentBeadsCheck) Run(ctx *CheckContext) *CheckResult {
// Load routes to get prefixes (routes.jsonl is source of truth for prefixes)
beadsDir := filepath.Join(ctx.TownRoot, ".beads")
routes, err := beads.LoadRoutes(beadsDir)
if err != nil {
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: "Could not load routes.jsonl",
}
}
// Build prefix -> rigInfo map from routes
// Routes have format: prefix "gt-" -> path "gastown/mayor/rig" or "my-saas"
prefixToRig := make(map[string]rigInfo) // prefix (without hyphen) -> rigInfo
for _, r := range routes {
// Extract rig name from path (first component)
parts := strings.Split(r.Path, "/")
if len(parts) >= 1 && parts[0] != "." {
rigName := parts[0]
prefix := strings.TrimSuffix(r.Prefix, "-")
prefixToRig[prefix] = rigInfo{
name: rigName,
beadsPath: r.Path, // Use the full route path
}
}
}
if len(prefixToRig) == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No rigs with beads routes (agent beads created on rig add)",
}
}
var missing []string
var checked int
// Find the first rig (by name, alphabetically) for global agents
// Only consider gt-prefix rigs since other prefixes can't have agent beads yet
var firstRigName string
for prefix, info := range prefixToRig {
if prefix != "gt" {
continue // Skip non-gt prefixes for first rig selection
}
if firstRigName == "" || info.name < firstRigName {
firstRigName = info.name
}
}
// Check each rig for its agents
for prefix, info := range prefixToRig {
// Get beads client for this rig using the route path directly
rigBeadsPath := filepath.Join(ctx.TownRoot, info.beadsPath)
bd := beads.New(rigBeadsPath)
rigName := info.name
// Check rig-specific agents (using canonical naming: prefix-rig-role-name)
witnessID := beads.WitnessBeadIDWithPrefix(prefix, rigName)
refineryID := beads.RefineryBeadIDWithPrefix(prefix, rigName)
if _, err := bd.Show(witnessID); err != nil {
missing = append(missing, witnessID)
}
checked++
if _, err := bd.Show(refineryID); err != nil {
missing = append(missing, refineryID)
}
checked++
// Check crew worker agents
crewWorkers := listCrewWorkers(ctx.TownRoot, rigName)
for _, workerName := range crewWorkers {
crewID := beads.CrewBeadIDWithPrefix(prefix, rigName, workerName)
if _, err := bd.Show(crewID); err != nil {
missing = append(missing, crewID)
}
checked++
}
// Check global agents in first rig
if rigName == firstRigName {
deaconID := beads.DeaconBeadID()
mayorID := beads.MayorBeadID()
if _, err := bd.Show(deaconID); err != nil {
missing = append(missing, deaconID)
}
checked++
if _, err := bd.Show(mayorID); err != nil {
missing = append(missing, mayorID)
}
checked++
}
}
if len(missing) == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: fmt.Sprintf("All %d agent beads exist", checked),
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: fmt.Sprintf("%d agent bead(s) missing", len(missing)),
Details: missing,
FixHint: "Run 'gt doctor --fix' to create missing agent beads",
}
}
// Fix creates missing agent beads.
func (c *AgentBeadsCheck) Fix(ctx *CheckContext) error {
// Load routes to get prefixes (same as Run)
beadsDir := filepath.Join(ctx.TownRoot, ".beads")
routes, err := beads.LoadRoutes(beadsDir)
if err != nil {
return fmt.Errorf("loading routes.jsonl: %w", err)
}
// Build prefix -> rigInfo map from routes
prefixToRig := make(map[string]rigInfo)
for _, r := range routes {
parts := strings.Split(r.Path, "/")
if len(parts) >= 1 && parts[0] != "." {
rigName := parts[0]
prefix := strings.TrimSuffix(r.Prefix, "-")
prefixToRig[prefix] = rigInfo{
name: rigName,
beadsPath: r.Path, // Use the full route path
}
}
}
if len(prefixToRig) == 0 {
return nil // Nothing to fix
}
// Find the first rig for global agents (only gt-prefix rigs)
var firstRigName string
for prefix, info := range prefixToRig {
if prefix != "gt" {
continue
}
if firstRigName == "" || info.name < firstRigName {
firstRigName = info.name
}
}
// Create missing agents for each rig
for prefix, info := range prefixToRig {
// Use the route path directly instead of hardcoding /mayor/rig
rigBeadsPath := filepath.Join(ctx.TownRoot, info.beadsPath)
bd := beads.New(rigBeadsPath)
rigName := info.name
// Create rig-specific agents if missing (using canonical naming: prefix-rig-role-name)
witnessID := beads.WitnessBeadIDWithPrefix(prefix, rigName)
if _, err := bd.Show(witnessID); err != nil {
fields := &beads.AgentFields{
RoleType: "witness",
Rig: rigName,
AgentState: "idle",
RoleBead: "gt-witness-role",
}
desc := fmt.Sprintf("Witness for %s - monitors polecat health and progress.", rigName)
if _, err := bd.CreateAgentBead(witnessID, desc, fields); err != nil {
return fmt.Errorf("creating %s: %w", witnessID, err)
}
}
refineryID := beads.RefineryBeadIDWithPrefix(prefix, rigName)
if _, err := bd.Show(refineryID); err != nil {
fields := &beads.AgentFields{
RoleType: "refinery",
Rig: rigName,
AgentState: "idle",
RoleBead: "gt-refinery-role",
}
desc := fmt.Sprintf("Refinery for %s - processes merge queue.", rigName)
if _, err := bd.CreateAgentBead(refineryID, desc, fields); err != nil {
return fmt.Errorf("creating %s: %w", refineryID, err)
}
}
// Create crew worker agents if missing
crewWorkers := listCrewWorkers(ctx.TownRoot, rigName)
for _, workerName := range crewWorkers {
crewID := beads.CrewBeadIDWithPrefix(prefix, rigName, workerName)
if _, err := bd.Show(crewID); err != nil {
fields := &beads.AgentFields{
RoleType: "crew",
Rig: rigName,
AgentState: "idle",
RoleBead: "gt-crew-role",
}
desc := fmt.Sprintf("Crew worker %s in %s - human-managed persistent workspace.", workerName, rigName)
if _, err := bd.CreateAgentBead(crewID, desc, fields); err != nil {
return fmt.Errorf("creating %s: %w", crewID, err)
}
}
}
// Create global agents in first rig if missing
if rigName == firstRigName {
deaconID := beads.DeaconBeadID()
if _, err := bd.Show(deaconID); err != nil {
fields := &beads.AgentFields{
RoleType: "deacon",
Rig: "",
AgentState: "idle",
RoleBead: "gt-deacon-role",
}
desc := "Deacon (daemon beacon) - receives mechanical heartbeats, runs town plugins and monitoring."
if _, err := bd.CreateAgentBead(deaconID, desc, fields); err != nil {
return fmt.Errorf("creating %s: %w", deaconID, err)
}
}
mayorID := beads.MayorBeadID()
if _, err := bd.Show(mayorID); err != nil {
fields := &beads.AgentFields{
RoleType: "mayor",
Rig: "",
AgentState: "idle",
RoleBead: "gt-mayor-role",
}
desc := "Mayor - global coordinator, handles cross-rig communication and escalations."
if _, err := bd.CreateAgentBead(mayorID, desc, fields); err != nil {
return fmt.Errorf("creating %s: %w", mayorID, err)
}
}
}
}
return nil
}
// listCrewWorkers returns the names of all crew workers in a rig.
func listCrewWorkers(townRoot, rigName string) []string {
crewDir := filepath.Join(townRoot, rigName, "crew")
entries, err := os.ReadDir(crewDir)
if err != nil {
return nil // No crew directory or can't read it
}
var workers []string
for _, entry := range entries {
if entry.IsDir() && !strings.HasPrefix(entry.Name(), ".") {
workers = append(workers, entry.Name())
}
}
return workers
}