fix(beads): align agent bead prefixes and force multi-hyphen IDs (#482)

* fix(beads): align agent bead prefixes and force multi-hyphen IDs

* fix(checkpoint): treat threshold as stale at boundary
This commit is contained in:
JJ
2026-01-16 15:33:51 -05:00
committed by GitHub
parent 03213a7307
commit b1a5241430
17 changed files with 141 additions and 26 deletions

View File

@@ -424,6 +424,9 @@ func (b *Beads) Create(opts CreateOptions) (*Issue, error) {
// deterministic IDs rather than auto-generated ones.
func (b *Beads) CreateWithID(id string, opts CreateOptions) (*Issue, error) {
args := []string{"create", "--json", "--id=" + id}
if NeedsForceForID(id) {
args = append(args, "--force")
}
if opts.Title != "" {
args = append(args, "--title="+opts.Title)

View File

@@ -139,6 +139,9 @@ func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue,
"--type=agent",
"--labels=gt:agent",
}
if NeedsForceForID(id) {
args = append(args, "--force")
}
// Default actor from BD_ACTOR env var for provenance tracking
if actor := os.Getenv("BD_ACTOR"); actor != "" {

View File

@@ -85,6 +85,9 @@ func (b *Beads) CreateRigBead(id, title string, fields *RigFields) (*Issue, erro
"--description=" + description,
"--labels=gt:rig",
}
if NeedsForceForID(id) {
args = append(args, "--force")
}
// Default actor from BD_ACTOR env var for provenance tracking
if actor := os.Getenv("BD_ACTOR"); actor != "" {

11
internal/beads/force.go Normal file
View File

@@ -0,0 +1,11 @@
package beads
import "strings"
// NeedsForceForID returns true when a bead ID uses multiple hyphens.
// Recent bd versions infer the prefix from the last hyphen, which can cause
// prefix-mismatch errors for valid system IDs like "st-stockdrop-polecat-nux"
// and "hq-cv-abc". We pass --force to honor the explicit ID in those cases.
func NeedsForceForID(id string) bool {
return strings.Count(id, "-") > 1
}

View File

@@ -0,0 +1,23 @@
package beads
import "testing"
func TestNeedsForceForID(t *testing.T) {
tests := []struct {
id string
want bool
}{
{id: "", want: false},
{id: "hq-mayor", want: false},
{id: "gt-abc123", want: false},
{id: "hq-mayor-role", want: true},
{id: "st-stockdrop-polecat-nux", want: true},
{id: "hq-cv-abc", want: true},
}
for _, tc := range tests {
if got := NeedsForceForID(tc.id); got != tc.want {
t.Fatalf("NeedsForceForID(%q) = %v, want %v", tc.id, got, tc.want)
}
}
}

View File

@@ -8,6 +8,8 @@ import (
"os"
"path/filepath"
"strings"
"github.com/steveyegge/gastown/internal/config"
)
// Route represents a prefix-to-path routing rule.
@@ -150,7 +152,7 @@ func GetPrefixForRig(townRoot, rigName string) string {
beadsDir := filepath.Join(townRoot, ".beads")
routes, err := LoadRoutes(beadsDir)
if err != nil || routes == nil {
return "gt" // Default prefix
return config.GetRigPrefix(townRoot, rigName)
}
// Look for a route where the path starts with the rig name
@@ -163,7 +165,7 @@ func GetPrefixForRig(townRoot, rigName string) string {
}
}
return "gt" // Default prefix
return config.GetRigPrefix(townRoot, rigName)
}
// FindConflictingPrefixes checks for duplicate prefixes in routes.

View File

@@ -4,6 +4,8 @@ import (
"os"
"path/filepath"
"testing"
"github.com/steveyegge/gastown/internal/config"
)
func TestGetPrefixForRig(t *testing.T) {
@@ -52,6 +54,33 @@ func TestGetPrefixForRig_NoRoutesFile(t *testing.T) {
}
}
func TestGetPrefixForRig_RigsConfigFallback(t *testing.T) {
tmpDir := t.TempDir()
// Write rigs.json with a non-gt prefix
rigsPath := filepath.Join(tmpDir, "mayor", "rigs.json")
if err := os.MkdirAll(filepath.Dir(rigsPath), 0755); err != nil {
t.Fatal(err)
}
cfg := &config.RigsConfig{
Version: config.CurrentRigsVersion,
Rigs: map[string]config.RigEntry{
"project_ideas": {
BeadsConfig: &config.BeadsConfig{Prefix: "pi"},
},
},
}
if err := config.SaveRigsConfig(rigsPath, cfg); err != nil {
t.Fatalf("SaveRigsConfig: %v", err)
}
result := GetPrefixForRig(tmpDir, "project_ideas")
if result != "pi" {
t.Errorf("Expected prefix from rigs config, got %q", result)
}
}
func TestExtractPrefix(t *testing.T) {
tests := []struct {
beadID string
@@ -100,7 +129,7 @@ func TestGetRigPathForPrefix(t *testing.T) {
}{
{"ap-", filepath.Join(tmpDir, "ai_platform/mayor/rig")},
{"gt-", filepath.Join(tmpDir, "gastown/mayor/rig")},
{"hq-", tmpDir}, // Town-level beads return townRoot
{"hq-", tmpDir}, // Town-level beads return townRoot
{"unknown-", ""}, // Unknown prefix returns empty
{"", ""}, // Empty prefix returns empty
}

View File

@@ -181,9 +181,9 @@ func (cp *Checkpoint) Age() time.Duration {
return time.Since(cp.Timestamp)
}
// IsStale returns true if the checkpoint is older than the threshold.
// IsStale returns true if the checkpoint is at or older than the threshold.
func (cp *Checkpoint) IsStale(threshold time.Duration) bool {
return cp.Age() > threshold
return cp.Age() >= threshold
}
// Summary returns a concise summary of the checkpoint.

View File

@@ -16,6 +16,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tui/convoy"
"github.com/steveyegge/gastown/internal/workspace"
@@ -319,6 +320,9 @@ func runConvoyCreate(cmd *cobra.Command, args []string) error {
"--description=" + description,
"--json",
}
if beads.NeedsForceForID(convoyID) {
createArgs = append(createArgs, "--force")
}
createCmd := exec.Command("bd", createArgs...)
createCmd.Dir = townBeads

View File

@@ -211,7 +211,14 @@ func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) {
if wsErr != nil {
t.Fatalf("workspace.FindFromCwdOrError failed: %v", wsErr)
}
if foundTownRoot != townRoot {
normalizePath := func(path string) string {
resolved, err := filepath.EvalSymlinks(path)
if err != nil {
return filepath.Clean(path)
}
return resolved
}
if normalizePath(foundTownRoot) != normalizePath(townRoot) {
t.Errorf("workspace.FindFromCwdOrError returned %s, expected %s", foundTownRoot, townRoot)
}

View File

@@ -11,6 +11,7 @@ import (
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
@@ -335,6 +336,9 @@ func executeConvoyFormula(f *formulaData, formulaName, targetRig string) error {
"--title=" + convoyTitle,
"--description=" + description,
}
if beads.NeedsForceForID(convoyID) {
createArgs = append(createArgs, "--force")
}
createCmd := exec.Command("bd", createArgs...)
createCmd.Dir = townBeads
@@ -365,6 +369,9 @@ func executeConvoyFormula(f *formulaData, formulaName, targetRig string) error {
"--title=" + leg.Title,
"--description=" + legDesc,
}
if beads.NeedsForceForID(legBeadID) {
legArgs = append(legArgs, "--force")
}
legCmd := exec.Command("bd", legArgs...)
legCmd.Dir = townBeads
@@ -405,6 +412,9 @@ func executeConvoyFormula(f *formulaData, formulaName, targetRig string) error {
"--title=" + f.Synthesis.Title,
"--description=" + synDesc,
}
if beads.NeedsForceForID(synthesisBeadID) {
synArgs = append(synArgs, "--force")
}
synCmd := exec.Command("bd", synArgs...)
synCmd.Dir = townBeads

View File

@@ -957,7 +957,7 @@ func runPolecatCheckRecovery(cmd *cobra.Command, args []string) error {
// We need to read it directly from beads since manager doesn't expose it
rigPath := r.Path
bd := beads.New(rigPath)
agentBeadID := beads.PolecatBeadID(rigName, polecatName)
agentBeadID := polecatBeadIDForRig(r, rigName, polecatName)
_, fields, err := bd.GetAgentBead(agentBeadID)
status := RecoveryStatus{
@@ -1158,7 +1158,7 @@ func runPolecatNuke(cmd *cobra.Command, args []string) error {
fmt.Printf(" - Kill session: gt-%s-%s\n", p.rigName, p.polecatName)
fmt.Printf(" - Delete worktree: %s/polecats/%s\n", p.r.Path, p.polecatName)
fmt.Printf(" - Delete branch (if exists)\n")
fmt.Printf(" - Close agent bead: %s\n", beads.PolecatBeadID(p.rigName, p.polecatName))
fmt.Printf(" - Close agent bead: %s\n", polecatBeadIDForRig(p.r, p.rigName, p.polecatName))
displayDryRunSafetyCheck(p)
fmt.Println()
@@ -1214,7 +1214,7 @@ func runPolecatNuke(cmd *cobra.Command, args []string) error {
}
// Step 5: Close agent bead (if exists)
agentBeadID := beads.PolecatBeadID(p.rigName, p.polecatName)
agentBeadID := polecatBeadIDForRig(p.r, p.rigName, p.polecatName)
closeArgs := []string{"close", agentBeadID, "--reason=nuked"}
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
closeArgs = append(closeArgs, "--session="+sessionID)

View File

@@ -2,6 +2,7 @@ package cmd
import (
"fmt"
"path/filepath"
"strings"
"github.com/steveyegge/gastown/internal/beads"
@@ -104,7 +105,7 @@ func checkPolecatSafety(target polecatTarget) *SafetyCheckResult {
// Check 1: Unpushed commits via cleanup_status or git state
bd := beads.New(target.r.Path)
agentBeadID := beads.PolecatBeadID(target.rigName, target.polecatName)
agentBeadID := polecatBeadIDForRig(target.r, target.rigName, target.polecatName)
agentIssue, fields, err := bd.GetAgentBead(agentBeadID)
if err != nil || fields == nil {
@@ -176,6 +177,15 @@ func checkPolecatSafety(target polecatTarget) *SafetyCheckResult {
return result
}
func rigPrefix(r *rig.Rig) string {
townRoot := filepath.Dir(r.Path)
return beads.GetPrefixForRig(townRoot, r.Name)
}
func polecatBeadIDForRig(r *rig.Rig, rigName, polecatName string) string {
return beads.PolecatBeadIDWithPrefix(rigPrefix(r), rigName, polecatName)
}
// displaySafetyCheckBlocked prints blocked polecats and guidance.
func displaySafetyCheckBlocked(blocked []*SafetyCheckResult) {
fmt.Printf("%s Cannot nuke the following polecats:\n\n", style.Error.Render("Error:"))
@@ -202,7 +212,7 @@ func displayDryRunSafetyCheck(target polecatTarget) {
fmt.Printf("\n Safety checks:\n")
polecatInfo, infoErr := target.mgr.Get(target.polecatName)
bd := beads.New(target.r.Path)
agentBeadID := beads.PolecatBeadID(target.rigName, target.polecatName)
agentBeadID := polecatBeadIDForRig(target.r, target.rigName, target.polecatName)
agentIssue, fields, err := bd.GetAgentBead(agentBeadID)
// Check 1: Git state

View File

@@ -232,7 +232,7 @@ func runPolecatIdentityAdd(cmd *cobra.Command, args []string) error {
// Check if identity already exists
bd := beads.New(r.Path)
beadID := beads.PolecatBeadID(rigName, polecatName)
beadID := polecatBeadIDForRig(r, rigName, polecatName)
existingIssue, _, _ := bd.GetAgentBead(beadID)
if existingIssue != nil && existingIssue.Status != "closed" {
return fmt.Errorf("identity bead %s already exists", beadID)
@@ -385,7 +385,7 @@ func runPolecatIdentityShow(cmd *cobra.Command, args []string) error {
// Get identity bead
bd := beads.New(r.Path)
beadID := beads.PolecatBeadID(rigName, polecatName)
beadID := polecatBeadIDForRig(r, rigName, polecatName)
issue, fields, err := bd.GetAgentBead(beadID)
if err != nil {
return fmt.Errorf("getting identity bead: %w", err)
@@ -414,10 +414,10 @@ func runPolecatIdentityShow(cmd *cobra.Command, args []string) error {
if polecatIdentityShowJSON {
output := struct {
IdentityInfo
Title string `json:"title"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
CV *CVSummary `json:"cv,omitempty"`
Title string `json:"title"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
CV *CVSummary `json:"cv,omitempty"`
}{
IdentityInfo: IdentityInfo{
Rig: rigName,
@@ -563,8 +563,8 @@ func runPolecatIdentityRename(cmd *cobra.Command, args []string) error {
}
bd := beads.New(r.Path)
oldBeadID := beads.PolecatBeadID(rigName, oldName)
newBeadID := beads.PolecatBeadID(rigName, newName)
oldBeadID := polecatBeadIDForRig(r, rigName, oldName)
newBeadID := polecatBeadIDForRig(r, rigName, newName)
// Check old identity exists
oldIssue, oldFields, err := bd.GetAgentBead(oldBeadID)
@@ -631,7 +631,7 @@ func runPolecatIdentityRemove(cmd *cobra.Command, args []string) error {
}
bd := beads.New(r.Path)
beadID := beads.PolecatBeadID(rigName, polecatName)
beadID := polecatBeadIDForRig(r, rigName, polecatName)
// Check identity exists
issue, fields, err := bd.GetAgentBead(beadID)

View File

@@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
@@ -80,6 +81,9 @@ func createAutoConvoy(beadID, beadTitle string) (string, error) {
"--title=" + convoyTitle,
"--description=" + description,
}
if beads.NeedsForceForID(convoyID) {
createArgs = append(createArgs, "--force")
}
createCmd := exec.Command("bd", append([]string{"--no-daemon"}, createArgs...)...)
createCmd.Dir = townBeads

View File

@@ -751,7 +751,8 @@ func (d *Daemon) checkPolecatHealth(rigName, polecatName string) {
}
// Session is dead. Check if the polecat has work-on-hook.
agentBeadID := beads.PolecatBeadID(rigName, polecatName)
prefix := beads.GetPrefixForRig(d.config.TownRoot, rigName)
agentBeadID := beads.PolecatBeadIDWithPrefix(prefix, rigName, polecatName)
info, err := d.getAgentBeadInfo(agentBeadID)
if err != nil {
// Agent bead doesn't exist or error - polecat might not be registered

View File

@@ -100,12 +100,17 @@ func (c *RoleBeadsCheck) Fix(ctx *CheckContext) error {
}
// Create role bead using bd create --type=role
cmd := exec.Command("bd", "create",
args := []string{
"create",
"--type=role",
"--id="+role.ID,
"--title="+role.Title,
"--description="+role.Desc,
)
"--id=" + role.ID,
"--title=" + role.Title,
"--description=" + role.Desc,
}
if beads.NeedsForceForID(role.ID) {
args = append(args, "--force")
}
cmd := exec.Command("bd", args...)
cmd.Dir = ctx.TownRoot
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("creating %s: %s", role.ID, strings.TrimSpace(string(output)))