fix: create town role beads before agents

Install now creates town role beads before agents and only skips agent creation
when the exact agent bead exists, so role slots get set reliably. Add an
integration test that asserts role slots are populated after install.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Dan Shapiro
2026-01-04 07:46:49 -08:00
parent 60ecf1ff76
commit 87bdd6c63e
2 changed files with 90 additions and 38 deletions

View File

@@ -345,44 +345,6 @@ func ensureRepoFingerprint(beadsPath string) error {
func initTownAgentBeads(townPath string) error {
bd := beads.New(townPath)
// Town-level agent beads
agentDefs := []struct {
id string
roleType string
title string
}{
{
id: beads.MayorBeadIDTown(),
roleType: "mayor",
title: "Mayor - global coordinator, handles cross-rig communication and escalations.",
},
{
id: beads.DeaconBeadIDTown(),
roleType: "deacon",
title: "Deacon (daemon beacon) - receives mechanical heartbeats, runs town plugins and monitoring.",
},
}
for _, agent := range agentDefs {
// Check if already exists
if _, err := bd.Show(agent.id); err == nil {
continue // Already exists
}
fields := &beads.AgentFields{
RoleType: agent.roleType,
Rig: "", // Town-level agents have no rig
AgentState: "idle",
HookBead: "",
RoleBead: beads.RoleBeadIDTown(agent.roleType),
}
if _, err := bd.CreateAgentBead(agent.id, agent.title, fields); err != nil {
return fmt.Errorf("creating %s: %w", agent.id, err)
}
fmt.Printf(" ✓ Created agent bead: %s\n", agent.id)
}
// Role beads (global templates)
roleDefs := []struct {
id string
@@ -444,5 +406,45 @@ func initTownAgentBeads(townPath string) error {
fmt.Printf(" ✓ Created role bead: %s\n", role.id)
}
// Town-level agent beads
agentDefs := []struct {
id string
roleType string
title string
}{
{
id: beads.MayorBeadIDTown(),
roleType: "mayor",
title: "Mayor - global coordinator, handles cross-rig communication and escalations.",
},
{
id: beads.DeaconBeadIDTown(),
roleType: "deacon",
title: "Deacon (daemon beacon) - receives mechanical heartbeats, runs town plugins and monitoring.",
},
}
for _, agent := range agentDefs {
// Check if already exists (exact ID + agent type).
if issue, err := bd.Show(agent.id); err == nil {
if issue.ID == agent.id && issue.Type == "agent" {
continue
}
}
fields := &beads.AgentFields{
RoleType: agent.roleType,
Rig: "", // Town-level agents have no rig
AgentState: "idle",
HookBead: "",
RoleBead: beads.RoleBeadIDTown(agent.roleType),
}
if _, err := bd.CreateAgentBead(agent.id, agent.title, fields); err != nil {
return fmt.Errorf("creating %s: %w", agent.id, err)
}
fmt.Printf(" ✓ Created agent bead: %s\n", agent.id)
}
return nil
}

View File

@@ -129,6 +129,31 @@ func TestInstallBeadsHasCorrectPrefix(t *testing.T) {
}
}
// TestInstallTownRoleSlots validates that town-level agent beads
// have their role slot set after install.
func TestInstallTownRoleSlots(t *testing.T) {
// Skip if bd is not available
if _, err := exec.LookPath("bd"); err != nil {
t.Skip("bd not installed, skipping role slot test")
}
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
gtBinary := buildGT(t)
// Run gt install (includes beads init by default)
cmd := exec.Command(gtBinary, "install", hqPath)
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
assertSlotValue(t, hqPath, "hq-mayor", "role", "hq-mayor-role")
assertSlotValue(t, hqPath, "hq-deacon", "role", "hq-deacon-role")
}
// TestInstallIdempotent validates that running gt install twice
// on the same directory fails without --force flag.
func TestInstallIdempotent(t *testing.T) {
@@ -312,3 +337,28 @@ func assertFileExists(t *testing.T, path, name string) {
t.Errorf("%s is a directory, expected file", name)
}
}
func assertSlotValue(t *testing.T, townRoot, issueID, slot, want string) {
t.Helper()
cmd := exec.Command("bd", "--no-daemon", "--json", "slot", "show", issueID)
cmd.Dir = townRoot
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("bd slot show %s failed: %v\nOutput: %s", issueID, err, output)
}
var parsed struct {
Slots map[string]*string `json:"slots"`
}
if err := json.Unmarshal(output, &parsed); err != nil {
t.Fatalf("parsing slot show output failed: %v\nOutput: %s", err, output)
}
var got string
if value, ok := parsed.Slots[slot]; ok && value != nil {
got = *value
}
if got != want {
t.Fatalf("slot %s for %s = %q, want %q", slot, issueID, got, want)
}
}