Files
gastown/internal/witness/manager.go
Steve Yegge 213b3bab20 feat: Add atomic write pattern for state files (gt-wled7)
Prevents data loss from concurrent/interrupted state file writes by using
atomic write pattern (write to .tmp, then rename).

Changes:
- Add internal/util package with AtomicWriteJSON/AtomicWriteFile helpers
- Update witness/manager.go saveState to use atomic writes
- Update refinery/manager.go saveState to use atomic writes
- Update crew/manager.go saveState to use atomic writes
- Update daemon/types.go SaveState to use atomic writes
- Update polecat/namepool.go Save to use atomic writes
- Add comprehensive tests for atomic write utilities

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 15:57:53 -08:00

141 lines
3.0 KiB
Go

package witness
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"time"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/util"
)
// Common errors
var (
ErrNotRunning = errors.New("witness not running")
ErrAlreadyRunning = errors.New("witness already running")
)
// Manager handles witness lifecycle and monitoring operations.
type Manager struct {
rig *rig.Rig
workDir string
}
// NewManager creates a new witness manager for a rig.
func NewManager(r *rig.Rig) *Manager {
return &Manager{
rig: r,
workDir: r.Path,
}
}
// stateFile returns the path to the witness state file.
func (m *Manager) stateFile() string {
return filepath.Join(m.rig.Path, ".runtime", "witness.json")
}
// 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
}
// 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)
}
// Status returns the current witness status.
// ZFC-compliant: trusts agent-reported state, no PID inference.
// The daemon reads agent bead state for liveness checks.
func (m *Manager) Status() (*Witness, error) {
w, err := m.loadState()
if err != nil {
return nil, err
}
// Update monitored polecats list (still useful for display)
w.MonitoredPolecats = m.rig.Polecats
return w, nil
}
// Start starts the witness (marks it as running).
// Patrol logic is now handled by mol-witness-patrol molecule executed by Claude.
func (m *Manager) Start() error {
w, err := m.loadState()
if err != nil {
return err
}
if w.State == StateRunning && w.PID > 0 && processExists(w.PID) {
return ErrAlreadyRunning
}
now := time.Now()
w.State = StateRunning
w.StartedAt = &now
w.PID = os.Getpid()
w.MonitoredPolecats = m.rig.Polecats
return m.saveState(w)
}
// Stop stops the witness.
func (m *Manager) Stop() error {
w, err := m.loadState()
if err != nil {
return err
}
if w.State != StateRunning {
return ErrNotRunning
}
// If we have a PID, try to stop it gracefully
if w.PID > 0 && w.PID != os.Getpid() {
// Send SIGTERM (best-effort graceful stop)
if proc, err := os.FindProcess(w.PID); err == nil {
_ = proc.Signal(os.Interrupt)
}
}
w.State = StateStopped
w.PID = 0
return m.saveState(w)
}
// processExists checks if a process with the given PID exists.
func processExists(pid int) bool {
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
// On Unix, FindProcess always succeeds; signal 0 tests existence
err = proc.Signal(nil)
return err == nil
}