Skip to content

Commit 4b750c8

Browse files
easelclaude
andcommitted
fix(beads): suppress redirect warnings via WorkDir-based detection
Add WorkDir field to AgentEnvConfig that enables automatic detection of beads redirects. When a redirect file exists in WorkDir/.beads/redirect, BEADS_SYNC_BRANCH is set to "" to suppress the "Redirect active... skipping sync-branch operations" warning. This consolidates redirect detection into internal/config/env.go, eliminating the need for callers to import the beads package or manually check for redirects. Callsites simply pass their working directory and the config package handles the rest. The fix only activates when a redirect is actually present, avoiding false positives for standalone crew/polecat workspaces without redirects. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 65fb81d commit 4b750c8

11 files changed

Lines changed: 110 additions & 15 deletions

File tree

internal/beads/beads_redirect.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ import (
88
"strings"
99
)
1010

11+
// HasRedirect returns true if the workDir has a beads redirect file.
12+
// This indicates the workdir uses a shared beads database (e.g., crew/polecat clones).
13+
func HasRedirect(workDir string) bool {
14+
if filepath.Base(workDir) == ".beads" {
15+
workDir = filepath.Dir(workDir)
16+
}
17+
redirectPath := filepath.Join(workDir, ".beads", "redirect")
18+
_, err := os.Stat(redirectPath)
19+
return err == nil
20+
}
21+
1122
// ResolveBeadsDir returns the actual beads directory, following any redirect.
1223
// If workDir/.beads/redirect exists, it reads the redirect path and resolves it
1324
// relative to workDir (not the .beads directory). Otherwise, returns workDir/.beads.

internal/cmd/crew_at.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
171171
TownRoot: townRoot,
172172
RuntimeConfigDir: claudeConfigDir,
173173
BeadsNoDaemon: true,
174+
WorkDir: worker.ClonePath,
174175
})
175176
for k, v := range envVars {
176177
_ = t.SetEnvironment(sessionID, k, v)
@@ -205,7 +206,7 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
205206
// Use respawn-pane to replace shell with runtime directly
206207
// This gives cleaner lifecycle: runtime exits → session ends (no intermediate shell)
207208
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
208-
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, beacon, crewAgentOverride)
209+
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, beacon, crewAgentOverride, worker.ClonePath)
209210
if err != nil {
210211
return fmt.Errorf("building startup command: %w", err)
211212
}
@@ -252,7 +253,7 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
252253

253254
// Use respawn-pane to replace shell with runtime directly
254255
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
255-
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, beacon, crewAgentOverride)
256+
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, beacon, crewAgentOverride, worker.ClonePath)
256257
if err != nil {
257258
return fmt.Errorf("building startup command: %w", err)
258259
}

internal/cmd/polecat_spawn.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ func (s *SpawnedPolecatInfo) StartSession() (string, error) {
216216
RuntimeConfigDir: claudeConfigDir,
217217
}
218218
if s.agent != "" {
219-
cmd, err := config.BuildPolecatStartupCommandWithAgentOverride(s.RigName, s.PolecatName, r.Path, "", s.agent)
219+
cmd, err := config.BuildPolecatStartupCommandWithAgentOverride(s.RigName, s.PolecatName, r.Path, "", s.agent, s.ClonePath)
220220
if err != nil {
221221
return "", err
222222
}

internal/cmd/start.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,8 @@ func startOrRestartCrewMember(t *tmux.Tmux, r *rig.Rig, crewName, townRoot strin
409409
Sender: "human",
410410
Topic: "restart",
411411
})
412-
agentCmd := config.BuildCrewStartupCommand(r.Name, crewName, r.Path, beacon)
412+
crewPath := filepath.Join(r.Path, "crew", crewName)
413+
agentCmd := config.BuildCrewStartupCommand(r.Name, crewName, r.Path, beacon, crewPath)
413414
if err := t.SendKeys(sessionID, agentCmd); err != nil {
414415
return fmt.Sprintf(" %s %s/%s restart failed: %v\n", style.Dim.Render("○"), r.Name, crewName, err), false
415416
}

