From 126ec84bb3ea6d36d87778a9f1bbddd61faec5ac Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Tue, 20 Jan 2026 19:57:28 -0800 Subject: [PATCH] fix(sling): check hooked status and send LIFECYCLE:Shutdown on --force (#828) * fix(sling): check hooked status and send LIFECYCLE:Shutdown on --force - Change sling validation to check both pinned and hooked status (was only checking pinned, likely a bug) - Add --force handling that sends LIFECYCLE:Shutdown message to witness when forcibly reassigning work from an already-hooked bead - Use existing LIFECYCLE:Shutdown protocol instead of new KILL_POLECAT - witness will auto-nuke if clean, or create cleanup wisp if dirty - Use agent.Self() to identify the requester (falls back to "unknown" for CLI users without GT_ROLE env vars) Co-Authored-By: Claude Opus 4.5 * fix: use env vars instead of undefined agent.Self() The agent.Self() function does not exist in the agent package. Replace with direct env var lookups for GT_POLECAT (when running as a polecat) or USER as fallback. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: julianknutsen Co-authored-by: Claude Opus 4.5 Co-authored-by: beads/crew/lizzy --- internal/cmd/sling.go | 53 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index 3bf896bb..3c32b43e 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/events" + "github.com/steveyegge/gastown/internal/mail" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/workspace" ) @@ -312,17 +313,63 @@ func runSling(cmd *cobra.Command, args []string) error { fmt.Printf("%s Slinging %s to %s...\n", style.Bold.Render("🎯"), beadID, targetAgent) } - // Check if bead is already pinned (guard against accidental re-sling) + // Check if bead is already assigned (guard against accidental re-sling) info, err := getBeadInfo(beadID) if err != nil { return fmt.Errorf("checking bead status: %w", err) } - if info.Status == "pinned" && !slingForce { + if (info.Status == "pinned" || info.Status == "hooked") && !slingForce { assignee := info.Assignee if assignee == "" { assignee = "(unknown)" } - return fmt.Errorf("bead %s is already pinned to %s\nUse --force to re-sling", beadID, assignee) + return fmt.Errorf("bead %s is already %s to %s\nUse --force to re-sling", beadID, info.Status, assignee) + } + + // Handle --force when bead is already hooked: send shutdown to old polecat and unhook + if info.Status == "hooked" && slingForce && info.Assignee != "" { + fmt.Printf("%s Bead already hooked to %s, forcing reassignment...\n", style.Warning.Render("⚠"), info.Assignee) + + // Determine requester identity from env vars, fall back to "gt-sling" + requester := "gt-sling" + if polecat := os.Getenv("GT_POLECAT"); polecat != "" { + requester = polecat + } else if user := os.Getenv("USER"); user != "" { + requester = user + } + + // Extract rig name from assignee (e.g., "gastown/polecats/Toast" -> "gastown") + assigneeParts := strings.Split(info.Assignee, "/") + if len(assigneeParts) >= 3 && assigneeParts[1] == "polecats" { + oldRigName := assigneeParts[0] + oldPolecatName := assigneeParts[2] + + // Send LIFECYCLE:Shutdown to witness - will auto-nuke if clean, + // otherwise create cleanup wisp for manual intervention + if townRoot != "" { + router := mail.NewRouter(townRoot) + shutdownMsg := &mail.Message{ + From: "gt-sling", + To: fmt.Sprintf("%s/witness", oldRigName), + Subject: fmt.Sprintf("LIFECYCLE:Shutdown %s", oldPolecatName), + Body: fmt.Sprintf("Reason: work_reassigned\nRequestedBy: %s\nBead: %s\nNewAssignee: %s", requester, beadID, targetAgent), + Type: mail.TypeTask, + Priority: mail.PriorityHigh, + } + if err := router.Send(shutdownMsg); err != nil { + fmt.Printf("%s Could not send shutdown to witness: %v\n", style.Dim.Render("Warning:"), err) + } else { + fmt.Printf("%s Sent LIFECYCLE:Shutdown to %s/witness for %s\n", style.Bold.Render("→"), oldRigName, oldPolecatName) + } + } + } + + // Unhook the bead from old owner (set status back to open) + unhookCmd := exec.Command("bd", "--no-daemon", "update", beadID, "--status=open", "--assignee=") + unhookCmd.Dir = beads.ResolveHookDir(townRoot, beadID, "") + if err := unhookCmd.Run(); err != nil { + fmt.Printf("%s Could not unhook bead from old owner: %v\n", style.Dim.Render("Warning:"), err) + } } // Auto-convoy: check if issue is already tracked by a convoy