From f2266db12dc8bf86d869e06d6b4f3015f475468a Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 23 Dec 2025 11:34:25 -0800 Subject: [PATCH] Add stale-attachments doctor check (gt-h6eq.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/cmd/doctor.go | 3 + internal/doctor/stale_check.go | 283 +++++++++++++++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 internal/doctor/stale_check.go diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index ee6ff0d7..a7d3e561 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -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()) diff --git a/internal/doctor/stale_check.go b/internal/doctor/stale_check.go new file mode 100644 index 00000000..5fbae89d --- /dev/null +++ b/internal/doctor/stale_check.go @@ -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 ' or 'gt polecat kill ' 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) +}