Skip to content

Commit 50584ce

Browse files
mk-imaginesteveyeggeclaude
authored
fix(beads): route cross-rig agent bead creation from town root (#3520)
Make FindTownRoot prefer the outermost town root so nested/imported rig layouts resolve correctly. Run routed CreateAgentBead calls from the town root to prevent double-stacking rig paths in BEADS_DIR. Adds test coverage for outermost town-root detection and a mock-bd regression test asserting routed CreateAgentBead executes from town root. Fixes PR #2972 (gt-u30t) Co-authored-by: cheedo <steve.yegge@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fa3d879 commit 50584ce

4 files changed

Lines changed: 146 additions & 1 deletion

File tree

internal/beads/beads_agent.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,15 @@ func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue,
218218
// Don't bail out — try the bd create calls anyway (GH#1769).
219219
_ = EnsureCustomTypes(targetDir)
220220

221+
// For routed cross-rig bead IDs, run bd from the town root so bd's own
222+
// prefix router resolves the target once. Running from a rig worktree with
223+
// a routed BEADS_DIR can double-stack the path for imported rigs.
224+
target := b
225+
townRoot := b.getTownRoot()
226+
if townRoot != "" && ExtractPrefix(id) != "" {
227+
target = NewWithBeadsDir(townRoot, filepath.Join(townRoot, ".beads"))
228+
}
229+
221230
description := FormatAgentDescription(title, fields)
222231

223232
buildArgs := func() []string {
@@ -233,7 +242,7 @@ func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue,
233242
}
234243
// Default actor from BD_ACTOR env var for provenance tracking
235244
// Uses getActor() to respect isolated mode (tests)
236-
if actor := b.getActor(); actor != "" {
245+
if actor := target.getActor(); actor != "" {
237246
a = append(a, "--actor="+actor)
238247
}
239248
return a

internal/beads/beads_agent_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package beads
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"os"
67
"path/filepath"
@@ -346,3 +347,119 @@ func TestMergeAgentBeadSources(t *testing.T) {
346347
}
347348
})
348349
}
350+
351+
func installMockBDCreateRecorder(t *testing.T, logPath string) {
352+
t.Helper()
353+
354+
binDir := t.TempDir()
355+
if runtime.GOOS == "windows" {
356+
t.Skip("cross-rig create recorder test not implemented on Windows")
357+
}
358+
359+
script := `#!/bin/sh
360+
printf 'pwd=%s\n' "$(pwd)" >> "$MOCK_BD_LOG"
361+
printf 'beads_dir=%s\n' "$BEADS_DIR" >> "$MOCK_BD_LOG"
362+
printf 'args=%s\n' "$*" >> "$MOCK_BD_LOG"
363+
364+
cmd=""
365+
for arg in "$@"; do
366+
case "$arg" in
367+
--*) ;;
368+
*) cmd="$arg"; break ;;
369+
esac
370+
done
371+
372+
case "$cmd" in
373+
create)
374+
printf '{"id":"pt-imported-polecat-shiny","title":"shiny","status":"open"}\n'
375+
exit 0
376+
;;
377+
slot|config|migrate|init|show|update)
378+
exit 0
379+
;;
380+
*)
381+
exit 0
382+
;;
383+
esac
384+
`
385+
scriptPath := filepath.Join(binDir, "bd")
386+
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
387+
t.Fatalf("write mock bd: %v", err)
388+
}
389+
390+
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
391+
t.Setenv("MOCK_BD_LOG", logPath)
392+
}
393+
394+
func TestCreateAgentBead_UsesTownRootForCrossRigRoutes(t *testing.T) {
395+
if runtime.GOOS == "windows" {
396+
t.Skip("path assertions are Unix-oriented")
397+
}
398+
399+
// Resolve symlinks so path assertions match shell pwd output.
400+
// On macOS, t.TempDir() returns /var/... but pwd resolves to /private/var/...
401+
townRoot, _ := filepath.EvalSymlinks(t.TempDir())
402+
for _, dir := range []string{
403+
filepath.Join(townRoot, "mayor"),
404+
filepath.Join(townRoot, ".beads"),
405+
filepath.Join(townRoot, "imported", "mayor", "rig", ".beads"),
406+
} {
407+
if err := os.MkdirAll(dir, 0755); err != nil {
408+
t.Fatalf("mkdir %s: %v", dir, err)
409+
}
410+
}
411+
if err := os.WriteFile(filepath.Join(townRoot, "mayor", "town.json"), []byte(`{"name":"test"}`), 0644); err != nil {
412+
t.Fatal(err)
413+
}
414+
if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte("{\"prefix\":\"pt-\",\"path\":\"imported/mayor/rig\"}\n"), 0644); err != nil {
415+
t.Fatal(err)
416+
}
417+
418+
logPath := filepath.Join(townRoot, "bd.log")
419+
installMockBDCreateRecorder(t, logPath)
420+
421+
workerDir := filepath.Join(townRoot, "imported", "mayor", "rig")
422+
bd := NewWithBeadsDir(workerDir, filepath.Join(workerDir, ".beads"))
423+
424+
issue, err := bd.CreateAgentBead("pt-imported-polecat-shiny", "shiny", &AgentFields{
425+
RoleType: "polecat",
426+
Rig: "imported",
427+
AgentState: "spawning",
428+
HookBead: "pt-task-1",
429+
})
430+
if err != nil {
431+
t.Fatalf("CreateAgentBead: %v", err)
432+
}
433+
if issue == nil {
434+
t.Fatal("CreateAgentBead returned nil issue")
435+
}
436+
437+
logData, err := os.ReadFile(logPath)
438+
if err != nil {
439+
t.Fatalf("read mock bd log: %v", err)
440+
}
441+
logOutput := string(logData)
442+
if !strings.Contains(logOutput, "pwd="+townRoot) {
443+
t.Fatalf("mock bd log missing town root cwd:\n%s", logOutput)
444+
}
445+
if !strings.Contains(logOutput, "beads_dir="+filepath.Join(townRoot, ".beads")) {
446+
t.Fatalf("mock bd log missing town-root BEADS_DIR:\n%s", logOutput)
447+
}
448+
if !strings.Contains(logOutput, "create --json --id=pt-imported-polecat-shiny") {
449+
t.Fatalf("mock bd log missing create call:\n%s", logOutput)
450+
}
451+
if !strings.Contains(logOutput, "slot set pt-imported-polecat-shiny hook pt-task-1") {
452+
t.Fatalf("mock bd log missing slot set call:\n%s", logOutput)
453+
}
454+
}
455+
456+
func TestCreateAgentBead_ParsesMockCreateOutput(t *testing.T) {
457+
raw := []byte(`{"id":"pt-imported-polecat-shiny","title":"shiny","status":"open"}`)
458+
var issue Issue
459+
if err := json.Unmarshal(raw, &issue); err != nil {
460+
t.Fatalf("json.Unmarshal: %v", err)
461+
}
462+
if issue.ID != "pt-imported-polecat-shiny" {
463+
t.Fatalf("issue.ID = %q", issue.ID)
464+
}
465+
}

