feat(roles): switch daemon to config-based roles, remove role beads (Phase 2+3)

Phase 2: Daemon now uses config.LoadRoleDefinition() instead of role beads
- lifecycle.go: getRoleConfigForIdentity() reads from TOML configs
- Layered override resolution: builtin → town → rig

Phase 3: Remove role bead creation and references
- Remove RoleBead field from AgentFields struct
- gt install no longer creates role beads
- Remove 'role' from custom types list
- Delete migrate_agents.go (no longer needed)
- Deprecate beads_role.go (kept for reading existing beads)
- Rewrite role_beads_check.go to validate TOML configs

Existing role beads are orphaned but harmless.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gastown/crew/max
2026-01-20 12:49:52 -08:00
committed by Steve Yegge
parent 544cacf36d
commit a610283078
16 changed files with 347 additions and 713 deletions
-9
View File
@@ -7,19 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.4.0] - 2026-01-17
### Fixed ### Fixed
- **Orphan cleanup skips valid tmux sessions** - `gt orphans kill` and automatic orphan cleanup now check for Claude processes belonging to valid Gas Town tmux sessions (gt-*/hq-*) before killing. This prevents false kills of witnesses, refineries, and deacon during startup when they may temporarily show TTY "?" - **Orphan cleanup skips valid tmux sessions** - `gt orphans kill` and automatic orphan cleanup now check for Claude processes belonging to valid Gas Town tmux sessions (gt-*/hq-*) before killing. This prevents false kills of witnesses, refineries, and deacon during startup when they may temporarily show TTY "?"
## [0.3.1] - 2026-01-17
### Fixed
- **Orphan cleanup on macOS** - Fixed TTY comparison (`??` vs `?`) so orphan detection works on macOS
- **Session kill leaves orphans** - `gt done` and `gt crew stop` now use `KillSessionWithProcesses` to properly terminate all child processes before killing the tmux session
## [0.3.0] - 2026-01-17 ## [0.3.0] - 2026-01-17
### Added ### Added
+1 -1
View File
@@ -44,8 +44,8 @@ type Issue struct {
// Agent bead slots (type=agent only) // Agent bead slots (type=agent only)
HookBead string `json:"hook_bead,omitempty"` // Current work attached to agent's hook HookBead string `json:"hook_bead,omitempty"` // Current work attached to agent's hook
RoleBead string `json:"role_bead,omitempty"` // Role definition bead (shared)
AgentState string `json:"agent_state,omitempty"` // Agent lifecycle state (spawning, working, done, stuck) AgentState string `json:"agent_state,omitempty"` // Agent lifecycle state (spawning, working, done, stuck)
// Note: role_bead field removed - role definitions are now config-based
// Counts from list output // Counts from list output
DependencyCount int `json:"dependency_count,omitempty"` DependencyCount int `json:"dependency_count,omitempty"`
+6 -21
View File
@@ -15,10 +15,11 @@ type AgentFields struct {
Rig string // Rig name (empty for global agents like mayor/deacon) Rig string // Rig name (empty for global agents like mayor/deacon)
AgentState string // spawning, working, done, stuck AgentState string // spawning, working, done, stuck
HookBead string // Currently pinned work bead ID HookBead string // Currently pinned work bead ID
RoleBead string // Role definition bead ID (canonical location; may not exist yet)
CleanupStatus string // ZFC: polecat self-reports git state (clean, has_uncommitted, has_stash, has_unpushed) CleanupStatus string // ZFC: polecat self-reports git state (clean, has_uncommitted, has_stash, has_unpushed)
ActiveMR string // Currently active merge request bead ID (for traceability) ActiveMR string // Currently active merge request bead ID (for traceability)
NotificationLevel string // DND mode: verbose, normal, muted (default: normal) NotificationLevel string // DND mode: verbose, normal, muted (default: normal)
// Note: RoleBead field removed - role definitions are now config-based.
// See internal/config/roles/*.toml and config-based-roles.md.
} }
// Notification level constants // Notification level constants
@@ -53,11 +54,7 @@ func FormatAgentDescription(title string, fields *AgentFields) string {
lines = append(lines, "hook_bead: null") lines = append(lines, "hook_bead: null")
} }
if fields.RoleBead != "" { // Note: role_bead field no longer written - role definitions are config-based
lines = append(lines, fmt.Sprintf("role_bead: %s", fields.RoleBead))
} else {
lines = append(lines, "role_bead: null")
}
if fields.CleanupStatus != "" { if fields.CleanupStatus != "" {
lines = append(lines, fmt.Sprintf("cleanup_status: %s", fields.CleanupStatus)) lines = append(lines, fmt.Sprintf("cleanup_status: %s", fields.CleanupStatus))
@@ -111,7 +108,7 @@ func ParseAgentFields(description string) *AgentFields {
case "hook_bead": case "hook_bead":
fields.HookBead = value fields.HookBead = value
case "role_bead": case "role_bead":
fields.RoleBead = value // Ignored - role definitions are now config-based (backward compat)
case "cleanup_status": case "cleanup_status":
fields.CleanupStatus = value fields.CleanupStatus = value
case "active_mr": case "active_mr":
@@ -158,13 +155,7 @@ func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue,
return nil, fmt.Errorf("parsing bd create output: %w", err) return nil, fmt.Errorf("parsing bd create output: %w", err)
} }
// Set the role slot if specified (this is the authoritative storage) // Note: role slot no longer set - role definitions are config-based
if fields != nil && fields.RoleBead != "" {
if _, err := b.run("slot", "set", id, "role", fields.RoleBead); err != nil {
// Non-fatal: warn but continue
fmt.Printf("Warning: could not set role slot: %v\n", err)
}
}
// Set the hook slot if specified (this is the authoritative storage) // Set the hook slot if specified (this is the authoritative storage)
// This fixes the slot inconsistency bug where bead status is 'hooked' but // This fixes the slot inconsistency bug where bead status is 'hooked' but
@@ -223,13 +214,7 @@ func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) (
return nil, fmt.Errorf("updating reopened agent bead: %w", err) return nil, fmt.Errorf("updating reopened agent bead: %w", err)
} }
// Set the role slot if specified // Note: role slot no longer set - role definitions are config-based
if fields != nil && fields.RoleBead != "" {
if _, err := b.run("slot", "set", id, "role", fields.RoleBead); err != nil {
// Non-fatal: warn but continue
fmt.Printf("Warning: could not set role slot: %v\n", err)
}
}
// Clear any existing hook slot (handles stale state from previous lifecycle) // Clear any existing hook slot (handles stale state from previous lifecycle)
_, _ = b.run("slot", "clear", id, "hook") _, _ = b.run("slot", "clear", id, "hook")
+23 -8
View File
@@ -1,4 +1,11 @@
// Package beads provides role bead management. // Package beads provides role bead management.
//
// DEPRECATED: Role beads are deprecated. Role definitions are now config-based.
// See internal/config/roles/*.toml and config-based-roles.md for the new system.
//
// This file is kept for backward compatibility with existing role beads but
// new code should use config.LoadRoleDefinition() instead of reading role beads.
// The daemon no longer uses role beads as of Phase 2 (config-based roles).
package beads package beads
import ( import (
@@ -6,10 +13,12 @@ import (
"fmt" "fmt"
) )
// Role bead ID naming convention: // DEPRECATED: Role bead ID naming convention is no longer used.
// Role beads are stored in town beads (~/.beads/) with hq- prefix. // Role definitions are now config-based (internal/config/roles/*.toml).
// //
// Canonical format: hq-<role>-role // Role beads were stored in town beads (~/.beads/) with hq- prefix.
//
// Canonical format was: hq-<role>-role
// //
// Examples: // Examples:
// - hq-mayor-role // - hq-mayor-role
@@ -19,8 +28,8 @@ import (
// - hq-crew-role // - hq-crew-role
// - hq-polecat-role // - hq-polecat-role
// //
// Use RoleBeadIDTown() to get canonical role bead IDs. // Legacy functions RoleBeadID() and RoleBeadIDTown() still work for
// The legacy RoleBeadID() function returns gt-<role>-role for backward compatibility. // backward compatibility but should not be used in new code.
// RoleBeadID returns the role bead ID for a given role type. // RoleBeadID returns the role bead ID for a given role type.
// Role beads define lifecycle configuration for each agent type. // Role beads define lifecycle configuration for each agent type.
@@ -67,6 +76,9 @@ func PolecatRoleBeadID() string {
// GetRoleConfig looks up a role bead and returns its parsed RoleConfig. // GetRoleConfig looks up a role bead and returns its parsed RoleConfig.
// Returns nil, nil if the role bead doesn't exist or has no config. // Returns nil, nil if the role bead doesn't exist or has no config.
//
// Deprecated: Use config.LoadRoleDefinition() instead. Role definitions
// are now config-based, not stored as beads.
func (b *Beads) GetRoleConfig(roleBeadID string) (*RoleConfig, error) { func (b *Beads) GetRoleConfig(roleBeadID string) (*RoleConfig, error) {
issue, err := b.Show(roleBeadID) issue, err := b.Show(roleBeadID)
if err != nil { if err != nil {
@@ -94,7 +106,9 @@ func HasLabel(issue *Issue, label string) bool {
} }
// RoleBeadDef defines a role bead's metadata. // RoleBeadDef defines a role bead's metadata.
// Used by gt install and gt doctor to create missing role beads. //
// Deprecated: Role beads are no longer created. Role definitions are
// now config-based (internal/config/roles/*.toml).
type RoleBeadDef struct { type RoleBeadDef struct {
ID string // e.g., "hq-witness-role" ID string // e.g., "hq-witness-role"
Title string // e.g., "Witness Role" Title string // e.g., "Witness Role"
@@ -102,8 +116,9 @@ type RoleBeadDef struct {
} }
// AllRoleBeadDefs returns all role bead definitions. // AllRoleBeadDefs returns all role bead definitions.
// This is the single source of truth for role beads used by both //
// gt install (initial creation) and gt doctor --fix (repair). // Deprecated: Role beads are no longer created by gt install or gt doctor.
// This function is kept for backward compatibility only.
func AllRoleBeadDefs() []RoleBeadDef { func AllRoleBeadDefs() []RoleBeadDef {
return []RoleBeadDef{ return []RoleBeadDef{
{ {
-6
View File
@@ -1972,7 +1972,6 @@ func TestCreateOrReopenAgentBead_ClosedBead(t *testing.T) {
Rig: "testrig", Rig: "testrig",
AgentState: "spawning", AgentState: "spawning",
HookBead: "test-task-1", HookBead: "test-task-1",
RoleBead: "test-polecat-role",
}) })
if err != nil { if err != nil {
t.Fatalf("Spawn 1 - CreateOrReopenAgentBead: %v", err) t.Fatalf("Spawn 1 - CreateOrReopenAgentBead: %v", err)
@@ -1993,7 +1992,6 @@ func TestCreateOrReopenAgentBead_ClosedBead(t *testing.T) {
Rig: "testrig", Rig: "testrig",
AgentState: "spawning", AgentState: "spawning",
HookBead: "test-task-2", // Different task HookBead: "test-task-2", // Different task
RoleBead: "test-polecat-role",
}) })
if err != nil { if err != nil {
t.Fatalf("Spawn 2 - CreateOrReopenAgentBead: %v", err) t.Fatalf("Spawn 2 - CreateOrReopenAgentBead: %v", err)
@@ -2020,7 +2018,6 @@ func TestCreateOrReopenAgentBead_ClosedBead(t *testing.T) {
Rig: "testrig", Rig: "testrig",
AgentState: "spawning", AgentState: "spawning",
HookBead: "test-task-3", HookBead: "test-task-3",
RoleBead: "test-polecat-role",
}) })
if err != nil { if err != nil {
t.Fatalf("Spawn 3 - CreateOrReopenAgentBead: %v", err) t.Fatalf("Spawn 3 - CreateOrReopenAgentBead: %v", err)
@@ -2059,7 +2056,6 @@ func TestCloseAndClearAgentBead_FieldClearing(t *testing.T) {
Rig: "testrig", Rig: "testrig",
AgentState: "running", AgentState: "running",
HookBead: "test-issue-123", HookBead: "test-issue-123",
RoleBead: "test-polecat-role",
CleanupStatus: "clean", CleanupStatus: "clean",
ActiveMR: "test-mr-456", ActiveMR: "test-mr-456",
NotificationLevel: "normal", NotificationLevel: "normal",
@@ -2279,7 +2275,6 @@ func TestCloseAndClearAgentBead_ReopenHasCleanState(t *testing.T) {
Rig: "testrig", Rig: "testrig",
AgentState: "running", AgentState: "running",
HookBead: "test-old-issue", HookBead: "test-old-issue",
RoleBead: "test-polecat-role",
CleanupStatus: "clean", CleanupStatus: "clean",
ActiveMR: "test-old-mr", ActiveMR: "test-old-mr",
NotificationLevel: "normal", NotificationLevel: "normal",
@@ -2300,7 +2295,6 @@ func TestCloseAndClearAgentBead_ReopenHasCleanState(t *testing.T) {
Rig: "testrig", Rig: "testrig",
AgentState: "spawning", AgentState: "spawning",
HookBead: "test-new-issue", HookBead: "test-new-issue",
RoleBead: "test-polecat-role",
}) })
if err != nil { if err != nil {
t.Fatalf("CreateOrReopenAgentBead: %v", err) t.Fatalf("CreateOrReopenAgentBead: %v", err)
-1
View File
@@ -106,7 +106,6 @@ func runCrewAdd(cmd *cobra.Command, args []string) error {
RoleType: "crew", RoleType: "crew",
Rig: rigName, Rig: rigName,
AgentState: "idle", AgentState: "idle",
RoleBead: beads.RoleBeadIDTown("crew"),
} }
desc := fmt.Sprintf("Crew worker %s in %s - human-managed persistent workspace.", name, rigName) desc := fmt.Sprintf("Crew worker %s in %s - human-managed persistent workspace.", name, rigName)
if _, err := bd.CreateAgentBead(crewID, desc, fields); err != nil { if _, err := bd.CreateAgentBead(crewID, desc, fields); err != nil {
+10 -38
View File
@@ -248,7 +248,7 @@ func runInstall(cmd *cobra.Command, args []string) error {
} }
} }
// Create town-level agent beads (Mayor, Deacon) and role beads. // Create town-level agent beads (Mayor, Deacon).
// These use hq- prefix and are stored in town beads for cross-rig coordination. // These use hq- prefix and are stored in town beads for cross-rig coordination.
if err := initTownAgentBeads(absPath); err != nil { if err := initTownAgentBeads(absPath); err != nil {
fmt.Printf(" %s Could not create town-level agent beads: %v\n", style.Dim.Render("⚠"), err) fmt.Printf(" %s Could not create town-level agent beads: %v\n", style.Dim.Render("⚠"), err)
@@ -448,58 +448,30 @@ func ensureCustomTypes(beadsPath string) error {
return nil return nil
} }
// initTownAgentBeads creates town-level agent and role beads using hq- prefix. // initTownAgentBeads creates town-level agent beads using hq- prefix.
// This creates: // This creates:
// - hq-mayor, hq-deacon (agent beads for town-level agents) // - hq-mayor, hq-deacon (agent beads for town-level agents)
// - hq-mayor-role, hq-deacon-role, hq-witness-role, hq-refinery-role,
// hq-polecat-role, hq-crew-role (role definition beads)
// //
// These beads are stored in town beads (~/gt/.beads/) and are shared across all rigs. // These beads are stored in town beads (~/gt/.beads/) and are shared across all rigs.
// Rig-level agent beads (witness, refinery) are created by gt rig add in rig beads. // Rig-level agent beads (witness, refinery) are created by gt rig add in rig beads.
// //
// ERROR HANDLING ASYMMETRY: // Note: Role definitions are now config-based (internal/config/roles/*.toml),
// Agent beads (Mayor, Deacon) use hard fail - installation aborts if creation fails. // not stored as beads. See config-based-roles.md for details.
// Role beads use soft fail - logs warning and continues if creation fails.
// //
// Rationale: Agent beads are identity beads that track agent state, hooks, and // Agent beads use hard fail - installation aborts if creation fails.
// Agent beads are identity beads that track agent state, hooks, and
// form the foundation of the CV/reputation ledger. Without them, agents cannot // form the foundation of the CV/reputation ledger. Without them, agents cannot
// be properly tracked or coordinated. Role beads are documentation templates // be properly tracked or coordinated.
// that define role characteristics but are not required for agent operation -
// agents can function without their role bead existing.
func initTownAgentBeads(townPath string) error { func initTownAgentBeads(townPath string) error {
bd := beads.New(townPath) bd := beads.New(townPath)
// bd init doesn't enable "custom" issue types by default, but Gas Town uses // bd init doesn't enable "custom" issue types by default, but Gas Town uses
// agent/role beads during install and runtime. Ensure these types are enabled // agent beads during install and runtime. Ensure these types are enabled
// before attempting to create any town-level system beads. // before attempting to create any town-level system beads.
if err := ensureBeadsCustomTypes(townPath, []string{"agent", "role", "rig", "convoy", "slot"}); err != nil { if err := ensureBeadsCustomTypes(townPath, []string{"agent", "rig", "convoy", "slot"}); err != nil {
return err return err
} }
// Role beads (global templates) - use shared definitions from beads package
for _, role := range beads.AllRoleBeadDefs() {
// Check if already exists
if _, err := bd.Show(role.ID); err == nil {
continue // Already exists
}
// Create role bead using the beads API
// CreateWithID with Type: "role" automatically adds gt:role label
_, err := bd.CreateWithID(role.ID, beads.CreateOptions{
Title: role.Title,
Type: "role",
Description: role.Desc,
Priority: -1, // No priority
})
if err != nil {
// Log but continue - role beads are optional
fmt.Printf(" %s Could not create role bead %s: %v\n",
style.Dim.Render("⚠"), role.ID, err)
continue
}
fmt.Printf(" ✓ Created role bead: %s\n", role.ID)
}
// Town-level agent beads // Town-level agent beads
agentDefs := []struct { agentDefs := []struct {
id string id string
@@ -541,7 +513,7 @@ func initTownAgentBeads(townPath string) error {
Rig: "", // Town-level agents have no rig Rig: "", // Town-level agents have no rig
AgentState: "idle", AgentState: "idle",
HookBead: "", HookBead: "",
RoleBead: beads.RoleBeadIDTown(agent.roleType), // Note: RoleBead field removed - role definitions are now config-based
} }
if _, err := bd.CreateAgentBead(agent.id, agent.title, fields); err != nil { if _, err := bd.CreateAgentBead(agent.id, agent.title, fields); err != nil {
-325
View File
@@ -1,325 +0,0 @@
package cmd
import (
"fmt"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/workspace"
)
var (
migrateAgentsDryRun bool
migrateAgentsForce bool
)
var migrateAgentsCmd = &cobra.Command{
Use: "migrate-agents",
GroupID: GroupDiag,
Short: "Migrate agent beads to two-level architecture",
Long: `Migrate agent beads from the old single-tier to the two-level architecture.
This command migrates town-level agent beads (Mayor, Deacon) from rig beads
with gt-* prefix to town beads with hq-* prefix:
OLD (rig beads): gt-mayor, gt-deacon
NEW (town beads): hq-mayor, hq-deacon
Rig-level agents (Witness, Refinery, Polecats) remain in rig beads unchanged.
The migration:
1. Detects old gt-mayor/gt-deacon beads in rig beads
2. Creates new hq-mayor/hq-deacon beads in town beads
3. Copies agent state (hook_bead, agent_state, etc.)
4. Adds migration note to old beads (preserves them)
Safety:
- Dry-run mode by default (use --execute to apply changes)
- Old beads are preserved with migration notes
- Validates new beads exist before marking migration complete
- Skips if new beads already exist (idempotent)
Examples:
gt migrate-agents # Dry-run: show what would be migrated
gt migrate-agents --execute # Apply the migration
gt migrate-agents --force # Re-migrate even if new beads exist`,
RunE: runMigrateAgents,
}
func init() {
migrateAgentsCmd.Flags().BoolVar(&migrateAgentsDryRun, "dry-run", true, "Show what would be migrated without making changes (default)")
migrateAgentsCmd.Flags().BoolVar(&migrateAgentsForce, "force", false, "Re-migrate even if new beads already exist")
// Add --execute as inverse of --dry-run for clarity
migrateAgentsCmd.Flags().BoolP("execute", "x", false, "Actually apply the migration (opposite of --dry-run)")
rootCmd.AddCommand(migrateAgentsCmd)
}
// migrationResult holds the result of a single bead migration.
type migrationResult struct {
OldID string
NewID string
Status string // "migrated", "skipped", "error"
Message string
OldFields *beads.AgentFields
WasDryRun bool
}
func runMigrateAgents(cmd *cobra.Command, args []string) error {
// Handle --execute flag
if execute, _ := cmd.Flags().GetBool("execute"); execute {
migrateAgentsDryRun = false
}
// Find town root
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Get town beads path
townBeadsDir := filepath.Join(townRoot, ".beads")
// Load routes to find rig beads
routes, err := beads.LoadRoutes(townBeadsDir)
if err != nil {
return fmt.Errorf("loading routes.jsonl: %w", err)
}
// Find the first rig with gt- prefix (where global agents are currently stored)
var sourceRigPath string
for _, r := range routes {
if strings.TrimSuffix(r.Prefix, "-") == "gt" && r.Path != "." {
sourceRigPath = r.Path
break
}
}
if sourceRigPath == "" {
fmt.Println("No rig with gt- prefix found. Nothing to migrate.")
return nil
}
// Source beads (rig beads where old agent beads are)
sourceBeadsDir := filepath.Join(townRoot, sourceRigPath, ".beads")
sourceBd := beads.New(sourceBeadsDir)
// Target beads (town beads where new agent beads should go)
targetBd := beads.NewWithBeadsDir(townRoot, townBeadsDir)
// Agents to migrate: town-level agents only
agentsToMigrate := []struct {
oldID string
newID string
desc string
}{
{
oldID: beads.MayorBeadID(), // gt-mayor
newID: beads.MayorBeadIDTown(), // hq-mayor
desc: "Mayor - global coordinator, handles cross-rig communication and escalations.",
},
{
oldID: beads.DeaconBeadID(), // gt-deacon
newID: beads.DeaconBeadIDTown(), // hq-deacon
desc: "Deacon (daemon beacon) - receives mechanical heartbeats, runs town plugins and monitoring.",
},
}
// Also migrate role beads
rolesToMigrate := []string{"mayor", "deacon", "witness", "refinery", "polecat", "crew", "dog"}
if migrateAgentsDryRun {
fmt.Println("🔍 DRY RUN: Showing what would be migrated")
fmt.Println(" Use --execute to apply changes")
fmt.Println()
} else {
fmt.Println("🚀 Migrating agent beads to two-level architecture")
fmt.Println()
}
var results []migrationResult
// Migrate agent beads
fmt.Println("Agent Beads:")
for _, agent := range agentsToMigrate {
result := migrateAgentBead(sourceBd, targetBd, agent.oldID, agent.newID, agent.desc, migrateAgentsDryRun, migrateAgentsForce)
results = append(results, result)
printMigrationResult(result)
}
// Migrate role beads
fmt.Println("\nRole Beads:")
for _, role := range rolesToMigrate {
oldID := "gt-" + role + "-role"
newID := beads.RoleBeadIDTown(role) // hq-<role>-role
result := migrateRoleBead(sourceBd, targetBd, oldID, newID, role, migrateAgentsDryRun, migrateAgentsForce)
results = append(results, result)
printMigrationResult(result)
}
// Summary
fmt.Println()
printMigrationSummary(results, migrateAgentsDryRun)
return nil
}
// migrateAgentBead migrates a single agent bead from source to target.
func migrateAgentBead(sourceBd, targetBd *beads.Beads, oldID, newID, desc string, dryRun, force bool) migrationResult {
result := migrationResult{
OldID: oldID,
NewID: newID,
WasDryRun: dryRun,
}
// Check if old bead exists
oldIssue, oldFields, err := sourceBd.GetAgentBead(oldID)
if err != nil {
result.Status = "skipped"
result.Message = "old bead not found"
return result
}
result.OldFields = oldFields
// Check if new bead already exists
if _, err := targetBd.Show(newID); err == nil {
if !force {
result.Status = "skipped"
result.Message = "new bead already exists (use --force to re-migrate)"
return result
}
}
if dryRun {
result.Status = "would migrate"
result.Message = fmt.Sprintf("would copy state from %s", oldIssue.ID)
return result
}
// Create new bead in town beads
newFields := &beads.AgentFields{
RoleType: oldFields.RoleType,
Rig: oldFields.Rig,
AgentState: oldFields.AgentState,
HookBead: oldFields.HookBead,
RoleBead: beads.RoleBeadIDTown(oldFields.RoleType), // Update to hq- role
CleanupStatus: oldFields.CleanupStatus,
ActiveMR: oldFields.ActiveMR,
NotificationLevel: oldFields.NotificationLevel,
}
_, err = targetBd.CreateAgentBead(newID, desc, newFields)
if err != nil {
result.Status = "error"
result.Message = fmt.Sprintf("failed to create: %v", err)
return result
}
// Add migration label to old bead
migrationLabel := fmt.Sprintf("migrated-to:%s", newID)
if err := sourceBd.Update(oldID, beads.UpdateOptions{AddLabels: []string{migrationLabel}}); err != nil {
// Non-fatal: just log it
result.Message = fmt.Sprintf("created but couldn't add migration label: %v", err)
}
result.Status = "migrated"
result.Message = "successfully migrated"
return result
}
// migrateRoleBead migrates a role definition bead.
func migrateRoleBead(sourceBd, targetBd *beads.Beads, oldID, newID, role string, dryRun, force bool) migrationResult {
result := migrationResult{
OldID: oldID,
NewID: newID,
WasDryRun: dryRun,
}
// Check if old bead exists
oldIssue, err := sourceBd.Show(oldID)
if err != nil {
result.Status = "skipped"
result.Message = "old bead not found"
return result
}
// Check if new bead already exists
if _, err := targetBd.Show(newID); err == nil {
if !force {
result.Status = "skipped"
result.Message = "new bead already exists (use --force to re-migrate)"
return result
}
}
if dryRun {
result.Status = "would migrate"
result.Message = fmt.Sprintf("would copy from %s", oldIssue.ID)
return result
}
// Create new role bead in town beads
// Role beads are simple - just copy the description
_, err = targetBd.CreateWithID(newID, beads.CreateOptions{
Title: fmt.Sprintf("Role: %s", role),
Type: "role",
Description: oldIssue.Title, // Use old title as description
})
if err != nil {
result.Status = "error"
result.Message = fmt.Sprintf("failed to create: %v", err)
return result
}
// Add migration label to old bead
migrationLabel := fmt.Sprintf("migrated-to:%s", newID)
if err := sourceBd.Update(oldID, beads.UpdateOptions{AddLabels: []string{migrationLabel}}); err != nil {
// Non-fatal
result.Message = fmt.Sprintf("created but couldn't add migration label: %v", err)
}
result.Status = "migrated"
result.Message = "successfully migrated"
return result
}
func getMigrationStatusIcon(status string) string {
switch status {
case "migrated", "would migrate":
return " ✓"
case "skipped":
return " ⊘"
case "error":
return " ✗"
default:
return " ?"
}
}
func printMigrationResult(r migrationResult) {
fmt.Printf("%s %s → %s: %s\n", getMigrationStatusIcon(r.Status), r.OldID, r.NewID, r.Message)
}
func printMigrationSummary(results []migrationResult, dryRun bool) {
var migrated, skipped, errors int
for _, r := range results {
switch r.Status {
case "migrated", "would migrate":
migrated++
case "skipped":
skipped++
case "error":
errors++
}
}
if dryRun {
fmt.Printf("Summary (dry-run): %d would migrate, %d skipped, %d errors\n", migrated, skipped, errors)
if migrated > 0 {
fmt.Println("\nRun with --execute to apply these changes.")
}
} else {
fmt.Printf("Summary: %d migrated, %d skipped, %d errors\n", migrated, skipped, errors)
}
}
-87
View File
@@ -1,87 +0,0 @@
package cmd
import (
"testing"
"github.com/steveyegge/gastown/internal/beads"
)
func TestMigrationResultStatus(t *testing.T) {
tests := []struct {
name string
result migrationResult
wantIcon string
}{
{
name: "migrated shows checkmark",
result: migrationResult{
OldID: "gt-mayor",
NewID: "hq-mayor",
Status: "migrated",
Message: "successfully migrated",
},
wantIcon: " ✓",
},
{
name: "would migrate shows checkmark",
result: migrationResult{
OldID: "gt-mayor",
NewID: "hq-mayor",
Status: "would migrate",
Message: "would copy state from gt-mayor",
},
wantIcon: " ✓",
},
{
name: "skipped shows empty circle",
result: migrationResult{
OldID: "gt-mayor",
NewID: "hq-mayor",
Status: "skipped",
Message: "already exists",
},
wantIcon: " ⊘",
},
{
name: "error shows X",
result: migrationResult{
OldID: "gt-mayor",
NewID: "hq-mayor",
Status: "error",
Message: "failed to create",
},
wantIcon: " ✗",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
icon := getMigrationStatusIcon(tt.result.Status)
if icon != tt.wantIcon {
t.Errorf("getMigrationStatusIcon(%q) = %q, want %q", tt.result.Status, icon, tt.wantIcon)
}
})
}
}
func TestTownBeadIDHelpers(t *testing.T) {
tests := []struct {
name string
got string
want string
}{
{"MayorBeadIDTown", beads.MayorBeadIDTown(), "hq-mayor"},
{"DeaconBeadIDTown", beads.DeaconBeadIDTown(), "hq-deacon"},
{"DogBeadIDTown", beads.DogBeadIDTown("fido"), "hq-dog-fido"},
{"RoleBeadIDTown mayor", beads.RoleBeadIDTown("mayor"), "hq-mayor-role"},
{"RoleBeadIDTown witness", beads.RoleBeadIDTown("witness"), "hq-witness-role"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got != tt.want {
t.Errorf("%s = %q, want %q", tt.name, tt.got, tt.want)
}
})
}
}
+34 -34
View File
@@ -211,7 +211,7 @@ func (d *Daemon) executeLifecycleAction(request *LifecycleRequest) error {
} }
// ParsedIdentity holds the components extracted from an agent identity string. // ParsedIdentity holds the components extracted from an agent identity string.
// This is used to look up the appropriate role bead for lifecycle config. // This is used to look up the appropriate role config for lifecycle management.
type ParsedIdentity struct { type ParsedIdentity struct {
RoleType string // mayor, deacon, witness, refinery, crew, polecat RoleType string // mayor, deacon, witness, refinery, crew, polecat
RigName string // Empty for town-level agents (mayor, deacon) RigName string // Empty for town-level agents (mayor, deacon)
@@ -220,7 +220,7 @@ type ParsedIdentity struct {
// parseIdentity extracts role type, rig name, and agent name from an identity string. // parseIdentity extracts role type, rig name, and agent name from an identity string.
// This is the ONLY place where identity string patterns are parsed. // This is the ONLY place where identity string patterns are parsed.
// All other functions should use the extracted components to look up role beads. // All other functions should use the extracted components to look up role config.
func parseIdentity(identity string) (*ParsedIdentity, error) { func parseIdentity(identity string) (*ParsedIdentity, error) {
switch identity { switch identity {
case "mayor": case "mayor":
@@ -268,49 +268,50 @@ func parseIdentity(identity string) (*ParsedIdentity, error) {
return nil, fmt.Errorf("unknown identity format: %s", identity) return nil, fmt.Errorf("unknown identity format: %s", identity)
} }
// getRoleConfigForIdentity looks up the role bead for an identity and returns its config. // getRoleConfigForIdentity loads role configuration from the config-based role system.
// Falls back to default config if role bead doesn't exist or has no config. // Uses config.LoadRoleDefinition() with layered override resolution (builtin → town → rig).
// Returns config in beads.RoleConfig format for backward compatibility.
func (d *Daemon) getRoleConfigForIdentity(identity string) (*beads.RoleConfig, *ParsedIdentity, error) { func (d *Daemon) getRoleConfigForIdentity(identity string) (*beads.RoleConfig, *ParsedIdentity, error) {
parsed, err := parseIdentity(identity) parsed, err := parseIdentity(identity)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
// Look up role bead // Determine rig path for rig-scoped roles
b := beads.New(d.config.TownRoot) rigPath := ""
if parsed.RigName != "" {
rigPath = filepath.Join(d.config.TownRoot, parsed.RigName)
}
roleBeadID := beads.RoleBeadIDTown(parsed.RoleType) // Load role definition from config system (Phase 2: config-based roles)
roleConfig, err := b.GetRoleConfig(roleBeadID) roleDef, err := config.LoadRoleDefinition(d.config.TownRoot, rigPath, parsed.RoleType)
if err != nil { if err != nil {
d.logger.Printf("Warning: failed to get role config for %s: %v", roleBeadID, err) d.logger.Printf("Warning: failed to load role definition for %s: %v", parsed.RoleType, err)
// Return parsed identity even if config fails (caller can use defaults)
return nil, parsed, nil
} }
// Backward compatibility: fall back to legacy role bead IDs. // Convert to beads.RoleConfig for backward compatibility
if roleConfig == nil { roleConfig := &beads.RoleConfig{
legacyRoleBeadID := beads.RoleBeadID(parsed.RoleType) // gt-<role>-role SessionPattern: roleDef.Session.Pattern,
if legacyRoleBeadID != roleBeadID { WorkDirPattern: roleDef.Session.WorkDir,
legacyCfg, legacyErr := b.GetRoleConfig(legacyRoleBeadID) NeedsPreSync: roleDef.Session.NeedsPreSync,
if legacyErr != nil { StartCommand: roleDef.Session.StartCommand,
d.logger.Printf("Warning: failed to get legacy role config for %s: %v", legacyRoleBeadID, legacyErr) EnvVars: roleDef.Env,
} else if legacyCfg != nil {
roleConfig = legacyCfg
}
}
} }
// Return parsed identity even if config is nil (caller can use defaults)
return roleConfig, parsed, nil return roleConfig, parsed, nil
} }
// identityToSession converts a beads identity to a tmux session name. // identityToSession converts a beads identity to a tmux session name.
// Uses role bead config if available, falls back to hardcoded patterns. // Uses role config if available, falls back to hardcoded patterns.
func (d *Daemon) identityToSession(identity string) string { func (d *Daemon) identityToSession(identity string) string {
config, parsed, err := d.getRoleConfigForIdentity(identity) config, parsed, err := d.getRoleConfigForIdentity(identity)
if err != nil { if err != nil {
return "" return ""
} }
// If role bead has session_pattern, use it // If role config has session_pattern, use it
if config != nil && config.SessionPattern != "" { if config != nil && config.SessionPattern != "" {
return beads.ExpandRolePattern(config.SessionPattern, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType) return beads.ExpandRolePattern(config.SessionPattern, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType)
} }
@@ -333,7 +334,7 @@ func (d *Daemon) identityToSession(identity string) string {
} }
// restartSession starts a new session for the given agent. // restartSession starts a new session for the given agent.
// Uses role bead config if available, falls back to hardcoded defaults. // Uses role config if available, falls back to hardcoded defaults.
func (d *Daemon) restartSession(sessionName, identity string) error { func (d *Daemon) restartSession(sessionName, identity string) error {
// Get role config for this identity // Get role config for this identity
config, parsed, err := d.getRoleConfigForIdentity(identity) config, parsed, err := d.getRoleConfigForIdentity(identity)
@@ -409,9 +410,9 @@ func (d *Daemon) restartSession(sessionName, identity string) error {
} }
// getWorkDir determines the working directory for an agent. // getWorkDir determines the working directory for an agent.
// Uses role bead config if available, falls back to hardcoded defaults. // Uses role config if available, falls back to hardcoded defaults.
func (d *Daemon) getWorkDir(config *beads.RoleConfig, parsed *ParsedIdentity) string { func (d *Daemon) getWorkDir(config *beads.RoleConfig, parsed *ParsedIdentity) string {
// If role bead has work_dir_pattern, use it // If role config has work_dir_pattern, use it
if config != nil && config.WorkDirPattern != "" { if config != nil && config.WorkDirPattern != "" {
return beads.ExpandRolePattern(config.WorkDirPattern, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType) return beads.ExpandRolePattern(config.WorkDirPattern, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType)
} }
@@ -442,9 +443,9 @@ func (d *Daemon) getWorkDir(config *beads.RoleConfig, parsed *ParsedIdentity) st
} }
// getNeedsPreSync determines if a workspace needs git sync before starting. // getNeedsPreSync determines if a workspace needs git sync before starting.
// Uses role bead config if available, falls back to hardcoded defaults. // Uses role config if available, falls back to hardcoded defaults.
func (d *Daemon) getNeedsPreSync(config *beads.RoleConfig, parsed *ParsedIdentity) bool { func (d *Daemon) getNeedsPreSync(config *beads.RoleConfig, parsed *ParsedIdentity) bool {
// If role bead has explicit config, use it // If role config is available, use it
if config != nil { if config != nil {
return config.NeedsPreSync return config.NeedsPreSync
} }
@@ -459,9 +460,9 @@ func (d *Daemon) getNeedsPreSync(config *beads.RoleConfig, parsed *ParsedIdentit
} }
// getStartCommand determines the startup command for an agent. // getStartCommand determines the startup command for an agent.
// Uses role bead config if available, then role-based agent selection, then hardcoded defaults. // Uses role config if available, then role-based agent selection, then hardcoded defaults.
func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIdentity) string { func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIdentity) string {
// If role bead has explicit config, use it // If role config is available, use it
if roleConfig != nil && roleConfig.StartCommand != "" { if roleConfig != nil && roleConfig.StartCommand != "" {
// Expand any patterns in the command // Expand any patterns in the command
return beads.ExpandRolePattern(roleConfig.StartCommand, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType) return beads.ExpandRolePattern(roleConfig.StartCommand, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType)
@@ -516,7 +517,7 @@ func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIde
} }
// setSessionEnvironment sets environment variables for the tmux session. // setSessionEnvironment sets environment variables for the tmux session.
// Uses centralized AgentEnv for consistency, plus role bead custom env vars if available. // Uses centralized AgentEnv for consistency, plus custom env vars from role config if available.
func (d *Daemon) setSessionEnvironment(sessionName string, roleConfig *beads.RoleConfig, parsed *ParsedIdentity) { func (d *Daemon) setSessionEnvironment(sessionName string, roleConfig *beads.RoleConfig, parsed *ParsedIdentity) {
// Use centralized AgentEnv for base environment variables // Use centralized AgentEnv for base environment variables
envVars := config.AgentEnv(config.AgentEnvConfig{ envVars := config.AgentEnv(config.AgentEnvConfig{
@@ -529,7 +530,7 @@ func (d *Daemon) setSessionEnvironment(sessionName string, roleConfig *beads.Rol
_ = d.tmux.SetEnvironment(sessionName, k, v) _ = d.tmux.SetEnvironment(sessionName, k, v)
} }
// Set any custom env vars from role config (bead-defined overrides) // Set any custom env vars from role config
if roleConfig != nil { if roleConfig != nil {
for k, v := range roleConfig.EnvVars { for k, v := range roleConfig.EnvVars {
expanded := beads.ExpandRolePattern(v, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType) expanded := beads.ExpandRolePattern(v, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType)
@@ -637,10 +638,10 @@ type AgentBeadInfo struct {
Type string `json:"issue_type"` Type string `json:"issue_type"`
State string // Parsed from description: agent_state State string // Parsed from description: agent_state
HookBead string // Parsed from description: hook_bead HookBead string // Parsed from description: hook_bead
RoleBead string // Parsed from description: role_bead
RoleType string // Parsed from description: role_type RoleType string // Parsed from description: role_type
Rig string // Parsed from description: rig Rig string // Parsed from description: rig
LastUpdate string `json:"updated_at"` LastUpdate string `json:"updated_at"`
// Note: RoleBead field removed - role definitions are now config-based
} }
// getAgentBeadState reads non-observable agent state from an agent bead. // getAgentBeadState reads non-observable agent state from an agent bead.
@@ -699,7 +700,6 @@ func (d *Daemon) getAgentBeadInfo(agentBeadID string) (*AgentBeadInfo, error) {
if fields != nil { if fields != nil {
info.State = fields.AgentState info.State = fields.AgentState
info.RoleBead = fields.RoleBead
info.RoleType = fields.RoleType info.RoleType = fields.RoleType
info.Rig = fields.Rig info.Rig = fields.Rig
} }
+95 -46
View File
@@ -5,41 +5,60 @@ package daemon
import ( import (
"io" "io"
"log" "log"
"os/exec" "os"
"strings" "path/filepath"
"testing" "testing"
) )
func runBd(t *testing.T, dir string, args ...string) string { // TestGetRoleConfigForIdentity_UsesBuiltinDefaults tests that the daemon
t.Helper() // uses built-in role definitions from embedded TOML files when no overrides exist.
cmd := exec.Command("bd", args...) //nolint:gosec // bd is a trusted internal tool in this repo func TestGetRoleConfigForIdentity_UsesBuiltinDefaults(t *testing.T) {
cmd.Dir = dir townRoot := t.TempDir()
out, err := cmd.CombinedOutput()
if err != nil { d := &Daemon{
t.Fatalf("bd %s failed: %v\n%s", strings.Join(args, " "), err, string(out)) config: &Config{TownRoot: townRoot},
logger: log.New(io.Discard, "", 0),
}
// Should load witness role from built-in defaults
cfg, parsed, err := d.getRoleConfigForIdentity("myrig-witness")
if err != nil {
t.Fatalf("getRoleConfigForIdentity: %v", err)
}
if parsed == nil || parsed.RoleType != "witness" {
t.Fatalf("parsed = %#v, want roleType witness", parsed)
}
if cfg == nil {
t.Fatal("cfg is nil, expected built-in defaults")
}
// Built-in witness has session pattern "gt-{rig}-witness"
if cfg.SessionPattern != "gt-{rig}-witness" {
t.Errorf("cfg.SessionPattern = %q, want %q", cfg.SessionPattern, "gt-{rig}-witness")
} }
return string(out)
} }
func TestGetRoleConfigForIdentity_PrefersTownRoleBead(t *testing.T) { // TestGetRoleConfigForIdentity_TownOverride tests that town-level TOML overrides
if _, err := exec.LookPath("bd"); err != nil { // are merged with built-in defaults.
t.Skip("bd not installed") func TestGetRoleConfigForIdentity_TownOverride(t *testing.T) {
townRoot := t.TempDir()
// Create town-level override
rolesDir := filepath.Join(townRoot, "roles")
if err := os.MkdirAll(rolesDir, 0755); err != nil {
t.Fatalf("mkdir roles: %v", err)
} }
townRoot := t.TempDir() // Override start_command for witness role
runBd(t, townRoot, "init", "--quiet", "--prefix", "hq") witnessOverride := `
role = "witness"
scope = "rig"
runBd(t, townRoot, "config", "set", "types.custom", "agent,role,rig,convoy,event") [session]
start_command = "exec echo custom-town-command"
runBd(t, townRoot, "config", "set", "types.custom", "agent,role,rig,convoy,event") `
if err := os.WriteFile(filepath.Join(rolesDir, "witness.toml"), []byte(witnessOverride), 0644); err != nil {
// Create canonical role bead. t.Fatalf("write witness.toml: %v", err)
runBd(t, townRoot, "create", }
"--id", "hq-witness-role",
"--type", "role",
"--title", "Witness Role",
"--description", "start_command: exec echo hq\n",
)
d := &Daemon{ d := &Daemon{
config: &Config{TownRoot: townRoot}, config: &Config{TownRoot: townRoot},
@@ -53,30 +72,56 @@ func TestGetRoleConfigForIdentity_PrefersTownRoleBead(t *testing.T) {
if parsed == nil || parsed.RoleType != "witness" { if parsed == nil || parsed.RoleType != "witness" {
t.Fatalf("parsed = %#v, want roleType witness", parsed) t.Fatalf("parsed = %#v, want roleType witness", parsed)
} }
if cfg == nil || cfg.StartCommand != "exec echo hq" { if cfg == nil {
t.Fatalf("cfg.StartCommand = %#v, want %q", cfg, "exec echo hq") t.Fatal("cfg is nil")
}
// Should have the overridden start_command
if cfg.StartCommand != "exec echo custom-town-command" {
t.Errorf("cfg.StartCommand = %q, want %q", cfg.StartCommand, "exec echo custom-town-command")
}
// Should still have built-in session pattern (not overridden)
if cfg.SessionPattern != "gt-{rig}-witness" {
t.Errorf("cfg.SessionPattern = %q, want %q", cfg.SessionPattern, "gt-{rig}-witness")
} }
} }
func TestGetRoleConfigForIdentity_FallsBackToLegacyRoleBead(t *testing.T) { // TestGetRoleConfigForIdentity_RigOverride tests that rig-level TOML overrides
if _, err := exec.LookPath("bd"); err != nil { // take precedence over town-level overrides.
t.Skip("bd not installed") func TestGetRoleConfigForIdentity_RigOverride(t *testing.T) {
townRoot := t.TempDir()
rigPath := filepath.Join(townRoot, "myrig")
// Create town-level override
townRolesDir := filepath.Join(townRoot, "roles")
if err := os.MkdirAll(townRolesDir, 0755); err != nil {
t.Fatalf("mkdir town roles: %v", err)
}
townOverride := `
role = "witness"
scope = "rig"
[session]
start_command = "exec echo town-command"
`
if err := os.WriteFile(filepath.Join(townRolesDir, "witness.toml"), []byte(townOverride), 0644); err != nil {
t.Fatalf("write town witness.toml: %v", err)
} }
townRoot := t.TempDir() // Create rig-level override (should take precedence)
runBd(t, townRoot, "init", "--quiet", "--prefix", "gt") rigRolesDir := filepath.Join(rigPath, "roles")
if err := os.MkdirAll(rigRolesDir, 0755); err != nil {
t.Fatalf("mkdir rig roles: %v", err)
}
rigOverride := `
role = "witness"
scope = "rig"
runBd(t, townRoot, "config", "set", "types.custom", "agent,role,rig,convoy,event") [session]
start_command = "exec echo rig-command"
runBd(t, townRoot, "config", "set", "types.custom", "agent,role,rig,convoy,event") `
if err := os.WriteFile(filepath.Join(rigRolesDir, "witness.toml"), []byte(rigOverride), 0644); err != nil {
// Only legacy role bead exists. t.Fatalf("write rig witness.toml: %v", err)
runBd(t, townRoot, "create", }
"--id", "gt-witness-role",
"--type", "role",
"--title", "Witness Role (legacy)",
"--description", "start_command: exec echo gt\n",
)
d := &Daemon{ d := &Daemon{
config: &Config{TownRoot: townRoot}, config: &Config{TownRoot: townRoot},
@@ -90,7 +135,11 @@ func TestGetRoleConfigForIdentity_FallsBackToLegacyRoleBead(t *testing.T) {
if parsed == nil || parsed.RoleType != "witness" { if parsed == nil || parsed.RoleType != "witness" {
t.Fatalf("parsed = %#v, want roleType witness", parsed) t.Fatalf("parsed = %#v, want roleType witness", parsed)
} }
if cfg == nil || cfg.StartCommand != "exec echo gt" { if cfg == nil {
t.Fatalf("cfg.StartCommand = %#v, want %q", cfg, "exec echo gt") t.Fatal("cfg is nil")
}
// Should have the rig-level override (takes precedence over town)
if cfg.StartCommand != "exec echo rig-command" {
t.Errorf("cfg.StartCommand = %q, want %q", cfg.StartCommand, "exec echo rig-command")
} }
} }
-5
View File
@@ -170,7 +170,6 @@ func (c *AgentBeadsCheck) Fix(ctx *CheckContext) error {
RoleType: "deacon", RoleType: "deacon",
Rig: "", Rig: "",
AgentState: "idle", AgentState: "idle",
RoleBead: beads.DeaconRoleBeadIDTown(),
} }
desc := "Deacon (daemon beacon) - receives mechanical heartbeats, runs town plugins and monitoring." desc := "Deacon (daemon beacon) - receives mechanical heartbeats, runs town plugins and monitoring."
if _, err := townBd.CreateAgentBead(deaconID, desc, fields); err != nil { if _, err := townBd.CreateAgentBead(deaconID, desc, fields); err != nil {
@@ -184,7 +183,6 @@ func (c *AgentBeadsCheck) Fix(ctx *CheckContext) error {
RoleType: "mayor", RoleType: "mayor",
Rig: "", Rig: "",
AgentState: "idle", AgentState: "idle",
RoleBead: beads.MayorRoleBeadIDTown(),
} }
desc := "Mayor - global coordinator, handles cross-rig communication and escalations." desc := "Mayor - global coordinator, handles cross-rig communication and escalations."
if _, err := townBd.CreateAgentBead(mayorID, desc, fields); err != nil { if _, err := townBd.CreateAgentBead(mayorID, desc, fields); err != nil {
@@ -231,7 +229,6 @@ func (c *AgentBeadsCheck) Fix(ctx *CheckContext) error {
RoleType: "witness", RoleType: "witness",
Rig: rigName, Rig: rigName,
AgentState: "idle", AgentState: "idle",
RoleBead: beads.RoleBeadIDTown("witness"),
} }
desc := fmt.Sprintf("Witness for %s - monitors polecat health and progress.", rigName) desc := fmt.Sprintf("Witness for %s - monitors polecat health and progress.", rigName)
if _, err := bd.CreateAgentBead(witnessID, desc, fields); err != nil { if _, err := bd.CreateAgentBead(witnessID, desc, fields); err != nil {
@@ -245,7 +242,6 @@ func (c *AgentBeadsCheck) Fix(ctx *CheckContext) error {
RoleType: "refinery", RoleType: "refinery",
Rig: rigName, Rig: rigName,
AgentState: "idle", AgentState: "idle",
RoleBead: beads.RoleBeadIDTown("refinery"),
} }
desc := fmt.Sprintf("Refinery for %s - processes merge queue.", rigName) desc := fmt.Sprintf("Refinery for %s - processes merge queue.", rigName)
if _, err := bd.CreateAgentBead(refineryID, desc, fields); err != nil { if _, err := bd.CreateAgentBead(refineryID, desc, fields); err != nil {
@@ -262,7 +258,6 @@ func (c *AgentBeadsCheck) Fix(ctx *CheckContext) error {
RoleType: "crew", RoleType: "crew",
Rig: rigName, Rig: rigName,
AgentState: "idle", AgentState: "idle",
RoleBead: beads.RoleBeadIDTown("crew"),
} }
desc := fmt.Sprintf("Crew worker %s in %s - human-managed persistent workspace.", workerName, rigName) desc := fmt.Sprintf("Crew worker %s in %s - human-managed persistent workspace.", workerName, rigName)
if _, err := bd.CreateAgentBead(crewID, desc, fields); err != nil { if _, err := bd.CreateAgentBead(crewID, desc, fields); err != nil {
+82 -85
View File
@@ -2,119 +2,116 @@ package doctor
import ( import (
"fmt" "fmt"
"os/exec" "os"
"strings" "path/filepath"
"github.com/steveyegge/gastown/internal/beads" "github.com/BurntSushi/toml"
"github.com/steveyegge/gastown/internal/config"
) )
// RoleBeadsCheck verifies that role definition beads exist. // RoleConfigCheck verifies that role configuration is valid.
// Role beads are templates that define role characteristics and lifecycle hooks. // Role definitions are now config-based (internal/config/roles/*.toml),
// They are stored in town beads (~/.beads/) with hq- prefix: // not stored as beads. Built-in defaults are embedded in the binary.
// - hq-mayor-role, hq-deacon-role, hq-dog-role // This check validates any user-provided overrides at:
// - hq-witness-role, hq-refinery-role, hq-polecat-role, hq-crew-role // - <town>/roles/<role>.toml (town-level overrides)
// // - <rig>/roles/<role>.toml (rig-level overrides)
// Role beads are created by gt install, but creation may fail silently. type RoleConfigCheck struct {
// Without role beads, agents fall back to defaults which may differ from BaseCheck
// user expectations.
type RoleBeadsCheck struct {
FixableCheck
missing []string // Track missing role beads for fix
} }
// NewRoleBeadsCheck creates a new role beads check. // NewRoleBeadsCheck creates a new role config check.
func NewRoleBeadsCheck() *RoleBeadsCheck { // Note: Function name kept as NewRoleBeadsCheck for backward compatibility
return &RoleBeadsCheck{ // with existing doctor.go registration code.
FixableCheck: FixableCheck{ func NewRoleBeadsCheck() *RoleConfigCheck {
BaseCheck: BaseCheck{ return &RoleConfigCheck{
CheckName: "role-beads-exist", BaseCheck: BaseCheck{
CheckDescription: "Verify role definition beads exist", CheckName: "role-config-valid",
CheckCategory: CategoryConfig, CheckDescription: "Verify role configuration is valid",
}, CheckCategory: CategoryConfig,
}, },
} }
} }
// Run checks if role beads exist. // Run checks if role config is valid.
func (c *RoleBeadsCheck) Run(ctx *CheckContext) *CheckResult { func (c *RoleConfigCheck) Run(ctx *CheckContext) *CheckResult {
c.missing = nil // Reset var warnings []string
var overrideCount int
townBeadsPath := beads.GetTownBeadsPath(ctx.TownRoot) // Check town-level overrides
bd := beads.New(townBeadsPath) townRolesDir := filepath.Join(ctx.TownRoot, "roles")
if entries, err := os.ReadDir(townRolesDir); err == nil {
var missing []string for _, entry := range entries {
roleDefs := beads.AllRoleBeadDefs() if !entry.IsDir() && filepath.Ext(entry.Name()) == ".toml" {
overrideCount++
for _, role := range roleDefs { path := filepath.Join(townRolesDir, entry.Name())
if _, err := bd.Show(role.ID); err != nil { if err := validateRoleOverride(path); err != nil {
missing = append(missing, role.ID) warnings = append(warnings, fmt.Sprintf("town override %s: %v", entry.Name(), err))
}
}
} }
} }
c.missing = missing // Check rig-level overrides for each rig
// Discover rigs by looking for directories with rig.json
if entries, err := os.ReadDir(ctx.TownRoot); err == nil {
for _, entry := range entries {
if !entry.IsDir() {
continue
}
rigName := entry.Name()
// Check if this is a rig (has rig.json)
if _, err := os.Stat(filepath.Join(ctx.TownRoot, rigName, "rig.json")); err != nil {
continue
}
rigRolesDir := filepath.Join(ctx.TownRoot, rigName, "roles")
if roleEntries, err := os.ReadDir(rigRolesDir); err == nil {
for _, roleEntry := range roleEntries {
if !roleEntry.IsDir() && filepath.Ext(roleEntry.Name()) == ".toml" {
overrideCount++
path := filepath.Join(rigRolesDir, roleEntry.Name())
if err := validateRoleOverride(path); err != nil {
warnings = append(warnings, fmt.Sprintf("rig %s override %s: %v", rigName, roleEntry.Name(), err))
}
}
}
}
}
}
if len(missing) == 0 { if len(warnings) > 0 {
return &CheckResult{ return &CheckResult{
Name: c.Name(), Name: c.Name(),
Status: StatusOK, Status: StatusWarning,
Message: fmt.Sprintf("All %d role beads exist", len(roleDefs)), Message: fmt.Sprintf("%d role config override(s) have issues", len(warnings)),
Details: warnings,
FixHint: "Check TOML syntax in role override files",
Category: c.Category(), Category: c.Category(),
} }
} }
msg := "Role config uses built-in defaults"
if overrideCount > 0 {
msg = fmt.Sprintf("Role config valid (%d override file(s))", overrideCount)
}
return &CheckResult{ return &CheckResult{
Name: c.Name(), Name: c.Name(),
Status: StatusWarning, // Warning, not error - agents work without role beads Status: StatusOK,
Message: fmt.Sprintf("%d role bead(s) missing (agents will use defaults)", len(missing)), Message: msg,
Details: missing,
FixHint: "Run 'gt doctor --fix' to create missing role beads",
Category: c.Category(), Category: c.Category(),
} }
} }
// Fix creates missing role beads. // validateRoleOverride checks if a role override file is valid TOML.
func (c *RoleBeadsCheck) Fix(ctx *CheckContext) error { func validateRoleOverride(path string) error {
// Re-run check to populate missing if needed data, err := os.ReadFile(path)
if c.missing == nil { if err != nil {
result := c.Run(ctx) return err
if result.Status == StatusOK {
return nil // Nothing to fix
}
} }
if len(c.missing) == 0 { var def config.RoleDefinition
return nil if err := toml.Unmarshal(data, &def); err != nil {
} return fmt.Errorf("invalid TOML: %w", err)
// Build lookup map for role definitions
roleDefMap := make(map[string]beads.RoleBeadDef)
for _, role := range beads.AllRoleBeadDefs() {
roleDefMap[role.ID] = role
}
// Create missing role beads
for _, id := range c.missing {
role, ok := roleDefMap[id]
if !ok {
continue // Shouldn't happen
}
// Create role bead using bd create --type=role
args := []string{
"create",
"--type=role",
"--id=" + role.ID,
"--title=" + role.Title,
"--description=" + role.Desc,
}
if beads.NeedsForceForID(role.ID) {
args = append(args, "--force")
}
cmd := exec.Command("bd", args...)
cmd.Dir = ctx.TownRoot
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("creating %s: %s", role.ID, strings.TrimSpace(string(output)))
}
} }
return nil return nil
+95 -42
View File
@@ -4,15 +4,64 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/steveyegge/gastown/internal/beads"
) )
func TestRoleBeadsCheck_Run(t *testing.T) { func TestRoleConfigCheck_Run(t *testing.T) {
t.Run("no town beads returns warning", func(t *testing.T) { t.Run("no overrides returns OK with defaults message", func(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
// Create minimal town structure without .beads
if err := os.MkdirAll(filepath.Join(tmpDir, "mayor"), 0755); err != nil { check := NewRoleBeadsCheck()
ctx := &CheckContext{TownRoot: tmpDir}
result := check.Run(ctx)
if result.Status != StatusOK {
t.Errorf("expected StatusOK, got %v: %s", result.Status, result.Message)
}
if result.Message != "Role config uses built-in defaults" {
t.Errorf("unexpected message: %s", result.Message)
}
})
t.Run("valid town override returns OK", func(t *testing.T) {
tmpDir := t.TempDir()
rolesDir := filepath.Join(tmpDir, "roles")
if err := os.MkdirAll(rolesDir, 0755); err != nil {
t.Fatal(err)
}
// Create a valid TOML override
override := `
role = "witness"
scope = "rig"
[session]
start_command = "exec echo test"
`
if err := os.WriteFile(filepath.Join(rolesDir, "witness.toml"), []byte(override), 0644); err != nil {
t.Fatal(err)
}
check := NewRoleBeadsCheck()
ctx := &CheckContext{TownRoot: tmpDir}
result := check.Run(ctx)
if result.Status != StatusOK {
t.Errorf("expected StatusOK, got %v: %s", result.Status, result.Message)
}
if result.Message != "Role config valid (1 override file(s))" {
t.Errorf("unexpected message: %s", result.Message)
}
})
t.Run("invalid town override returns warning", func(t *testing.T) {
tmpDir := t.TempDir()
rolesDir := filepath.Join(tmpDir, "roles")
if err := os.MkdirAll(rolesDir, 0755); err != nil {
t.Fatal(err)
}
// Create an invalid TOML file
if err := os.WriteFile(filepath.Join(rolesDir, "witness.toml"), []byte("invalid { toml"), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -20,49 +69,53 @@ func TestRoleBeadsCheck_Run(t *testing.T) {
ctx := &CheckContext{TownRoot: tmpDir} ctx := &CheckContext{TownRoot: tmpDir}
result := check.Run(ctx) result := check.Run(ctx)
// Without .beads directory, all role beads are "missing"
expectedCount := len(beads.AllRoleBeadDefs())
if result.Status != StatusWarning { if result.Status != StatusWarning {
t.Errorf("expected StatusWarning, got %v: %s", result.Status, result.Message) t.Errorf("expected StatusWarning, got %v: %s", result.Status, result.Message)
} }
if len(result.Details) != expectedCount { if len(result.Details) != 1 {
t.Errorf("expected %d missing role beads, got %d: %v", expectedCount, len(result.Details), result.Details) t.Errorf("expected 1 warning detail, got %d", len(result.Details))
} }
}) })
t.Run("check is fixable", func(t *testing.T) { t.Run("valid rig override returns OK", func(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
rigDir := filepath.Join(tmpDir, rigName)
rigRolesDir := filepath.Join(rigDir, "roles")
if err := os.MkdirAll(rigRolesDir, 0755); err != nil {
t.Fatal(err)
}
// Create rig.json to mark this as a rig
if err := os.WriteFile(filepath.Join(rigDir, "rig.json"), []byte(`{"name": "testrig"}`), 0644); err != nil {
t.Fatal(err)
}
// Create a valid TOML override
override := `
role = "refinery"
scope = "rig"
[session]
needs_pre_sync = true
`
if err := os.WriteFile(filepath.Join(rigRolesDir, "refinery.toml"), []byte(override), 0644); err != nil {
t.Fatal(err)
}
check := NewRoleBeadsCheck() check := NewRoleBeadsCheck()
if !check.CanFix() { ctx := &CheckContext{TownRoot: tmpDir}
t.Error("RoleBeadsCheck should be fixable") result := check.Run(ctx)
if result.Status != StatusOK {
t.Errorf("expected StatusOK, got %v: %s", result.Status, result.Message)
}
})
t.Run("check is not fixable", func(t *testing.T) {
check := NewRoleBeadsCheck()
if check.CanFix() {
t.Error("RoleConfigCheck should not be fixable (config issues need manual fix)")
} }
}) })
} }
func TestRoleBeadsCheck_usesSharedDefs(t *testing.T) {
// Verify the check uses beads.AllRoleBeadDefs()
roleDefs := beads.AllRoleBeadDefs()
if len(roleDefs) < 7 {
t.Errorf("expected at least 7 role beads, got %d", len(roleDefs))
}
// Verify key roles are present
expectedIDs := map[string]bool{
"hq-mayor-role": false,
"hq-deacon-role": false,
"hq-witness-role": false,
"hq-refinery-role": false,
}
for _, role := range roleDefs {
if _, exists := expectedIDs[role.ID]; exists {
expectedIDs[role.ID] = true
}
}
for id, found := range expectedIDs {
if !found {
t.Errorf("expected role %s not found in AllRoleBeadDefs()", id)
}
}
}
-2
View File
@@ -353,7 +353,6 @@ func (m *Manager) AddWithOptions(name string, opts AddOptions) (*Polecat, error)
RoleType: "polecat", RoleType: "polecat",
Rig: m.rig.Name, Rig: m.rig.Name,
AgentState: "spawning", AgentState: "spawning",
RoleBead: beads.RoleBeadIDTown("polecat"),
HookBead: opts.HookBead, // Set atomically at spawn time HookBead: opts.HookBead, // Set atomically at spawn time
}) })
if err != nil { if err != nil {
@@ -648,7 +647,6 @@ func (m *Manager) RepairWorktreeWithOptions(name string, force bool, opts AddOpt
RoleType: "polecat", RoleType: "polecat",
Rig: m.rig.Name, Rig: m.rig.Name,
AgentState: "spawning", AgentState: "spawning",
RoleBead: beads.RoleBeadIDTown("polecat"),
HookBead: opts.HookBead, // Set atomically at spawn time HookBead: opts.HookBead, // Set atomically at spawn time
}) })
if err != nil { if err != nil {
+1 -3
View File
@@ -750,14 +750,12 @@ func (m *Manager) initAgentBeads(rigPath, rigName, prefix string) error {
continue // Already exists continue // Already exists
} }
// RoleBead points to the shared role definition bead for this agent type. // Note: RoleBead field removed - role definitions are now config-based
// Role beads are in town beads with hq- prefix (e.g., hq-witness-role).
fields := &beads.AgentFields{ fields := &beads.AgentFields{
RoleType: agent.roleType, RoleType: agent.roleType,
Rig: agent.rig, Rig: agent.rig,
AgentState: "idle", AgentState: "idle",
HookBead: "", HookBead: "",
RoleBead: beads.RoleBeadIDTown(agent.roleType),
} }
if _, err := bd.CreateAgentBead(agent.id, agent.desc, fields); err != nil { if _, err := bd.CreateAgentBead(agent.id, agent.desc, fields); err != nil {