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 <noreply@anthropic.com>
This commit is contained in:
julianknutsen
2026-01-08 00:51:42 -08:00
parent f4cbcb4ce9
commit 4985bdfbcc
4 changed files with 276 additions and 2 deletions

View File

@@ -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())

View File

@@ -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 {

View File

@@ -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 &GTRootCheck{
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",
}
}

View File

@@ -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)
}
}