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:
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