internal/beads/beads_types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ var (
3939
// shadow the real town root above them.
4040
// Returns empty string if not found (reached filesystem root).
4141
func FindTownRoot(startDir string) string {
42+
var found string
4243
dir := startDir
4344
candidate := ""
4445
for {

internal/beads/beads_types_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,24 @@ func TestFindTownRoot(t *testing.T) {
177177
{"nested rig dir prefers outermost", rigDir, tmpDir},
178178
}
179179

180+
// Add nested town test case: inner town inside outer town
181+
innerTown := filepath.Join(tmpDir, "imported", "gastown")
182+
if err := os.MkdirAll(filepath.Join(innerTown, "mayor"), 0755); err != nil {
183+
t.Fatal(err)
184+
}
185+
if err := os.WriteFile(filepath.Join(innerTown, "mayor", "town.json"), []byte("{}"), 0644); err != nil {
186+
t.Fatal(err)
187+
}
188+
innerDeepDir := filepath.Join(innerTown, "crew", "worker2")
189+
if err := os.MkdirAll(innerDeepDir, 0755); err != nil {
190+
t.Fatal(err)
191+
}
192+
tests = append(tests, struct {
193+
name string
194+
startDir string
195+
expected string
196+
}{"prefers outermost town root", innerDeepDir, tmpDir})
197+
180198
for _, tc := range tests {
181199
t.Run(tc.name, func(t *testing.T) {
182200
result := FindTownRoot(tc.startDir)

0 commit comments

Comments
 (0)