feat(witness): honor role start_command + add --agent/--env overrides (#293)
Adds comprehensive override support for witness start/restart: - Honor hq-witness-role start_command and env_vars from role bead - Add --agent flag to override the agent/model - Add --env flag for arbitrary env var overrides (KEY=VALUE, repeatable) Precedence (highest to lowest): 1. CLI --env overrides 2. Role bead env_vars 3. config.AgentEnv() defaults Examples: gt witness start greenplace --agent codex gt witness start greenplace --env ANTHROPIC_MODEL=claude-3-haiku Co-authored-by: joshuavial <git@codewithjv.com>
This commit is contained in:
+3
-3
@@ -759,7 +759,7 @@ func runRigBoot(cmd *cobra.Command, args []string) error {
|
||||
} else {
|
||||
fmt.Printf(" Starting witness...\n")
|
||||
witMgr := witness.NewManager(r)
|
||||
if err := witMgr.Start(false); err != nil {
|
||||
if err := witMgr.Start(false, "", nil); err != nil {
|
||||
if err == witness.ErrAlreadyRunning {
|
||||
skipped = append(skipped, "witness (already running)")
|
||||
} else {
|
||||
@@ -839,7 +839,7 @@ func runRigStart(cmd *cobra.Command, args []string) error {
|
||||
} else {
|
||||
fmt.Printf(" Starting witness...\n")
|
||||
witMgr := witness.NewManager(r)
|
||||
if err := witMgr.Start(false); err != nil {
|
||||
if err := witMgr.Start(false, "", nil); err != nil {
|
||||
if err == witness.ErrAlreadyRunning {
|
||||
skipped = append(skipped, "witness")
|
||||
} else {
|
||||
@@ -1418,7 +1418,7 @@ func runRigRestart(cmd *cobra.Command, args []string) error {
|
||||
skipped = append(skipped, "witness")
|
||||
} else {
|
||||
fmt.Printf(" Starting witness...\n")
|
||||
if err := witMgr.Start(false); err != nil {
|
||||
if err := witMgr.Start(false, "", nil); err != nil {
|
||||
if err == witness.ErrAlreadyRunning {
|
||||
skipped = append(skipped, "witness")
|
||||
} else {
|
||||
|
||||
@@ -235,7 +235,7 @@ func startRigAgents(t *tmux.Tmux, townRoot string) {
|
||||
fmt.Printf(" %s %s witness already running\n", style.Dim.Render("○"), r.Name)
|
||||
} else {
|
||||
witMgr := witness.NewManager(r)
|
||||
if err := witMgr.Start(false); err != nil {
|
||||
if err := witMgr.Start(false, "", nil); err != nil {
|
||||
if err == witness.ErrAlreadyRunning {
|
||||
fmt.Printf(" %s %s witness already running\n", style.Dim.Render("○"), r.Name)
|
||||
} else {
|
||||
|
||||
+1
-1
@@ -118,7 +118,7 @@ func runUp(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
mgr := witness.NewManager(r)
|
||||
if err := mgr.Start(false); err != nil {
|
||||
if err := mgr.Start(false, "", nil); err != nil {
|
||||
if err == witness.ErrAlreadyRunning {
|
||||
printStatus(fmt.Sprintf("Witness (%s)", rigName), true, mgr.SessionName())
|
||||
} else {
|
||||
|
||||
+18
-6
@@ -15,8 +15,10 @@ import (
|
||||
|
||||
// Witness command flags
|
||||
var (
|
||||
witnessForeground bool
|
||||
witnessStatusJSON bool
|
||||
witnessForeground bool
|
||||
witnessStatusJSON bool
|
||||
witnessAgentOverride string
|
||||
witnessEnvOverrides []string
|
||||
)
|
||||
|
||||
var witnessCmd = &cobra.Command{
|
||||
@@ -41,6 +43,8 @@ states and takes action to keep work flowing.
|
||||
|
||||
Examples:
|
||||
gt witness start greenplace
|
||||
gt witness start greenplace --agent codex
|
||||
gt witness start greenplace --env ANTHROPIC_MODEL=claude-3-haiku
|
||||
gt witness start greenplace --foreground`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runWitnessStart,
|
||||
@@ -93,7 +97,9 @@ var witnessRestartCmd = &cobra.Command{
|
||||
Stops the current session (if running) and starts a fresh one.
|
||||
|
||||
Examples:
|
||||
gt witness restart greenplace`,
|
||||
gt witness restart greenplace
|
||||
gt witness restart greenplace --agent codex
|
||||
gt witness restart greenplace --env ANTHROPIC_MODEL=claude-3-haiku`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runWitnessRestart,
|
||||
}
|
||||
@@ -101,10 +107,16 @@ Examples:
|
||||
func init() {
|
||||
// Start flags
|
||||
witnessStartCmd.Flags().BoolVar(&witnessForeground, "foreground", false, "Run in foreground (default: background)")
|
||||
witnessStartCmd.Flags().StringVar(&witnessAgentOverride, "agent", "", "Agent alias to run the Witness with (overrides town default)")
|
||||
witnessStartCmd.Flags().StringArrayVar(&witnessEnvOverrides, "env", nil, "Environment variable override (KEY=VALUE, can be repeated)")
|
||||
|
||||
// Status flags
|
||||
witnessStatusCmd.Flags().BoolVar(&witnessStatusJSON, "json", false, "Output as JSON")
|
||||
|
||||
// Restart flags
|
||||
witnessRestartCmd.Flags().StringVar(&witnessAgentOverride, "agent", "", "Agent alias to run the Witness with (overrides town default)")
|
||||
witnessRestartCmd.Flags().StringArrayVar(&witnessEnvOverrides, "env", nil, "Environment variable override (KEY=VALUE, can be repeated)")
|
||||
|
||||
// Add subcommands
|
||||
witnessCmd.AddCommand(witnessStartCmd)
|
||||
witnessCmd.AddCommand(witnessStopCmd)
|
||||
@@ -136,7 +148,7 @@ func runWitnessStart(cmd *cobra.Command, args []string) error {
|
||||
|
||||
fmt.Printf("Starting witness for %s...\n", rigName)
|
||||
|
||||
if err := mgr.Start(witnessForeground); err != nil {
|
||||
if err := mgr.Start(witnessForeground, witnessAgentOverride, witnessEnvOverrides); err != nil {
|
||||
if err == witness.ErrAlreadyRunning {
|
||||
fmt.Printf("%s Witness is already running\n", style.Dim.Render("⚠"))
|
||||
fmt.Printf(" %s\n", style.Dim.Render("Use 'gt witness attach' to connect"))
|
||||
@@ -289,7 +301,7 @@ func runWitnessAttach(cmd *cobra.Command, args []string) error {
|
||||
sessionName := witnessSessionName(rigName)
|
||||
|
||||
// Ensure session exists (creates if needed)
|
||||
if err := mgr.Start(false); err != nil && err != witness.ErrAlreadyRunning {
|
||||
if err := mgr.Start(false, "", nil); err != nil && err != witness.ErrAlreadyRunning {
|
||||
return err
|
||||
} else if err == nil {
|
||||
fmt.Printf("Started witness session for %s\n", rigName)
|
||||
@@ -322,7 +334,7 @@ func runWitnessRestart(cmd *cobra.Command, args []string) error {
|
||||
_ = mgr.Stop()
|
||||
|
||||
// Start fresh
|
||||
if err := mgr.Start(false); err != nil {
|
||||
if err := mgr.Start(false, witnessAgentOverride, witnessEnvOverrides); err != nil {
|
||||
return fmt.Errorf("starting witness: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWitnessRestartAgentFlag(t *testing.T) {
|
||||
flag := witnessRestartCmd.Flags().Lookup("agent")
|
||||
if flag == nil {
|
||||
t.Fatal("expected witness restart to define --agent flag")
|
||||
}
|
||||
if flag.DefValue != "" {
|
||||
t.Errorf("expected default agent override to be empty, got %q", flag.DefValue)
|
||||
}
|
||||
if !strings.Contains(flag.Usage, "overrides town default") {
|
||||
t.Errorf("expected --agent usage to mention overrides town default, got %q", flag.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWitnessStartAgentFlag(t *testing.T) {
|
||||
flag := witnessStartCmd.Flags().Lookup("agent")
|
||||
if flag == nil {
|
||||
t.Fatal("expected witness start to define --agent flag")
|
||||
}
|
||||
if flag.DefValue != "" {
|
||||
t.Errorf("expected default agent override to be empty, got %q", flag.DefValue)
|
||||
}
|
||||
if !strings.Contains(flag.Usage, "overrides town default") {
|
||||
t.Errorf("expected --agent usage to mention overrides town default, got %q", flag.Usage)
|
||||
}
|
||||
}
|
||||
@@ -396,7 +396,7 @@ func (d *Daemon) ensureWitnessRunning(rigName string) {
|
||||
}
|
||||
|
||||
// Manager.Start() handles: zombie detection, session creation, env vars, theming,
|
||||
// WaitForClaudeReady, and crucially - startup/propulsion nudges (GUPP).
|
||||
// startup readiness waits, and crucially - startup/propulsion nudges (GUPP).
|
||||
// It returns ErrAlreadyRunning if Claude is already running in tmux.
|
||||
r := &rig.Rig{
|
||||
Name: rigName,
|
||||
@@ -404,7 +404,7 @@ func (d *Daemon) ensureWitnessRunning(rigName string) {
|
||||
}
|
||||
mgr := witness.NewManager(r)
|
||||
|
||||
if err := mgr.Start(false); err != nil {
|
||||
if err := mgr.Start(false, "", nil); err != nil {
|
||||
if err == witness.ErrAlreadyRunning {
|
||||
// Already running - nothing to do
|
||||
return
|
||||
|
||||
@@ -46,7 +46,9 @@ func Remove() error {
|
||||
}
|
||||
|
||||
hookPath := filepath.Join(state.ConfigDir(), "shell-hook.sh")
|
||||
os.Remove(hookPath)
|
||||
if err := os.Remove(hookPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("removing hook script: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -97,7 +99,9 @@ func addToRCFile(path string) error {
|
||||
|
||||
if len(data) > 0 {
|
||||
backupPath := path + ".gastown-backup"
|
||||
os.WriteFile(backupPath, data, 0644)
|
||||
if err := os.WriteFile(backupPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("writing backup: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return os.WriteFile(path, []byte(content+block), 0644)
|
||||
|
||||
+96
-17
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/agent"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/steveyegge/gastown/internal/session"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
"github.com/steveyegge/gastown/internal/util"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// Common errors
|
||||
@@ -99,7 +101,9 @@ func (m *Manager) witnessDir() string {
|
||||
// Start starts the witness.
|
||||
// If foreground is true, only updates state (no tmux session - deprecated).
|
||||
// Otherwise, spawns a Claude agent in a tmux session.
|
||||
func (m *Manager) Start(foreground bool) error {
|
||||
// agentOverride optionally specifies a different agent alias to use.
|
||||
// envOverrides are KEY=VALUE pairs that override all other env var sources.
|
||||
func (m *Manager) Start(foreground bool, agentOverride string, envOverrides []string) error {
|
||||
w, err := m.loadState()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -152,21 +156,25 @@ func (m *Manager) Start(foreground bool) error {
|
||||
return fmt.Errorf("ensuring Claude settings: %w", err)
|
||||
}
|
||||
|
||||
// Build startup command first
|
||||
// Pass m.rig.Path so rig agent settings are honored (not town-level defaults)
|
||||
bdActor := fmt.Sprintf("%s/witness", m.rig.Name)
|
||||
command := config.BuildAgentStartupCommand("witness", bdActor, m.rig.Path, "")
|
||||
runtimeConfig := config.LoadRuntimeConfig(m.rig.Path)
|
||||
|
||||
// Create session with command directly to avoid send-keys race condition.
|
||||
// See: https://github.com/anthropics/gastown/issues/280
|
||||
if err := t.NewSessionWithCommand(sessionID, witnessDir, command); err != nil {
|
||||
// Create new tmux session
|
||||
if err := t.NewSession(sessionID, witnessDir); err != nil {
|
||||
return fmt.Errorf("creating tmux session: %w", err)
|
||||
}
|
||||
|
||||
// Apply Gas Town theming (non-fatal: theming failure doesn't affect operation)
|
||||
theme := tmux.AssignTheme(m.rig.Name)
|
||||
_ = t.ConfigureGasTownSession(sessionID, theme, m.rig.Name, "witness", "witness")
|
||||
|
||||
roleConfig, err := m.roleConfig()
|
||||
if err != nil {
|
||||
_ = t.KillSession(sessionID)
|
||||
return err
|
||||
}
|
||||
|
||||
townRoot := m.townRoot()
|
||||
|
||||
// Set environment variables (non-fatal: session works without these)
|
||||
// Use centralized AgentEnv for consistency across all role startup paths
|
||||
townRoot := filepath.Dir(m.rig.Path)
|
||||
envVars := config.AgentEnv(config.AgentEnvConfig{
|
||||
Role: "witness",
|
||||
Rig: m.rig.Name,
|
||||
@@ -176,10 +184,16 @@ func (m *Manager) Start(foreground bool) error {
|
||||
for k, v := range envVars {
|
||||
_ = t.SetEnvironment(sessionID, k, v)
|
||||
}
|
||||
|
||||
// Apply Gas Town theming (non-fatal: theming failure doesn't affect operation)
|
||||
theme := tmux.AssignTheme(m.rig.Name)
|
||||
_ = t.ConfigureGasTownSession(sessionID, theme, m.rig.Name, "witness", "witness")
|
||||
// Apply role config env vars if present (non-fatal).
|
||||
for key, value := range roleConfigEnvVars(roleConfig, townRoot, m.rig.Name) {
|
||||
_ = t.SetEnvironment(sessionID, key, value)
|
||||
}
|
||||
// Apply CLI env overrides (highest priority, non-fatal).
|
||||
for _, override := range envOverrides {
|
||||
if key, value, ok := strings.Cut(override, "="); ok {
|
||||
_ = t.SetEnvironment(sessionID, key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Update state to running
|
||||
now := time.Now()
|
||||
@@ -192,8 +206,28 @@ func (m *Manager) Start(foreground bool) error {
|
||||
return fmt.Errorf("saving state: %w", err)
|
||||
}
|
||||
|
||||
// Wait for runtime to start and show its prompt (non-fatal)
|
||||
if err := t.WaitForRuntimeReady(sessionID, runtimeConfig, constants.ClaudeStartTimeout); err != nil {
|
||||
// Launch Claude directly (no shell respawn loop)
|
||||
// Restarts are handled by daemon via LIFECYCLE mail or deacon health-scan
|
||||
// NOTE: No gt prime injection needed - SessionStart hook handles it automatically
|
||||
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
|
||||
// Pass m.rig.Path so rig agent settings are honored (not town-level defaults)
|
||||
command, err := buildWitnessStartCommand(m.rig.Path, m.rig.Name, townRoot, agentOverride, roleConfig)
|
||||
if err != nil {
|
||||
_ = t.KillSession(sessionID)
|
||||
return err
|
||||
}
|
||||
// Wait for shell to be ready before sending keys (prevents "can't find pane" under load)
|
||||
if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil {
|
||||
_ = t.KillSession(sessionID)
|
||||
return fmt.Errorf("waiting for shell: %w", err)
|
||||
}
|
||||
if err := t.SendKeys(sessionID, command); err != nil {
|
||||
_ = t.KillSession(sessionID) // best-effort cleanup
|
||||
return fmt.Errorf("starting Claude agent: %w", err)
|
||||
}
|
||||
|
||||
// Wait for Claude to start (non-fatal).
|
||||
if err := t.WaitForCommand(sessionID, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
||||
// Non-fatal - try to continue anyway
|
||||
}
|
||||
|
||||
@@ -219,6 +253,51 @@ func (m *Manager) Start(foreground bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) roleConfig() (*beads.RoleConfig, error) {
|
||||
beadsPath := m.rig.BeadsPath()
|
||||
beadsDir := beads.ResolveBeadsDir(beadsPath)
|
||||
bd := beads.NewWithBeadsDir(beadsPath, beadsDir)
|
||||
roleConfig, err := bd.GetRoleConfig(beads.RoleBeadIDTown("witness"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading witness role config: %w", err)
|
||||
}
|
||||
return roleConfig, nil
|
||||
}
|
||||
|
||||
func (m *Manager) townRoot() string {
|
||||
townRoot, err := workspace.Find(m.rig.Path)
|
||||
if err != nil || townRoot == "" {
|
||||
return m.rig.Path
|
||||
}
|
||||
return townRoot
|
||||
}
|
||||
|
||||
func roleConfigEnvVars(roleConfig *beads.RoleConfig, townRoot, rigName string) map[string]string {
|
||||
if roleConfig == nil || len(roleConfig.EnvVars) == 0 {
|
||||
return nil
|
||||
}
|
||||
expanded := make(map[string]string, len(roleConfig.EnvVars))
|
||||
for key, value := range roleConfig.EnvVars {
|
||||
expanded[key] = beads.ExpandRolePattern(value, townRoot, rigName, "", "witness")
|
||||
}
|
||||
return expanded
|
||||
}
|
||||
|
||||
func buildWitnessStartCommand(rigPath, rigName, townRoot, agentOverride string, roleConfig *beads.RoleConfig) (string, error) {
|
||||
if agentOverride != "" {
|
||||
roleConfig = nil
|
||||
}
|
||||
if roleConfig != nil && roleConfig.StartCommand != "" {
|
||||
return beads.ExpandRolePattern(roleConfig.StartCommand, townRoot, rigName, "", "witness"), nil
|
||||
}
|
||||
bdActor := fmt.Sprintf("%s/witness", rigName)
|
||||
command, err := config.BuildAgentStartupCommandWithAgentOverride("witness", bdActor, rigPath, "", agentOverride)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("building startup command: %w", err)
|
||||
}
|
||||
return command, nil
|
||||
}
|
||||
|
||||
// Stop stops the witness.
|
||||
func (m *Manager) Stop() error {
|
||||
w, err := m.loadState()
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package witness
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
)
|
||||
|
||||
func TestBuildWitnessStartCommand_UsesRoleConfig(t *testing.T) {
|
||||
roleConfig := &beads.RoleConfig{
|
||||
StartCommand: "exec run --town {town} --rig {rig} --role {role}",
|
||||
}
|
||||
|
||||
got, err := buildWitnessStartCommand("/town/rig", "gastown", "/town", "", roleConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("buildWitnessStartCommand: %v", err)
|
||||
}
|
||||
|
||||
want := "exec run --town /town --rig gastown --role witness"
|
||||
if got != want {
|
||||
t.Errorf("buildWitnessStartCommand = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWitnessStartCommand_DefaultsToRuntime(t *testing.T) {
|
||||
got, err := buildWitnessStartCommand("/town/rig", "gastown", "/town", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildWitnessStartCommand: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(got, "GT_ROLE=witness") {
|
||||
t.Errorf("expected GT_ROLE=witness in command, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "BD_ACTOR=gastown/witness") {
|
||||
t.Errorf("expected BD_ACTOR=gastown/witness in command, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWitnessStartCommand_AgentOverrideWins(t *testing.T) {
|
||||
roleConfig := &beads.RoleConfig{
|
||||
StartCommand: "exec run --role {role}",
|
||||
}
|
||||
|
||||
got, err := buildWitnessStartCommand("/town/rig", "gastown", "/town", "codex", roleConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("buildWitnessStartCommand: %v", err)
|
||||
}
|
||||
if strings.Contains(got, "exec run") {
|
||||
t.Fatalf("expected agent override to bypass role start_command, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "GT_ROLE=witness") {
|
||||
t.Errorf("expected GT_ROLE=witness in command, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,9 @@ func Remove() error {
|
||||
wrappers := []string{"gt-codex", "gt-opencode"}
|
||||
for _, name := range wrappers {
|
||||
destPath := filepath.Join(binDir, name)
|
||||
os.Remove(destPath)
|
||||
if err := os.Remove(destPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("removing %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user