fix(beads): multi-repo routing for custom types and role slots

Fixes two bugs in multi-repo routing scenarios:

1. "invalid issue type: agent" error when creating agent beads
   - Added EnsureCustomTypes() with two-level caching (in-memory + sentinel file)
   - CreateAgentBead() now resolves routing target and ensures custom types

2. "could not set role slot: issue not found" warning when setting slots
   - Added runSlotSet() and runSlotClear() helpers that run bd from correct directory
   - Slot operations now use the resolved target directory

New files:
- internal/beads/beads_types.go - routing resolution and custom types logic
- internal/beads/beads_types_test.go - unit tests

Based on PR #811 by Perttulands, rebased onto current main.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gastown/crew/dennis
2026-01-21 10:44:37 -08:00
committed by beads/crew/emma
parent b2b9cbc836
commit fa1f812ce9
4 changed files with 435 additions and 4 deletions

View File

@@ -5,9 +5,32 @@ import (
"encoding/json"
"errors"
"fmt"
"os/exec"
"strings"
)
// runSlotSet runs `bd slot set` from a specific directory.
// This is needed when the agent bead was created via routing to a different
// database than the Beads wrapper's default directory.
func runSlotSet(workDir, beadID, slotName, slotValue string) error {
cmd := exec.Command("bd", "slot", "set", beadID, slotName, slotValue)
cmd.Dir = workDir
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err)
}
return nil
}
// runSlotClear runs `bd slot clear` from a specific directory.
func runSlotClear(workDir, beadID, slotName string) error {
cmd := exec.Command("bd", "slot", "clear", beadID, slotName)
cmd.Dir = workDir
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err)
}
return nil
}
// AgentFields holds structured fields for agent beads.
// These are stored as "key: value" lines in the description.
type AgentFields struct {
@@ -125,7 +148,21 @@ func ParseAgentFields(description string) *AgentFields {
// 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.
//
// This function automatically ensures custom types are configured in the target
// database before creating the bead. This handles multi-repo routing scenarios
// where the bead may be routed to a different database than the one this wrapper
// is connected to.
func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue, error) {
// Resolve where this bead will actually be written (handles multi-repo routing)
targetDir := ResolveRoutingTarget(b.getTownRoot(), id, b.getResolvedBeadsDir())
// Ensure target database has custom types configured
// This is cached (sentinel file + in-memory) so repeated calls are fast
if err := EnsureCustomTypes(targetDir); err != nil {
return nil, fmt.Errorf("prepare target for agent bead %s: %w", id, err)
}
description := FormatAgentDescription(title, fields)
args := []string{"create", "--json",
@@ -160,8 +197,9 @@ func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue,
// 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.
// Must run from targetDir since that's where the agent bead was created
if fields != nil && fields.HookBead != "" {
if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil {
if err := runSlotSet(targetDir, 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)
}
@@ -195,6 +233,9 @@ func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) (
return nil, err
}
// Resolve where this bead lives (for slot operations)
targetDir := ResolveRoutingTarget(b.getTownRoot(), id, b.getResolvedBeadsDir())
// The bead already exists (should be closed from previous polecat lifecycle)
// Reopen it and update its fields
if _, reopenErr := b.run("reopen", id, "--reason=re-spawning agent"); reopenErr != nil {
@@ -217,12 +258,14 @@ func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) (
// Note: role slot no longer set - role definitions are config-based
// Clear any existing hook slot (handles stale state from previous lifecycle)
_, _ = b.run("slot", "clear", id, "hook")
// Must run from targetDir since that's where the agent bead lives
_ = runSlotClear(targetDir, id, "hook")
// Set the hook slot if specified
// Must run from targetDir since that's where the agent bead lives
if fields != nil && fields.HookBead != "" {
if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil {
// Non-fatal: warn but continue
if err := runSlotSet(targetDir, 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)
}
}