Files
gastown/internal/cmd/migrate_agents.go
organic 0b88dc204b feat(migration): Add gt migrate-agents command for two-level architecture (gt-nnub1)
Implements Phase 4 of the two-level beads architecture migration:
- Add hq- prefix helper functions for town-level agent beads
- Add CreateWithID method for deterministic bead creation
- Create gt migrate-agents command with dry-run/execute modes
- Migrate gt-mayor/gt-deacon to hq-mayor/hq-deacon in town beads
- Migrate role beads (gt-*-role) to town beads (hq-*-role)
- Add migration labels to old beads for tracking
- Idempotent: skips already-migrated beads

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:00:00 -08:00

322 lines
9.2 KiB
Go

package cmd
import (
"fmt"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/workspace"
)
var (
migrateAgentsDryRun bool
migrateAgentsForce bool
)
var migrateAgentsCmd = &cobra.Command{
Use: "migrate-agents",
GroupID: GroupDiag,
Short: "Migrate agent beads to two-level architecture",
Long: `Migrate agent beads from the old single-tier to the two-level architecture.
This command migrates town-level agent beads (Mayor, Deacon) from rig beads
with gt-* prefix to town beads with hq-* prefix:
OLD (rig beads): gt-mayor, gt-deacon
NEW (town beads): hq-mayor, hq-deacon
Rig-level agents (Witness, Refinery, Polecats) remain in rig beads unchanged.
The migration:
1. Detects old gt-mayor/gt-deacon beads in rig beads
2. Creates new hq-mayor/hq-deacon beads in town beads
3. Copies agent state (hook_bead, agent_state, etc.)
4. Adds migration note to old beads (preserves them)
Safety:
- Dry-run mode by default (use --execute to apply changes)
- Old beads are preserved with migration notes
- Validates new beads exist before marking migration complete
- Skips if new beads already exist (idempotent)
Examples:
gt migrate-agents # Dry-run: show what would be migrated
gt migrate-agents --execute # Apply the migration
gt migrate-agents --force # Re-migrate even if new beads exist`,
RunE: runMigrateAgents,
}
func init() {
migrateAgentsCmd.Flags().BoolVar(&migrateAgentsDryRun, "dry-run", true, "Show what would be migrated without making changes (default)")
migrateAgentsCmd.Flags().BoolVar(&migrateAgentsForce, "force", false, "Re-migrate even if new beads already exist")
// Add --execute as inverse of --dry-run for clarity
migrateAgentsCmd.Flags().BoolP("execute", "x", false, "Actually apply the migration (opposite of --dry-run)")
rootCmd.AddCommand(migrateAgentsCmd)
}
// migrationResult holds the result of a single bead migration.
type migrationResult struct {
OldID string
NewID string
Status string // "migrated", "skipped", "error"
Message string
OldFields *beads.AgentFields
WasDryRun bool
}
func runMigrateAgents(cmd *cobra.Command, args []string) error {
// Handle --execute flag
if execute, _ := cmd.Flags().GetBool("execute"); execute {
migrateAgentsDryRun = false
}
// Find town root
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Get town beads path
townBeadsDir := filepath.Join(townRoot, ".beads")
// Load routes to find rig beads
routes, err := beads.LoadRoutes(townBeadsDir)
if err != nil {
return fmt.Errorf("loading routes.jsonl: %w", err)
}
// Find the first rig with gt- prefix (where global agents are currently stored)
var sourceRigPath string
for _, r := range routes {
if strings.TrimSuffix(r.Prefix, "-") == "gt" && r.Path != "." {
sourceRigPath = r.Path
break
}
}
if sourceRigPath == "" {
fmt.Println("No rig with gt- prefix found. Nothing to migrate.")
return nil
}
// Source beads (rig beads where old agent beads are)
sourceBeadsDir := filepath.Join(townRoot, sourceRigPath, ".beads")
sourceBd := beads.New(sourceBeadsDir)
// Target beads (town beads where new agent beads should go)
targetBd := beads.NewWithBeadsDir(townRoot, townBeadsDir)
// Agents to migrate: town-level agents only
agentsToMigrate := []struct {
oldID string
newID string
desc string
}{
{
oldID: beads.MayorBeadID(), // gt-mayor
newID: beads.MayorBeadIDTown(), // hq-mayor
desc: "Mayor - global coordinator, handles cross-rig communication and escalations.",
},
{
oldID: beads.DeaconBeadID(), // gt-deacon
newID: beads.DeaconBeadIDTown(), // hq-deacon
desc: "Deacon (daemon beacon) - receives mechanical heartbeats, runs town plugins and monitoring.",
},
}
// Also migrate role beads
rolesToMigrate := []string{"mayor", "deacon", "witness", "refinery", "polecat", "crew", "dog"}
if migrateAgentsDryRun {
fmt.Println("🔍 DRY RUN: Showing what would be migrated")
fmt.Println(" Use --execute to apply changes")
fmt.Println()
} else {
fmt.Println("🚀 Migrating agent beads to two-level architecture")
fmt.Println()
}
var results []migrationResult
// Migrate agent beads
fmt.Println("Agent Beads:")
for _, agent := range agentsToMigrate {
result := migrateAgentBead(sourceBd, targetBd, agent.oldID, agent.newID, agent.desc, migrateAgentsDryRun, migrateAgentsForce)
results = append(results, result)
printMigrationResult(result)
}
// Migrate role beads
fmt.Println("\nRole Beads:")
for _, role := range rolesToMigrate {
oldID := "gt-" + role + "-role"
newID := beads.RoleBeadIDTown(role) // hq-<role>-role
result := migrateRoleBead(sourceBd, targetBd, oldID, newID, role, migrateAgentsDryRun, migrateAgentsForce)
results = append(results, result)
printMigrationResult(result)
}
// Summary
fmt.Println()
printMigrationSummary(results, migrateAgentsDryRun)
return nil
}
// migrateAgentBead migrates a single agent bead from source to target.
func migrateAgentBead(sourceBd, targetBd *beads.Beads, oldID, newID, desc string, dryRun, force bool) migrationResult {
result := migrationResult{
OldID: oldID,
NewID: newID,
WasDryRun: dryRun,
}
// Check if old bead exists
oldIssue, oldFields, err := sourceBd.GetAgentBead(oldID)
if err != nil {
result.Status = "skipped"
result.Message = "old bead not found"
return result
}
result.OldFields = oldFields
// Check if new bead already exists
if _, err := targetBd.Show(newID); err == nil {
if !force {
result.Status = "skipped"
result.Message = "new bead already exists (use --force to re-migrate)"
return result
}
}
if dryRun {
result.Status = "would migrate"
result.Message = fmt.Sprintf("would copy state from %s", oldIssue.ID)
return result
}
// Create new bead in town beads
newFields := &beads.AgentFields{
RoleType: oldFields.RoleType,
Rig: oldFields.Rig,
AgentState: oldFields.AgentState,
HookBead: oldFields.HookBead,
RoleBead: beads.RoleBeadIDTown(oldFields.RoleType), // Update to hq- role
CleanupStatus: oldFields.CleanupStatus,
ActiveMR: oldFields.ActiveMR,
NotificationLevel: oldFields.NotificationLevel,
}
_, err = targetBd.CreateAgentBead(newID, desc, newFields)
if err != nil {
result.Status = "error"
result.Message = fmt.Sprintf("failed to create: %v", err)
return result
}
// Add migration label to old bead
migrationLabel := fmt.Sprintf("migrated-to:%s", newID)
if err := sourceBd.Update(oldID, beads.UpdateOptions{AddLabels: []string{migrationLabel}}); err != nil {
// Non-fatal: just log it
result.Message = fmt.Sprintf("created but couldn't add migration label: %v", err)
}
result.Status = "migrated"
result.Message = "successfully migrated"
return result
}
// migrateRoleBead migrates a role definition bead.
func migrateRoleBead(sourceBd, targetBd *beads.Beads, oldID, newID, role string, dryRun, force bool) migrationResult {
result := migrationResult{
OldID: oldID,
NewID: newID,
WasDryRun: dryRun,
}
// Check if old bead exists
oldIssue, err := sourceBd.Show(oldID)
if err != nil {
result.Status = "skipped"
result.Message = "old bead not found"
return result
}
// Check if new bead already exists
if _, err := targetBd.Show(newID); err == nil {
if !force {
result.Status = "skipped"
result.Message = "new bead already exists (use --force to re-migrate)"
return result
}
}
if dryRun {
result.Status = "would migrate"
result.Message = fmt.Sprintf("would copy from %s", oldIssue.ID)
return result
}
// Create new role bead in town beads
// Role beads are simple - just copy the description
_, err = targetBd.CreateWithID(newID, beads.CreateOptions{
Title: fmt.Sprintf("Role: %s", role),
Type: "role",
Description: oldIssue.Title, // Use old title as description
})
if err != nil {
result.Status = "error"
result.Message = fmt.Sprintf("failed to create: %v", err)
return result
}
// Add migration label to old bead
migrationLabel := fmt.Sprintf("migrated-to:%s", newID)
if err := sourceBd.Update(oldID, beads.UpdateOptions{AddLabels: []string{migrationLabel}}); err != nil {
// Non-fatal
result.Message = fmt.Sprintf("created but couldn't add migration label: %v", err)
}
result.Status = "migrated"
result.Message = "successfully migrated"
return result
}
func printMigrationResult(r migrationResult) {
var icon string
switch r.Status {
case "migrated", "would migrate":
icon = " ✓"
case "skipped":
icon = " ⊘"
case "error":
icon = " ✗"
}
fmt.Printf("%s %s → %s: %s\n", icon, r.OldID, r.NewID, r.Message)
}
func printMigrationSummary(results []migrationResult, dryRun bool) {
var migrated, skipped, errors int
for _, r := range results {
switch r.Status {
case "migrated", "would migrate":
migrated++
case "skipped":
skipped++
case "error":
errors++
}
}
if dryRun {
fmt.Printf("Summary (dry-run): %d would migrate, %d skipped, %d errors\n", migrated, skipped, errors)
if migrated > 0 {
fmt.Println("\nRun with --execute to apply these changes.")
}
} else {
fmt.Printf("Summary: %d migrated, %d skipped, %d errors\n", migrated, skipped, errors)
}
}