diff --git a/internal/cmd/down.go b/internal/cmd/down.go index 043a34e0..d119a89c 100644 --- a/internal/cmd/down.go +++ b/internal/cmd/down.go @@ -105,6 +105,9 @@ func runDown(cmd *cobra.Command, args []string) error { rigs := discoverRigs(townRoot) + // Pre-fetch all sessions once for O(1) lookups (avoids N+1 subprocess calls) + sessionSet, _ := t.GetSessionSet() // Ignore error - empty set is safe fallback + // Phase 0.5: Stop polecats if --polecats if downPolecats { if downDryRun { @@ -161,12 +164,12 @@ func runDown(cmd *cobra.Command, args []string) error { for _, rigName := range rigs { sessionName := fmt.Sprintf("gt-%s-refinery", rigName) if downDryRun { - if running, _ := t.HasSession(sessionName); running { + if sessionSet.Has(sessionName) { printDownStatus(fmt.Sprintf("Refinery (%s)", rigName), true, "would stop") } continue } - wasRunning, err := stopSession(t, sessionName) + wasRunning, err := stopSessionWithCache(t, sessionName, sessionSet) if err != nil { printDownStatus(fmt.Sprintf("Refinery (%s)", rigName), false, err.Error()) allOK = false @@ -181,12 +184,12 @@ func runDown(cmd *cobra.Command, args []string) error { for _, rigName := range rigs { sessionName := fmt.Sprintf("gt-%s-witness", rigName) if downDryRun { - if running, _ := t.HasSession(sessionName); running { + if sessionSet.Has(sessionName) { printDownStatus(fmt.Sprintf("Witness (%s)", rigName), true, "would stop") } continue } - wasRunning, err := stopSession(t, sessionName) + wasRunning, err := stopSessionWithCache(t, sessionName, sessionSet) if err != nil { printDownStatus(fmt.Sprintf("Witness (%s)", rigName), false, err.Error()) allOK = false @@ -200,12 +203,12 @@ func runDown(cmd *cobra.Command, args []string) error { // Phase 3: Stop town-level sessions (Mayor, Boot, Deacon) for _, ts := range session.TownSessions() { if downDryRun { - if running, _ := t.HasSession(ts.SessionID); running { + if sessionSet.Has(ts.SessionID) { printDownStatus(ts.Name, true, "would stop") } continue } - stopped, err := session.StopTownSession(t, ts, downForce) + stopped, err := session.StopTownSessionWithCache(t, ts, downForce, sessionSet) if err != nil { printDownStatus(ts.Name, false, err.Error()) allOK = false @@ -390,6 +393,23 @@ func stopSession(t *tmux.Tmux, sessionName string) (bool, error) { return true, t.KillSessionWithProcesses(sessionName) } +// stopSessionWithCache is like stopSession but uses a pre-fetched SessionSet +// for O(1) existence check instead of spawning a subprocess. +func stopSessionWithCache(t *tmux.Tmux, sessionName string, cache *tmux.SessionSet) (bool, error) { + if !cache.Has(sessionName) { + return false, nil // Already stopped + } + + // Try graceful shutdown first (Ctrl-C, best-effort interrupt) + if !downForce { + _ = t.SendKeysRaw(sessionName, "C-c") + time.Sleep(100 * time.Millisecond) + } + + // Kill the session (with explicit process termination to prevent orphans) + return true, t.KillSessionWithProcesses(sessionName) +} + // acquireShutdownLock prevents concurrent shutdowns. // Returns the lock (caller must defer Unlock()) or error if lock held. func acquireShutdownLock(townRoot string) (*flock.Flock, error) { diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index a522d6fb..bb8cb080 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -380,8 +380,9 @@ func runSling(cmd *cobra.Command, args []string) error { formulaWorkDir := beads.ResolveHookDir(townRoot, beadID, hookWorkDir) // Step 1: Cook the formula (ensures proto exists) - // Cook doesn't need database context - runs from cwd like gt formula show + // Cook runs from rig directory to access the correct formula database cookCmd := exec.Command("bd", "--no-daemon", "cook", formulaName) + cookCmd.Dir = formulaWorkDir cookCmd.Stderr = os.Stderr if err := cookCmd.Run(); err != nil { return fmt.Errorf("cooking formula %s: %w", formulaName, err) diff --git a/internal/session/town.go b/internal/session/town.go index e52380aa..d5dd1f15 100644 --- a/internal/session/town.go +++ b/internal/session/town.go @@ -39,6 +39,21 @@ func StopTownSession(t *tmux.Tmux, ts TownSession, force bool) (bool, error) { return false, nil } + return stopTownSessionInternal(t, ts, force) +} + +// StopTownSessionWithCache is like StopTownSession but uses a pre-fetched +// SessionSet for O(1) existence check instead of spawning a subprocess. +func StopTownSessionWithCache(t *tmux.Tmux, ts TownSession, force bool, cache *tmux.SessionSet) (bool, error) { + if !cache.Has(ts.SessionID) { + return false, nil + } + + return stopTownSessionInternal(t, ts, force) +} + +// stopTownSessionInternal performs the actual session stop. +func stopTownSessionInternal(t *tmux.Tmux, ts TownSession, force bool) (bool, error) { // Try graceful shutdown first (unless forced) if !force { _ = t.SendKeysRaw(ts.SessionID, "C-c") diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 29bebe16..1db1b6e3 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -244,6 +244,72 @@ func (t *Tmux) ListSessions() ([]string, error) { return strings.Split(out, "\n"), nil } +// SessionSet provides O(1) session existence checks by caching session names. +// Use this when you need to check multiple sessions to avoid N+1 subprocess calls. +type SessionSet struct { + sessions map[string]struct{} +} + +// GetSessionSet returns a SessionSet containing all current sessions. +// Call this once at the start of an operation, then use Has() for O(1) checks. +// This replaces multiple HasSession() calls with a single ListSessions() call. +// +// Builds the map directly from tmux output to avoid intermediate slice allocation. +func (t *Tmux) GetSessionSet() (*SessionSet, error) { + out, err := t.run("list-sessions", "-F", "#{session_name}") + if err != nil { + if errors.Is(err, ErrNoServer) { + return &SessionSet{sessions: make(map[string]struct{})}, nil + } + return nil, err + } + + // Count newlines to pre-size map (avoids rehashing during insertion) + count := strings.Count(out, "\n") + 1 + set := &SessionSet{ + sessions: make(map[string]struct{}, count), + } + + // Parse directly without intermediate slice allocation + for len(out) > 0 { + idx := strings.IndexByte(out, '\n') + var line string + if idx >= 0 { + line = out[:idx] + out = out[idx+1:] + } else { + line = out + out = "" + } + if line != "" { + set.sessions[line] = struct{}{} + } + } + return set, nil +} + +// Has returns true if the session exists in the set. +// This is an O(1) lookup - no subprocess is spawned. +func (s *SessionSet) Has(name string) bool { + if s == nil { + return false + } + _, ok := s.sessions[name] + return ok +} + +// Names returns all session names in the set. +func (s *SessionSet) Names() []string { + if s == nil || len(s.sessions) == 0 { + return nil + } + names := make([]string, 0, len(s.sessions)) + for name := range s.sessions { + names = append(names, name) + } + return names +} + // ListSessionIDs returns a map of session name to session ID. // Session IDs are in the format "$N" where N is a number. func (t *Tmux) ListSessionIDs() (map[string]string, error) { diff --git a/internal/tmux/tmux_test.go b/internal/tmux/tmux_test.go index 09203831..a1f71f00 100644 --- a/internal/tmux/tmux_test.go +++ b/internal/tmux/tmux_test.go @@ -552,3 +552,56 @@ func TestGetAllDescendants(t *testing.T) { } } } + +func TestSessionSet(t *testing.T) { + if !hasTmux() { + t.Skip("tmux not installed") + } + + tm := NewTmux() + sessionName := "gt-test-sessionset-" + t.Name() + + // Clean up any existing session + _ = tm.KillSession(sessionName) + + // Create a test session + if err := tm.NewSession(sessionName, ""); err != nil { + t.Fatalf("NewSession: %v", err) + } + defer func() { _ = tm.KillSession(sessionName) }() + + // Get the session set + set, err := tm.GetSessionSet() + if err != nil { + t.Fatalf("GetSessionSet: %v", err) + } + + // Test Has() for existing session + if !set.Has(sessionName) { + t.Errorf("SessionSet.Has(%q) = false, want true", sessionName) + } + + // Test Has() for non-existing session + if set.Has("nonexistent-session-xyz-12345") { + t.Error("SessionSet.Has(nonexistent) = true, want false") + } + + // Test nil safety + var nilSet *SessionSet + if nilSet.Has("anything") { + t.Error("nil SessionSet.Has() = true, want false") + } + + // Test Names() returns the session + names := set.Names() + found := false + for _, n := range names { + if n == sessionName { + found = true + break + } + } + if !found { + t.Errorf("SessionSet.Names() doesn't contain %q", sessionName) + } +}