internal/config/env.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package config
44
import (
55
"fmt"
66
"os"
7+
"path/filepath"
78
"sort"
89
"strings"
910
)
@@ -35,6 +36,26 @@ type AgentEnvConfig struct {
3536
// BeadsNoDaemon sets BEADS_NO_DAEMON=1 if true
3637
// Used for polecats that should bypass the beads daemon
3738
BeadsNoDaemon bool
39+
40+
// WorkDir is the working directory for the agent session.
41+
// For crew/polecat roles, this is used to check for beads redirects.
42+
// When a redirect is detected, BEADS_SYNC_BRANCH is set to "" to suppress
43+
// warnings, since the redirect target handles sync operations.
44+
WorkDir string
45+
}
46+
47+
// hasBeadsRedirect checks if the workDir has a beads redirect file.
48+
// This indicates the workdir uses a shared beads database (e.g., crew/polecat clones).
49+
func hasBeadsRedirect(workDir string) bool {
50+
if workDir == "" {
51+
return false
52+
}
53+
if filepath.Base(workDir) == ".beads" {
54+
workDir = filepath.Dir(workDir)
55+
}
56+
redirectPath := filepath.Join(workDir, ".beads", "redirect")
57+
_, err := os.Stat(redirectPath)
58+
return err == nil
3859
}
3960

4061
// AgentEnv returns all environment variables for an agent based on the config.
@@ -99,6 +120,12 @@ func AgentEnv(cfg AgentEnvConfig) map[string]string {
99120
env["BEADS_AGENT_NAME"] = fmt.Sprintf("%s/%s", cfg.Rig, cfg.AgentName)
100121
}
101122

123+
// Disable sync-branch when redirect is active - the canonical location
124+
// (e.g., mayor/rig/.beads) handles sync operations.
125+
if hasBeadsRedirect(cfg.WorkDir) {
126+
env["BEADS_SYNC_BRANCH"] = ""
127+
}
128+
102129
if cfg.BeadsNoDaemon {
103130
env["BEADS_NO_DAEMON"] = "1"
104131
}

internal/config/env_test.go

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

33
import (
4+
"os"
45
"testing"
56
)
67

@@ -17,6 +18,7 @@ func TestAgentEnv_Mayor(t *testing.T) {
1718
assertEnv(t, env, "GT_ROOT", "/town")
1819
assertNotSet(t, env, "GT_RIG")
1920
assertNotSet(t, env, "BEADS_NO_DAEMON")
21+
assertNotSet(t, env, "BEADS_SYNC_BRANCH")
2022
}
2123

