fix(beads): add CreateOrReopenAgentBead for polecat re-spawn (#333)

When a polecat is nuked and re-spawned with the same name, CreateAgentBead
fails with a UNIQUE constraint error because the old agent bead exists as
a tombstone.

This adds CreateOrReopenAgentBead that:
1. First tries to create the agent bead normally
2. If UNIQUE constraint fails, reopens the existing bead and updates fields

Updated both spawn paths in polecat manager to use the new function.

Fixes #332

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Brown
2026-01-11 01:56:37 -05:00
committed by GitHub
parent 6a705f6210
commit 3246c7c6b7
2 changed files with 67 additions and 4 deletions

View File

@@ -176,6 +176,67 @@ func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue,
return &issue, nil return &issue, nil
} }
// CreateOrReopenAgentBead creates an agent bead or reopens an existing one.
// This handles the case where a polecat is nuked and re-spawned with the same name:
// the old agent bead exists as a tombstone, so we reopen and update it instead of
// failing with a UNIQUE constraint error.
//
// The function:
// 1. Tries to create the agent bead
// 2. If UNIQUE constraint fails, reopens the existing bead and updates its fields
func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) (*Issue, error) {
// First try to create the bead
issue, err := b.CreateAgentBead(id, title, fields)
if err == nil {
return issue, nil
}
// Check if it's a UNIQUE constraint error
if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
return nil, err
}
// The bead already exists (likely a tombstone from a previous nuked polecat)
// Reopen it and update its fields
if _, reopenErr := b.run("reopen", id, "--reason=re-spawning agent"); reopenErr != nil {
// If reopen fails, the bead might already be open - continue with update
if !strings.Contains(reopenErr.Error(), "already open") {
return nil, fmt.Errorf("reopening existing agent bead: %w (original error: %v)", reopenErr, err)
}
}
// Update the bead with new fields
description := FormatAgentDescription(title, fields)
updateOpts := UpdateOptions{
Title: &title,
Description: &description,
}
if err := b.Update(id, updateOpts); err != nil {
return nil, fmt.Errorf("updating reopened agent bead: %w", err)
}
// Set the role slot if specified
if fields != nil && fields.RoleBead != "" {
if _, err := b.run("slot", "set", id, "role", fields.RoleBead); err != nil {
// Non-fatal: warn but continue
fmt.Printf("Warning: could not set role slot: %v\n", err)
}
}
// Set the hook slot if specified
if fields != nil && fields.HookBead != "" {
// Clear any existing hook first, then set new one
_, _ = b.run("slot", "clear", id, "hook")
if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil {
// Non-fatal: warn but continue
fmt.Printf("Warning: could not set hook slot: %v\n", err)
}
}
// Return the updated bead
return b.Show(id)
}
// UpdateAgentState updates the agent_state field in an agent bead. // UpdateAgentState updates the agent_state field in an agent bead.
// Optionally updates hook_bead if provided. // Optionally updates hook_bead if provided.
// //

View File

@@ -307,11 +307,12 @@ func (m *Manager) AddWithOptions(name string, opts AddOptions) (*Polecat, error)
// NOTE: Slash commands (.claude/commands/) are provisioned at town level by gt install. // NOTE: Slash commands (.claude/commands/) are provisioned at town level by gt install.
// All agents inherit them via Claude's directory traversal - no per-workspace copies needed. // All agents inherit them via Claude's directory traversal - no per-workspace copies needed.
// Create agent bead for ZFC compliance (self-report state). // Create or reopen agent bead for ZFC compliance (self-report state).
// State starts as "spawning" - will be updated to "working" when Claude starts. // State starts as "spawning" - will be updated to "working" when Claude starts.
// HookBead is set atomically at creation time if provided (avoids cross-beads routing issues). // HookBead is set atomically at creation time if provided (avoids cross-beads routing issues).
// Uses CreateOrReopenAgentBead to handle re-spawning with same name (GH #332).
agentID := m.agentBeadID(name) agentID := m.agentBeadID(name)
_, err = m.beads.CreateAgentBead(agentID, agentID, &beads.AgentFields{ _, err = m.beads.CreateOrReopenAgentBead(agentID, agentID, &beads.AgentFields{
RoleType: "polecat", RoleType: "polecat",
Rig: m.rig.Name, Rig: m.rig.Name,
AgentState: "spawning", AgentState: "spawning",
@@ -562,9 +563,10 @@ func (m *Manager) RepairWorktreeWithOptions(name string, force bool, opts AddOpt
// NOTE: Slash commands inherited from town level - no per-workspace copies needed. // NOTE: Slash commands inherited from town level - no per-workspace copies needed.
// Create fresh agent bead for ZFC compliance // Create or reopen agent bead for ZFC compliance
// HookBead is set atomically at recreation time if provided. // HookBead is set atomically at recreation time if provided.
_, err = m.beads.CreateAgentBead(agentID, agentID, &beads.AgentFields{ // Uses CreateOrReopenAgentBead to handle re-spawning with same name (GH #332).
_, err = m.beads.CreateOrReopenAgentBead(agentID, agentID, &beads.AgentFields{
RoleType: "polecat", RoleType: "polecat",
Rig: m.rig.Name, Rig: m.rig.Name,
AgentState: "spawning", AgentState: "spawning",