* fix(beads): align agent bead prefixes and force multi-hyphen IDs * fix(checkpoint): treat threshold as stale at boundary
219 lines
5.7 KiB
Go
219 lines
5.7 KiB
Go
// Package checkpoint provides session checkpointing for crash recovery.
|
|
// When a polecat session dies (context limit, crash, timeout), checkpoints
|
|
// allow the next session to recover state and resume work.
|
|
package checkpoint
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/steveyegge/gastown/internal/runtime"
|
|
)
|
|
|
|
// Filename is the checkpoint file name within the polecat directory.
|
|
const Filename = ".polecat-checkpoint.json"
|
|
|
|
// Checkpoint represents a session recovery checkpoint.
|
|
type Checkpoint struct {
|
|
// MoleculeID is the current molecule being worked.
|
|
MoleculeID string `json:"molecule_id,omitempty"`
|
|
|
|
// CurrentStep is the step ID currently in progress.
|
|
CurrentStep string `json:"current_step,omitempty"`
|
|
|
|
// StepTitle is the human-readable title of the current step.
|
|
StepTitle string `json:"step_title,omitempty"`
|
|
|
|
// ModifiedFiles lists files modified since the last commit.
|
|
ModifiedFiles []string `json:"modified_files,omitempty"`
|
|
|
|
// LastCommit is the SHA of the last commit.
|
|
LastCommit string `json:"last_commit,omitempty"`
|
|
|
|
// Branch is the current git branch.
|
|
Branch string `json:"branch,omitempty"`
|
|
|
|
// HookedBead is the bead ID on the agent's hook.
|
|
HookedBead string `json:"hooked_bead,omitempty"`
|
|
|
|
// Timestamp is when the checkpoint was written.
|
|
Timestamp time.Time `json:"timestamp"`
|
|
|
|
// SessionID identifies the session that wrote the checkpoint.
|
|
SessionID string `json:"session_id,omitempty"`
|
|
|
|
// Notes contains optional context from the session.
|
|
Notes string `json:"notes,omitempty"`
|
|
}
|
|
|
|
// Path returns the checkpoint file path for a given polecat directory.
|
|
func Path(polecatDir string) string {
|
|
return filepath.Join(polecatDir, Filename)
|
|
}
|
|
|
|
// Read loads a checkpoint from the polecat directory.
|
|
// Returns nil, nil if no checkpoint exists.
|
|
func Read(polecatDir string) (*Checkpoint, error) {
|
|
path := Path(polecatDir)
|
|
|
|
data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed from trusted polecatDir
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("reading checkpoint: %w", err)
|
|
}
|
|
|
|
var cp Checkpoint
|
|
if err := json.Unmarshal(data, &cp); err != nil {
|
|
return nil, fmt.Errorf("parsing checkpoint: %w", err)
|
|
}
|
|
|
|
return &cp, nil
|
|
}
|
|
|
|
// Write saves a checkpoint to the polecat directory.
|
|
func Write(polecatDir string, cp *Checkpoint) error {
|
|
// Set timestamp if not already set
|
|
if cp.Timestamp.IsZero() {
|
|
cp.Timestamp = time.Now()
|
|
}
|
|
|
|
// Set session ID from environment if available
|
|
if cp.SessionID == "" {
|
|
cp.SessionID = runtime.SessionIDFromEnv()
|
|
if cp.SessionID == "" {
|
|
cp.SessionID = fmt.Sprintf("pid-%d", os.Getpid())
|
|
}
|
|
}
|
|
|
|
data, err := json.MarshalIndent(cp, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling checkpoint: %w", err)
|
|
}
|
|
|
|
path := Path(polecatDir)
|
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
|
return fmt.Errorf("writing checkpoint: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Remove deletes the checkpoint file.
|
|
func Remove(polecatDir string) error {
|
|
path := Path(polecatDir)
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("removing checkpoint: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Capture creates a checkpoint by capturing current git and work state.
|
|
func Capture(polecatDir string) (*Checkpoint, error) {
|
|
cp := &Checkpoint{
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
// Get modified files from git status
|
|
cmd := exec.Command("git", "status", "--porcelain")
|
|
cmd.Dir = polecatDir
|
|
output, err := cmd.Output()
|
|
if err == nil {
|
|
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
|
for _, line := range lines {
|
|
if len(line) > 3 {
|
|
// Format: XY filename
|
|
file := strings.TrimSpace(line[3:])
|
|
if file != "" {
|
|
cp.ModifiedFiles = append(cp.ModifiedFiles, file)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get last commit SHA
|
|
cmd = exec.Command("git", "rev-parse", "HEAD")
|
|
cmd.Dir = polecatDir
|
|
output, err = cmd.Output()
|
|
if err == nil {
|
|
cp.LastCommit = strings.TrimSpace(string(output))
|
|
}
|
|
|
|
// Get current branch
|
|
cmd = exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
cmd.Dir = polecatDir
|
|
output, err = cmd.Output()
|
|
if err == nil {
|
|
cp.Branch = strings.TrimSpace(string(output))
|
|
}
|
|
|
|
return cp, nil
|
|
}
|
|
|
|
// WithMolecule adds molecule context to a checkpoint.
|
|
func (cp *Checkpoint) WithMolecule(moleculeID, stepID, stepTitle string) *Checkpoint {
|
|
cp.MoleculeID = moleculeID
|
|
cp.CurrentStep = stepID
|
|
cp.StepTitle = stepTitle
|
|
return cp
|
|
}
|
|
|
|
// WithHookedBead adds hooked bead context to a checkpoint.
|
|
func (cp *Checkpoint) WithHookedBead(beadID string) *Checkpoint {
|
|
cp.HookedBead = beadID
|
|
return cp
|
|
}
|
|
|
|
// WithNotes adds context notes to a checkpoint.
|
|
func (cp *Checkpoint) WithNotes(notes string) *Checkpoint {
|
|
cp.Notes = notes
|
|
return cp
|
|
}
|
|
|
|
// Age returns how long ago the checkpoint was written.
|
|
func (cp *Checkpoint) Age() time.Duration {
|
|
return time.Since(cp.Timestamp)
|
|
}
|
|
|
|
// IsStale returns true if the checkpoint is at or older than the threshold.
|
|
func (cp *Checkpoint) IsStale(threshold time.Duration) bool {
|
|
return cp.Age() >= threshold
|
|
}
|
|
|
|
// Summary returns a concise summary of the checkpoint.
|
|
func (cp *Checkpoint) Summary() string {
|
|
var parts []string
|
|
|
|
if cp.MoleculeID != "" {
|
|
if cp.CurrentStep != "" {
|
|
parts = append(parts, fmt.Sprintf("molecule %s, step %s", cp.MoleculeID, cp.CurrentStep))
|
|
} else {
|
|
parts = append(parts, fmt.Sprintf("molecule %s", cp.MoleculeID))
|
|
}
|
|
}
|
|
|
|
if cp.HookedBead != "" {
|
|
parts = append(parts, fmt.Sprintf("hooked: %s", cp.HookedBead))
|
|
}
|
|
|
|
if len(cp.ModifiedFiles) > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d modified files", len(cp.ModifiedFiles)))
|
|
}
|
|
|
|
if cp.Branch != "" {
|
|
parts = append(parts, fmt.Sprintf("branch: %s", cp.Branch))
|
|
}
|
|
|
|
if len(parts) == 0 {
|
|
return "no significant state"
|
|
}
|
|
|
|
return strings.Join(parts, ", ")
|
|
}
|