Files
gastown/internal/beads/beads_agent.go
gastown/crew/dennis b60f016955 refactor(beads,mail): split large files into focused modules
Break down monolithic beads.go and mail.go into smaller, single-purpose files:

beads package:
- beads_agent.go: Agent-related bead operations
- beads_delegation.go: Delegation bead handling
- beads_dog.go: Dog pool operations
- beads_merge_slot.go: Merge slot management
- beads_mr.go: Merge request operations
- beads_redirect.go: Redirect bead handling
- beads_rig.go: Rig bead operations
- beads_role.go: Role bead management

cmd package:
- mail_announce.go: Announcement subcommand
- mail_check.go: Mail check subcommand
- mail_identity.go: Identity management
- mail_inbox.go: Inbox operations
- mail_queue.go: Queue subcommand
- mail_search.go: Search functionality
- mail_send.go: Send subcommand
- mail_thread.go: Thread operations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 23:01:55 -08:00

385 lines
12 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,
"--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
}
// 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
}