2224
func TestAgentEnv_Witness(t *testing.T) {
@@ -32,6 +34,7 @@ func TestAgentEnv_Witness(t *testing.T) {
3234
assertEnv(t, env, "BD_ACTOR", "myrig/witness")
3335
assertEnv(t, env, "GIT_AUTHOR_NAME", "myrig/witness")
3436
assertEnv(t, env, "GT_ROOT", "/town")
37+
assertNotSet(t, env, "BEADS_SYNC_BRANCH")
3538
}
3639

3740
func TestAgentEnv_Polecat(t *testing.T) {
@@ -51,6 +54,7 @@ func TestAgentEnv_Polecat(t *testing.T) {
5154
assertEnv(t, env, "GIT_AUTHOR_NAME", "Toast")
5255
assertEnv(t, env, "BEADS_AGENT_NAME", "myrig/Toast")
5356
assertEnv(t, env, "BEADS_NO_DAEMON", "1")
57+
assertNotSet(t, env, "BEADS_SYNC_BRANCH") // Not set without redirect
5458
}
5559

5660
func TestAgentEnv_Crew(t *testing.T) {
@@ -70,6 +74,7 @@ func TestAgentEnv_Crew(t *testing.T) {
7074
assertEnv(t, env, "GIT_AUTHOR_NAME", "emma")
7175
assertEnv(t, env, "BEADS_AGENT_NAME", "myrig/emma")
7276
assertEnv(t, env, "BEADS_NO_DAEMON", "1")
77+
assertNotSet(t, env, "BEADS_SYNC_BRANCH") // Not set without redirect
7378
}
7479

7580
func TestAgentEnv_Refinery(t *testing.T) {
@@ -86,6 +91,7 @@ func TestAgentEnv_Refinery(t *testing.T) {
8691
assertEnv(t, env, "BD_ACTOR", "myrig/refinery")
8792
assertEnv(t, env, "GIT_AUTHOR_NAME", "myrig/refinery")
8893
assertEnv(t, env, "BEADS_NO_DAEMON", "1")
94+
assertNotSet(t, env, "BEADS_SYNC_BRANCH")
8995
}
9096

9197
func TestAgentEnv_Deacon(t *testing.T) {
@@ -101,6 +107,7 @@ func TestAgentEnv_Deacon(t *testing.T) {
101107
assertEnv(t, env, "GT_ROOT", "/town")
102108
assertNotSet(t, env, "GT_RIG")
103109
assertNotSet(t, env, "BEADS_NO_DAEMON")
110+
assertNotSet(t, env, "BEADS_SYNC_BRANCH")
104111
}
105112

106113
func TestAgentEnv_Boot(t *testing.T) {
@@ -116,6 +123,7 @@ func TestAgentEnv_Boot(t *testing.T) {
116123
assertEnv(t, env, "GT_ROOT", "/town")
117124
assertNotSet(t, env, "GT_RIG")
118125
assertNotSet(t, env, "BEADS_NO_DAEMON")
126+
assertNotSet(t, env, "BEADS_SYNC_BRANCH")
119127
}
120128

121129
func TestAgentEnv_WithRuntimeConfigDir(t *testing.T) {
@@ -420,6 +428,45 @@ func TestEnvToSlice(t *testing.T) {
420428
}
421429
}
422430

431+
func TestAgentEnv_WithBeadsRedirect(t *testing.T) {
432+
t.Parallel()
433+
// Create temp dir with .beads/redirect file
434+
workDir := t.TempDir()
435+
beadsDir := workDir + "/.beads"
436+
if err := os.MkdirAll(beadsDir, 0755); err != nil {
437+
t.Fatal(err)
438+
}
439+
if err := os.WriteFile(beadsDir+"/redirect", []byte("../../mayor/rig/.beads\n"), 0644); err != nil {
440+
t.Fatal(err)
441+
}
442+
443+
env := AgentEnv(AgentEnvConfig{
444+
Role: "crew",
445+
Rig: "myrig",
446+
AgentName: "emma",
447+
WorkDir: workDir,
448+
})
449+
450+
// With redirect present, BEADS_SYNC_BRANCH should be set to empty string
451+
assertEnv(t, env, "BEADS_SYNC_BRANCH", "")
452+
}
453+
454+
func TestAgentEnv_WithoutBeadsRedirect(t *testing.T) {
455+
t.Parallel()
456+
// Create temp dir WITHOUT redirect file
457+
workDir := t.TempDir()
458+
459+
env := AgentEnv(AgentEnvConfig{
460+
Role: "crew",
461+
Rig: "myrig",
462+
AgentName: "emma",
463+
WorkDir: workDir,
464+
})
465+
466+
// Without redirect, BEADS_SYNC_BRANCH should NOT be set
467+
assertNotSet(t, env, "BEADS_SYNC_BRANCH")
468+
}
469+
423470
// Helper functions
424471

425472
func assertEnv(t *testing.T, env map[string]string, key, expected string) {

internal/config/integration_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func TestRigLevelCustomAgentIntegration(t *testing.T) {
5757

5858
// Test 2: Verify BuildPolecatStartupCommand includes the custom agent
5959
t.Run("BuildPolecatStartupCommand uses custom agent", func(t *testing.T) {
60-
cmd := BuildPolecatStartupCommand(rigName, "test-polecat", rigPath, "")
60+
cmd := BuildPolecatStartupCommand(rigName, "test-polecat", rigPath, "", "")
6161

6262
if !strings.Contains(cmd, stubAgentPath) {
6363
t.Errorf("Expected command to contain stub agent path %q, got: %s", stubAgentPath, cmd)

internal/config/loader.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1516,7 +1516,8 @@ func BuildAgentStartupCommandWithAgentOverride(role, rig, townRoot, rigPath, pro
15161516

15171517
// BuildPolecatStartupCommand builds the startup command for a polecat.
15181518
// Sets GT_ROLE, GT_RIG, GT_POLECAT, BD_ACTOR, GIT_AUTHOR_NAME, and GT_ROOT.
1519-
func BuildPolecatStartupCommand(rigName, polecatName, rigPath, prompt string) string {
1519+
// workDir is the polecat's working directory, used to check for beads redirects.
1520+
func BuildPolecatStartupCommand(rigName, polecatName, rigPath, prompt, workDir string) string {
15201521
var townRoot string
15211522
if rigPath != "" {
15221523
townRoot = filepath.Dir(rigPath)
@@ -1526,12 +1527,13 @@ func BuildPolecatStartupCommand(rigName, polecatName, rigPath, prompt string) st
15261527
Rig: rigName,
15271528
AgentName: polecatName,
15281529
TownRoot: townRoot,
1530+
WorkDir: workDir,
15291531
})
15301532
return BuildStartupCommand(envVars, rigPath, prompt)
15311533
}
15321534

15331535
// BuildPolecatStartupCommandWithAgentOverride is like BuildPolecatStartupCommand, but uses agentOverride if non-empty.
1534-
func BuildPolecatStartupCommandWithAgentOverride(rigName, polecatName, rigPath, prompt, agentOverride string) (string, error) {
1536+
func BuildPolecatStartupCommandWithAgentOverride(rigName, polecatName, rigPath, prompt, agentOverride, workDir string) (string, error) {
15351537
var townRoot string
15361538
if rigPath != "" {
15371539
townRoot = filepath.Dir(rigPath)
@@ -1541,13 +1543,15 @@ func BuildPolecatStartupCommandWithAgentOverride(rigName, polecatName, rigPath,
15411543
Rig: rigName,
15421544
AgentName: polecatName,
15431545
TownRoot: townRoot,
1546+
WorkDir: workDir,
15441547
})
15451548
return BuildStartupCommandWithAgentOverride(envVars, rigPath, prompt, agentOverride)
15461549
}
15471550

15481551
// BuildCrewStartupCommand builds the startup command for a crew member.
15491552
// Sets GT_ROLE, GT_RIG, GT_CREW, BD_ACTOR, GIT_AUTHOR_NAME, and GT_ROOT.
1550-
func BuildCrewStartupCommand(rigName, crewName, rigPath, prompt string) string {
1553+
// workDir is the crew member's working directory, used to check for beads redirects.
1554+
func BuildCrewStartupCommand(rigName, crewName, rigPath, prompt, workDir string) string {
15511555
var townRoot string
15521556
if rigPath != "" {
15531557
townRoot = filepath.Dir(rigPath)
@@ -1557,12 +1561,13 @@ func BuildCrewStartupCommand(rigName, crewName, rigPath, prompt string) string {
15571561
Rig: rigName,
15581562
AgentName: crewName,
15591563
TownRoot: townRoot,
1564+
WorkDir: workDir,
15601565
})
15611566
return BuildStartupCommand(envVars, rigPath, prompt)
15621567
}
15631568

15641569
// BuildCrewStartupCommandWithAgentOverride is like BuildCrewStartupCommand, but uses agentOverride if non-empty.
1565-
func BuildCrewStartupCommandWithAgentOverride(rigName, crewName, rigPath, prompt, agentOverride string) (string, error) {
1570+
func BuildCrewStartupCommandWithAgentOverride(rigName, crewName, rigPath, prompt, agentOverride, workDir string) (string, error) {
15661571
var townRoot string
15671572
if rigPath != "" {
15681573
townRoot = filepath.Dir(rigPath)
@@ -1572,6 +1577,7 @@ func BuildCrewStartupCommandWithAgentOverride(rigName, crewName, rigPath, prompt
15721577
Rig: rigName,
15731578
AgentName: crewName,
15741579
TownRoot: townRoot,
1580+
WorkDir: workDir,
15751581
})
15761582
return BuildStartupCommandWithAgentOverride(envVars, rigPath, prompt, agentOverride)
15771583
}

internal/config/loader_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,7 +1001,7 @@ func TestBuildAgentStartupCommand(t *testing.T) {
10011001

10021002
func TestBuildPolecatStartupCommand(t *testing.T) {
10031003
t.Parallel()
1004-
cmd := BuildPolecatStartupCommand("gastown", "toast", "", "")
1004+
cmd := BuildPolecatStartupCommand("gastown", "toast", "", "", "")
10051005

10061006
if !strings.Contains(cmd, "GT_ROLE=gastown/polecats/toast") {
10071007
t.Error("expected GT_ROLE=gastown/polecats/toast in command")
@@ -1019,7 +1019,7 @@ func TestBuildPolecatStartupCommand(t *testing.T) {
10191019

10201020
func TestBuildCrewStartupCommand(t *testing.T) {
10211021
t.Parallel()
1022-
cmd := BuildCrewStartupCommand("gastown", "max", "", "")
1022+
cmd := BuildCrewStartupCommand("gastown", "max", "", "", "")
10231023

10241024
if !strings.Contains(cmd, "GT_ROLE=gastown/crew/max") {
10251025
t.Error("expected GT_ROLE=gastown/crew/max in command")
@@ -1125,7 +1125,7 @@ func TestBuildPolecatStartupCommandWithAgentOverride(t *testing.T) {
11251125
t.Fatalf("SaveRigSettings: %v", err)
11261126
}
11271127

1128-
cmd, err := BuildPolecatStartupCommandWithAgentOverride("testrig", "toast", rigPath, "", "gemini")
1128+
cmd, err := BuildPolecatStartupCommandWithAgentOverride("testrig", "toast", rigPath, "", "gemini", "")
11291129
if err != nil {
11301130
t.Fatalf("BuildPolecatStartupCommandWithAgentOverride: %v", err)
11311131
}
@@ -1208,7 +1208,7 @@ func TestBuildCrewStartupCommandWithAgentOverride(t *testing.T) {
12081208
t.Fatalf("SaveRigSettings: %v", err)
12091209
}
12101210

1211-
cmd, err := BuildCrewStartupCommandWithAgentOverride("testrig", "max", rigPath, "gt prime", "gemini")
1211+
cmd, err := BuildCrewStartupCommandWithAgentOverride("testrig", "max", rigPath, "gt prime", "gemini", "")
12121212
if err != nil {
12131213
t.Fatalf("BuildCrewStartupCommandWithAgentOverride: %v", err)
12141214
}

internal/crew/manager.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -501,7 +501,7 @@ func (m *Manager) Start(name string, opts StartOptions) error {
501501

502502
// Build startup command first
503503
// SessionStart hook handles context loading (gt prime --hook)
504-
claudeCmd, err := config.BuildCrewStartupCommandWithAgentOverride(m.rig.Name, name, m.rig.Path, beacon, opts.AgentOverride)
504+
claudeCmd, err := config.BuildCrewStartupCommandWithAgentOverride(m.rig.Name, name, m.rig.Path, beacon, opts.AgentOverride, worker.ClonePath)
505505
if err != nil {
506506
return fmt.Errorf("building startup command: %w", err)
507507
}
@@ -526,6 +526,7 @@ func (m *Manager) Start(name string, opts StartOptions) error {
526526
TownRoot: townRoot,
527527
RuntimeConfigDir: opts.ClaudeConfigDir,
528528
BeadsNoDaemon: true,
529+
WorkDir: worker.ClonePath,
529530
})
530531
for k, v := range envVars {
531532
_ = t.SetEnvironment(sessionID, k, v)

0 commit comments

Comments
 (0)