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

View File

@@ -211,7 +211,7 @@ func (d *Daemon) executeLifecycleAction(request *LifecycleRequest) error {
}
// 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 {
RoleType string // mayor, deacon, witness, refinery, crew, polecat
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.
// 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) {
switch identity {
case "mayor":
@@ -268,49 +268,50 @@ func parseIdentity(identity string) (*ParsedIdentity, error) {
return nil, fmt.Errorf("unknown identity format: %s", identity)
}
// getRoleConfigForIdentity looks up the role bead for an identity and returns its config.
// Falls back to default config if role bead doesn't exist or has no config.
// getRoleConfigForIdentity loads role configuration from the config-based role system.
// 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) {
parsed, err := parseIdentity(identity)
if err != nil {
return nil, nil, err
}
// Look up role bead
b := beads.New(d.config.TownRoot)
// Determine rig path for rig-scoped roles
rigPath := ""
if parsed.RigName != "" {
rigPath = filepath.Join(d.config.TownRoot, parsed.RigName)
}
roleBeadID := beads.RoleBeadIDTown(parsed.RoleType)
roleConfig, err := b.GetRoleConfig(roleBeadID)
// Load role definition from config system (Phase 2: config-based roles)
roleDef, err := config.LoadRoleDefinition(d.config.TownRoot, rigPath, parsed.RoleType)
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.
if roleConfig == nil {
legacyRoleBeadID := beads.RoleBeadID(parsed.RoleType) // gt-<role>-role
if legacyRoleBeadID != roleBeadID {
legacyCfg, legacyErr := b.GetRoleConfig(legacyRoleBeadID)
if legacyErr != nil {
d.logger.Printf("Warning: failed to get legacy role config for %s: %v", legacyRoleBeadID, legacyErr)
} else if legacyCfg != nil {
roleConfig = legacyCfg
}
}
// Convert to beads.RoleConfig for backward compatibility
roleConfig := &beads.RoleConfig{
SessionPattern: roleDef.Session.Pattern,
WorkDirPattern: roleDef.Session.WorkDir,
NeedsPreSync: roleDef.Session.NeedsPreSync,
StartCommand: roleDef.Session.StartCommand,
EnvVars: roleDef.Env,
}
// Return parsed identity even if config is nil (caller can use defaults)
return roleConfig, parsed, nil
}
// 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 {
config, parsed, err := d.getRoleConfigForIdentity(identity)
if err != nil {
return ""
}
// If role bead has session_pattern, use it
// If role config has session_pattern, use it
if config != nil && config.SessionPattern != "" {
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.
// 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 {
// Get role config for this 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.
// 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 {
// If role bead has work_dir_pattern, use it
// If role config has work_dir_pattern, use it
if config != nil && config.WorkDirPattern != "" {
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.
// 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 {
// If role bead has explicit config, use it
// If role config is available, use it
if config != nil {
return config.NeedsPreSync
}
@@ -459,9 +460,9 @@ func (d *Daemon) getNeedsPreSync(config *beads.RoleConfig, parsed *ParsedIdentit
}
// 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 {
// If role bead has explicit config, use it
// If role config is available, use it
if roleConfig != nil && roleConfig.StartCommand != "" {
// Expand any patterns in the command
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.
// 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) {
// Use centralized AgentEnv for base environment variables
envVars := config.AgentEnv(config.AgentEnvConfig{
@@ -529,7 +530,7 @@ func (d *Daemon) setSessionEnvironment(sessionName string, roleConfig *beads.Rol
_ = 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 {
for k, v := range roleConfig.EnvVars {
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"`
State string // Parsed from description: agent_state
HookBead string // Parsed from description: hook_bead
RoleBead string // Parsed from description: role_bead
RoleType string // Parsed from description: role_type
Rig string // Parsed from description: rig
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.
@@ -699,7 +700,6 @@ func (d *Daemon) getAgentBeadInfo(agentBeadID string) (*AgentBeadInfo, error) {
if fields != nil {
info.State = fields.AgentState
info.RoleBead = fields.RoleBead
info.RoleType = fields.RoleType
info.Rig = fields.Rig
}

View File

@@ -5,41 +5,60 @@ package daemon
import (
"io"
"log"
"os/exec"
"strings"
"os"
"path/filepath"
"testing"
)
func runBd(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("bd", args...) //nolint:gosec // bd is a trusted internal tool in this repo
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("bd %s failed: %v\n%s", strings.Join(args, " "), err, string(out))
// TestGetRoleConfigForIdentity_UsesBuiltinDefaults tests that the daemon
// uses built-in role definitions from embedded TOML files when no overrides exist.
func TestGetRoleConfigForIdentity_UsesBuiltinDefaults(t *testing.T) {
townRoot := t.TempDir()
d := &Daemon{
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) {
if _, err := exec.LookPath("bd"); err != nil {
t.Skip("bd not installed")
// TestGetRoleConfigForIdentity_TownOverride tests that town-level TOML overrides
// are merged with built-in defaults.
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()
runBd(t, townRoot, "init", "--quiet", "--prefix", "hq")
// Override start_command for witness role
witnessOverride := `
role = "witness"
scope = "rig"
runBd(t, townRoot, "config", "set", "types.custom", "agent,role,rig,convoy,event")
runBd(t, townRoot, "config", "set", "types.custom", "agent,role,rig,convoy,event")
// Create canonical role bead.
runBd(t, townRoot, "create",
"--id", "hq-witness-role",
"--type", "role",
"--title", "Witness Role",
"--description", "start_command: exec echo hq\n",
)
[session]
start_command = "exec echo custom-town-command"
`
if err := os.WriteFile(filepath.Join(rolesDir, "witness.toml"), []byte(witnessOverride), 0644); err != nil {
t.Fatalf("write witness.toml: %v", err)
}
d := &Daemon{
config: &Config{TownRoot: townRoot},
@@ -53,30 +72,56 @@ func TestGetRoleConfigForIdentity_PrefersTownRoleBead(t *testing.T) {
if parsed == nil || parsed.RoleType != "witness" {
t.Fatalf("parsed = %#v, want roleType witness", parsed)
}
if cfg == nil || cfg.StartCommand != "exec echo hq" {
t.Fatalf("cfg.StartCommand = %#v, want %q", cfg, "exec echo hq")
if cfg == nil {
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) {
if _, err := exec.LookPath("bd"); err != nil {
t.Skip("bd not installed")
// TestGetRoleConfigForIdentity_RigOverride tests that rig-level TOML overrides
// take precedence over town-level overrides.
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()
runBd(t, townRoot, "init", "--quiet", "--prefix", "gt")
// Create rig-level override (should take precedence)
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")
runBd(t, townRoot, "config", "set", "types.custom", "agent,role,rig,convoy,event")
// Only legacy role bead exists.
runBd(t, townRoot, "create",
"--id", "gt-witness-role",
"--type", "role",
"--title", "Witness Role (legacy)",
"--description", "start_command: exec echo gt\n",
)
[session]
start_command = "exec echo rig-command"
`
if err := os.WriteFile(filepath.Join(rigRolesDir, "witness.toml"), []byte(rigOverride), 0644); err != nil {
t.Fatalf("write rig witness.toml: %v", err)
}
d := &Daemon{
config: &Config{TownRoot: townRoot},
@@ -90,7 +135,11 @@ func TestGetRoleConfigForIdentity_FallsBackToLegacyRoleBead(t *testing.T) {
if parsed == nil || parsed.RoleType != "witness" {
t.Fatalf("parsed = %#v, want roleType witness", parsed)
}
if cfg == nil || cfg.StartCommand != "exec echo gt" {
t.Fatalf("cfg.StartCommand = %#v, want %q", cfg, "exec echo gt")
if cfg == nil {
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")
}
}