Add stale-attachments doctor check (gt-h6eq.4)

Implements a new doctor check that detects attached molecules that
haven't been updated in too long, which may indicate stuck work.
The check:
- Finds all pinned beads with attachments across rigs
- Checks the attached molecule's UpdatedAt timestamp
- Reports molecules in_progress with no activity for >1 hour
- Provides actionable fix hints for stuck polecats

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-23 11:34:25 -08:00
parent 9fa4a42030
commit f2266db12d
2 changed files with 286 additions and 0 deletions

View File

@@ -85,6 +85,9 @@ func runDoctor(cmd *cobra.Command, args []string) error {
d.Register(doctor.NewPatrolPluginsAccessibleCheck())
d.Register(doctor.NewPatrolRolesHavePromptsCheck())
// Attachment checks
d.Register(doctor.NewStaleAttachmentsCheck())
// Config architecture checks
d.Register(doctor.NewSettingsCheck())
d.Register(doctor.NewRuntimeGitignoreCheck())

View File

@@ -0,0 +1,283 @@
package doctor
import (
"fmt"
"path/filepath"
"time"
"github.com/steveyegge/gastown/internal/beads"
)
// DefaultStaleThreshold is the default time after which an attachment is considered stale.
// Attachments with no molecule activity in this duration may indicate stuck work.
const DefaultStaleThreshold = 1 * time.Hour
// StaleAttachmentsCheck detects attached molecules that haven't been updated in too long.
// This may indicate stuck work - a polecat that crashed or got stuck during processing.
type StaleAttachmentsCheck struct {
BaseCheck
Threshold time.Duration // Configurable staleness threshold
}
// NewStaleAttachmentsCheck creates a new stale attachments check with the default threshold.
func NewStaleAttachmentsCheck() *StaleAttachmentsCheck {
return NewStaleAttachmentsCheckWithThreshold(DefaultStaleThreshold)
}
// NewStaleAttachmentsCheckWithThreshold creates a new stale attachments check with a custom threshold.
func NewStaleAttachmentsCheckWithThreshold(threshold time.Duration) *StaleAttachmentsCheck {
return &StaleAttachmentsCheck{
BaseCheck: BaseCheck{
CheckName: "stale-attachments",
CheckDescription: "Check for attached molecules that haven't been updated in too long",
},
Threshold: threshold,
}
}
// StaleAttachment represents a single stale attachment finding.
type StaleAttachment struct {
Rig string
PinnedBeadID string
PinnedTitle string
Assignee string
MoleculeID string
MoleculeTitle string
LastUpdated time.Time
StaleDuration time.Duration
}
// Run checks for stale attachments across all rigs.
func (c *StaleAttachmentsCheck) Run(ctx *CheckContext) *CheckResult {
// If a specific rig is specified, only check that one
var rigsToCheck []string
if ctx.RigName != "" {
rigsToCheck = []string{ctx.RigName}
} else {
// Discover all rigs
rigs, err := discoverRigs(ctx.TownRoot)
if err != nil {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "Failed to discover rigs",
Details: []string{err.Error()},
}
}
rigsToCheck = rigs
}
if len(rigsToCheck) == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No rigs configured",
}
}
// Find stale attachments across all rigs
var staleAttachments []StaleAttachment
var checkedCount int
cutoff := time.Now().Add(-c.Threshold)
for _, rigName := range rigsToCheck {
stale, checked, err := c.checkRig(ctx.TownRoot, rigName, cutoff)
if err != nil {
// Log but continue with other rigs
continue
}
staleAttachments = append(staleAttachments, stale...)
checkedCount += checked
}
// Also check town-level beads for pinned attachments
townStale, townChecked, err := c.checkBeadsDir(ctx.TownRoot, filepath.Join(ctx.TownRoot, ".beads"), cutoff)
if err == nil {
staleAttachments = append(staleAttachments, townStale...)
checkedCount += townChecked
}
if len(staleAttachments) > 0 {
details := make([]string, 0, len(staleAttachments))
for _, sa := range staleAttachments {
location := sa.Rig
if location == "" {
location = "town"
}
assigneeInfo := ""
if sa.Assignee != "" {
assigneeInfo = fmt.Sprintf(" (assignee: %s)", sa.Assignee)
}
details = append(details, fmt.Sprintf("%s: %s → %s%s (stale for %s)",
location, sa.PinnedTitle, sa.MoleculeTitle, assigneeInfo, formatDuration(sa.StaleDuration)))
}
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: fmt.Sprintf("%d stale attachment(s) found (no activity for >%s)", len(staleAttachments), formatDuration(c.Threshold)),
Details: details,
FixHint: "Check if polecats are stuck or crashed. Use 'gt witness nudge <polecat>' or 'gt polecat kill <name>' if needed",
}
}
if checkedCount == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No attachments to check",
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: fmt.Sprintf("Checked %d attachment(s), none stale", checkedCount),
}
}
// checkRig checks a single rig for stale attachments.
func (c *StaleAttachmentsCheck) checkRig(townRoot, rigName string, cutoff time.Time) ([]StaleAttachment, int, error) {
// Check rig-level beads and polecats
rigPath := filepath.Join(townRoot, rigName)
// Each polecat has its own beads directory
polecatsDir := filepath.Join(rigPath, "polecats")
polecatDirs, err := filepath.Glob(filepath.Join(polecatsDir, "*", ".beads"))
if err != nil {
return nil, 0, err
}
var allStale []StaleAttachment
var totalChecked int
for _, beadsPath := range polecatDirs {
// Extract polecat name from path
polecatPath := filepath.Dir(beadsPath)
polecatName := filepath.Base(polecatPath)
stale, checked, err := c.checkBeadsDirWithContext(rigPath, beadsPath, cutoff, rigName, polecatName)
if err != nil {
continue
}
allStale = append(allStale, stale...)
totalChecked += checked
}
// Also check rig-level beads (crew workers, etc.)
crewDir := filepath.Join(rigPath, "crew")
crewDirs, err := filepath.Glob(filepath.Join(crewDir, "*", ".beads"))
if err == nil {
for _, beadsPath := range crewDirs {
workerPath := filepath.Dir(beadsPath)
workerName := filepath.Base(workerPath)
stale, checked, err := c.checkBeadsDirWithContext(rigPath, beadsPath, cutoff, rigName, "crew/"+workerName)
if err != nil {
continue
}
allStale = append(allStale, stale...)
totalChecked += checked
}
}
return allStale, totalChecked, nil
}
// checkBeadsDir checks a beads directory for stale attachments.
func (c *StaleAttachmentsCheck) checkBeadsDir(townRoot, beadsDir string, cutoff time.Time) ([]StaleAttachment, int, error) {
return c.checkBeadsDirWithContext(townRoot, beadsDir, cutoff, "", "")
}
// checkBeadsDirWithContext checks a beads directory for stale attachments with rig context.
func (c *StaleAttachmentsCheck) checkBeadsDirWithContext(workDir, beadsDir string, cutoff time.Time, rigName, workerName string) ([]StaleAttachment, int, error) {
// Create beads client for the directory containing .beads
parentDir := filepath.Dir(beadsDir)
bd := beads.New(parentDir)
// List all pinned beads (attachments are stored on pinned beads)
pinnedIssues, err := bd.List(beads.ListOptions{
Status: beads.StatusPinned,
Priority: -1, // No filter
})
if err != nil {
return nil, 0, err
}
var staleAttachments []StaleAttachment
var checked int
for _, pinned := range pinnedIssues {
// Parse attachment fields
attachment := beads.ParseAttachmentFields(pinned)
if attachment == nil || attachment.AttachedMolecule == "" {
continue // No attachment
}
checked++
// Fetch the attached molecule to check its updated_at timestamp
mol, err := bd.Show(attachment.AttachedMolecule)
if err != nil {
// Molecule might have been deleted or is inaccessible
// This itself could be a problem worth reporting
staleAttachments = append(staleAttachments, StaleAttachment{
Rig: rigName,
PinnedBeadID: pinned.ID,
PinnedTitle: pinned.Title,
Assignee: pinned.Assignee,
MoleculeID: attachment.AttachedMolecule,
MoleculeTitle: "(molecule not found)",
LastUpdated: time.Time{},
StaleDuration: time.Since(cutoff) + c.Threshold, // Report as stale
})
continue
}
// Parse the molecule's updated_at timestamp
updatedAt, err := parseTimestamp(mol.UpdatedAt)
if err != nil {
continue // Skip if we can't parse the timestamp
}
// Check if the molecule is stale (hasn't been updated since cutoff)
// Only check molecules that are still in progress
if mol.Status == "in_progress" && updatedAt.Before(cutoff) {
staleAttachments = append(staleAttachments, StaleAttachment{
Rig: rigName,
PinnedBeadID: pinned.ID,
PinnedTitle: pinned.Title,
Assignee: pinned.Assignee,
MoleculeID: mol.ID,
MoleculeTitle: mol.Title,
LastUpdated: updatedAt,
StaleDuration: time.Since(updatedAt),
})
}
}
return staleAttachments, checked, nil
}
// parseTimestamp parses an ISO 8601 timestamp string.
func parseTimestamp(ts string) (time.Time, error) {
// Try RFC3339 first (most common)
t, err := time.Parse(time.RFC3339, ts)
if err == nil {
return t, nil
}
// Try without timezone
t, err = time.Parse("2006-01-02T15:04:05", ts)
if err == nil {
return t, nil
}
// Try date only
t, err = time.Parse("2006-01-02", ts)
if err == nil {
return t, nil
}
return time.Time{}, fmt.Errorf("unable to parse timestamp: %s", ts)
}