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>
447 lines
14 KiB
Go
447 lines
14 KiB
Go
// Package beads provides agent bead management.
|
|
package beads
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// AgentFields holds structured fields for agent beads.
|
|
// These are stored as "key: value" lines in the description.
|
|
type AgentFields struct {
|
|
RoleType string // polecat, witness, refinery, deacon, mayor
|
|
Rig string // Rig name (empty for global agents like mayor/deacon)
|
|
AgentState string // spawning, working, done, stuck
|
|
HookBead string // Currently pinned work bead ID
|
|
RoleBead string // Role definition bead ID (canonical location; may not exist yet)
|
|
CleanupStatus string // ZFC: polecat self-reports git state (clean, has_uncommitted, has_stash, has_unpushed)
|
|
ActiveMR string // Currently active merge request bead ID (for traceability)
|
|
NotificationLevel string // DND mode: verbose, normal, muted (default: normal)
|
|
}
|
|
|
|
// Notification level constants
|
|
const (
|
|
NotifyVerbose = "verbose" // All notifications (mail, convoy events, etc.)
|
|
NotifyNormal = "normal" // Important events only (default)
|
|
NotifyMuted = "muted" // Silent/DND mode - batch for later
|
|
)
|
|
|
|
// FormatAgentDescription creates a description string from agent fields.
|
|
func FormatAgentDescription(title string, fields *AgentFields) string {
|
|
if fields == nil {
|
|
return title
|
|
}
|
|
|
|
var lines []string
|
|
lines = append(lines, title)
|
|
lines = append(lines, "")
|
|
lines = append(lines, fmt.Sprintf("role_type: %s", fields.RoleType))
|
|
|
|
if fields.Rig != "" {
|
|
lines = append(lines, fmt.Sprintf("rig: %s", fields.Rig))
|
|
} else {
|
|
lines = append(lines, "rig: null")
|
|
}
|
|
|
|
lines = append(lines, fmt.Sprintf("agent_state: %s", fields.AgentState))
|
|
|
|
if fields.HookBead != "" {
|
|
lines = append(lines, fmt.Sprintf("hook_bead: %s", fields.HookBead))
|
|
} else {
|
|
lines = append(lines, "hook_bead: null")
|
|
}
|
|
|
|
if fields.RoleBead != "" {
|
|
lines = append(lines, fmt.Sprintf("role_bead: %s", fields.RoleBead))
|
|
} else {
|
|
lines = append(lines, "role_bead: null")
|
|
}
|
|
|
|
if fields.CleanupStatus != "" {
|
|
lines = append(lines, fmt.Sprintf("cleanup_status: %s", fields.CleanupStatus))
|
|
} else {
|
|
lines = append(lines, "cleanup_status: null")
|
|
}
|
|
|
|
if fields.ActiveMR != "" {
|
|
lines = append(lines, fmt.Sprintf("active_mr: %s", fields.ActiveMR))
|
|
} else {
|
|
lines = append(lines, "active_mr: null")
|
|
}
|
|
|
|
if fields.NotificationLevel != "" {
|
|
lines = append(lines, fmt.Sprintf("notification_level: %s", fields.NotificationLevel))
|
|
} else {
|
|
lines = append(lines, "notification_level: null")
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// ParseAgentFields extracts agent fields from an issue's description.
|
|
func ParseAgentFields(description string) *AgentFields {
|
|
fields := &AgentFields{}
|
|
|
|
for _, line := range strings.Split(description, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
colonIdx := strings.Index(line, ":")
|
|
if colonIdx == -1 {
|
|
continue
|
|
}
|
|
|
|
key := strings.TrimSpace(line[:colonIdx])
|
|
value := strings.TrimSpace(line[colonIdx+1:])
|
|
if value == "null" || value == "" {
|
|
value = ""
|
|
}
|
|
|
|
switch strings.ToLower(key) {
|
|
case "role_type":
|
|
fields.RoleType = value
|
|
case "rig":
|
|
fields.Rig = value
|
|
case "agent_state":
|
|
fields.AgentState = value
|
|
case "hook_bead":
|
|
fields.HookBead = value
|
|
case "role_bead":
|
|
fields.RoleBead = value
|
|
case "cleanup_status":
|
|
fields.CleanupStatus = value
|
|
case "active_mr":
|
|
fields.ActiveMR = value
|
|
case "notification_level":
|
|
fields.NotificationLevel = value
|
|
}
|
|
}
|
|
|
|
return fields
|
|
}
|
|
|
|
// CreateAgentBead creates an agent bead for tracking agent lifecycle.
|
|
// The ID format is: <prefix>-<rig>-<role>-<name> (e.g., gt-gastown-polecat-Toast)
|
|
// Use AgentBeadID() helper to generate correct IDs.
|
|
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
|
|
func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue, error) {
|
|
description := FormatAgentDescription(title, fields)
|
|
|
|
args := []string{"create", "--json",
|
|
"--id=" + id,
|
|
"--title=" + title,
|
|
"--description=" + description,
|
|
"--type=agent",
|
|
"--labels=gt:agent",
|
|
}
|
|
|
|
// Default actor from BD_ACTOR env var for provenance tracking
|
|
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
|
args = append(args, "--actor="+actor)
|
|
}
|
|
|
|
out, err := b.run(args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var issue Issue
|
|
if err := json.Unmarshal(out, &issue); err != nil {
|
|
return nil, fmt.Errorf("parsing bd create output: %w", err)
|
|
}
|
|
|
|
// Set the role slot if specified (this is the authoritative storage)
|
|
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 (this is the authoritative storage)
|
|
// This fixes the slot inconsistency bug where bead status is 'hooked' but
|
|
// agent's hook slot is empty. See mi-619.
|
|
if fields != nil && fields.HookBead != "" {
|
|
if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil {
|
|
// Non-fatal: warn but continue - description text has the backup
|
|
fmt.Printf("Warning: could not set hook slot: %v\n", err)
|
|
}
|
|
}
|
|
|
|
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.
|
|
// Optionally updates hook_bead if provided.
|
|
//
|
|
// IMPORTANT: This function uses the proper bd commands to update agent fields:
|
|
// - `bd agent state` for agent_state (uses SQLite column directly)
|
|
// - `bd slot set/clear` for hook_bead (uses SQLite column directly)
|
|
//
|
|
// This ensures consistency with `bd slot show` and other beads commands.
|
|
// Previously, this function embedded these fields in the description text,
|
|
// which caused inconsistencies with bd slot commands (see GH #gt-9v52).
|
|
func (b *Beads) UpdateAgentState(id string, state string, hookBead *string) error {
|
|
// Update agent state using bd agent state command
|
|
// This updates the agent_state column directly in SQLite
|
|
_, err := b.run("agent", "state", id, state)
|
|
if err != nil {
|
|
return fmt.Errorf("updating agent state: %w", err)
|
|
}
|
|
|
|
// Update hook_bead if provided
|
|
if hookBead != nil {
|
|
if *hookBead != "" {
|
|
// Set the hook using bd slot set
|
|
// This updates the hook_bead column directly in SQLite
|
|
_, err = b.run("slot", "set", id, "hook", *hookBead)
|
|
if err != nil {
|
|
// If slot is already occupied, clear it first then retry
|
|
// This handles re-slinging scenarios where we're updating the hook
|
|
errStr := err.Error()
|
|
if strings.Contains(errStr, "already occupied") {
|
|
_, _ = b.run("slot", "clear", id, "hook")
|
|
_, err = b.run("slot", "set", id, "hook", *hookBead)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("setting hook: %w", err)
|
|
}
|
|
}
|
|
} else {
|
|
// Clear the hook
|
|
_, err = b.run("slot", "clear", id, "hook")
|
|
if err != nil {
|
|
return fmt.Errorf("clearing hook: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetHookBead sets the hook_bead slot on an agent bead.
|
|
// This is a convenience wrapper that only sets the hook without changing agent_state.
|
|
// Per gt-zecmc: agent_state ("running", "dead", "idle") is observable from tmux
|
|
// and should not be recorded in beads ("discover, don't track" principle).
|
|
func (b *Beads) SetHookBead(agentBeadID, hookBeadID string) error {
|
|
// Set the hook using bd slot set
|
|
// This updates the hook_bead column directly in SQLite
|
|
_, err := b.run("slot", "set", agentBeadID, "hook", hookBeadID)
|
|
if err != nil {
|
|
// If slot is already occupied, clear it first then retry
|
|
errStr := err.Error()
|
|
if strings.Contains(errStr, "already occupied") {
|
|
_, _ = b.run("slot", "clear", agentBeadID, "hook")
|
|
_, err = b.run("slot", "set", agentBeadID, "hook", hookBeadID)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("setting hook: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ClearHookBead clears the hook_bead slot on an agent bead.
|
|
// Used when work is complete or unslung.
|
|
func (b *Beads) ClearHookBead(agentBeadID string) error {
|
|
_, err := b.run("slot", "clear", agentBeadID, "hook")
|
|
if err != nil {
|
|
return fmt.Errorf("clearing hook: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateAgentCleanupStatus updates the cleanup_status field in an agent bead.
|
|
// This is called by the polecat to self-report its git state (ZFC compliance).
|
|
// Valid statuses: clean, has_uncommitted, has_stash, has_unpushed
|
|
func (b *Beads) UpdateAgentCleanupStatus(id string, cleanupStatus string) error {
|
|
// First get current issue to preserve other fields
|
|
issue, err := b.Show(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Parse existing fields
|
|
fields := ParseAgentFields(issue.Description)
|
|
fields.CleanupStatus = cleanupStatus
|
|
|
|
// Format new description
|
|
description := FormatAgentDescription(issue.Title, fields)
|
|
|
|
return b.Update(id, UpdateOptions{Description: &description})
|
|
}
|
|
|
|
// UpdateAgentActiveMR updates the active_mr field in an agent bead.
|
|
// This links the agent to their current merge request for traceability.
|
|
// Pass empty string to clear the field (e.g., after merge completes).
|
|
func (b *Beads) UpdateAgentActiveMR(id string, activeMR string) error {
|
|
// First get current issue to preserve other fields
|
|
issue, err := b.Show(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Parse existing fields
|
|
fields := ParseAgentFields(issue.Description)
|
|
fields.ActiveMR = activeMR
|
|
|
|
// Format new description
|
|
description := FormatAgentDescription(issue.Title, fields)
|
|
|
|
return b.Update(id, UpdateOptions{Description: &description})
|
|
}
|
|
|
|
// UpdateAgentNotificationLevel updates the notification_level field in an agent bead.
|
|
// Valid levels: verbose, normal, muted (DND mode).
|
|
// Pass empty string to reset to default (normal).
|
|
func (b *Beads) UpdateAgentNotificationLevel(id string, level string) error {
|
|
// Validate level
|
|
if level != "" && level != NotifyVerbose && level != NotifyNormal && level != NotifyMuted {
|
|
return fmt.Errorf("invalid notification level %q: must be verbose, normal, or muted", level)
|
|
}
|
|
|
|
// First get current issue to preserve other fields
|
|
issue, err := b.Show(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Parse existing fields
|
|
fields := ParseAgentFields(issue.Description)
|
|
fields.NotificationLevel = level
|
|
|
|
// Format new description
|
|
description := FormatAgentDescription(issue.Title, fields)
|
|
|
|
return b.Update(id, UpdateOptions{Description: &description})
|
|
}
|
|
|
|
// GetAgentNotificationLevel returns the notification level for an agent.
|
|
// Returns "normal" if not set (the default).
|
|
func (b *Beads) GetAgentNotificationLevel(id string) (string, error) {
|
|
_, fields, err := b.GetAgentBead(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if fields == nil {
|
|
return NotifyNormal, nil
|
|
}
|
|
if fields.NotificationLevel == "" {
|
|
return NotifyNormal, nil
|
|
}
|
|
return fields.NotificationLevel, nil
|
|
}
|
|
|
|
// DeleteAgentBead permanently deletes an agent bead.
|
|
// Uses --hard --force for immediate permanent deletion (no tombstone).
|
|
func (b *Beads) DeleteAgentBead(id string) error {
|
|
_, err := b.run("delete", id, "--hard", "--force")
|
|
return err
|
|
}
|
|
|
|
// GetAgentBead retrieves an agent bead by ID.
|
|
// Returns nil if not found.
|
|
func (b *Beads) GetAgentBead(id string) (*Issue, *AgentFields, error) {
|
|
issue, err := b.Show(id)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return nil, nil, nil
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
|
|
if !HasLabel(issue, "gt:agent") {
|
|
return nil, nil, fmt.Errorf("issue %s is not an agent bead (missing gt:agent label)", id)
|
|
}
|
|
|
|
fields := ParseAgentFields(issue.Description)
|
|
return issue, fields, nil
|
|
}
|
|
|
|
// ListAgentBeads returns all agent beads in a single query.
|
|
// Returns a map of agent bead ID to Issue.
|
|
func (b *Beads) ListAgentBeads() (map[string]*Issue, error) {
|
|
out, err := b.run("list", "--label=gt:agent", "--json")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var issues []*Issue
|
|
if err := json.Unmarshal(out, &issues); err != nil {
|
|
return nil, fmt.Errorf("parsing bd list output: %w", err)
|
|
}
|
|
|
|
result := make(map[string]*Issue, len(issues))
|
|
for _, issue := range issues {
|
|
result[issue.ID] = issue
|
|
}
|
|
|
|
return result, nil
|
|
}
|