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:
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
119
internal/doctor/gtroot_check.go
Normal file
119
internal/doctor/gtroot_check.go
Normal 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 >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",
|
||||
}
|
||||
}
|
||||
147
internal/doctor/gtroot_check_test.go
Normal file
147
internal/doctor/gtroot_check_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user