From d42a9bd6e0ea1cce5c0dc62dff2018ecfa7b9b14 Mon Sep 17 00:00:00 2001 From: gastown/crew/gus Date: Fri, 16 Jan 2026 15:18:54 -0800 Subject: [PATCH] fix(polecat): validate issue exists before starting session Add validateIssue() to check that an issue exists and is not tombstoned before creating the tmux session. This prevents CPU spin loops from agents retrying work on invalid issues. Fixes #569 Co-Authored-By: Claude Opus 4.5 --- internal/polecat/session_manager.go | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/internal/polecat/session_manager.go b/internal/polecat/session_manager.go index f9b9c6ad..a77f646f 100644 --- a/internal/polecat/session_manager.go +++ b/internal/polecat/session_manager.go @@ -2,6 +2,7 @@ package polecat import ( + "encoding/json" "errors" "fmt" "os" @@ -29,6 +30,7 @@ func debugSession(context string, err error) { var ( ErrSessionRunning = errors.New("session already running") ErrSessionNotFound = errors.New("session not found") + ErrIssueInvalid = errors.New("issue not found or tombstoned") ) // SessionManager handles polecat session lifecycle. @@ -161,6 +163,14 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error { workDir = m.clonePath(polecat) } + // Validate issue exists and isn't tombstoned BEFORE creating session. + // This prevents CPU spin loops from agents retrying work on invalid issues. + if opts.Issue != "" { + if err := m.validateIssue(opts.Issue, workDir); err != nil { + return err + } + } + runtimeConfig := config.LoadRuntimeConfig(m.rig.Path) // Ensure runtime settings exist in polecats/ (not polecats//) so we don't @@ -449,6 +459,32 @@ func (m *SessionManager) StopAll(force bool) error { return lastErr } +// validateIssue checks that an issue exists and is not tombstoned. +// This must be called before starting a session to avoid CPU spin loops +// from agents retrying work on invalid issues. +func (m *SessionManager) validateIssue(issueID, workDir string) error { + cmd := exec.Command("bd", "show", issueID, "--json") //nolint:gosec + cmd.Dir = workDir + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("%w: %s", ErrIssueInvalid, issueID) + } + + var issues []struct { + Status string `json:"status"` + } + if err := json.Unmarshal(output, &issues); err != nil { + return fmt.Errorf("parsing issue: %w", err) + } + if len(issues) == 0 { + return fmt.Errorf("%w: %s", ErrIssueInvalid, issueID) + } + if issues[0].Status == "tombstone" { + return fmt.Errorf("%w: %s is tombstoned", ErrIssueInvalid, issueID) + } + return nil +} + // hookIssue pins an issue to a polecat's hook using bd update. func (m *SessionManager) hookIssue(issueID, agentID, workDir string) error { cmd := exec.Command("bd", "update", issueID, "--status=hooked", "--assignee="+agentID) //nolint:gosec