feat(doctor): add orphaned-attachments check (gt-h6eq.3)
Add doctor check to detect handoff beads for agents that no longer exist. This happens when a polecat worktree is deleted but its handoff bead remains. - Check: orphaned-attachments - Warning if: Handoff bead exists for agent that no longer has worktree - Supports polecats (rig/name), crew (rig/crew/name), mayor, witness, refinery - Suggests re-sling to active agent or close the molecule 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -102,6 +102,7 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
|||||||
// Hook attachment checks
|
// Hook attachment checks
|
||||||
d.Register(doctor.NewHookAttachmentValidCheck())
|
d.Register(doctor.NewHookAttachmentValidCheck())
|
||||||
d.Register(doctor.NewHookSingletonCheck())
|
d.Register(doctor.NewHookSingletonCheck())
|
||||||
|
d.Register(doctor.NewOrphanedAttachmentsCheck())
|
||||||
|
|
||||||
// Run checks
|
// Run checks
|
||||||
var report *doctor.Report
|
var report *doctor.Report
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package doctor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -323,3 +324,172 @@ func (c *HookSingletonCheck) Fix(ctx *CheckContext) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OrphanedAttachmentsCheck detects handoff beads for agents that no longer exist.
|
||||||
|
// This happens when a polecat worktree is deleted but its handoff bead remains,
|
||||||
|
// leaving molecules attached to non-existent agents.
|
||||||
|
type OrphanedAttachmentsCheck struct {
|
||||||
|
BaseCheck
|
||||||
|
orphans []orphanedHandoff
|
||||||
|
}
|
||||||
|
|
||||||
|
type orphanedHandoff struct {
|
||||||
|
beadID string
|
||||||
|
beadTitle string
|
||||||
|
beadsDir string
|
||||||
|
agent string // Parsed agent identity
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOrphanedAttachmentsCheck creates a new orphaned attachments check.
|
||||||
|
func NewOrphanedAttachmentsCheck() *OrphanedAttachmentsCheck {
|
||||||
|
return &OrphanedAttachmentsCheck{
|
||||||
|
BaseCheck: BaseCheck{
|
||||||
|
CheckName: "orphaned-attachments",
|
||||||
|
CheckDescription: "Detect handoff beads for non-existent agents",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run checks all handoff beads for orphaned agents.
|
||||||
|
func (c *OrphanedAttachmentsCheck) Run(ctx *CheckContext) *CheckResult {
|
||||||
|
c.orphans = nil
|
||||||
|
|
||||||
|
var details []string
|
||||||
|
|
||||||
|
// Check town-level beads
|
||||||
|
townBeadsDir := filepath.Join(ctx.TownRoot, ".beads")
|
||||||
|
townOrphans := c.checkBeadsDir(townBeadsDir, ctx.TownRoot)
|
||||||
|
for _, orph := range townOrphans {
|
||||||
|
details = append(details, c.formatOrphan(orph))
|
||||||
|
}
|
||||||
|
c.orphans = append(c.orphans, townOrphans...)
|
||||||
|
|
||||||
|
// Check rig-level beads using the shared helper
|
||||||
|
attachCheck := &HookAttachmentValidCheck{}
|
||||||
|
rigDirs := attachCheck.findRigBeadsDirs(ctx.TownRoot)
|
||||||
|
for _, rigDir := range rigDirs {
|
||||||
|
rigOrphans := c.checkBeadsDir(rigDir, ctx.TownRoot)
|
||||||
|
for _, orph := range rigOrphans {
|
||||||
|
details = append(details, c.formatOrphan(orph))
|
||||||
|
}
|
||||||
|
c.orphans = append(c.orphans, rigOrphans...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.orphans) == 0 {
|
||||||
|
return &CheckResult{
|
||||||
|
Name: c.Name(),
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "No orphaned handoff beads found",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CheckResult{
|
||||||
|
Name: c.Name(),
|
||||||
|
Status: StatusWarning,
|
||||||
|
Message: fmt.Sprintf("Found %d orphaned handoff bead(s)", len(c.orphans)),
|
||||||
|
Details: details,
|
||||||
|
FixHint: "Re-sling molecule to active agent with 'gt sling', or close with 'bd close <id>'",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkBeadsDir checks for orphaned handoff beads in a directory.
|
||||||
|
func (c *OrphanedAttachmentsCheck) checkBeadsDir(beadsDir, townRoot string) []orphanedHandoff {
|
||||||
|
var orphans []orphanedHandoff
|
||||||
|
|
||||||
|
b := beads.New(filepath.Dir(beadsDir))
|
||||||
|
|
||||||
|
// List all pinned beads
|
||||||
|
pinnedBeads, err := b.List(beads.ListOptions{
|
||||||
|
Status: beads.StatusPinned,
|
||||||
|
Priority: -1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bead := range pinnedBeads {
|
||||||
|
// Check if title matches handoff pattern (ends with " Handoff")
|
||||||
|
if !strings.HasSuffix(bead.Title, " Handoff") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract agent identity from title
|
||||||
|
agent := strings.TrimSuffix(bead.Title, " Handoff")
|
||||||
|
if agent == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if agent worktree exists
|
||||||
|
if !c.agentExists(agent, townRoot) {
|
||||||
|
orphans = append(orphans, orphanedHandoff{
|
||||||
|
beadID: bead.ID,
|
||||||
|
beadTitle: bead.Title,
|
||||||
|
beadsDir: beadsDir,
|
||||||
|
agent: agent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return orphans
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentExists checks if an agent's worktree exists.
|
||||||
|
// Agent identities follow patterns like:
|
||||||
|
// - "gastown/nux" → polecat at <townRoot>/gastown/polecats/nux
|
||||||
|
// - "gastown/crew/joe" → crew at <townRoot>/gastown/crew/joe
|
||||||
|
// - "mayor" → mayor at <townRoot>/mayor
|
||||||
|
// - "gastown-witness" → witness at <townRoot>/gastown/witness
|
||||||
|
// - "gastown-refinery" → refinery at <townRoot>/gastown/refinery
|
||||||
|
func (c *OrphanedAttachmentsCheck) agentExists(agent, townRoot string) bool {
|
||||||
|
// Handle special roles with hyphen separator
|
||||||
|
if strings.HasSuffix(agent, "-witness") {
|
||||||
|
rig := strings.TrimSuffix(agent, "-witness")
|
||||||
|
path := filepath.Join(townRoot, rig, "witness")
|
||||||
|
return dirExists(path)
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(agent, "-refinery") {
|
||||||
|
rig := strings.TrimSuffix(agent, "-refinery")
|
||||||
|
path := filepath.Join(townRoot, rig, "refinery")
|
||||||
|
return dirExists(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle mayor
|
||||||
|
if agent == "mayor" {
|
||||||
|
return dirExists(filepath.Join(townRoot, "mayor"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle crew (rig/crew/name pattern)
|
||||||
|
if strings.Contains(agent, "/crew/") {
|
||||||
|
parts := strings.SplitN(agent, "/crew/", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
path := filepath.Join(townRoot, parts[0], "crew", parts[1])
|
||||||
|
return dirExists(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle polecats (rig/name pattern) - most common case
|
||||||
|
if strings.Contains(agent, "/") {
|
||||||
|
parts := strings.SplitN(agent, "/", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
path := filepath.Join(townRoot, parts[0], "polecats", parts[1])
|
||||||
|
return dirExists(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown pattern - assume exists to avoid false positives
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// dirExists checks if a directory exists.
|
||||||
|
func dirExists(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatOrphan formats an orphaned handoff for display.
|
||||||
|
func (c *OrphanedAttachmentsCheck) formatOrphan(orph orphanedHandoff) string {
|
||||||
|
return fmt.Sprintf("%s: agent %q no longer exists", orph.beadID, orph.agent)
|
||||||
|
}
|
||||||
|
|||||||
@@ -202,3 +202,117 @@ func TestHookSingletonCheck_FormatDuplicate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tests for OrphanedAttachmentsCheck
|
||||||
|
|
||||||
|
func TestNewOrphanedAttachmentsCheck(t *testing.T) {
|
||||||
|
check := NewOrphanedAttachmentsCheck()
|
||||||
|
|
||||||
|
if check.Name() != "orphaned-attachments" {
|
||||||
|
t.Errorf("expected name 'orphaned-attachments', got %q", check.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
if check.Description() != "Detect handoff beads for non-existent agents" {
|
||||||
|
t.Errorf("unexpected description: %q", check.Description())
|
||||||
|
}
|
||||||
|
|
||||||
|
// This check is not auto-fixable (uses BaseCheck, not FixableCheck)
|
||||||
|
if check.CanFix() {
|
||||||
|
t.Error("expected CanFix to return false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrphanedAttachmentsCheck_NoBeadsDir(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
check := NewOrphanedAttachmentsCheck()
|
||||||
|
ctx := &CheckContext{TownRoot: tmpDir}
|
||||||
|
|
||||||
|
result := check.Run(ctx)
|
||||||
|
|
||||||
|
// No beads dir means nothing to check, should be OK
|
||||||
|
if result.Status != StatusOK {
|
||||||
|
t.Errorf("expected StatusOK when no beads dir, got %v", result.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrphanedAttachmentsCheck_FormatOrphan(t *testing.T) {
|
||||||
|
check := NewOrphanedAttachmentsCheck()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
orph orphanedHandoff
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
orph: orphanedHandoff{
|
||||||
|
beadID: "hq-123",
|
||||||
|
agent: "gastown/nux",
|
||||||
|
},
|
||||||
|
expected: `hq-123: agent "gastown/nux" no longer exists`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
orph: orphanedHandoff{
|
||||||
|
beadID: "gt-456",
|
||||||
|
agent: "gastown/crew/joe",
|
||||||
|
},
|
||||||
|
expected: `gt-456: agent "gastown/crew/joe" no longer exists`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
result := check.formatOrphan(tt.orph)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("formatOrphan() = %q, want %q", result, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrphanedAttachmentsCheck_AgentExists(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create some agent directories
|
||||||
|
polecatDir := filepath.Join(tmpDir, "gastown", "polecats", "nux")
|
||||||
|
if err := os.MkdirAll(polecatDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
crewDir := filepath.Join(tmpDir, "gastown", "crew", "joe")
|
||||||
|
if err := os.MkdirAll(crewDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mayorDir := filepath.Join(tmpDir, "mayor")
|
||||||
|
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
witnessDir := filepath.Join(tmpDir, "gastown", "witness")
|
||||||
|
if err := os.MkdirAll(witnessDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
check := NewOrphanedAttachmentsCheck()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
agent string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
// Existing agents
|
||||||
|
{"gastown/nux", true},
|
||||||
|
{"gastown/crew/joe", true},
|
||||||
|
{"mayor", true},
|
||||||
|
{"gastown-witness", true},
|
||||||
|
|
||||||
|
// Non-existent agents
|
||||||
|
{"gastown/deleted", false},
|
||||||
|
{"gastown/crew/gone", false},
|
||||||
|
{"otherrig-witness", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
result := check.agentExists(tt.agent, tmpDir)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("agentExists(%q) = %v, want %v", tt.agent, result, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user