fix: gt unsling now correctly clears hooks for remote agents (gt-yea94)
The unsling command had two bugs: 1. It used the local beads directory instead of the target agent's rig 2. It looked for status=hooked beads instead of the agent bead's hook_bead field Changes: - unsling.go: Rewrote to use agent bead's hook_bead field (matches how sling works) - unsling.go: Now uses target agent's rig beads path, not local - status.go: Prefer SQLite columns (issue.HookBead, issue.AgentState) over parsing description text, with fallback for legacy beads - beads.go: Added AgentState field to Issue struct for SQLite column access This fixes the issue where `gt unsling gastown/crew/joe` would say "no work hooked" even when gt status showed joe had a hook. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
4bafbfb0f7
commit
121150b49f
@@ -103,8 +103,9 @@ type Issue struct {
|
|||||||
Labels []string `json:"labels,omitempty"`
|
Labels []string `json:"labels,omitempty"`
|
||||||
|
|
||||||
// Agent bead slots (type=agent only)
|
// Agent bead slots (type=agent only)
|
||||||
HookBead string `json:"hook_bead,omitempty"` // Current work attached to agent's hook
|
HookBead string `json:"hook_bead,omitempty"` // Current work attached to agent's hook
|
||||||
RoleBead string `json:"role_bead,omitempty"` // Role definition bead (shared)
|
RoleBead string `json:"role_bead,omitempty"` // Role definition bead (shared)
|
||||||
|
AgentState string `json:"agent_state,omitempty"` // Agent lifecycle state (spawning, working, done, stuck)
|
||||||
|
|
||||||
// Counts from list output
|
// Counts from list output
|
||||||
DependencyCount int `json:"dependency_count,omitempty"`
|
DependencyCount int `json:"dependency_count,omitempty"`
|
||||||
|
|||||||
+32
-20
@@ -626,16 +626,22 @@ func discoverGlobalAgents(allSessions map[string]bool, allAgentBeads map[string]
|
|||||||
|
|
||||||
// Look up agent bead from preloaded map (O(1))
|
// Look up agent bead from preloaded map (O(1))
|
||||||
if issue, ok := allAgentBeads[d.beadID]; ok {
|
if issue, ok := allAgentBeads[d.beadID]; ok {
|
||||||
fields := beads.ParseAgentFields(issue.Description)
|
// Prefer SQLite columns over description parsing
|
||||||
if fields != nil {
|
// HookBead column is authoritative (cleared by unsling)
|
||||||
agent.HookBead = fields.HookBead
|
agent.HookBead = issue.HookBead
|
||||||
agent.State = fields.AgentState
|
agent.State = issue.AgentState
|
||||||
if fields.HookBead != "" {
|
if agent.HookBead != "" {
|
||||||
agent.HasWork = true
|
agent.HasWork = true
|
||||||
// Get hook title from preloaded map
|
// Get hook title from preloaded map
|
||||||
if pinnedIssue, ok := allHookBeads[fields.HookBead]; ok {
|
if pinnedIssue, ok := allHookBeads[agent.HookBead]; ok {
|
||||||
agent.WorkTitle = pinnedIssue.Title
|
agent.WorkTitle = pinnedIssue.Title
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Fallback to description for legacy beads without SQLite columns
|
||||||
|
if agent.State == "" {
|
||||||
|
fields := beads.ParseAgentFields(issue.Description)
|
||||||
|
if fields != nil {
|
||||||
|
agent.State = fields.AgentState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -758,16 +764,22 @@ func discoverRigAgents(allSessions map[string]bool, r *rig.Rig, crews []string,
|
|||||||
|
|
||||||
// Look up agent bead from preloaded map (O(1))
|
// Look up agent bead from preloaded map (O(1))
|
||||||
if issue, ok := allAgentBeads[d.beadID]; ok {
|
if issue, ok := allAgentBeads[d.beadID]; ok {
|
||||||
fields := beads.ParseAgentFields(issue.Description)
|
// Prefer SQLite columns over description parsing
|
||||||
if fields != nil {
|
// HookBead column is authoritative (cleared by unsling)
|
||||||
agent.HookBead = fields.HookBead
|
agent.HookBead = issue.HookBead
|
||||||
agent.State = fields.AgentState
|
agent.State = issue.AgentState
|
||||||
if fields.HookBead != "" {
|
if agent.HookBead != "" {
|
||||||
agent.HasWork = true
|
agent.HasWork = true
|
||||||
// Get hook title from preloaded map
|
// Get hook title from preloaded map
|
||||||
if pinnedIssue, ok := allHookBeads[fields.HookBead]; ok {
|
if pinnedIssue, ok := allHookBeads[agent.HookBead]; ok {
|
||||||
agent.WorkTitle = pinnedIssue.Title
|
agent.WorkTitle = pinnedIssue.Title
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Fallback to description for legacy beads without SQLite columns
|
||||||
|
if agent.State == "" {
|
||||||
|
fields := beads.ParseAgentFields(issue.Description)
|
||||||
|
if fields != nil {
|
||||||
|
agent.State = fields.AgentState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-29
@@ -2,11 +2,14 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/beads"
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
"github.com/steveyegge/gastown/internal/events"
|
"github.com/steveyegge/gastown/internal/events"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
var unslingCmd = &cobra.Command{
|
var unslingCmd = &cobra.Command{
|
||||||
@@ -84,25 +87,39 @@ func runUnsling(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find beads directory
|
// Find town root and rig path for agent beads
|
||||||
workDir, err := findLocalBeadsDir()
|
townRoot, err := workspace.FindFromCwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("not in a beads workspace: %w", err)
|
return fmt.Errorf("finding town root: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
b := beads.New(workDir)
|
// Extract rig name from agent ID (e.g., "gastown/crew/joe" -> "gastown")
|
||||||
|
// For town-level agents like "mayor/", use town root
|
||||||
// Find hooked bead for this agent
|
rigName := strings.Split(agentID, "/")[0]
|
||||||
pinnedBeads, err := b.List(beads.ListOptions{
|
var beadsPath string
|
||||||
Status: beads.StatusHooked,
|
if rigName == "mayor" || rigName == "deacon" {
|
||||||
Assignee: agentID,
|
beadsPath = townRoot
|
||||||
Priority: -1,
|
} else {
|
||||||
})
|
beadsPath = filepath.Join(townRoot, rigName)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("checking hooked beads: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(pinnedBeads) == 0 {
|
b := beads.New(beadsPath)
|
||||||
|
|
||||||
|
// Convert agent ID to agent bead ID and look up the agent bead
|
||||||
|
agentBeadID := agentIDToBeadID(agentID)
|
||||||
|
if agentBeadID == "" {
|
||||||
|
return fmt.Errorf("could not convert agent ID %s to bead ID", agentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the agent bead to find current hook
|
||||||
|
agentBead, err := b.Show(agentBeadID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting agent bead %s: %w", agentBeadID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if agent has work hooked (via hook_bead field)
|
||||||
|
hookedBeadID := agentBead.HookBead
|
||||||
|
if hookedBeadID == "" {
|
||||||
if targetAgent != "" {
|
if targetAgent != "" {
|
||||||
fmt.Printf("%s No work hooked for %s\n", style.Dim.Render("ℹ"), agentID)
|
fmt.Printf("%s No work hooked for %s\n", style.Dim.Render("ℹ"), agentID)
|
||||||
} else {
|
} else {
|
||||||
@@ -111,42 +128,51 @@ func runUnsling(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
pinned := pinnedBeads[0]
|
|
||||||
|
|
||||||
// If specific bead requested, verify it matches
|
// If specific bead requested, verify it matches
|
||||||
if targetBeadID != "" && pinned.ID != targetBeadID {
|
if targetBeadID != "" && hookedBeadID != targetBeadID {
|
||||||
return fmt.Errorf("bead %s is not hooked (current hook: %s)", targetBeadID, pinned.ID)
|
return fmt.Errorf("bead %s is not hooked (current hook: %s)", targetBeadID, hookedBeadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the hooked bead to check completion and show title
|
||||||
|
hookedBead, err := b.Show(hookedBeadID)
|
||||||
|
if err != nil {
|
||||||
|
// Bead might be deleted - still allow unsling with --force
|
||||||
|
if !unslingForce {
|
||||||
|
return fmt.Errorf("getting hooked bead %s: %w\n Use --force to unsling anyway", hookedBeadID, err)
|
||||||
|
}
|
||||||
|
// Force mode - proceed without the bead details
|
||||||
|
hookedBead = &beads.Issue{ID: hookedBeadID, Title: "(unknown)"}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if work is complete (warn if not, unless --force)
|
// Check if work is complete (warn if not, unless --force)
|
||||||
isComplete, _ := checkPinnedBeadComplete(b, pinned)
|
isComplete := hookedBead.Status == "closed"
|
||||||
if !isComplete && !unslingForce {
|
if !isComplete && !unslingForce {
|
||||||
return fmt.Errorf("hooked work %s is incomplete (%s)\n Use --force to unsling anyway",
|
return fmt.Errorf("hooked work %s is incomplete (%s)\n Use --force to unsling anyway",
|
||||||
pinned.ID, pinned.Title)
|
hookedBeadID, hookedBead.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
if targetAgent != "" {
|
if targetAgent != "" {
|
||||||
fmt.Printf("%s Unslinging %s from %s...\n", style.Bold.Render("🪝"), pinned.ID, agentID)
|
fmt.Printf("%s Unslinging %s from %s...\n", style.Bold.Render("🪝"), hookedBeadID, agentID)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("%s Unslinging %s...\n", style.Bold.Render("🪝"), pinned.ID)
|
fmt.Printf("%s Unslinging %s...\n", style.Bold.Render("🪝"), hookedBeadID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if unslingDryRun {
|
if unslingDryRun {
|
||||||
fmt.Printf("Would run: bd update %s --status=open\n", pinned.ID)
|
fmt.Printf("Would clear hook_bead from agent bead %s\n", agentBeadID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unpin by setting status back to open
|
// Clear the hook by updating agent bead with empty hook_bead
|
||||||
status := "open"
|
emptyHook := ""
|
||||||
if err := b.Update(pinned.ID, beads.UpdateOptions{Status: &status}); err != nil {
|
if err := b.UpdateAgentState(agentBeadID, "running", &emptyHook); err != nil {
|
||||||
return fmt.Errorf("unhooking bead %s: %w", pinned.ID, err)
|
return fmt.Errorf("clearing hook from agent bead %s: %w", agentBeadID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log unhook event
|
// Log unhook event
|
||||||
_ = events.LogFeed(events.TypeUnhook, agentID, events.UnhookPayload(pinned.ID))
|
_ = events.LogFeed(events.TypeUnhook, agentID, events.UnhookPayload(hookedBeadID))
|
||||||
|
|
||||||
fmt.Printf("%s Work removed from hook\n", style.Bold.Render("✓"))
|
fmt.Printf("%s Work removed from hook\n", style.Bold.Render("✓"))
|
||||||
fmt.Printf(" Bead %s is now status=open\n", pinned.ID)
|
fmt.Printf(" Agent %s hook cleared (was: %s)\n", agentID, hookedBeadID)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user