feat: Add polecat agent bead lifecycle (gt-rxa7v)
Create ephemeral agent beads for ZFC-compliant polecat state tracking. - Add AgentFields struct and helpers to beads package - CreateAgentBead: Creates agent bead with role_type/rig/agent_state - UpdateAgentState: Updates agent_state and hook_bead fields - DeleteAgentBead: Hard-deletes ephemeral agent bead - GetAgentBead: Retrieves and parses agent bead - Integrate lifecycle in polecat manager: - Add(): Creates gt-polecat-<rig>-<name> bead with state=spawning - Recreate(): Deletes old bead, creates fresh with state=spawning - RemoveWithOptions(): Deletes agent bead on nuke This enables Witness to read polecat state from beads instead of tmux scraping. State updates (spawning→working→done) are done by polecats via bd agent state (separate beads CLI enhancement). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -510,3 +510,158 @@ func (b *Beads) IsBeadsRepo() bool {
|
|||||||
_, err := b.run("list", "--limit=1")
|
_, err := b.run("list", "--limit=1")
|
||||||
return err == nil || !errors.Is(err, ErrNotARepo)
|
return err == nil || !errors.Is(err, ErrNotARepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAgentBead creates an agent bead for tracking agent lifecycle.
|
||||||
|
// The ID format is: <prefix>-<role>-<rig>-<name> (e.g., gt-polecat-gastown-Toast)
|
||||||
|
func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue, error) {
|
||||||
|
description := FormatAgentDescription(title, fields)
|
||||||
|
|
||||||
|
args := []string{"create", "--json",
|
||||||
|
"--id=" + id,
|
||||||
|
"--type=agent",
|
||||||
|
"--title=" + title,
|
||||||
|
"--description=" + description,
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &issue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAgentState updates the agent_state field in an agent bead.
|
||||||
|
// Optionally updates hook_bead if provided.
|
||||||
|
func (b *Beads) UpdateAgentState(id string, state string, hookBead *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.AgentState = state
|
||||||
|
if hookBead != nil {
|
||||||
|
fields.HookBead = *hookBead
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format new description
|
||||||
|
description := FormatAgentDescription(issue.Title, fields)
|
||||||
|
|
||||||
|
return b.Update(id, UpdateOptions{Description: &description})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 issue.Type != "agent" {
|
||||||
|
return nil, nil, fmt.Errorf("issue %s is not an agent bead (type: %s)", id, issue.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := ParseAgentFields(issue.Description)
|
||||||
|
return issue, fields, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ func (m *Manager) assigneeID(name string) string {
|
|||||||
return fmt.Sprintf("%s/%s", m.rig.Name, name)
|
return fmt.Sprintf("%s/%s", m.rig.Name, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// agentBeadID returns the agent bead ID for a polecat.
|
||||||
|
// Format: "gt-polecat-<rig>-<name>" (e.g., "gt-polecat-gastown-Toast")
|
||||||
|
func (m *Manager) agentBeadID(name string) string {
|
||||||
|
return fmt.Sprintf("gt-polecat-%s-%s", m.rig.Name, name)
|
||||||
|
}
|
||||||
|
|
||||||
// repoBase returns the git directory and Git object to use for worktree operations.
|
// repoBase returns the git directory and Git object to use for worktree operations.
|
||||||
// Prefers the shared bare repo (.repo.git) if it exists, otherwise falls back to mayor/rig.
|
// Prefers the shared bare repo (.repo.git) if it exists, otherwise falls back to mayor/rig.
|
||||||
// The bare repo architecture allows all worktrees (refinery, polecats) to share branch visibility.
|
// The bare repo architecture allows all worktrees (refinery, polecats) to share branch visibility.
|
||||||
@@ -155,6 +161,19 @@ func (m *Manager) Add(name string) (*Polecat, error) {
|
|||||||
fmt.Printf("Warning: could not set up shared beads: %v\n", err)
|
fmt.Printf("Warning: could not set up shared beads: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create agent bead for ZFC compliance (self-report state).
|
||||||
|
// State starts as "spawning" - will be updated to "working" when Claude starts.
|
||||||
|
agentID := m.agentBeadID(name)
|
||||||
|
_, err = m.beads.CreateAgentBead(agentID, agentID, &beads.AgentFields{
|
||||||
|
RoleType: "polecat",
|
||||||
|
Rig: m.rig.Name,
|
||||||
|
AgentState: "spawning",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// Non-fatal - log warning but continue
|
||||||
|
fmt.Printf("Warning: could not create agent bead: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Return polecat with derived state (no issue assigned yet = idle)
|
// Return polecat with derived state (no issue assigned yet = idle)
|
||||||
// State is derived from beads, not stored in state.json
|
// State is derived from beads, not stored in state.json
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -228,6 +247,15 @@ func (m *Manager) RemoveWithOptions(name string, force, nuclear bool) error {
|
|||||||
m.namePool.Release(name)
|
m.namePool.Release(name)
|
||||||
_ = m.namePool.Save()
|
_ = m.namePool.Save()
|
||||||
|
|
||||||
|
// Delete agent bead (non-fatal: may not exist or beads may not be available)
|
||||||
|
agentID := m.agentBeadID(name)
|
||||||
|
if err := m.beads.DeleteAgentBead(agentID); err != nil {
|
||||||
|
// Only log if not "not found" - it's ok if it doesn't exist
|
||||||
|
if !errors.Is(err, beads.ErrNotFound) {
|
||||||
|
fmt.Printf("Warning: could not delete agent bead %s: %v\n", agentID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +314,14 @@ func (m *Manager) Recreate(name string, force bool) (*Polecat, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete old agent bead before recreation (non-fatal)
|
||||||
|
agentID := m.agentBeadID(name)
|
||||||
|
if err := m.beads.DeleteAgentBead(agentID); err != nil {
|
||||||
|
if !errors.Is(err, beads.ErrNotFound) {
|
||||||
|
fmt.Printf("Warning: could not delete old agent bead %s: %v\n", agentID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Remove the worktree (use force for git worktree removal)
|
// Remove the worktree (use force for git worktree removal)
|
||||||
if err := repoGit.WorktreeRemove(polecatPath, true); err != nil {
|
if err := repoGit.WorktreeRemove(polecatPath, true); err != nil {
|
||||||
// Fall back to direct removal
|
// Fall back to direct removal
|
||||||
@@ -313,6 +349,16 @@ func (m *Manager) Recreate(name string, force bool) (*Polecat, error) {
|
|||||||
fmt.Printf("Warning: could not set up shared beads: %v\n", err)
|
fmt.Printf("Warning: could not set up shared beads: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create fresh agent bead for ZFC compliance
|
||||||
|
_, err = m.beads.CreateAgentBead(agentID, agentID, &beads.AgentFields{
|
||||||
|
RoleType: "polecat",
|
||||||
|
Rig: m.rig.Name,
|
||||||
|
AgentState: "spawning",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: could not create agent bead: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Return fresh polecat
|
// Return fresh polecat
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return &Polecat{
|
return &Polecat{
|
||||||
|
|||||||
Reference in New Issue
Block a user