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:
@@ -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)
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
11
internal/beads/force.go
Normal 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
|
||||
}
|
||||
23
internal/beads/force_test.go
Normal file
23
internal/beads/force_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user