fix(polecat): remove pending.json tracking anti-pattern (ZFC)

Removed the pending.json file that shadowed observable state. Now
discovers pending spawns directly from POLECAT_STARTED messages in
the Deacon's inbox.

Changes:
- CheckInboxForSpawns: Discovers from mail, no more LoadPending/SavePending
- TriggerPendingSpawns: Archives mail after successful trigger
- PruneStalePending: Archives old messages instead of pruning from JSON

The mail system is now the source of truth for pending spawns.

Closes: hq-i31f7

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gastown/crew/george
2026-01-09 22:11:03 -08:00
committed by Steve Yegge
parent fc8718e680
commit b92e46474a

View File

@@ -2,9 +2,7 @@
package polecat package polecat
import ( import (
"encoding/json"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@@ -15,6 +13,7 @@ import (
) )
// PendingSpawn represents a polecat that has been spawned but not yet triggered. // PendingSpawn represents a polecat that has been spawned but not yet triggered.
// This is discovered from POLECAT_STARTED messages in the Deacon inbox (ZFC).
type PendingSpawn struct { type PendingSpawn struct {
// Rig is the rig name (e.g., "gastown") // Rig is the rig name (e.g., "gastown")
Rig string `json:"rig"` Rig string `json:"rig"`
@@ -28,52 +27,18 @@ type PendingSpawn struct {
// Issue is the assigned issue ID // Issue is the assigned issue ID
Issue string `json:"issue"` Issue string `json:"issue"`
// SpawnedAt is when the spawn was detected // SpawnedAt is when the spawn was detected (from mail timestamp)
SpawnedAt time.Time `json:"spawned_at"` SpawnedAt time.Time `json:"spawned_at"`
// MailID is the ID of the POLECAT_STARTED message // MailID is the ID of the POLECAT_STARTED message
MailID string `json:"mail_id"` MailID string `json:"mail_id"`
// mailbox is kept for archiving after trigger (not serialized)
mailbox *mail.Mailbox `json:"-"`
} }
// PendingFile returns the path to the pending spawns file. // CheckInboxForSpawns discovers pending spawns from POLECAT_STARTED messages
func PendingFile(townRoot string) string { // in the Deacon's inbox. Uses mail as source of truth (ZFC principle).
return filepath.Join(townRoot, "spawn", "pending.json")
}
// LoadPending loads the pending spawns from disk.
func LoadPending(townRoot string) ([]*PendingSpawn, error) {
path := PendingFile(townRoot)
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var pending []*PendingSpawn
if err := json.Unmarshal(data, &pending); err != nil {
return nil, err
}
return pending, nil
}
// SavePending saves the pending spawns to disk.
func SavePending(townRoot string, pending []*PendingSpawn) error {
path := PendingFile(townRoot)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(pending, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// CheckInboxForSpawns reads the Deacon's inbox for POLECAT_STARTED messages
// and adds them to the pending list.
func CheckInboxForSpawns(townRoot string) ([]*PendingSpawn, error) { func CheckInboxForSpawns(townRoot string) ([]*PendingSpawn, error) {
// Get Deacon's mailbox // Get Deacon's mailbox
router := mail.NewRouter(townRoot) router := mail.NewRouter(townRoot)
@@ -82,23 +47,13 @@ func CheckInboxForSpawns(townRoot string) ([]*PendingSpawn, error) {
return nil, fmt.Errorf("getting deacon mailbox: %w", err) return nil, fmt.Errorf("getting deacon mailbox: %w", err)
} }
// Get unread messages // Get all messages (both read and unread - we track by archival status)
messages, err := mailbox.ListUnread() messages, err := mailbox.List()
if err != nil { if err != nil {
return nil, fmt.Errorf("listing unread: %w", err) return nil, fmt.Errorf("listing messages: %w", err)
} }
// Load existing pending var pending []*PendingSpawn
pending, err := LoadPending(townRoot)
if err != nil {
return nil, fmt.Errorf("loading pending: %w", err)
}
// Track existing by mail ID to avoid duplicates
existing := make(map[string]bool)
for _, p := range pending {
existing[p.MailID] = true
}
// Look for POLECAT_STARTED messages // Look for POLECAT_STARTED messages
for _, msg := range messages { for _, msg := range messages {
@@ -106,11 +61,6 @@ func CheckInboxForSpawns(townRoot string) ([]*PendingSpawn, error) {
continue continue
} }
// Skip if already tracked
if existing[msg.ID] {
continue
}
// Parse subject: "POLECAT_STARTED rig/polecat" // Parse subject: "POLECAT_STARTED rig/polecat"
parts := strings.SplitN(strings.TrimPrefix(msg.Subject, "POLECAT_STARTED "), "/", 2) parts := strings.SplitN(strings.TrimPrefix(msg.Subject, "POLECAT_STARTED "), "/", 2)
if len(parts) != 2 { if len(parts) != 2 {
@@ -138,17 +88,9 @@ func CheckInboxForSpawns(townRoot string) ([]*PendingSpawn, error) {
Issue: issue, Issue: issue,
SpawnedAt: msg.Timestamp, SpawnedAt: msg.Timestamp,
MailID: msg.ID, MailID: msg.ID,
mailbox: mailbox,
} }
pending = append(pending, ps) pending = append(pending, ps)
existing[msg.ID] = true
// Mark message as read (non-fatal: message tracking)
_ = mailbox.MarkRead(msg.ID)
}
// Save updated pending list
if err := SavePending(townRoot, pending); err != nil {
return nil, fmt.Errorf("saving pending: %w", err)
} }
return pending, nil return pending, nil
@@ -162,11 +104,11 @@ type TriggerResult struct {
} }
// TriggerPendingSpawns polls each pending spawn and triggers when ready. // TriggerPendingSpawns polls each pending spawn and triggers when ready.
// Returns the spawns that were successfully triggered. // Archives mail after successful trigger (ZFC: mail is source of truth).
func TriggerPendingSpawns(townRoot string, timeout time.Duration) ([]TriggerResult, error) { func TriggerPendingSpawns(townRoot string, timeout time.Duration) ([]TriggerResult, error) {
pending, err := LoadPending(townRoot) pending, err := CheckInboxForSpawns(townRoot)
if err != nil { if err != nil {
return nil, fmt.Errorf("loading pending: %w", err) return nil, fmt.Errorf("checking inbox: %w", err)
} }
if len(pending) == 0 { if len(pending) == 0 {
@@ -175,23 +117,24 @@ func TriggerPendingSpawns(townRoot string, timeout time.Duration) ([]TriggerResu
t := tmux.NewTmux() t := tmux.NewTmux()
var results []TriggerResult var results []TriggerResult
var remaining []*PendingSpawn
for _, ps := range pending { for _, ps := range pending {
result := TriggerResult{Spawn: ps} result := TriggerResult{Spawn: ps}
// Check if session still exists // Check if session still exists (ZFC: query tmux directly)
running, err := t.HasSession(ps.Session) running, err := t.HasSession(ps.Session)
if err != nil { if err != nil {
result.Error = fmt.Errorf("checking session: %w", err) result.Error = fmt.Errorf("checking session: %w", err)
results = append(results, result) results = append(results, result)
remaining = append(remaining, ps)
continue continue
} }
if !running { if !running {
// Session gone - remove from pending // Session gone - archive the mail (spawn is dead)
result.Error = fmt.Errorf("session no longer exists") result.Error = fmt.Errorf("session no longer exists")
if ps.mailbox != nil {
_ = ps.mailbox.Archive(ps.MailID)
}
results = append(results, result) results = append(results, result)
continue continue
} }
@@ -201,8 +144,7 @@ func TriggerPendingSpawns(townRoot string, timeout time.Duration) ([]TriggerResu
runtimeConfig := config.LoadRuntimeConfig(rigPath) runtimeConfig := config.LoadRuntimeConfig(rigPath)
err = t.WaitForRuntimeReady(ps.Session, runtimeConfig, timeout) err = t.WaitForRuntimeReady(ps.Session, runtimeConfig, timeout)
if err != nil { if err != nil {
// Not ready yet - keep in pending // Not ready yet - leave mail in inbox for next poll
remaining = append(remaining, ps)
continue continue
} }
@@ -211,46 +153,38 @@ func TriggerPendingSpawns(townRoot string, timeout time.Duration) ([]TriggerResu
if err := t.NudgeSession(ps.Session, triggerMsg); err != nil { if err := t.NudgeSession(ps.Session, triggerMsg); err != nil {
result.Error = fmt.Errorf("nudging session: %w", err) result.Error = fmt.Errorf("nudging session: %w", err)
results = append(results, result) results = append(results, result)
remaining = append(remaining, ps)
continue continue
} }
// Successfully triggered // Successfully triggered - archive the mail
result.Triggered = true result.Triggered = true
if ps.mailbox != nil {
_ = ps.mailbox.Archive(ps.MailID)
}
results = append(results, result) results = append(results, result)
} }
// Save remaining (untriggered) spawns
if err := SavePending(townRoot, remaining); err != nil {
return results, fmt.Errorf("saving remaining: %w", err)
}
return results, nil return results, nil
} }
// PruneStalePending removes pending spawns older than the given age. // PruneStalePending archives POLECAT_STARTED messages older than the given age.
// Spawns that are too old likely had their sessions die. // Old spawns likely had their sessions die without triggering.
func PruneStalePending(townRoot string, maxAge time.Duration) (int, error) { func PruneStalePending(townRoot string, maxAge time.Duration) (int, error) {
pending, err := LoadPending(townRoot) pending, err := CheckInboxForSpawns(townRoot)
if err != nil { if err != nil {
return 0, err return 0, err
} }
cutoff := time.Now().Add(-maxAge) cutoff := time.Now().Add(-maxAge)
var remaining []*PendingSpawn
pruned := 0 pruned := 0
for _, ps := range pending { for _, ps := range pending {
if ps.SpawnedAt.Before(cutoff) { if ps.SpawnedAt.Before(cutoff) {
// Archive stale spawn message
if ps.mailbox != nil {
_ = ps.mailbox.Archive(ps.MailID)
}
pruned++ pruned++
} else {
remaining = append(remaining, ps)
}
}
if pruned > 0 {
if err := SavePending(townRoot, remaining); err != nil {
return pruned, err
} }
} }