Skip to content

Commit bdf9af2

Browse files
julianknutsenclaude
andcommitted
fix: address review findings for gt deacon pending command
1. MAJOR: Add PruneStalePending call to runDeaconPending no-arg path 2. MAJOR: Add comprehensive tests with archive side-effect verification 3. MINOR: ClearPendingSpawn now clears ALL matching sessions (not just first) 4. MINOR: ClearPendingSpawn is idempotent (no error if session not found) 5. MINOR: nil mailbox now returns error instead of silent success Refactored pending.go to extract mailbox-dependent logic into testable internal functions (checkMailboxForSpawns, clearPendingSpawnFromList, pruneStalePendingFromList) while keeping public API unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 471473c commit bdf9af2

File tree

3 files changed

+548
-27
lines changed

3 files changed

+548
-27
lines changed

internal/cmd/deacon.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,6 +1180,15 @@ func runDeaconPending(cmd *cobra.Command, args []string) error {
11801180
}
11811181

11821182
// No args: list pending spawns with captured output
1183+
// First, prune stale pending spawns (>5 min old with dead sessions)
1184+
pruned, err := polecat.PruneStalePending(townRoot, 5*time.Minute)
1185+
if err != nil {
1186+
// Non-fatal: log and continue
1187+
fmt.Printf("%s Warning: prune stale pending: %v\n", style.Dim.Render("⚠"), err)
1188+
} else if pruned > 0 {
1189+
fmt.Printf("%s Pruned %d stale pending spawn(s)\n", style.Dim.Render("♻"), pruned)
1190+
}
1191+
11831192
pending, err := polecat.CheckInboxForSpawns(townRoot)
11841193
if err != nil {
11851194
return fmt.Errorf("checking inbox: %w", err)

internal/polecat/pending.go

Lines changed: 52 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -37,25 +37,26 @@ type PendingSpawn struct {
3737
mailbox *mail.Mailbox `json:"-"`
3838
}
3939

40-
// CheckInboxForSpawns discovers pending spawns from POLECAT_STARTED messages
41-
// in the Deacon's inbox. Uses mail as source of truth (ZFC principle).
42-
func CheckInboxForSpawns(townRoot string) ([]*PendingSpawn, error) {
43-
// Get Deacon's mailbox
40+
// getDeaconMailbox resolves the Deacon's mailbox from a town root.
41+
func getDeaconMailbox(townRoot string) (*mail.Mailbox, error) {
4442
router := mail.NewRouter(townRoot)
4543
mailbox, err := router.GetMailbox("deacon/")
4644
if err != nil {
4745
return nil, fmt.Errorf("getting deacon mailbox: %w", err)
4846
}
47+
return mailbox, nil
48+
}
4949

50-
// Get all messages (both read and unread - we track by archival status)
50+
// checkMailboxForSpawns discovers pending spawns from POLECAT_STARTED messages
51+
// in the given mailbox. Extracted from CheckInboxForSpawns for testability.
52+
func checkMailboxForSpawns(mailbox *mail.Mailbox) ([]*PendingSpawn, error) {
5153
messages, err := mailbox.List()
5254
if err != nil {
5355
return nil, fmt.Errorf("listing messages: %w", err)
5456
}
5557

5658
var pending []*PendingSpawn
5759

58-
// Look for POLECAT_STARTED messages
5960
for _, msg := range messages {
6061
if !strings.HasPrefix(msg.Subject, "POLECAT_STARTED ") {
6162
continue
@@ -96,6 +97,16 @@ func CheckInboxForSpawns(townRoot string) ([]*PendingSpawn, error) {
9697
return pending, nil
9798
}
9899

100+
// CheckInboxForSpawns discovers pending spawns from POLECAT_STARTED messages
101+
// in the Deacon's inbox. Uses mail as source of truth (ZFC principle).
102+
func CheckInboxForSpawns(townRoot string) ([]*PendingSpawn, error) {
103+
mailbox, err := getDeaconMailbox(townRoot)
104+
if err != nil {
105+
return nil, err
106+
}
107+
return checkMailboxForSpawns(mailbox)
108+
}
109+
99110
// TriggerResult holds the result of attempting to trigger a pending spawn.
100111
type TriggerResult struct {
101112
Spawn *PendingSpawn
@@ -167,47 +178,61 @@ func TriggerPendingSpawns(townRoot string, timeout time.Duration) ([]TriggerResu
167178
return results, nil
168179
}
169180

170-
// ClearPendingSpawn archives the POLECAT_STARTED message for a specific session,
171-
// removing it from the pending list. Used by the Deacon after observing that a
172-
// session has been triggered via AI-based observation.
173-
func ClearPendingSpawn(townRoot, sessionName string) error {
174-
pending, err := CheckInboxForSpawns(townRoot)
175-
if err != nil {
176-
return fmt.Errorf("checking inbox: %w", err)
177-
}
178-
181+
// clearPendingSpawnFromList archives all POLECAT_STARTED messages for a specific
182+
// session from the given pending list. Extracted for testability.
183+
func clearPendingSpawnFromList(pending []*PendingSpawn, sessionName string) error {
179184
for _, ps := range pending {
180185
if ps.Session == sessionName {
181-
if ps.mailbox != nil {
182-
return ps.mailbox.Archive(ps.MailID)
186+
if ps.mailbox == nil {
187+
return fmt.Errorf("nil mailbox for pending spawn session %s (mail_id: %s)", sessionName, ps.MailID)
188+
}
189+
if err := ps.mailbox.Archive(ps.MailID); err != nil {
190+
return fmt.Errorf("archiving mail %s for session %s: %w", ps.MailID, sessionName, err)
183191
}
184-
return nil
185192
}
186193
}
187-
188-
return fmt.Errorf("no pending spawn found for session: %s", sessionName)
194+
return nil
189195
}
190196

191-
// PruneStalePending archives POLECAT_STARTED messages older than the given age.
192-
// Old spawns likely had their sessions die without triggering.
193-
func PruneStalePending(townRoot string, maxAge time.Duration) (int, error) {
197+
// ClearPendingSpawn archives all POLECAT_STARTED messages for a specific session,
198+
// removing it from the pending list. Used by the Deacon after observing that a
199+
// session has been triggered via AI-based observation.
200+
// Idempotent: returns nil if no matching pending spawn is found (race-safe).
201+
func ClearPendingSpawn(townRoot, sessionName string) error {
194202
pending, err := CheckInboxForSpawns(townRoot)
195203
if err != nil {
196-
return 0, err
204+
return fmt.Errorf("checking inbox: %w", err)
197205
}
206+
return clearPendingSpawnFromList(pending, sessionName)
207+
}
198208

209+
// pruneStalePendingFromList archives POLECAT_STARTED messages older than the
210+
// given age from the provided pending list. Extracted for testability.
211+
func pruneStalePendingFromList(pending []*PendingSpawn, maxAge time.Duration) (int, error) {
199212
cutoff := time.Now().Add(-maxAge)
200213
pruned := 0
201214

202215
for _, ps := range pending {
203216
if ps.SpawnedAt.Before(cutoff) {
204-
// Archive stale spawn message
205-
if ps.mailbox != nil {
206-
_ = ps.mailbox.Archive(ps.MailID)
217+
if ps.mailbox == nil {
218+
return pruned, fmt.Errorf("nil mailbox for stale pending spawn (mail_id: %s)", ps.MailID)
219+
}
220+
if err := ps.mailbox.Archive(ps.MailID); err != nil {
221+
return pruned, fmt.Errorf("archiving stale mail %s: %w", ps.MailID, err)
207222
}
208223
pruned++
209224
}
210225
}
211226

212227
return pruned, nil
213228
}
229+
230+
// PruneStalePending archives POLECAT_STARTED messages older than the given age.
231+
// Old spawns likely had their sessions die without triggering.
232+
func PruneStalePending(townRoot string, maxAge time.Duration) (int, error) {
233+
pending, err := CheckInboxForSpawns(townRoot)
234+
if err != nil {
235+
return 0, err
236+
}
237+
return pruneStalePendingFromList(pending, maxAge)
238+
}

0 commit comments

Comments
 (0)