feat: add session management for polecat lifecycle
Manager operations: - Start: create tmux session with optional issue assignment - Stop: graceful shutdown with Ctrl+C then kill - IsRunning: check if session exists - List: enumerate all sessions for rig - Attach: attach to session - Capture: get recent output - Inject: send message to session - StopAll: terminate all sessions Session naming: gt-<rig>-<polecat> StartOptions: - WorkDir: override working directory - Issue: initial issue to work on - Command: override default "claude" command Environment variables set on start: - GT_RIG: rig name - GT_POLECAT: polecat name Closes gt-u1j.7 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
254
internal/session/manager.go
Normal file
254
internal/session/manager.go
Normal file
@@ -0,0 +1,254 @@
|
||||
// Package session provides polecat session lifecycle management.
|
||||
package session
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/rig"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
)
|
||||
|
||||
// Common errors
|
||||
var (
|
||||
ErrSessionRunning = errors.New("session already running")
|
||||
ErrSessionNotFound = errors.New("session not found")
|
||||
ErrPolecatNotFound = errors.New("polecat not found")
|
||||
)
|
||||
|
||||
// Manager handles polecat session lifecycle.
|
||||
type Manager struct {
|
||||
tmux *tmux.Tmux
|
||||
rig *rig.Rig
|
||||
}
|
||||
|
||||
// NewManager creates a new session manager for a rig.
|
||||
func NewManager(t *tmux.Tmux, r *rig.Rig) *Manager {
|
||||
return &Manager{
|
||||
tmux: t,
|
||||
rig: r,
|
||||
}
|
||||
}
|
||||
|
||||
// StartOptions configures session startup.
|
||||
type StartOptions struct {
|
||||
// WorkDir overrides the default working directory (polecat clone dir).
|
||||
WorkDir string
|
||||
|
||||
// Issue is an optional issue ID to work on.
|
||||
Issue string
|
||||
|
||||
// Command overrides the default "claude" command.
|
||||
Command string
|
||||
}
|
||||
|
||||
// Info contains information about a running session.
|
||||
type Info struct {
|
||||
// Polecat is the polecat name.
|
||||
Polecat string `json:"polecat"`
|
||||
|
||||
// SessionID is the tmux session identifier.
|
||||
SessionID string `json:"session_id"`
|
||||
|
||||
// Running indicates if the session is currently active.
|
||||
Running bool `json:"running"`
|
||||
|
||||
// RigName is the rig this session belongs to.
|
||||
RigName string `json:"rig_name"`
|
||||
}
|
||||
|
||||
// sessionName generates the tmux session name for a polecat.
|
||||
func (m *Manager) sessionName(polecat string) string {
|
||||
return fmt.Sprintf("gt-%s-%s", m.rig.Name, polecat)
|
||||
}
|
||||
|
||||
// polecatDir returns the working directory for a polecat.
|
||||
func (m *Manager) polecatDir(polecat string) string {
|
||||
return filepath.Join(m.rig.Path, "polecats", polecat)
|
||||
}
|
||||
|
||||
// hasPolecat checks if the polecat exists in this rig.
|
||||
func (m *Manager) hasPolecat(polecat string) bool {
|
||||
for _, p := range m.rig.Polecats {
|
||||
if p == polecat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Start creates and starts a new session for a polecat.
|
||||
func (m *Manager) Start(polecat string, opts StartOptions) error {
|
||||
if !m.hasPolecat(polecat) {
|
||||
return fmt.Errorf("%w: %s", ErrPolecatNotFound, polecat)
|
||||
}
|
||||
|
||||
sessionID := m.sessionName(polecat)
|
||||
|
||||
// Check if session already exists
|
||||
running, err := m.tmux.HasSession(sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
}
|
||||
if running {
|
||||
return fmt.Errorf("%w: %s", ErrSessionRunning, sessionID)
|
||||
}
|
||||
|
||||
// Determine working directory
|
||||
workDir := opts.WorkDir
|
||||
if workDir == "" {
|
||||
workDir = m.polecatDir(polecat)
|
||||
}
|
||||
|
||||
// Create session
|
||||
if err := m.tmux.NewSession(sessionID, workDir); err != nil {
|
||||
return fmt.Errorf("creating session: %w", err)
|
||||
}
|
||||
|
||||
// Set environment
|
||||
m.tmux.SetEnvironment(sessionID, "GT_RIG", m.rig.Name)
|
||||
m.tmux.SetEnvironment(sessionID, "GT_POLECAT", polecat)
|
||||
|
||||
// Send initial command
|
||||
command := opts.Command
|
||||
if command == "" {
|
||||
command = "claude"
|
||||
}
|
||||
if err := m.tmux.SendKeys(sessionID, command); err != nil {
|
||||
return fmt.Errorf("sending command: %w", err)
|
||||
}
|
||||
|
||||
// If issue specified, wait a bit then inject it
|
||||
if opts.Issue != "" {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
prompt := fmt.Sprintf("Work on issue: %s", opts.Issue)
|
||||
if err := m.Inject(polecat, prompt); err != nil {
|
||||
// Non-fatal, just log
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop terminates a polecat session.
|
||||
func (m *Manager) Stop(polecat string) error {
|
||||
sessionID := m.sessionName(polecat)
|
||||
|
||||
// Check if session exists
|
||||
running, err := m.tmux.HasSession(sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
}
|
||||
if !running {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
// Try graceful shutdown first
|
||||
m.tmux.SendKeysRaw(sessionID, "C-c") // Ctrl+C
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Kill the session
|
||||
if err := m.tmux.KillSession(sessionID); err != nil {
|
||||
return fmt.Errorf("killing session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsRunning checks if a polecat session is active.
|
||||
func (m *Manager) IsRunning(polecat string) (bool, error) {
|
||||
sessionID := m.sessionName(polecat)
|
||||
return m.tmux.HasSession(sessionID)
|
||||
}
|
||||
|
||||
// List returns information about all sessions for this rig.
|
||||
func (m *Manager) List() ([]Info, error) {
|
||||
sessions, err := m.tmux.ListSessions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prefix := fmt.Sprintf("gt-%s-", m.rig.Name)
|
||||
var infos []Info
|
||||
|
||||
for _, sessionID := range sessions {
|
||||
if !strings.HasPrefix(sessionID, prefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
polecat := strings.TrimPrefix(sessionID, prefix)
|
||||
infos = append(infos, Info{
|
||||
Polecat: polecat,
|
||||
SessionID: sessionID,
|
||||
Running: true,
|
||||
RigName: m.rig.Name,
|
||||
})
|
||||
}
|
||||
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// Attach attaches to a polecat session.
|
||||
func (m *Manager) Attach(polecat string) error {
|
||||
sessionID := m.sessionName(polecat)
|
||||
|
||||
running, err := m.tmux.HasSession(sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
}
|
||||
if !running {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
return m.tmux.AttachSession(sessionID)
|
||||
}
|
||||
|
||||
// Capture returns the recent output from a polecat session.
|
||||
func (m *Manager) Capture(polecat string, lines int) (string, error) {
|
||||
sessionID := m.sessionName(polecat)
|
||||
|
||||
running, err := m.tmux.HasSession(sessionID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("checking session: %w", err)
|
||||
}
|
||||
if !running {
|
||||
return "", ErrSessionNotFound
|
||||
}
|
||||
|
||||
return m.tmux.CapturePane(sessionID, lines)
|
||||
}
|
||||
|
||||
// Inject sends a message to a polecat session.
|
||||
func (m *Manager) Inject(polecat, message string) error {
|
||||
sessionID := m.sessionName(polecat)
|
||||
|
||||
running, err := m.tmux.HasSession(sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
}
|
||||
if !running {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
return m.tmux.SendKeys(sessionID, message)
|
||||
}
|
||||
|
||||
// StopAll terminates all sessions for this rig.
|
||||
func (m *Manager) StopAll() error {
|
||||
infos, err := m.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, info := range infos {
|
||||
if err := m.Stop(info.Polecat); err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
138
internal/session/manager_test.go
Normal file
138
internal/session/manager_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/rig"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
)
|
||||
|
||||
func TestSessionName(t *testing.T) {
|
||||
r := &rig.Rig{
|
||||
Name: "gastown",
|
||||
Polecats: []string{"Toast"},
|
||||
}
|
||||
m := NewManager(tmux.NewTmux(), r)
|
||||
|
||||
name := m.sessionName("Toast")
|
||||
if name != "gt-gastown-Toast" {
|
||||
t.Errorf("sessionName = %q, want gt-gastown-Toast", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolecatDir(t *testing.T) {
|
||||
r := &rig.Rig{
|
||||
Name: "gastown",
|
||||
Path: "/home/user/ai/gastown",
|
||||
Polecats: []string{"Toast"},
|
||||
}
|
||||
m := NewManager(tmux.NewTmux(), r)
|
||||
|
||||
dir := m.polecatDir("Toast")
|
||||
expected := "/home/user/ai/gastown/polecats/Toast"
|
||||
if dir != expected {
|
||||
t.Errorf("polecatDir = %q, want %q", dir, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPolecat(t *testing.T) {
|
||||
r := &rig.Rig{
|
||||
Name: "gastown",
|
||||
Polecats: []string{"Toast", "Cheedo"},
|
||||
}
|
||||
m := NewManager(tmux.NewTmux(), r)
|
||||
|
||||
if !m.hasPolecat("Toast") {
|
||||
t.Error("expected hasPolecat(Toast) = true")
|
||||
}
|
||||
if !m.hasPolecat("Cheedo") {
|
||||
t.Error("expected hasPolecat(Cheedo) = true")
|
||||
}
|
||||
if m.hasPolecat("Unknown") {
|
||||
t.Error("expected hasPolecat(Unknown) = false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPolecatNotFound(t *testing.T) {
|
||||
r := &rig.Rig{
|
||||
Name: "gastown",
|
||||
Polecats: []string{"Toast"},
|
||||
}
|
||||
m := NewManager(tmux.NewTmux(), r)
|
||||
|
||||
err := m.Start("Unknown", StartOptions{})
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown polecat")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRunningNoSession(t *testing.T) {
|
||||
r := &rig.Rig{
|
||||
Name: "gastown",
|
||||
Polecats: []string{"Toast"},
|
||||
}
|
||||
m := NewManager(tmux.NewTmux(), r)
|
||||
|
||||
running, err := m.IsRunning("Toast")
|
||||
if err != nil {
|
||||
t.Fatalf("IsRunning: %v", err)
|
||||
}
|
||||
if running {
|
||||
t.Error("expected IsRunning = false for non-existent session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListEmpty(t *testing.T) {
|
||||
r := &rig.Rig{
|
||||
Name: "test-rig-unlikely-name",
|
||||
Polecats: []string{},
|
||||
}
|
||||
m := NewManager(tmux.NewTmux(), r)
|
||||
|
||||
infos, err := m.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(infos) != 0 {
|
||||
t.Errorf("infos count = %d, want 0", len(infos))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopNotFound(t *testing.T) {
|
||||
r := &rig.Rig{
|
||||
Name: "test-rig",
|
||||
Polecats: []string{"Toast"},
|
||||
}
|
||||
m := NewManager(tmux.NewTmux(), r)
|
||||
|
||||
err := m.Stop("Toast")
|
||||
if err != ErrSessionNotFound {
|
||||
t.Errorf("Stop = %v, want ErrSessionNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureNotFound(t *testing.T) {
|
||||
r := &rig.Rig{
|
||||
Name: "test-rig",
|
||||
Polecats: []string{"Toast"},
|
||||
}
|
||||
m := NewManager(tmux.NewTmux(), r)
|
||||
|
||||
_, err := m.Capture("Toast", 50)
|
||||
if err != ErrSessionNotFound {
|
||||
t.Errorf("Capture = %v, want ErrSessionNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectNotFound(t *testing.T) {
|
||||
r := &rig.Rig{
|
||||
Name: "test-rig",
|
||||
Polecats: []string{"Toast"},
|
||||
}
|
||||
m := NewManager(tmux.NewTmux(), r)
|
||||
|
||||
err := m.Inject("Toast", "hello")
|
||||
if err != ErrSessionNotFound {
|
||||
t.Errorf("Inject = %v, want ErrSessionNotFound", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user