From f49197243d5c6ad3a3a48a1e7a29d51dccc742ee Mon Sep 17 00:00:00 2001 From: mediocre Date: Mon, 5 Jan 2026 00:19:41 -0800 Subject: [PATCH] refactor: extract shared AgentStateManager pattern (gt-gaw8e) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create internal/agent package with shared State type and StateManager - StateManager uses Go generics for type-safe load/save operations - Update witness and refinery to use shared State type alias - Replace loadState/saveState implementations with StateManager delegation - Maintains backwards compatibility through re-exported constants 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/agent/state.go | 76 ++++++++++++++++++++++++++++++++++++ internal/refinery/manager.go | 42 +++++++------------- internal/refinery/types.go | 18 ++++----- internal/witness/manager.go | 41 ++++++------------- internal/witness/types.go | 18 ++++----- 5 files changed, 119 insertions(+), 76 deletions(-) create mode 100644 internal/agent/state.go diff --git a/internal/agent/state.go b/internal/agent/state.go new file mode 100644 index 00000000..aab96125 --- /dev/null +++ b/internal/agent/state.go @@ -0,0 +1,76 @@ +// Package agent provides shared types and utilities for Gas Town agents +// (witness, refinery, deacon, etc.). +package agent + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/steveyegge/gastown/internal/util" +) + +// State represents an agent's running state. +type State string + +const ( + // StateStopped means the agent is not running. + StateStopped State = "stopped" + + // StateRunning means the agent is actively operating. + StateRunning State = "running" + + // StatePaused means the agent is paused (not operating but not stopped). + StatePaused State = "paused" +) + +// StateManager handles loading and saving agent state to disk. +// It uses generics to work with any state type. +type StateManager[T any] struct { + stateFilePath string + defaultFactory func() *T +} + +// NewStateManager creates a new StateManager for the given state file path. +// The defaultFactory function is called when the state file doesn't exist +// to create a new state with default values. +func NewStateManager[T any](rigPath, stateFileName string, defaultFactory func() *T) *StateManager[T] { + return &StateManager[T]{ + stateFilePath: filepath.Join(rigPath, ".runtime", stateFileName), + defaultFactory: defaultFactory, + } +} + +// StateFile returns the path to the state file. +func (m *StateManager[T]) StateFile() string { + return m.stateFilePath +} + +// Load loads agent state from disk. +// If the file doesn't exist, returns a new state created by the default factory. +func (m *StateManager[T]) Load() (*T, error) { + data, err := os.ReadFile(m.stateFilePath) + if err != nil { + if os.IsNotExist(err) { + return m.defaultFactory(), nil + } + return nil, err + } + + var state T + if err := json.Unmarshal(data, &state); err != nil { + return nil, err + } + + return &state, nil +} + +// Save persists agent state to disk using atomic write. +func (m *StateManager[T]) Save(state *T) error { + dir := filepath.Dir(m.stateFilePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + return util.AtomicWriteJSON(m.stateFilePath, state) +} diff --git a/internal/refinery/manager.go b/internal/refinery/manager.go index 799d38d6..3b72148d 100644 --- a/internal/refinery/manager.go +++ b/internal/refinery/manager.go @@ -2,7 +2,6 @@ package refinery import ( "bytes" - "encoding/json" "errors" "fmt" "io" @@ -13,6 +12,7 @@ import ( "strings" "time" + "github.com/steveyegge/gastown/internal/agent" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/claude" "github.com/steveyegge/gastown/internal/config" @@ -33,9 +33,10 @@ var ( // Manager handles refinery lifecycle and queue operations. type Manager struct { - rig *rig.Rig - workDir string - output io.Writer // Output destination for user-facing messages + rig *rig.Rig + workDir string + output io.Writer // Output destination for user-facing messages + stateManager *agent.StateManager[Refinery] } // NewManager creates a new refinery manager for a rig. @@ -44,6 +45,12 @@ func NewManager(r *rig.Rig) *Manager { rig: r, workDir: r.Path, output: os.Stdout, + stateManager: agent.NewStateManager[Refinery](r.Path, "refinery.json", func() *Refinery { + return &Refinery{ + RigName: r.Name, + State: StateStopped, + } + }), } } @@ -55,7 +62,7 @@ func (m *Manager) SetOutput(w io.Writer) { // stateFile returns the path to the refinery state file. func (m *Manager) stateFile() string { - return filepath.Join(m.rig.Path, ".runtime", "refinery.json") + return m.stateManager.StateFile() } // sessionName returns the tmux session name for this refinery. @@ -65,33 +72,12 @@ func (m *Manager) sessionName() string { // loadState loads refinery state from disk. func (m *Manager) loadState() (*Refinery, error) { - data, err := os.ReadFile(m.stateFile()) - if err != nil { - if os.IsNotExist(err) { - return &Refinery{ - RigName: m.rig.Name, - State: StateStopped, - }, nil - } - return nil, err - } - - var ref Refinery - if err := json.Unmarshal(data, &ref); err != nil { - return nil, err - } - - return &ref, nil + return m.stateManager.Load() } // saveState persists refinery state to disk using atomic write. func (m *Manager) saveState(ref *Refinery) error { - dir := filepath.Dir(m.stateFile()) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - - return util.AtomicWriteJSON(m.stateFile(), ref) + return m.stateManager.Save(ref) } // Status returns the current refinery status. diff --git a/internal/refinery/types.go b/internal/refinery/types.go index 6526261d..97f8e6b6 100644 --- a/internal/refinery/types.go +++ b/internal/refinery/types.go @@ -5,20 +5,18 @@ import ( "errors" "fmt" "time" + + "github.com/steveyegge/gastown/internal/agent" ) -// State represents the refinery's running state. -type State string +// State is an alias for agent.State for backwards compatibility. +type State = agent.State +// State constants - re-exported from agent package for backwards compatibility. const ( - // StateStopped means the refinery is not running. - StateStopped State = "stopped" - - // StateRunning means the refinery is actively processing. - StateRunning State = "running" - - // StatePaused means the refinery is paused (not processing new items). - StatePaused State = "paused" + StateStopped = agent.StateStopped + StateRunning = agent.StateRunning + StatePaused = agent.StatePaused ) // Refinery represents a rig's merge queue processor. diff --git a/internal/witness/manager.go b/internal/witness/manager.go index bcb3a8e8..77933a04 100644 --- a/internal/witness/manager.go +++ b/internal/witness/manager.go @@ -1,12 +1,11 @@ package witness import ( - "encoding/json" "errors" "os" - "path/filepath" "time" + "github.com/steveyegge/gastown/internal/agent" "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/util" ) @@ -19,8 +18,9 @@ var ( // Manager handles witness lifecycle and monitoring operations. type Manager struct { - rig *rig.Rig - workDir string + rig *rig.Rig + workDir string + stateManager *agent.StateManager[Witness] } // NewManager creates a new witness manager for a rig. @@ -28,43 +28,28 @@ func NewManager(r *rig.Rig) *Manager { return &Manager{ rig: r, workDir: r.Path, + stateManager: agent.NewStateManager[Witness](r.Path, "witness.json", func() *Witness { + return &Witness{ + RigName: r.Name, + State: StateStopped, + } + }), } } // stateFile returns the path to the witness state file. func (m *Manager) stateFile() string { - return filepath.Join(m.rig.Path, ".runtime", "witness.json") + return m.stateManager.StateFile() } // loadState loads witness state from disk. func (m *Manager) loadState() (*Witness, error) { - data, err := os.ReadFile(m.stateFile()) - if err != nil { - if os.IsNotExist(err) { - return &Witness{ - RigName: m.rig.Name, - State: StateStopped, - }, nil - } - return nil, err - } - - var w Witness - if err := json.Unmarshal(data, &w); err != nil { - return nil, err - } - - return &w, nil + return m.stateManager.Load() } // saveState persists witness state to disk using atomic write. func (m *Manager) saveState(w *Witness) error { - dir := filepath.Dir(m.stateFile()) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - - return util.AtomicWriteJSON(m.stateFile(), w) + return m.stateManager.Save(w) } // Status returns the current witness status. diff --git a/internal/witness/types.go b/internal/witness/types.go index 077bc2e1..681989e7 100644 --- a/internal/witness/types.go +++ b/internal/witness/types.go @@ -3,20 +3,18 @@ package witness import ( "time" + + "github.com/steveyegge/gastown/internal/agent" ) -// State represents the witness's running state. -type State string +// State is an alias for agent.State for backwards compatibility. +type State = agent.State +// State constants - re-exported from agent package for backwards compatibility. const ( - // StateStopped means the witness is not running. - StateStopped State = "stopped" - - // StateRunning means the witness is actively monitoring. - StateRunning State = "running" - - // StatePaused means the witness is paused (not monitoring). - StatePaused State = "paused" + StateStopped = agent.StateStopped + StateRunning = agent.StateRunning + StatePaused = agent.StatePaused ) // Witness represents a rig's polecat monitoring agent.