* fix(config): implement role_agents support in BuildStartupCommand The role_agents field in TownSettings and RigSettings existed but was not being used by the startup command builders. All services fell back to the default agent instead of using role-specific agent assignments. Changes: - BuildStartupCommand now extracts GT_ROLE from envVars and uses ResolveRoleAgentConfig() for role-based agent selection - BuildStartupCommandWithAgentOverride follows the same pattern when no explicit override is provided - refinery/manager.go uses ResolveRoleAgentConfig with constants - cmd/start.go uses ResolveRoleAgentConfig with constants - Updated comments from hardcoded agent name to generic "agent" - Added ValidateAgentConfig() to check agent exists and binary is in PATH - Added lookupAgentConfigIfExists() helper for validation - ResolveRoleAgentConfig now warns to stderr and falls back to default if configured agent is invalid or binary is missing Resolution priority (now working): 1. Explicit --agent override 2. Rig's role_agents[role] (validated) 3. Town's role_agents[role] (validated) 4. Rig's agent setting 5. Town's default_agent 6. Hardcoded default fallback Adds tests for: - TestBuildStartupCommand_UsesRoleAgentsFromTownSettings - TestBuildStartupCommand_RigRoleAgentsOverridesTownRoleAgents - TestBuildAgentStartupCommand_UsesRoleAgents - TestValidateAgentConfig - TestResolveRoleAgentConfig_FallsBackOnInvalidAgent Fixes: role_agents configuration not being applied to services * fix(config): add GT_ROOT to BuildStartupCommandWithAgentOverride - Fixes missing GT_ROOT and GT_SESSION_ID_ENV exports in BuildStartupCommandWithAgentOverride, matching BuildStartupCommand behavior - Adds test for override priority over role_agents - Adds test verifying GT_ROOT is included in command This addresses the Greptile review comment about agents started with an override not having access to town-level resources. Co-authored-by: Steve Yegge <steve.yegge@gmail.com>
This commit is contained in:
@@ -381,10 +381,10 @@ func startConfiguredCrew(t *tmux.Tmux, rigs []*rig.Rig, townRoot string, mu *syn
|
|||||||
func startOrRestartCrewMember(t *tmux.Tmux, r *rig.Rig, crewName, townRoot string) (msg string, started bool) {
|
func startOrRestartCrewMember(t *tmux.Tmux, r *rig.Rig, crewName, townRoot string) (msg string, started bool) {
|
||||||
sessionID := crewSessionName(r.Name, crewName)
|
sessionID := crewSessionName(r.Name, crewName)
|
||||||
if running, _ := t.HasSession(sessionID); running {
|
if running, _ := t.HasSession(sessionID); running {
|
||||||
// Session exists - check if Claude is still running
|
// Session exists - check if agent is still running
|
||||||
agentCfg := config.ResolveAgentConfig(townRoot, r.Path)
|
agentCfg := config.ResolveRoleAgentConfig(constants.RoleCrew, townRoot, r.Path)
|
||||||
if !t.IsAgentRunning(sessionID, config.ExpectedPaneCommands(agentCfg)...) {
|
if !t.IsAgentRunning(sessionID, config.ExpectedPaneCommands(agentCfg)...) {
|
||||||
// Claude has exited, restart it
|
// Agent has exited, restart it
|
||||||
// Build startup beacon for predecessor discovery via /resume
|
// Build startup beacon for predecessor discovery via /resume
|
||||||
address := fmt.Sprintf("%s/crew/%s", r.Name, crewName)
|
address := fmt.Sprintf("%s/crew/%s", r.Name, crewName)
|
||||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
||||||
@@ -392,11 +392,11 @@ func startOrRestartCrewMember(t *tmux.Tmux, r *rig.Rig, crewName, townRoot strin
|
|||||||
Sender: "human",
|
Sender: "human",
|
||||||
Topic: "restart",
|
Topic: "restart",
|
||||||
})
|
})
|
||||||
claudeCmd := config.BuildCrewStartupCommand(r.Name, crewName, r.Path, beacon)
|
agentCmd := config.BuildCrewStartupCommand(r.Name, crewName, r.Path, beacon)
|
||||||
if err := t.SendKeys(sessionID, claudeCmd); err != nil {
|
if err := t.SendKeys(sessionID, agentCmd); err != nil {
|
||||||
return fmt.Sprintf(" %s %s/%s restart failed: %v\n", style.Dim.Render("○"), r.Name, crewName, err), false
|
return fmt.Sprintf(" %s %s/%s restart failed: %v\n", style.Dim.Render("○"), r.Name, crewName, err), false
|
||||||
}
|
}
|
||||||
return fmt.Sprintf(" %s %s/%s Claude restarted\n", style.Bold.Render("✓"), r.Name, crewName), true
|
return fmt.Sprintf(" %s %s/%s agent restarted\n", style.Bold.Render("✓"), r.Name, crewName), true
|
||||||
}
|
}
|
||||||
return fmt.Sprintf(" %s %s/%s already running\n", style.Dim.Render("○"), r.Name, crewName), false
|
return fmt.Sprintf(" %s %s/%s already running\n", style.Dim.Render("○"), r.Name, crewName), false
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-4
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -898,6 +899,48 @@ func ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride string) (*R
|
|||||||
return lookupAgentConfig(agentName, townSettings, rigSettings), agentName, nil
|
return lookupAgentConfig(agentName, townSettings, rigSettings), agentName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateAgentConfig checks if an agent configuration is valid and the binary exists.
|
||||||
|
// Returns an error describing the issue, or nil if valid.
|
||||||
|
func ValidateAgentConfig(agentName string, townSettings *TownSettings, rigSettings *RigSettings) error {
|
||||||
|
// Check if agent exists in config
|
||||||
|
rc := lookupAgentConfigIfExists(agentName, townSettings, rigSettings)
|
||||||
|
if rc == nil {
|
||||||
|
return fmt.Errorf("agent %q not found in config or built-in presets", agentName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if binary exists on system
|
||||||
|
if _, err := exec.LookPath(rc.Command); err != nil {
|
||||||
|
return fmt.Errorf("agent %q binary %q not found in PATH", agentName, rc.Command)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupAgentConfigIfExists looks up an agent by name but returns nil if not found
|
||||||
|
// (instead of falling back to default). Used for validation.
|
||||||
|
func lookupAgentConfigIfExists(name string, townSettings *TownSettings, rigSettings *RigSettings) *RuntimeConfig {
|
||||||
|
// Check rig's custom agents
|
||||||
|
if rigSettings != nil && rigSettings.Agents != nil {
|
||||||
|
if custom, ok := rigSettings.Agents[name]; ok && custom != nil {
|
||||||
|
return fillRuntimeDefaults(custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check town's custom agents
|
||||||
|
if townSettings != nil && townSettings.Agents != nil {
|
||||||
|
if custom, ok := townSettings.Agents[name]; ok && custom != nil {
|
||||||
|
return fillRuntimeDefaults(custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check built-in presets
|
||||||
|
if preset := GetAgentPresetByName(name); preset != nil {
|
||||||
|
return RuntimeConfigFromPreset(AgentPreset(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ResolveRoleAgentConfig resolves the agent configuration for a specific role.
|
// ResolveRoleAgentConfig resolves the agent configuration for a specific role.
|
||||||
// It checks role-specific agent assignments before falling back to the default agent.
|
// It checks role-specific agent assignments before falling back to the default agent.
|
||||||
//
|
//
|
||||||
@@ -906,6 +949,9 @@ func ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride string) (*R
|
|||||||
// 2. Town's RoleAgents[role] - if set, look up that agent
|
// 2. Town's RoleAgents[role] - if set, look up that agent
|
||||||
// 3. Fall back to ResolveAgentConfig (rig's Agent → town's DefaultAgent → "claude")
|
// 3. Fall back to ResolveAgentConfig (rig's Agent → town's DefaultAgent → "claude")
|
||||||
//
|
//
|
||||||
|
// If a configured agent is not found or its binary doesn't exist, a warning is
|
||||||
|
// printed to stderr and it falls back to the default agent.
|
||||||
|
//
|
||||||
// role is one of: "mayor", "deacon", "witness", "refinery", "polecat", "crew".
|
// role is one of: "mayor", "deacon", "witness", "refinery", "polecat", "crew".
|
||||||
// townRoot is the path to the town directory (e.g., ~/gt).
|
// townRoot is the path to the town directory (e.g., ~/gt).
|
||||||
// rigPath is the path to the rig directory (e.g., ~/gt/gastown), or empty for town-level roles.
|
// rigPath is the path to the rig directory (e.g., ~/gt/gastown), or empty for town-level roles.
|
||||||
@@ -935,16 +981,24 @@ func ResolveRoleAgentConfig(role, townRoot, rigPath string) *RuntimeConfig {
|
|||||||
// Check rig's RoleAgents first
|
// Check rig's RoleAgents first
|
||||||
if rigSettings != nil && rigSettings.RoleAgents != nil {
|
if rigSettings != nil && rigSettings.RoleAgents != nil {
|
||||||
if agentName, ok := rigSettings.RoleAgents[role]; ok && agentName != "" {
|
if agentName, ok := rigSettings.RoleAgents[role]; ok && agentName != "" {
|
||||||
|
if err := ValidateAgentConfig(agentName, townSettings, rigSettings); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: role_agents[%s]=%s - %v, falling back to default\n", role, agentName, err)
|
||||||
|
} else {
|
||||||
return lookupAgentConfig(agentName, townSettings, rigSettings)
|
return lookupAgentConfig(agentName, townSettings, rigSettings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check town's RoleAgents
|
// Check town's RoleAgents
|
||||||
if townSettings.RoleAgents != nil {
|
if townSettings.RoleAgents != nil {
|
||||||
if agentName, ok := townSettings.RoleAgents[role]; ok && agentName != "" {
|
if agentName, ok := townSettings.RoleAgents[role]; ok && agentName != "" {
|
||||||
|
if err := ValidateAgentConfig(agentName, townSettings, rigSettings); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: role_agents[%s]=%s - %v, falling back to default\n", role, agentName, err)
|
||||||
|
} else {
|
||||||
return lookupAgentConfig(agentName, townSettings, rigSettings)
|
return lookupAgentConfig(agentName, townSettings, rigSettings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fall back to existing resolution (rig's Agent → town's DefaultAgent → "claude")
|
// Fall back to existing resolution (rig's Agent → town's DefaultAgent → "claude")
|
||||||
return ResolveAgentConfig(townRoot, rigPath)
|
return ResolveAgentConfig(townRoot, rigPath)
|
||||||
@@ -1150,23 +1204,41 @@ func findTownRootFromCwd() (string, error) {
|
|||||||
// envVars is a map of environment variable names to values.
|
// envVars is a map of environment variable names to values.
|
||||||
// rigPath is optional - if empty, tries to detect town root from cwd.
|
// rigPath is optional - if empty, tries to detect town root from cwd.
|
||||||
// prompt is optional - if provided, appended as the initial prompt.
|
// prompt is optional - if provided, appended as the initial prompt.
|
||||||
|
//
|
||||||
|
// If envVars contains GT_ROLE, the function uses role-based agent resolution
|
||||||
|
// (ResolveRoleAgentConfig) to select the appropriate agent for the role.
|
||||||
|
// This enables per-role model selection via role_agents in settings.
|
||||||
func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) string {
|
func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) string {
|
||||||
var rc *RuntimeConfig
|
var rc *RuntimeConfig
|
||||||
var townRoot string
|
var townRoot string
|
||||||
|
|
||||||
|
// Extract role from envVars for role-based agent resolution
|
||||||
|
role := envVars["GT_ROLE"]
|
||||||
|
|
||||||
if rigPath != "" {
|
if rigPath != "" {
|
||||||
// Derive town root from rig path
|
// Derive town root from rig path
|
||||||
townRoot = filepath.Dir(rigPath)
|
townRoot = filepath.Dir(rigPath)
|
||||||
|
if role != "" {
|
||||||
|
// Use role-based agent resolution for per-role model selection
|
||||||
|
rc = ResolveRoleAgentConfig(role, townRoot, rigPath)
|
||||||
|
} else {
|
||||||
rc = ResolveAgentConfig(townRoot, rigPath)
|
rc = ResolveAgentConfig(townRoot, rigPath)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Try to detect town root from cwd for town-level agents (mayor, deacon)
|
// Try to detect town root from cwd for town-level agents (mayor, deacon)
|
||||||
var err error
|
var err error
|
||||||
townRoot, err = findTownRootFromCwd()
|
townRoot, err = findTownRootFromCwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rc = DefaultRuntimeConfig()
|
rc = DefaultRuntimeConfig()
|
||||||
|
} else {
|
||||||
|
if role != "" {
|
||||||
|
// Use role-based agent resolution for per-role model selection
|
||||||
|
rc = ResolveRoleAgentConfig(role, townRoot, "")
|
||||||
} else {
|
} else {
|
||||||
rc = ResolveAgentConfig(townRoot, "")
|
rc = ResolveAgentConfig(townRoot, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Copy env vars to avoid mutating caller map
|
// Copy env vars to avoid mutating caller map
|
||||||
resolvedEnv := make(map[string]string, len(envVars)+2)
|
resolvedEnv := make(map[string]string, len(envVars)+2)
|
||||||
@@ -1222,32 +1294,69 @@ func PrependEnv(command string, envVars map[string]string) string {
|
|||||||
|
|
||||||
// BuildStartupCommandWithAgentOverride builds a startup command like BuildStartupCommand,
|
// BuildStartupCommandWithAgentOverride builds a startup command like BuildStartupCommand,
|
||||||
// but uses agentOverride if non-empty.
|
// but uses agentOverride if non-empty.
|
||||||
|
//
|
||||||
|
// Resolution priority:
|
||||||
|
// 1. agentOverride (explicit override)
|
||||||
|
// 2. role_agents[GT_ROLE] (if GT_ROLE is in envVars)
|
||||||
|
// 3. Default agent resolution (rig's Agent → town's DefaultAgent → "claude")
|
||||||
func BuildStartupCommandWithAgentOverride(envVars map[string]string, rigPath, prompt, agentOverride string) (string, error) {
|
func BuildStartupCommandWithAgentOverride(envVars map[string]string, rigPath, prompt, agentOverride string) (string, error) {
|
||||||
var rc *RuntimeConfig
|
var rc *RuntimeConfig
|
||||||
|
var townRoot string
|
||||||
|
|
||||||
|
// Extract role from envVars for role-based agent resolution (when no override)
|
||||||
|
role := envVars["GT_ROLE"]
|
||||||
|
|
||||||
if rigPath != "" {
|
if rigPath != "" {
|
||||||
townRoot := filepath.Dir(rigPath)
|
townRoot = filepath.Dir(rigPath)
|
||||||
|
if agentOverride != "" {
|
||||||
var err error
|
var err error
|
||||||
rc, _, err = ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride)
|
rc, _, err = ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
} else if role != "" {
|
||||||
|
// No override, use role-based agent resolution
|
||||||
|
rc = ResolveRoleAgentConfig(role, townRoot, rigPath)
|
||||||
} else {
|
} else {
|
||||||
townRoot, err := findTownRootFromCwd()
|
rc = ResolveAgentConfig(townRoot, rigPath)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
townRoot, err = findTownRootFromCwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rc = DefaultRuntimeConfig()
|
rc = DefaultRuntimeConfig()
|
||||||
} else {
|
} else {
|
||||||
|
if agentOverride != "" {
|
||||||
var resolveErr error
|
var resolveErr error
|
||||||
rc, _, resolveErr = ResolveAgentConfigWithOverride(townRoot, "", agentOverride)
|
rc, _, resolveErr = ResolveAgentConfigWithOverride(townRoot, "", agentOverride)
|
||||||
if resolveErr != nil {
|
if resolveErr != nil {
|
||||||
return "", resolveErr
|
return "", resolveErr
|
||||||
}
|
}
|
||||||
|
} else if role != "" {
|
||||||
|
// No override, use role-based agent resolution
|
||||||
|
rc = ResolveRoleAgentConfig(role, townRoot, "")
|
||||||
|
} else {
|
||||||
|
rc = ResolveAgentConfig(townRoot, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy env vars to avoid mutating caller map
|
||||||
|
resolvedEnv := make(map[string]string, len(envVars)+2)
|
||||||
|
for k, v := range envVars {
|
||||||
|
resolvedEnv[k] = v
|
||||||
|
}
|
||||||
|
// Add GT_ROOT so agents can find town-level resources (formulas, etc.)
|
||||||
|
if townRoot != "" {
|
||||||
|
resolvedEnv["GT_ROOT"] = townRoot
|
||||||
|
}
|
||||||
|
if rc.Session != nil && rc.Session.SessionIDEnv != "" {
|
||||||
|
resolvedEnv["GT_SESSION_ID_ENV"] = rc.Session.SessionIDEnv
|
||||||
|
}
|
||||||
|
|
||||||
// Build environment export prefix
|
// Build environment export prefix
|
||||||
var exports []string
|
var exports []string
|
||||||
for k, v := range envVars {
|
for k, v := range resolvedEnv {
|
||||||
exports = append(exports, fmt.Sprintf("%s=%s", k, v))
|
exports = append(exports, fmt.Sprintf("%s=%s", k, v))
|
||||||
}
|
}
|
||||||
sort.Strings(exports)
|
sort.Strings(exports)
|
||||||
@@ -1266,7 +1375,6 @@ func BuildStartupCommandWithAgentOverride(envVars map[string]string, rigPath, pr
|
|||||||
return cmd, nil
|
return cmd, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// BuildAgentStartupCommand is a convenience function for starting agent sessions.
|
// BuildAgentStartupCommand is a convenience function for starting agent sessions.
|
||||||
// It sets standard environment variables (GT_ROLE, BD_ACTOR, GIT_AUTHOR_NAME)
|
// It sets standard environment variables (GT_ROLE, BD_ACTOR, GIT_AUTHOR_NAME)
|
||||||
// and builds the full startup command.
|
// and builds the full startup command.
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTownConfigRoundTrip(t *testing.T) {
|
func TestTownConfigRoundTrip(t *testing.T) {
|
||||||
@@ -1193,6 +1195,189 @@ func TestBuildStartupCommand_UsesRigAgentWhenRigPathProvided(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildStartupCommand_UsesRoleAgentsFromTownSettings(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
townRoot := t.TempDir()
|
||||||
|
rigPath := filepath.Join(townRoot, "testrig")
|
||||||
|
|
||||||
|
// Configure town settings with role_agents
|
||||||
|
townSettings := NewTownSettings()
|
||||||
|
townSettings.DefaultAgent = "claude"
|
||||||
|
townSettings.RoleAgents = map[string]string{
|
||||||
|
constants.RoleRefinery: "gemini",
|
||||||
|
constants.RoleWitness: "codex",
|
||||||
|
}
|
||||||
|
if err := SaveTownSettings(TownSettingsPath(townRoot), townSettings); err != nil {
|
||||||
|
t.Fatalf("SaveTownSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create empty rig settings (no agent override)
|
||||||
|
rigSettings := NewRigSettings()
|
||||||
|
if err := SaveRigSettings(RigSettingsPath(rigPath), rigSettings); err != nil {
|
||||||
|
t.Fatalf("SaveRigSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("refinery role gets gemini from role_agents", func(t *testing.T) {
|
||||||
|
cmd := BuildStartupCommand(map[string]string{"GT_ROLE": constants.RoleRefinery}, rigPath, "")
|
||||||
|
if !strings.Contains(cmd, "gemini") {
|
||||||
|
t.Fatalf("expected gemini for refinery role, got: %q", cmd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("witness role gets codex from role_agents", func(t *testing.T) {
|
||||||
|
cmd := BuildStartupCommand(map[string]string{"GT_ROLE": constants.RoleWitness}, rigPath, "")
|
||||||
|
if !strings.Contains(cmd, "codex") {
|
||||||
|
t.Fatalf("expected codex for witness role, got: %q", cmd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("crew role falls back to default_agent (not in role_agents)", func(t *testing.T) {
|
||||||
|
cmd := BuildStartupCommand(map[string]string{"GT_ROLE": constants.RoleCrew}, rigPath, "")
|
||||||
|
if !strings.Contains(cmd, "claude") {
|
||||||
|
t.Fatalf("expected claude fallback for crew role, got: %q", cmd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no role falls back to default resolution", func(t *testing.T) {
|
||||||
|
cmd := BuildStartupCommand(map[string]string{}, rigPath, "")
|
||||||
|
if !strings.Contains(cmd, "claude") {
|
||||||
|
t.Fatalf("expected claude for no role, got: %q", cmd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildStartupCommand_RigRoleAgentsOverridesTownRoleAgents(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
townRoot := t.TempDir()
|
||||||
|
rigPath := filepath.Join(townRoot, "testrig")
|
||||||
|
|
||||||
|
// Town settings has witness = gemini
|
||||||
|
townSettings := NewTownSettings()
|
||||||
|
townSettings.DefaultAgent = "claude"
|
||||||
|
townSettings.RoleAgents = map[string]string{
|
||||||
|
constants.RoleWitness: "gemini",
|
||||||
|
}
|
||||||
|
if err := SaveTownSettings(TownSettingsPath(townRoot), townSettings); err != nil {
|
||||||
|
t.Fatalf("SaveTownSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rig settings overrides witness to codex
|
||||||
|
rigSettings := NewRigSettings()
|
||||||
|
rigSettings.RoleAgents = map[string]string{
|
||||||
|
constants.RoleWitness: "codex",
|
||||||
|
}
|
||||||
|
if err := SaveRigSettings(RigSettingsPath(rigPath), rigSettings); err != nil {
|
||||||
|
t.Fatalf("SaveRigSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := BuildStartupCommand(map[string]string{"GT_ROLE": constants.RoleWitness}, rigPath, "")
|
||||||
|
if !strings.Contains(cmd, "codex") {
|
||||||
|
t.Fatalf("expected codex from rig role_agents override, got: %q", cmd)
|
||||||
|
}
|
||||||
|
if strings.Contains(cmd, "gemini") {
|
||||||
|
t.Fatalf("did not expect town role_agents (gemini) in command: %q", cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAgentStartupCommand_UsesRoleAgents(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
townRoot := t.TempDir()
|
||||||
|
rigPath := filepath.Join(townRoot, "testrig")
|
||||||
|
|
||||||
|
// Configure town settings with role_agents
|
||||||
|
townSettings := NewTownSettings()
|
||||||
|
townSettings.DefaultAgent = "claude"
|
||||||
|
townSettings.RoleAgents = map[string]string{
|
||||||
|
constants.RoleRefinery: "codex",
|
||||||
|
}
|
||||||
|
if err := SaveTownSettings(TownSettingsPath(townRoot), townSettings); err != nil {
|
||||||
|
t.Fatalf("SaveTownSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create empty rig settings
|
||||||
|
rigSettings := NewRigSettings()
|
||||||
|
if err := SaveRigSettings(RigSettingsPath(rigPath), rigSettings); err != nil {
|
||||||
|
t.Fatalf("SaveRigSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildAgentStartupCommand passes role via GT_ROLE env var
|
||||||
|
cmd := BuildAgentStartupCommand(constants.RoleRefinery, "testrig/refinery", rigPath, "")
|
||||||
|
if !strings.Contains(cmd, "codex") {
|
||||||
|
t.Fatalf("expected codex for refinery role, got: %q", cmd)
|
||||||
|
}
|
||||||
|
if !strings.Contains(cmd, "GT_ROLE="+constants.RoleRefinery) {
|
||||||
|
t.Fatalf("expected GT_ROLE=%s in command: %q", constants.RoleRefinery, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAgentConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("valid built-in agent", func(t *testing.T) {
|
||||||
|
// claude is a built-in preset and binary should exist
|
||||||
|
err := ValidateAgentConfig("claude", nil, nil)
|
||||||
|
// Note: This may fail if claude binary is not installed, which is expected
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "not found in PATH") {
|
||||||
|
t.Errorf("unexpected error for claude: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid agent name", func(t *testing.T) {
|
||||||
|
err := ValidateAgentConfig("nonexistent-agent-xyz", nil, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for nonexistent agent")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not found in config or built-in presets") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("custom agent with missing binary", func(t *testing.T) {
|
||||||
|
townSettings := NewTownSettings()
|
||||||
|
townSettings.Agents = map[string]*RuntimeConfig{
|
||||||
|
"my-custom-agent": {
|
||||||
|
Command: "nonexistent-binary-xyz123",
|
||||||
|
Args: []string{"--some-flag"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := ValidateAgentConfig("my-custom-agent", townSettings, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing binary")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not found in PATH") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRoleAgentConfig_FallsBackOnInvalidAgent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
townRoot := t.TempDir()
|
||||||
|
rigPath := filepath.Join(townRoot, "testrig")
|
||||||
|
|
||||||
|
// Configure town settings with an invalid agent for refinery
|
||||||
|
townSettings := NewTownSettings()
|
||||||
|
townSettings.DefaultAgent = "claude"
|
||||||
|
townSettings.RoleAgents = map[string]string{
|
||||||
|
constants.RoleRefinery: "nonexistent-agent-xyz", // Invalid agent
|
||||||
|
}
|
||||||
|
if err := SaveTownSettings(TownSettingsPath(townRoot), townSettings); err != nil {
|
||||||
|
t.Fatalf("SaveTownSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create empty rig settings
|
||||||
|
rigSettings := NewRigSettings()
|
||||||
|
if err := SaveRigSettings(RigSettingsPath(rigPath), rigSettings); err != nil {
|
||||||
|
t.Fatalf("SaveRigSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should fall back to default (claude) when agent is invalid
|
||||||
|
rc := ResolveRoleAgentConfig(constants.RoleRefinery, townRoot, rigPath)
|
||||||
|
if rc.Command != "claude" {
|
||||||
|
t.Errorf("expected fallback to claude, got: %s", rc.Command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetRuntimeCommand_UsesRigAgentWhenRigPathProvided(t *testing.T) {
|
func TestGetRuntimeCommand_UsesRigAgentWhenRigPathProvided(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
townRoot := t.TempDir()
|
townRoot := t.TempDir()
|
||||||
@@ -2321,3 +2506,73 @@ func TestEscalationConfigPath(t *testing.T) {
|
|||||||
t.Errorf("EscalationConfigPath = %q, want %q", path, expected)
|
t.Errorf("EscalationConfigPath = %q, want %q", path, expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildStartupCommandWithAgentOverride_PriorityOverRoleAgents(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
townRoot := t.TempDir()
|
||||||
|
rigPath := filepath.Join(townRoot, "testrig")
|
||||||
|
|
||||||
|
// Configure town settings with role_agents: refinery = codex
|
||||||
|
townSettings := NewTownSettings()
|
||||||
|
townSettings.DefaultAgent = "claude"
|
||||||
|
townSettings.RoleAgents = map[string]string{
|
||||||
|
constants.RoleRefinery: "codex",
|
||||||
|
}
|
||||||
|
if err := SaveTownSettings(TownSettingsPath(townRoot), townSettings); err != nil {
|
||||||
|
t.Fatalf("SaveTownSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create empty rig settings
|
||||||
|
rigSettings := NewRigSettings()
|
||||||
|
if err := SaveRigSettings(RigSettingsPath(rigPath), rigSettings); err != nil {
|
||||||
|
t.Fatalf("SaveRigSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentOverride = "gemini" should take priority over role_agents[refinery] = "codex"
|
||||||
|
cmd, err := BuildStartupCommandWithAgentOverride(
|
||||||
|
map[string]string{"GT_ROLE": constants.RoleRefinery},
|
||||||
|
rigPath,
|
||||||
|
"",
|
||||||
|
"gemini", // explicit override
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildStartupCommandWithAgentOverride: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(cmd, "gemini") {
|
||||||
|
t.Errorf("expected gemini (override) in command, got: %q", cmd)
|
||||||
|
}
|
||||||
|
if strings.Contains(cmd, "codex") {
|
||||||
|
t.Errorf("did not expect codex (role_agents) when override is set: %q", cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildStartupCommandWithAgentOverride_IncludesGTRoot(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
townRoot := t.TempDir()
|
||||||
|
rigPath := filepath.Join(townRoot, "testrig")
|
||||||
|
|
||||||
|
// Create necessary config files
|
||||||
|
townSettings := NewTownSettings()
|
||||||
|
if err := SaveTownSettings(TownSettingsPath(townRoot), townSettings); err != nil {
|
||||||
|
t.Fatalf("SaveTownSettings: %v", err)
|
||||||
|
}
|
||||||
|
if err := SaveRigSettings(RigSettingsPath(rigPath), NewRigSettings()); err != nil {
|
||||||
|
t.Fatalf("SaveRigSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd, err := BuildStartupCommandWithAgentOverride(
|
||||||
|
map[string]string{"GT_ROLE": constants.RoleWitness},
|
||||||
|
rigPath,
|
||||||
|
"",
|
||||||
|
"gemini",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildStartupCommandWithAgentOverride: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should include GT_ROOT in export
|
||||||
|
if !strings.Contains(cmd, "GT_ROOT="+townRoot) {
|
||||||
|
t.Errorf("expected GT_ROOT=%s in command, got: %q", townRoot, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ func (m *Manager) Start(foreground bool, agentOverride string) error {
|
|||||||
if foreground {
|
if foreground {
|
||||||
// In foreground mode, check tmux session (no PID inference per ZFC)
|
// In foreground mode, check tmux session (no PID inference per ZFC)
|
||||||
townRoot := filepath.Dir(m.rig.Path)
|
townRoot := filepath.Dir(m.rig.Path)
|
||||||
agentCfg := config.ResolveAgentConfig(townRoot, m.rig.Path)
|
agentCfg := config.ResolveRoleAgentConfig(constants.RoleRefinery, townRoot, m.rig.Path)
|
||||||
if running, _ := t.HasSession(sessionID); running && t.IsAgentRunning(sessionID, config.ExpectedPaneCommands(agentCfg)...) {
|
if running, _ := t.HasSession(sessionID); running && t.IsAgentRunning(sessionID, config.ExpectedPaneCommands(agentCfg)...) {
|
||||||
return ErrAlreadyRunning
|
return ErrAlreadyRunning
|
||||||
}
|
}
|
||||||
@@ -138,15 +138,15 @@ func (m *Manager) Start(foreground bool, agentOverride string) error {
|
|||||||
// Background mode: check if session already exists
|
// Background mode: check if session already exists
|
||||||
running, _ := t.HasSession(sessionID)
|
running, _ := t.HasSession(sessionID)
|
||||||
if running {
|
if running {
|
||||||
// Session exists - check if Claude is actually running (healthy vs zombie)
|
// Session exists - check if agent is actually running (healthy vs zombie)
|
||||||
townRoot := filepath.Dir(m.rig.Path)
|
townRoot := filepath.Dir(m.rig.Path)
|
||||||
agentCfg := config.ResolveAgentConfig(townRoot, m.rig.Path)
|
agentCfg := config.ResolveRoleAgentConfig(constants.RoleRefinery, townRoot, m.rig.Path)
|
||||||
if t.IsAgentRunning(sessionID, config.ExpectedPaneCommands(agentCfg)...) {
|
if t.IsAgentRunning(sessionID, config.ExpectedPaneCommands(agentCfg)...) {
|
||||||
// Healthy - Claude is running
|
// Healthy - agent is running
|
||||||
return ErrAlreadyRunning
|
return ErrAlreadyRunning
|
||||||
}
|
}
|
||||||
// Zombie - tmux alive but Claude dead. Kill and recreate.
|
// Zombie - tmux alive but agent dead. Kill and recreate.
|
||||||
_, _ = fmt.Fprintln(m.output, "⚠ Detected zombie session (tmux alive, Claude dead). Recreating...")
|
_, _ = fmt.Fprintln(m.output, "⚠ Detected zombie session (tmux alive, agent dead). Recreating...")
|
||||||
if err := t.KillSession(sessionID); err != nil {
|
if err := t.KillSession(sessionID); err != nil {
|
||||||
return fmt.Errorf("killing zombie session: %w", err)
|
return fmt.Errorf("killing zombie session: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user