* fix(sling_test): update test for cook dir change
The cook command no longer needs database context and runs from cwd,
not the target rig directory. Update test to match this behavior
change from bd2a5ab5.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(tests): skip tests requiring missing binaries, handle --allow-stale
- Add skipIfAgentBinaryMissing helper to skip tests when codex/gemini
binaries aren't available in the test environment
- Update rig manager test stub to handle --allow-stale flag
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(config): remove BEADS_DIR from agent environment
Stop exporting BEADS_DIR in AgentEnv - agents should use beads redirect
mechanism instead of relying on environment variable. This prevents
prefix mismatches when agents operate across different beads databases.
Changes:
- Remove BeadsDir field from AgentEnvConfig
- Remove BEADS_DIR from env vars set on agent sessions
- Update doctor env_check to not expect BEADS_DIR
- Update all manager Start() calls to not pass BeadsDir
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(doctor): detect BEADS_DIR in tmux session environment
Add a doctor check that warns when BEADS_DIR is set in any Gas Town
tmux session. BEADS_DIR in the environment overrides prefix-based
routing and breaks multi-rig lookups - agents should use the beads
redirect mechanism instead.
The check:
- Iterates over all Gas Town tmux sessions (gt-* and hq-*)
- Checks if BEADS_DIR is set in the session environment
- Returns a warning with fix hint to restart sessions
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: julianknutsen <julianknutsen@users.noreply.github>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
243 lines
6.9 KiB
Go
243 lines
6.9 KiB
Go
// Package boot manages the Boot watchdog - the daemon's entry point for Deacon triage.
|
|
// Boot is a dog that runs fresh on each daemon tick, deciding whether to wake/nudge/interrupt
|
|
// the Deacon or let it continue. This centralizes the "when to wake" decision in an agent.
|
|
package boot
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
"github.com/steveyegge/gastown/internal/tmux"
|
|
)
|
|
|
|
// SessionName is the tmux session name for Boot.
|
|
// Note: We use "gt-boot" instead of "hq-deacon-boot" to avoid tmux prefix
|
|
// matching collisions. Tmux matches session names by prefix, so "hq-deacon-boot"
|
|
// would match when checking for "hq-deacon", causing HasSession("hq-deacon")
|
|
// to return true when only Boot is running.
|
|
const SessionName = "gt-boot"
|
|
|
|
// MarkerFileName is the lock file for Boot startup coordination.
|
|
const MarkerFileName = ".boot-running"
|
|
|
|
// StatusFileName stores Boot's last execution status.
|
|
const StatusFileName = ".boot-status.json"
|
|
|
|
// Status represents Boot's execution status.
|
|
type Status struct {
|
|
Running bool `json:"running"`
|
|
StartedAt time.Time `json:"started_at,omitempty"`
|
|
CompletedAt time.Time `json:"completed_at,omitempty"`
|
|
LastAction string `json:"last_action,omitempty"` // start/wake/nudge/nothing
|
|
Target string `json:"target,omitempty"` // deacon, witness, etc.
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// Boot manages the Boot watchdog lifecycle.
|
|
type Boot struct {
|
|
townRoot string
|
|
bootDir string // ~/gt/deacon/dogs/boot/
|
|
deaconDir string // ~/gt/deacon/
|
|
tmux *tmux.Tmux
|
|
degraded bool
|
|
}
|
|
|
|
// New creates a new Boot manager.
|
|
func New(townRoot string) *Boot {
|
|
return &Boot{
|
|
townRoot: townRoot,
|
|
bootDir: filepath.Join(townRoot, "deacon", "dogs", "boot"),
|
|
deaconDir: filepath.Join(townRoot, "deacon"),
|
|
tmux: tmux.NewTmux(),
|
|
degraded: os.Getenv("GT_DEGRADED") == "true",
|
|
}
|
|
}
|
|
|
|
// EnsureDir ensures the Boot directory exists.
|
|
func (b *Boot) EnsureDir() error {
|
|
return os.MkdirAll(b.bootDir, 0755)
|
|
}
|
|
|
|
// markerPath returns the path to the marker file.
|
|
func (b *Boot) markerPath() string {
|
|
return filepath.Join(b.bootDir, MarkerFileName)
|
|
}
|
|
|
|
// statusPath returns the path to the status file.
|
|
func (b *Boot) statusPath() string {
|
|
return filepath.Join(b.bootDir, StatusFileName)
|
|
}
|
|
|
|
// IsRunning checks if Boot is currently running.
|
|
// Queries tmux directly for observable reality (ZFC principle).
|
|
func (b *Boot) IsRunning() bool {
|
|
return b.IsSessionAlive()
|
|
}
|
|
|
|
// IsSessionAlive checks if the Boot tmux session exists.
|
|
func (b *Boot) IsSessionAlive() bool {
|
|
has, err := b.tmux.HasSession(SessionName)
|
|
return err == nil && has
|
|
}
|
|
|
|
// AcquireLock creates the marker file to indicate Boot is starting.
|
|
// Returns error if Boot is already running.
|
|
func (b *Boot) AcquireLock() error {
|
|
if b.IsRunning() {
|
|
return fmt.Errorf("boot is already running (session exists)")
|
|
}
|
|
|
|
if err := b.EnsureDir(); err != nil {
|
|
return fmt.Errorf("ensuring boot dir: %w", err)
|
|
}
|
|
|
|
// Create marker file
|
|
f, err := os.Create(b.markerPath())
|
|
if err != nil {
|
|
return fmt.Errorf("creating marker: %w", err)
|
|
}
|
|
return f.Close()
|
|
}
|
|
|
|
// ReleaseLock removes the marker file.
|
|
func (b *Boot) ReleaseLock() error {
|
|
return os.Remove(b.markerPath())
|
|
}
|
|
|
|
// SaveStatus saves Boot's execution status.
|
|
func (b *Boot) SaveStatus(status *Status) error {
|
|
if err := b.EnsureDir(); err != nil {
|
|
return err
|
|
}
|
|
|
|
data, err := json.MarshalIndent(status, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.WriteFile(b.statusPath(), data, 0644) //nolint:gosec // G306: boot status is non-sensitive operational data
|
|
}
|
|
|
|
// LoadStatus loads Boot's last execution status.
|
|
func (b *Boot) LoadStatus() (*Status, error) {
|
|
data, err := os.ReadFile(b.statusPath())
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return &Status{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var status Status
|
|
if err := json.Unmarshal(data, &status); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &status, nil
|
|
}
|
|
|
|
// Spawn starts Boot in a fresh tmux session.
|
|
// Boot runs the mol-boot-triage molecule and exits when done.
|
|
// In degraded mode (no tmux), it runs in a subprocess.
|
|
// The agentOverride parameter allows specifying an agent alias to use instead of the town default.
|
|
func (b *Boot) Spawn(agentOverride string) error {
|
|
if b.IsRunning() {
|
|
return fmt.Errorf("boot is already running")
|
|
}
|
|
|
|
// Check for degraded mode
|
|
if b.degraded {
|
|
return b.spawnDegraded()
|
|
}
|
|
|
|
return b.spawnTmux(agentOverride)
|
|
}
|
|
|
|
// spawnTmux spawns Boot in a tmux session.
|
|
func (b *Boot) spawnTmux(agentOverride string) error {
|
|
// Kill any stale session first
|
|
if b.IsSessionAlive() {
|
|
_ = b.tmux.KillSession(SessionName)
|
|
}
|
|
|
|
// Ensure boot directory exists (it should have CLAUDE.md with Boot context)
|
|
if err := b.EnsureDir(); err != nil {
|
|
return fmt.Errorf("ensuring boot dir: %w", err)
|
|
}
|
|
|
|
// Build startup command with optional agent override
|
|
// The "gt boot triage" prompt tells Boot to immediately start triage (GUPP principle)
|
|
var startCmd string
|
|
if agentOverride != "" {
|
|
var err error
|
|
startCmd, err = config.BuildAgentStartupCommandWithAgentOverride("boot", "", b.townRoot, "", "gt boot triage", agentOverride)
|
|
if err != nil {
|
|
return fmt.Errorf("building startup command with agent override: %w", err)
|
|
}
|
|
} else {
|
|
startCmd = config.BuildAgentStartupCommand("boot", "", b.townRoot, "", "gt boot triage")
|
|
}
|
|
|
|
// Create session with command directly to avoid send-keys race condition.
|
|
// See: https://github.com/anthropics/gastown/issues/280
|
|
if err := b.tmux.NewSessionWithCommand(SessionName, b.bootDir, startCmd); err != nil {
|
|
return fmt.Errorf("creating boot session: %w", err)
|
|
}
|
|
|
|
// Set environment using centralized AgentEnv for consistency
|
|
envVars := config.AgentEnv(config.AgentEnvConfig{
|
|
Role: "boot",
|
|
TownRoot: b.townRoot,
|
|
})
|
|
for k, v := range envVars {
|
|
_ = b.tmux.SetEnvironment(SessionName, k, v)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// spawnDegraded spawns Boot in degraded mode (no tmux).
|
|
// Boot runs to completion and exits without handoff.
|
|
func (b *Boot) spawnDegraded() error {
|
|
// In degraded mode, we run gt boot triage directly
|
|
// This performs the triage logic without a full Claude session
|
|
cmd := exec.Command("gt", "boot", "triage", "--degraded")
|
|
cmd.Dir = b.deaconDir
|
|
|
|
// Use centralized AgentEnv for consistency with tmux mode
|
|
envVars := config.AgentEnv(config.AgentEnvConfig{
|
|
Role: "boot",
|
|
TownRoot: b.townRoot,
|
|
})
|
|
cmd.Env = config.EnvForExecCommand(envVars)
|
|
cmd.Env = append(cmd.Env, "GT_DEGRADED=true")
|
|
|
|
// Run async - don't wait for completion
|
|
return cmd.Start()
|
|
}
|
|
|
|
// IsDegraded returns whether Boot is in degraded mode.
|
|
func (b *Boot) IsDegraded() bool {
|
|
return b.degraded
|
|
}
|
|
|
|
// Dir returns Boot's working directory.
|
|
func (b *Boot) Dir() string {
|
|
return b.bootDir
|
|
}
|
|
|
|
// DeaconDir returns the Deacon's directory.
|
|
func (b *Boot) DeaconDir() string {
|
|
return b.deaconDir
|
|
}
|
|
|
|
// Tmux returns the tmux manager.
|
|
func (b *Boot) Tmux() *tmux.Tmux {
|
|
return b.tmux
|
|
}
|