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:
committed by
Steve Yegge
parent
fc8718e680
commit
b92e46474a
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user