Changes patrol-molecules-exist check to verify that patrol formulas are accessible via `bd formula list` instead of looking for placeholder molecule beads created by `bd create --type=molecule`. ## Problem The check was looking for molecule-type beads with titles "Deacon Patrol", "Witness Patrol", and "Refinery Patrol". These placeholder beads serve no functional purpose because: 1. Patrols actually use `bd mol wisp mol-deacon-patrol` which cooks formulas inline (on-the-fly) 2. The formulas already exist at the town level in .beads/formulas/ 3. The placeholder beads are never referenced by any patrol code ## Solution - Check for formula names (mol-deacon-patrol, mol-witness-patrol, mol-refinery-patrol) instead of bead titles - Use `bd formula list` instead of `bd list --type=molecule` - Remove Fix() method that created unnecessary placeholder beads - Update messages to reflect that formulas should exist in search paths ## Impact - Check now verifies what patrols actually need (formulas) - Eliminates creation of unnecessary placeholder beads - More accurate health check for patrol system Co-authored-by: Roland Tritsch <roland@ailtir.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
534 lines
15 KiB
Go
534 lines
15 KiB
Go
package doctor
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
"github.com/steveyegge/gastown/internal/templates"
|
|
)
|
|
|
|
// PatrolMoleculesExistCheck verifies that patrol formulas are accessible.
|
|
// Patrols use `bd mol wisp <formula-name>` to spawn workflows, so the formulas
|
|
// must exist in the formula search path (.beads/formulas/, ~/.beads/formulas/, or $GT_ROOT/.beads/formulas/).
|
|
type PatrolMoleculesExistCheck struct {
|
|
BaseCheck
|
|
missingFormulas map[string][]string // rig -> missing formula names
|
|
}
|
|
|
|
// NewPatrolMoleculesExistCheck creates a new patrol formulas exist check.
|
|
func NewPatrolMoleculesExistCheck() *PatrolMoleculesExistCheck {
|
|
return &PatrolMoleculesExistCheck{
|
|
BaseCheck: BaseCheck{
|
|
CheckName: "patrol-molecules-exist",
|
|
CheckDescription: "Check if patrol formulas are accessible",
|
|
CheckCategory: CategoryPatrol,
|
|
},
|
|
}
|
|
}
|
|
|
|
// patrolFormulas are the required patrol formula names.
|
|
var patrolFormulas = []string{
|
|
"mol-deacon-patrol",
|
|
"mol-witness-patrol",
|
|
"mol-refinery-patrol",
|
|
}
|
|
|
|
// Run checks if patrol formulas are accessible.
|
|
func (c *PatrolMoleculesExistCheck) Run(ctx *CheckContext) *CheckResult {
|
|
c.missingFormulas = make(map[string][]string)
|
|
|
|
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
|
|
for _, rigName := range rigs {
|
|
rigPath := filepath.Join(ctx.TownRoot, rigName)
|
|
missing := c.checkPatrolFormulas(rigPath)
|
|
if len(missing) > 0 {
|
|
c.missingFormulas[rigName] = missing
|
|
details = append(details, fmt.Sprintf("%s: missing %v", rigName, missing))
|
|
}
|
|
}
|
|
|
|
if len(details) > 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusWarning,
|
|
Message: fmt.Sprintf("%d rig(s) missing patrol formulas", len(c.missingFormulas)),
|
|
Details: details,
|
|
FixHint: "Formulas should exist in .beads/formulas/ at town or rig level, or in ~/.beads/formulas/",
|
|
}
|
|
}
|
|
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: fmt.Sprintf("All %d rig(s) have patrol formulas accessible", len(rigs)),
|
|
}
|
|
}
|
|
|
|
// checkPatrolFormulas returns missing patrol formula names for a rig.
|
|
func (c *PatrolMoleculesExistCheck) checkPatrolFormulas(rigPath string) []string {
|
|
// List formulas accessible from this rig using bd formula list
|
|
// This checks .beads/formulas/, ~/.beads/formulas/, and $GT_ROOT/.beads/formulas/
|
|
cmd := exec.Command("bd", "formula", "list")
|
|
cmd.Dir = rigPath
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
// Can't check formulas, assume all missing
|
|
return patrolFormulas
|
|
}
|
|
|
|
outputStr := string(output)
|
|
var missing []string
|
|
for _, formulaName := range patrolFormulas {
|
|
// Formula list output includes the formula name without extension
|
|
if !strings.Contains(outputStr, formulaName) {
|
|
missing = append(missing, formulaName)
|
|
}
|
|
}
|
|
return missing
|
|
}
|
|
|
|
// PatrolHooksWiredCheck verifies that hooks trigger patrol execution.
|
|
type PatrolHooksWiredCheck struct {
|
|
FixableCheck
|
|
}
|
|
|
|
// NewPatrolHooksWiredCheck creates a new patrol hooks wired check.
|
|
func NewPatrolHooksWiredCheck() *PatrolHooksWiredCheck {
|
|
return &PatrolHooksWiredCheck{
|
|
FixableCheck: FixableCheck{
|
|
BaseCheck: BaseCheck{
|
|
CheckName: "patrol-hooks-wired",
|
|
CheckDescription: "Check if hooks trigger patrol execution",
|
|
CheckCategory: CategoryPatrol,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// Run checks if patrol hooks are wired.
|
|
func (c *PatrolHooksWiredCheck) Run(ctx *CheckContext) *CheckResult {
|
|
daemonConfigPath := config.DaemonPatrolConfigPath(ctx.TownRoot)
|
|
relPath, _ := filepath.Rel(ctx.TownRoot, daemonConfigPath)
|
|
|
|
if _, err := os.Stat(daemonConfigPath); os.IsNotExist(err) {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusWarning,
|
|
Message: fmt.Sprintf("%s not found", relPath),
|
|
FixHint: "Run 'gt doctor --fix' to create default config, or 'gt daemon start' to start the daemon",
|
|
}
|
|
}
|
|
|
|
cfg, err := config.LoadDaemonPatrolConfig(daemonConfigPath)
|
|
if err != nil {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusError,
|
|
Message: "Failed to read daemon config",
|
|
Details: []string{err.Error()},
|
|
}
|
|
}
|
|
|
|
if len(cfg.Patrols) > 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: fmt.Sprintf("Daemon configured with %d patrol(s)", len(cfg.Patrols)),
|
|
}
|
|
}
|
|
|
|
if cfg.Heartbeat != nil && cfg.Heartbeat.Enabled {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "Daemon heartbeat enabled (triggers patrols)",
|
|
}
|
|
}
|
|
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusWarning,
|
|
Message: fmt.Sprintf("Configure patrols in %s or run 'gt daemon start'", relPath),
|
|
FixHint: "Run 'gt doctor --fix' to create default config",
|
|
}
|
|
}
|
|
|
|
// Fix creates the daemon patrol config with defaults.
|
|
func (c *PatrolHooksWiredCheck) Fix(ctx *CheckContext) error {
|
|
return config.EnsureDaemonPatrolConfig(ctx.TownRoot)
|
|
}
|
|
|
|
// PatrolNotStuckCheck detects wisps that have been in_progress too long.
|
|
type PatrolNotStuckCheck struct {
|
|
BaseCheck
|
|
stuckThreshold time.Duration
|
|
}
|
|
|
|
// DefaultStuckThreshold is the fallback when no role bead config exists.
|
|
// Per ZFC: "Let agents decide thresholds. 'Stuck' is a judgment call."
|
|
const DefaultStuckThreshold = 1 * time.Hour
|
|
|
|
// NewPatrolNotStuckCheck creates a new patrol not stuck check.
|
|
func NewPatrolNotStuckCheck() *PatrolNotStuckCheck {
|
|
return &PatrolNotStuckCheck{
|
|
BaseCheck: BaseCheck{
|
|
CheckName: "patrol-not-stuck",
|
|
CheckDescription: "Check for stuck patrol wisps (>1h in_progress)",
|
|
CheckCategory: CategoryPatrol,
|
|
},
|
|
stuckThreshold: DefaultStuckThreshold,
|
|
}
|
|
}
|
|
|
|
// loadStuckThreshold loads the stuck threshold from the Deacon's role bead.
|
|
// Returns the default if no config exists.
|
|
func loadStuckThreshold(townRoot string) time.Duration {
|
|
bd := beads.NewWithBeadsDir(townRoot, beads.ResolveBeadsDir(townRoot))
|
|
roleConfig, err := bd.GetRoleConfig(beads.RoleBeadIDTown("deacon"))
|
|
if err != nil || roleConfig == nil || roleConfig.StuckThreshold == "" {
|
|
return DefaultStuckThreshold
|
|
}
|
|
if d, err := time.ParseDuration(roleConfig.StuckThreshold); err == nil {
|
|
return d
|
|
}
|
|
return DefaultStuckThreshold
|
|
}
|
|
|
|
// Run checks for stuck patrol wisps.
|
|
func (c *PatrolNotStuckCheck) Run(ctx *CheckContext) *CheckResult {
|
|
// Load threshold from role bead (ZFC: agent-controlled)
|
|
c.stuckThreshold = loadStuckThreshold(ctx.TownRoot)
|
|
|
|
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 stuckWisps []string
|
|
for _, rigName := range rigs {
|
|
// Check main beads database for wisps (issues with Wisp=true)
|
|
// Follows redirect if present (rig root may redirect to mayor/rig/.beads)
|
|
rigPath := filepath.Join(ctx.TownRoot, rigName)
|
|
beadsDir := beads.ResolveBeadsDir(rigPath)
|
|
beadsPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
stuck := c.checkStuckWisps(beadsPath, rigName)
|
|
stuckWisps = append(stuckWisps, stuck...)
|
|
}
|
|
|
|
thresholdStr := c.stuckThreshold.String()
|
|
if len(stuckWisps) > 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusWarning,
|
|
Message: fmt.Sprintf("%d stuck patrol wisp(s) found (>%s)", len(stuckWisps), thresholdStr),
|
|
Details: stuckWisps,
|
|
FixHint: "Manual review required - wisps may need to be burned or sessions restarted",
|
|
}
|
|
}
|
|
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "No stuck patrol wisps found",
|
|
}
|
|
}
|
|
|
|
// checkStuckWisps returns descriptions of stuck wisps in a rig.
|
|
func (c *PatrolNotStuckCheck) checkStuckWisps(issuesPath string, rigName string) []string {
|
|
file, err := os.Open(issuesPath)
|
|
if err != nil {
|
|
return nil // No issues file
|
|
}
|
|
defer file.Close()
|
|
|
|
var stuck []string
|
|
cutoff := time.Now().Add(-c.stuckThreshold)
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var issue struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Check for in_progress issues older than threshold
|
|
if issue.Status == "in_progress" && !issue.UpdatedAt.IsZero() && issue.UpdatedAt.Before(cutoff) {
|
|
stuck = append(stuck, fmt.Sprintf("%s: %s (%s) - stale since %s",
|
|
rigName, issue.ID, issue.Title, issue.UpdatedAt.Format("2006-01-02 15:04")))
|
|
}
|
|
}
|
|
|
|
return stuck
|
|
}
|
|
|
|
// PatrolPluginsAccessibleCheck verifies plugin directories exist and are readable.
|
|
type PatrolPluginsAccessibleCheck struct {
|
|
FixableCheck
|
|
missingDirs []string
|
|
}
|
|
|
|
// NewPatrolPluginsAccessibleCheck creates a new patrol plugins accessible check.
|
|
func NewPatrolPluginsAccessibleCheck() *PatrolPluginsAccessibleCheck {
|
|
return &PatrolPluginsAccessibleCheck{
|
|
FixableCheck: FixableCheck{
|
|
BaseCheck: BaseCheck{
|
|
CheckName: "patrol-plugins-accessible",
|
|
CheckDescription: "Check if plugin directories exist and are readable",
|
|
CheckCategory: CategoryPatrol,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// Run checks if plugin directories are accessible.
|
|
func (c *PatrolPluginsAccessibleCheck) Run(ctx *CheckContext) *CheckResult {
|
|
c.missingDirs = nil
|
|
|
|
// Check town-level plugins directory
|
|
townPluginsDir := filepath.Join(ctx.TownRoot, "plugins")
|
|
if _, err := os.Stat(townPluginsDir); os.IsNotExist(err) {
|
|
c.missingDirs = append(c.missingDirs, townPluginsDir)
|
|
}
|
|
|
|
// Check rig-level plugins directories
|
|
rigs, err := discoverRigs(ctx.TownRoot)
|
|
if err == nil {
|
|
for _, rigName := range rigs {
|
|
rigPluginsDir := filepath.Join(ctx.TownRoot, rigName, "plugins")
|
|
if _, err := os.Stat(rigPluginsDir); os.IsNotExist(err) {
|
|
c.missingDirs = append(c.missingDirs, rigPluginsDir)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(c.missingDirs) > 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusWarning,
|
|
Message: fmt.Sprintf("%d plugin directory(ies) missing", len(c.missingDirs)),
|
|
Details: c.missingDirs,
|
|
FixHint: "Run 'gt doctor --fix' to create missing directories",
|
|
}
|
|
}
|
|
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "All plugin directories accessible",
|
|
}
|
|
}
|
|
|
|
// Fix creates missing plugin directories.
|
|
func (c *PatrolPluginsAccessibleCheck) Fix(ctx *CheckContext) error {
|
|
for _, dir := range c.missingDirs {
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return fmt.Errorf("creating %s: %w", dir, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PatrolRolesHavePromptsCheck verifies that internal/templates/roles/*.md.tmpl exist for each rig.
|
|
// Checks at <town>/<rig>/mayor/rig/internal/templates/roles/*.md.tmpl
|
|
// Fix copies embedded templates to missing locations.
|
|
type PatrolRolesHavePromptsCheck struct {
|
|
FixableCheck
|
|
// missingByRig tracks missing templates per rig: rigName -> []missingFiles
|
|
missingByRig map[string][]string
|
|
}
|
|
|
|
// NewPatrolRolesHavePromptsCheck creates a new patrol roles have prompts check.
|
|
func NewPatrolRolesHavePromptsCheck() *PatrolRolesHavePromptsCheck {
|
|
return &PatrolRolesHavePromptsCheck{
|
|
FixableCheck: FixableCheck{
|
|
BaseCheck: BaseCheck{
|
|
CheckName: "patrol-roles-have-prompts",
|
|
CheckDescription: "Check if internal/templates/roles/*.md.tmpl exist for each patrol role",
|
|
CheckCategory: CategoryPatrol,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
var requiredRolePrompts = []string{
|
|
"deacon.md.tmpl",
|
|
"witness.md.tmpl",
|
|
"refinery.md.tmpl",
|
|
}
|
|
|
|
func (c *PatrolRolesHavePromptsCheck) Run(ctx *CheckContext) *CheckResult {
|
|
c.missingByRig = make(map[string][]string)
|
|
|
|
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 missingPrompts []string
|
|
rigsChecked := 0
|
|
for _, rigName := range rigs {
|
|
// Check in mayor's clone (canonical for the rig)
|
|
mayorRig := filepath.Join(ctx.TownRoot, rigName, "mayor", "rig")
|
|
templatesDir := filepath.Join(mayorRig, "internal", "templates", "roles")
|
|
|
|
// Skip rigs that don't have internal/templates structure.
|
|
// Most repos won't have this - templates are embedded in gastown binary.
|
|
// Only check rigs that explicitly have their own template overrides.
|
|
if _, err := os.Stat(filepath.Join(mayorRig, "internal", "templates")); os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
rigsChecked++
|
|
|
|
var rigMissing []string
|
|
for _, roleFile := range requiredRolePrompts {
|
|
promptPath := filepath.Join(templatesDir, roleFile)
|
|
if _, err := os.Stat(promptPath); os.IsNotExist(err) {
|
|
missingPrompts = append(missingPrompts, fmt.Sprintf("%s: %s", rigName, roleFile))
|
|
rigMissing = append(rigMissing, roleFile)
|
|
}
|
|
}
|
|
if len(rigMissing) > 0 {
|
|
c.missingByRig[rigName] = rigMissing
|
|
}
|
|
}
|
|
|
|
// Templates are embedded in gastown binary - missing files in rig repos is normal.
|
|
// Only report as informational, not a warning.
|
|
if rigsChecked == 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "Using embedded role templates (no custom overrides)",
|
|
}
|
|
}
|
|
|
|
if len(missingPrompts) > 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: fmt.Sprintf("%d rig(s) using embedded templates for some roles", len(c.missingByRig)),
|
|
}
|
|
}
|
|
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "All patrol role prompt templates found",
|
|
}
|
|
}
|
|
|
|
func (c *PatrolRolesHavePromptsCheck) Fix(ctx *CheckContext) error {
|
|
allTemplates, err := templates.GetAllRoleTemplates()
|
|
if err != nil {
|
|
return fmt.Errorf("getting embedded templates: %w", err)
|
|
}
|
|
|
|
for rigName, missingFiles := range c.missingByRig {
|
|
mayorRig := filepath.Join(ctx.TownRoot, rigName, "mayor", "rig")
|
|
templatesDir := filepath.Join(mayorRig, "internal", "templates", "roles")
|
|
|
|
if err := os.MkdirAll(templatesDir, 0755); err != nil {
|
|
return fmt.Errorf("creating %s: %w", templatesDir, err)
|
|
}
|
|
|
|
for _, roleFile := range missingFiles {
|
|
content, ok := allTemplates[roleFile]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
destPath := filepath.Join(templatesDir, roleFile)
|
|
if err := os.WriteFile(destPath, content, 0644); err != nil {
|
|
return fmt.Errorf("writing %s in %s: %w", roleFile, rigName, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// discoverRigs finds all registered rigs.
|
|
func discoverRigs(townRoot string) ([]string, error) {
|
|
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
|
data, err := os.ReadFile(rigsPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil // No rigs configured
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var rigsConfig config.RigsConfig
|
|
if err := json.Unmarshal(data, &rigsConfig); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var rigs []string
|
|
for name := range rigsConfig.Rigs {
|
|
rigs = append(rigs, name)
|
|
}
|
|
return rigs, nil
|
|
}
|