From 4985bdfbccb154f7404900d2c527bc5a2623fe83 Mon Sep 17 00:00:00 2001 From: julianknutsen Date: Thu, 8 Jan 2026 00:51:42 -0800 Subject: [PATCH] feat(config): set GT_ROOT env var for all agent sessions Previously GT_ROOT was documented as a formula search path but never actually set, making $GT_ROOT/.beads/formulas/ unreachable for agents. Now BuildStartupCommand automatically sets GT_ROOT to the town root, enabling all agents (witness, refinery, polecat, crew, etc.) to find town-level formulas without relying on cwd-relative paths. Also adds a doctor check (gt-root-env) that warns when existing sessions are missing GT_ROOT, with instructions to restart sessions. Co-Authored-By: Claude Opus 4.5 --- internal/cmd/doctor.go | 1 + internal/config/loader.go | 11 +- internal/doctor/gtroot_check.go | 119 ++++++++++++++++++++++ internal/doctor/gtroot_check_test.go | 147 +++++++++++++++++++++++++++ 4 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 internal/doctor/gtroot_check.go create mode 100644 internal/doctor/gtroot_check_test.go diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index e25c30ac..79baf91a 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -120,6 +120,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { d.Register(doctor.NewRoutesCheck()) d.Register(doctor.NewOrphanSessionCheck()) d.Register(doctor.NewOrphanProcessCheck()) + d.Register(doctor.NewGTRootCheck()) d.Register(doctor.NewWispGCCheck()) d.Register(doctor.NewBranchCheck()) d.Register(doctor.NewBeadsSyncOrphanCheck()) diff --git a/internal/config/loader.go b/internal/config/loader.go index aa3f0265..cc98f75c 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -1064,13 +1064,15 @@ func findTownRootFromCwd() (string, error) { // prompt is optional - if provided, appended as the initial prompt. func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) string { var rc *RuntimeConfig + var townRoot string if rigPath != "" { // Derive town root from rig path - townRoot := filepath.Dir(rigPath) + townRoot = filepath.Dir(rigPath) rc = ResolveAgentConfig(townRoot, rigPath) } else { // Try to detect town root from cwd for town-level agents (mayor, deacon) - townRoot, err := findTownRootFromCwd() + var err error + townRoot, err = findTownRootFromCwd() if err != nil { rc = DefaultRuntimeConfig() } else { @@ -1078,6 +1080,11 @@ func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) stri } } + // Add GT_ROOT so agents can find town-level resources (formulas, etc.) + if townRoot != "" { + envVars["GT_ROOT"] = townRoot + } + // Build environment export prefix var exports []string for k, v := range envVars { diff --git a/internal/doctor/gtroot_check.go b/internal/doctor/gtroot_check.go new file mode 100644 index 00000000..d40859c9 --- /dev/null +++ b/internal/doctor/gtroot_check.go @@ -0,0 +1,119 @@ +package doctor + +import ( + "fmt" + "strings" + + "github.com/steveyegge/gastown/internal/tmux" +) + +// GTRootCheck verifies that tmux sessions have GT_ROOT set. +// Sessions without GT_ROOT cannot find town-level formulas. +type GTRootCheck struct { + BaseCheck + tmux TmuxEnvGetter // nil means use real tmux +} + +// TmuxEnvGetter abstracts tmux environment access for testing. +type TmuxEnvGetter interface { + ListSessions() ([]string, error) + GetEnvironment(session, key string) (string, error) +} + +// realTmux wraps real tmux operations. +type realTmux struct { + t *tmux.Tmux +} + +func (r *realTmux) ListSessions() ([]string, error) { + return r.t.ListSessions() +} + +func (r *realTmux) GetEnvironment(session, key string) (string, error) { + return r.t.GetEnvironment(session, key) +} + +// NewGTRootCheck creates a new GT_ROOT check. +func NewGTRootCheck() *GTRootCheck { + return >RootCheck{ + BaseCheck: BaseCheck{ + CheckName: "gt-root-env", + CheckDescription: "Verify sessions have GT_ROOT set for formula discovery", + }, + } +} + +// NewGTRootCheckWithTmux creates a check with a custom tmux interface (for testing). +func NewGTRootCheckWithTmux(t TmuxEnvGetter) *GTRootCheck { + c := NewGTRootCheck() + c.tmux = t + return c +} + +// Run checks GT_ROOT environment variable for all Gas Town sessions. +func (c *GTRootCheck) Run(ctx *CheckContext) *CheckResult { + t := c.tmux + if t == nil { + t = &realTmux{t: tmux.NewTmux()} + } + + sessions, err := t.ListSessions() + if err != nil { + // No tmux server - not an error, Gas Town might just be down + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No tmux sessions running", + } + } + + // Filter to Gas Town sessions (gt-* and hq-*) + var gtSessions []string + for _, sess := range sessions { + if strings.HasPrefix(sess, "gt-") || strings.HasPrefix(sess, "hq-") { + gtSessions = append(gtSessions, sess) + } + } + + if len(gtSessions) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No Gas Town sessions running", + } + } + + var missingSessions []string + var okCount int + + for _, sess := range gtSessions { + gtRoot, err := t.GetEnvironment(sess, "GT_ROOT") + if err != nil || gtRoot == "" { + missingSessions = append(missingSessions, sess) + } else { + okCount++ + } + } + + if len(missingSessions) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: fmt.Sprintf("All %d session(s) have GT_ROOT set", okCount), + } + } + + details := make([]string, 0, len(missingSessions)+2) + for _, sess := range missingSessions { + details = append(details, fmt.Sprintf("Missing GT_ROOT: %s", sess)) + } + details = append(details, "", "Sessions without GT_ROOT cannot find town-level formulas.") + + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("%d session(s) missing GT_ROOT environment variable", len(missingSessions)), + Details: details, + FixHint: "Restart sessions to pick up GT_ROOT: gt shutdown && gt up", + } +} diff --git a/internal/doctor/gtroot_check_test.go b/internal/doctor/gtroot_check_test.go new file mode 100644 index 00000000..1bd0f867 --- /dev/null +++ b/internal/doctor/gtroot_check_test.go @@ -0,0 +1,147 @@ +package doctor + +import ( + "testing" +) + +// mockTmuxEnv implements TmuxEnvGetter for testing. +type mockTmuxEnv struct { + sessions map[string]map[string]string // session -> env vars + listErr error + getErr error +} + +func (m *mockTmuxEnv) ListSessions() ([]string, error) { + if m.listErr != nil { + return nil, m.listErr + } + sessions := make([]string, 0, len(m.sessions)) + for s := range m.sessions { + sessions = append(sessions, s) + } + return sessions, nil +} + +func (m *mockTmuxEnv) GetEnvironment(session, key string) (string, error) { + if m.getErr != nil { + return "", m.getErr + } + if env, ok := m.sessions[session]; ok { + return env[key], nil + } + return "", nil +} + +func TestGTRootCheck_NoSessions(t *testing.T) { + mock := &mockTmuxEnv{sessions: map[string]map[string]string{}} + check := NewGTRootCheckWithTmux(mock) + + result := check.Run(&CheckContext{}) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK, got %v", result.Status) + } + if result.Message != "No Gas Town sessions running" { + t.Errorf("unexpected message: %s", result.Message) + } +} + +func TestGTRootCheck_NoGasTownSessions(t *testing.T) { + mock := &mockTmuxEnv{ + sessions: map[string]map[string]string{ + "other-session": {"SOME_VAR": "value"}, + }, + } + check := NewGTRootCheckWithTmux(mock) + + result := check.Run(&CheckContext{}) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK, got %v", result.Status) + } + if result.Message != "No Gas Town sessions running" { + t.Errorf("unexpected message: %s", result.Message) + } +} + +func TestGTRootCheck_AllSessionsHaveGTRoot(t *testing.T) { + mock := &mockTmuxEnv{ + sessions: map[string]map[string]string{ + "hq-mayor": {"GT_ROOT": "/home/user/gt", "GT_ROLE": "mayor"}, + "hq-deacon": {"GT_ROOT": "/home/user/gt", "GT_ROLE": "deacon"}, + "gt-myrig-witness": {"GT_ROOT": "/home/user/gt", "GT_ROLE": "witness"}, + "gt-myrig-refinery": {"GT_ROOT": "/home/user/gt", "GT_ROLE": "refinery"}, + }, + } + check := NewGTRootCheckWithTmux(mock) + + result := check.Run(&CheckContext{}) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK, got %v", result.Status) + } + if result.Message != "All 4 session(s) have GT_ROOT set" { + t.Errorf("unexpected message: %s", result.Message) + } +} + +func TestGTRootCheck_MissingGTRoot(t *testing.T) { + mock := &mockTmuxEnv{ + sessions: map[string]map[string]string{ + "hq-mayor": {"GT_ROOT": "/home/user/gt"}, + "gt-myrig-witness": {"GT_ROLE": "witness"}, // Missing GT_ROOT + "gt-myrig-refinery": {"GT_ROLE": "refinery"}, // Missing GT_ROOT + }, + } + check := NewGTRootCheckWithTmux(mock) + + result := check.Run(&CheckContext{}) + + if result.Status != StatusWarning { + t.Errorf("expected StatusWarning, got %v", result.Status) + } + if result.Message != "2 session(s) missing GT_ROOT environment variable" { + t.Errorf("unexpected message: %s", result.Message) + } + if result.FixHint != "Restart sessions to pick up GT_ROOT: gt shutdown && gt up" { + t.Errorf("unexpected fix hint: %s", result.FixHint) + } +} + +func TestGTRootCheck_EmptyGTRoot(t *testing.T) { + mock := &mockTmuxEnv{ + sessions: map[string]map[string]string{ + "hq-mayor": {"GT_ROOT": ""}, // Empty GT_ROOT should be treated as missing + }, + } + check := NewGTRootCheckWithTmux(mock) + + result := check.Run(&CheckContext{}) + + if result.Status != StatusWarning { + t.Errorf("expected StatusWarning, got %v", result.Status) + } +} + +func TestGTRootCheck_MixedPrefixes(t *testing.T) { + // Test that both gt-* and hq-* sessions are checked + mock := &mockTmuxEnv{ + sessions: map[string]map[string]string{ + "hq-mayor": {"GT_ROOT": "/home/user/gt"}, + "gt-rig-witness": {"GT_ROOT": "/home/user/gt"}, + "other-session": {}, // Should be ignored + "random": {}, // Should be ignored + }, + } + check := NewGTRootCheckWithTmux(mock) + + result := check.Run(&CheckContext{}) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK, got %v", result.Status) + } + // Should only count the 2 Gas Town sessions + if result.Message != "All 2 session(s) have GT_ROOT set" { + t.Errorf("unexpected message: %s", result.Message) + } +}