Revert IsWisp: false → true for patrol spawning. bd mol run now auto-discovers the main database for templates when --db contains .beads-wisp, so patrol molecules can spawn correctly into ephemeral storage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1435 lines
45 KiB
Go
1435 lines
45 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
"github.com/steveyegge/gastown/internal/git"
|
|
"github.com/steveyegge/gastown/internal/mail"
|
|
"github.com/steveyegge/gastown/internal/polecat"
|
|
"github.com/steveyegge/gastown/internal/rig"
|
|
"github.com/steveyegge/gastown/internal/session"
|
|
"github.com/steveyegge/gastown/internal/style"
|
|
"github.com/steveyegge/gastown/internal/suggest"
|
|
"github.com/steveyegge/gastown/internal/tmux"
|
|
"github.com/steveyegge/gastown/internal/workspace"
|
|
)
|
|
|
|
// Sling command flags
|
|
var (
|
|
slingWisp bool // Create wisp instead of durable mol
|
|
slingMolecule string // Molecule proto when slinging an issue
|
|
slingPriority int // Override priority (P0-P4)
|
|
slingForce bool // Re-sling even if hook has work
|
|
slingNoStart bool // Assign work but don't start session
|
|
slingCreate bool // Create polecat if it doesn't exist
|
|
slingUrgent bool // Interrupt patrol cycle, process immediately
|
|
slingReplace bool // Replace patrol with discrete work (break-glass)
|
|
)
|
|
|
|
var slingCmd = &cobra.Command{
|
|
Use: "sling <thing> <target>",
|
|
Short: "Unified work dispatch command",
|
|
Long: `Sling work at an agent - the universal Gas Town work dispatch.
|
|
|
|
This command implements spawn + assign + pin in one operation.
|
|
Based on the Universal Gas Town Propulsion Principle:
|
|
|
|
"If you find something on your hook, YOU RUN IT."
|
|
|
|
SLING MECHANICS:
|
|
┌─────────┐ ┌───────────────────────────────────────────┐
|
|
│ THING │─────▶│ SLING PIPELINE │
|
|
└─────────┘ │ │
|
|
proto │ 1. SPAWN Proto → Molecule instance │
|
|
issue │ 2. ASSIGN Molecule → Target agent │
|
|
epic │ 3. PIN Work → Agent's hook │
|
|
│ 4. IGNITE Session starts automatically │
|
|
│ │
|
|
│ ┌─────────────────────────────┐ │
|
|
│ │ 🪝 TARGET's HOOK │ │
|
|
│ │ └── [work lands here] │ │
|
|
│ └─────────────────────────────┘ │
|
|
└───────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
Agent runs the work!
|
|
|
|
THING TYPES:
|
|
proto Molecule template name (e.g., "feature", "bugfix")
|
|
issue Beads issue ID (e.g., "gt-abc123")
|
|
epic Epic ID for batch dispatch
|
|
|
|
TARGET FORMATS:
|
|
gastown/Toast → Polecat in rig (auto-starts session)
|
|
gastown/crew/dave → Crew member (human-managed, no auto-start)
|
|
gastown/witness → Rig's Witness
|
|
gastown/refinery → Rig's Refinery
|
|
deacon/ → Global Deacon
|
|
mayor/ → Town Mayor (human-managed)
|
|
|
|
Examples:
|
|
gt sling feature gastown/Toast # Spawn feature, sling to polecat
|
|
gt sling gt-abc gastown/Nux -m bugfix # Issue with workflow
|
|
gt sling patrol deacon/ --wisp # Patrol wisp to deacon
|
|
gt sling version-bump beads/crew/dave # Mol to crew member
|
|
gt sling epic-123 mayor/ # Epic to mayor`,
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: runSling,
|
|
}
|
|
|
|
func init() {
|
|
slingCmd.Flags().BoolVar(&slingWisp, "wisp", false, "Create wisp (burned on complete)")
|
|
slingCmd.Flags().StringVarP(&slingMolecule, "molecule", "m", "", "Molecule proto when slinging an issue")
|
|
slingCmd.Flags().IntVarP(&slingPriority, "priority", "p", -1, "Override priority (0-4)")
|
|
slingCmd.Flags().BoolVar(&slingForce, "force", false, "Re-sling even if hook has work")
|
|
slingCmd.Flags().BoolVar(&slingNoStart, "no-start", false, "Assign work but don't start session")
|
|
slingCmd.Flags().BoolVar(&slingCreate, "create", false, "Create polecat if it doesn't exist")
|
|
slingCmd.Flags().BoolVar(&slingUrgent, "urgent", false, "Interrupt patrol cycle (patrol roles only)")
|
|
slingCmd.Flags().BoolVar(&slingReplace, "replace", false, "Replace patrol with discrete work (break-glass)")
|
|
|
|
rootCmd.AddCommand(slingCmd)
|
|
}
|
|
|
|
// isPatrolRole returns true if the target kind is a patrol-based agent.
|
|
func isPatrolRole(kind string) bool {
|
|
switch kind {
|
|
case "witness", "refinery", "deacon":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// getDefaultPatrolMolecule returns the default patrol molecule title for a role.
|
|
func getDefaultPatrolMolecule(role string) string {
|
|
switch role {
|
|
case "witness":
|
|
return "mol-witness-patrol"
|
|
case "refinery":
|
|
return "mol-refinery-patrol"
|
|
case "deacon":
|
|
return "mol-deacon-patrol"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// resolvePatrolMoleculeID looks up the beads issue ID for a patrol molecule by title.
|
|
// Returns the issue ID (e.g., "gt-qflq") for the given molecule title (e.g., "mol-witness-patrol").
|
|
func resolvePatrolMoleculeID(beadsPath, title string) (string, error) {
|
|
// Use bd list --title to find the issue ID
|
|
cmd := exec.Command("bd", "--no-daemon", "list", "--title="+title, "--json")
|
|
cmd.Dir = beadsPath
|
|
|
|
var stdout bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = nil
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return "", fmt.Errorf("looking up patrol molecule: %w", err)
|
|
}
|
|
|
|
// Parse JSON array of issues
|
|
var issues []struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
|
|
return "", fmt.Errorf("parsing patrol molecule lookup: %w", err)
|
|
}
|
|
|
|
if len(issues) == 0 {
|
|
return "", fmt.Errorf("patrol molecule not found: %s", title)
|
|
}
|
|
|
|
return issues[0].ID, nil
|
|
}
|
|
|
|
// isPatrolRunning checks if a patrol is currently attached to the agent's hook.
|
|
func isPatrolRunning(beadsPath, agentAddress string) (bool, string) {
|
|
parts := strings.Split(agentAddress, "/")
|
|
var role string
|
|
if len(parts) >= 2 {
|
|
role = parts[len(parts)-1]
|
|
} else {
|
|
role = parts[0]
|
|
}
|
|
|
|
b := beads.New(beadsPath)
|
|
handoff, err := b.FindHandoffBead(role)
|
|
if err != nil || handoff == nil {
|
|
return false, ""
|
|
}
|
|
|
|
attachment := beads.ParseAttachmentFields(handoff)
|
|
if attachment == nil || attachment.AttachedMolecule == "" {
|
|
return false, ""
|
|
}
|
|
|
|
// Check if the attached molecule looks like a patrol
|
|
// Patrol molecules typically have "patrol" in the ID or are wisps
|
|
attachedID := attachment.AttachedMolecule
|
|
if strings.Contains(attachedID, "patrol") {
|
|
return true, attachedID
|
|
}
|
|
|
|
// Also check if it's the root of a patrol molecule by looking at the issue
|
|
issue, err := b.Show(attachedID)
|
|
if err == nil && issue != nil {
|
|
// Check title for patrol indication
|
|
if strings.Contains(strings.ToLower(issue.Title), "patrol") {
|
|
return true, attachedID
|
|
}
|
|
}
|
|
|
|
return false, ""
|
|
}
|
|
|
|
// SlingThing represents what's being slung.
|
|
type SlingThing struct {
|
|
Kind string // "proto", "issue", or "epic"
|
|
ID string // The identifier (proto name or issue ID)
|
|
Proto string // If Kind=="issue" and --molecule set, the proto name
|
|
IsWisp bool // If --wisp flag set
|
|
}
|
|
|
|
// SlingTarget represents who's being slung at.
|
|
type SlingTarget struct {
|
|
Kind string // "polecat", "deacon", "witness", "refinery"
|
|
Rig string // Rig name (empty for town-level agents)
|
|
Name string // Agent name (for polecats)
|
|
}
|
|
|
|
func runSling(cmd *cobra.Command, args []string) error {
|
|
thingArg := args[0]
|
|
targetArg := args[1]
|
|
|
|
// Find workspace
|
|
townRoot, err := workspace.FindFromCwdOrError()
|
|
if err != nil {
|
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
// Parse target first (needed to determine rig context)
|
|
target, err := parseSlingTarget(targetArg, townRoot)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid target: %w", err)
|
|
}
|
|
|
|
// Get rig context
|
|
rigPath := filepath.Join(townRoot, target.Rig)
|
|
beadsPath := rigPath
|
|
|
|
// Parse thing (needs beads context for proto lookup)
|
|
thing, err := parseSlingThing(thingArg, beadsPath)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid thing: %w", err)
|
|
}
|
|
|
|
// Apply flags to thing
|
|
thing.Proto = slingMolecule
|
|
thing.IsWisp = slingWisp
|
|
|
|
fmt.Printf("Slinging %s %s at %s\n",
|
|
thing.Kind, style.Bold.Render(thing.ID),
|
|
style.Bold.Render(targetArg))
|
|
|
|
// Route based on target kind
|
|
switch target.Kind {
|
|
case "polecat":
|
|
return slingToPolecat(townRoot, target, thing)
|
|
case "crew":
|
|
return slingToCrew(townRoot, target, thing)
|
|
case "deacon":
|
|
return slingToDeacon(townRoot, target, thing)
|
|
case "witness":
|
|
return slingToWitness(townRoot, target, thing)
|
|
case "refinery":
|
|
return slingToRefinery(townRoot, target, thing)
|
|
case "mayor":
|
|
return slingToMayor(townRoot, target, thing)
|
|
default:
|
|
return fmt.Errorf("unknown target kind: %s", target.Kind)
|
|
}
|
|
}
|
|
|
|
// parseSlingThing parses the <thing> argument.
|
|
// Returns the kind (proto, issue, epic) and ID.
|
|
func parseSlingThing(arg, beadsPath string) (*SlingThing, error) {
|
|
// Check if it looks like an issue ID (has a prefix like gt-, bd-, hq-)
|
|
if looksLikeIssueID(arg) {
|
|
// Fetch the issue to check its type
|
|
b := beads.New(beadsPath)
|
|
issue, err := b.Show(arg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("issue not found: %s", arg)
|
|
}
|
|
|
|
kind := "issue"
|
|
if issue.Type == "epic" {
|
|
kind = "epic"
|
|
}
|
|
|
|
return &SlingThing{
|
|
Kind: kind,
|
|
ID: arg,
|
|
}, nil
|
|
}
|
|
|
|
// Otherwise, assume it's a proto name
|
|
// Validate that the proto exists in the catalog
|
|
catalog, err := loadMoleculeCatalog(beadsPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading catalog: %w", err)
|
|
}
|
|
|
|
// Try both the exact name and with "mol-" prefix
|
|
protoID := arg
|
|
if catalog.Get(protoID) == nil {
|
|
protoID = "mol-" + arg
|
|
if catalog.Get(protoID) == nil {
|
|
return nil, fmt.Errorf("proto not found: %s (tried %s and mol-%s)", arg, arg, arg)
|
|
}
|
|
}
|
|
|
|
return &SlingThing{
|
|
Kind: "proto",
|
|
ID: protoID,
|
|
}, nil
|
|
}
|
|
|
|
// parseSlingTarget parses the <target> argument.
|
|
// Format: polecat/name, deacon/, witness/, refinery/
|
|
// Or with rig: gastown/polecat/name, gastown/witness
|
|
func parseSlingTarget(arg, townRoot string) (*SlingTarget, error) {
|
|
parts := strings.Split(arg, "/")
|
|
|
|
// Handle various formats
|
|
switch len(parts) {
|
|
case 1:
|
|
// Single word like "deacon" - need rig context
|
|
rigName, err := inferRigFromCwd(townRoot)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot infer rig: %w", err)
|
|
}
|
|
return parseAgentKind(parts[0], "", rigName)
|
|
|
|
case 2:
|
|
// Could be: polecat/name, rig/role, or role/ (trailing slash)
|
|
first, second := parts[0], parts[1]
|
|
|
|
// Check for trailing slash (e.g., "deacon/")
|
|
if second == "" {
|
|
rigName, err := inferRigFromCwd(townRoot)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot infer rig: %w", err)
|
|
}
|
|
return parseAgentKind(first, "", rigName)
|
|
}
|
|
|
|
// Check if first is a known role
|
|
if isAgentRole(first) {
|
|
// It's role/name (e.g., polecat/alpha)
|
|
rigName, err := inferRigFromCwd(townRoot)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot infer rig: %w", err)
|
|
}
|
|
return parseAgentKind(first, second, rigName)
|
|
}
|
|
|
|
// Otherwise it's rig/role (e.g., gastown/deacon)
|
|
return parseAgentKind(second, "", first)
|
|
|
|
case 3:
|
|
// rig/role/name (e.g., gastown/polecat/alpha)
|
|
rigName, role, name := parts[0], parts[1], parts[2]
|
|
return parseAgentKind(role, name, rigName)
|
|
|
|
default:
|
|
return nil, fmt.Errorf("invalid target format: %s", arg)
|
|
}
|
|
}
|
|
|
|
// parseAgentKind creates a SlingTarget from parsed components.
|
|
func parseAgentKind(role, name, rigName string) (*SlingTarget, error) {
|
|
role = strings.ToLower(role)
|
|
|
|
switch role {
|
|
case "polecat", "polecats":
|
|
if name == "" {
|
|
return nil, fmt.Errorf("polecat target requires a name (e.g., polecat/alpha)")
|
|
}
|
|
return &SlingTarget{Kind: "polecat", Rig: rigName, Name: name}, nil
|
|
|
|
case "crew":
|
|
if name == "" {
|
|
return nil, fmt.Errorf("crew target requires a name (e.g., crew/dave)")
|
|
}
|
|
return &SlingTarget{Kind: "crew", Rig: rigName, Name: name}, nil
|
|
|
|
case "deacon":
|
|
return &SlingTarget{Kind: "deacon", Rig: rigName}, nil
|
|
|
|
case "witness":
|
|
return &SlingTarget{Kind: "witness", Rig: rigName}, nil
|
|
|
|
case "refinery":
|
|
return &SlingTarget{Kind: "refinery", Rig: rigName}, nil
|
|
|
|
case "mayor":
|
|
// Mayor is town-level, rig is ignored
|
|
return &SlingTarget{Kind: "mayor", Rig: ""}, nil
|
|
|
|
default:
|
|
// Might be a polecat name without "polecat/" prefix
|
|
// Try to detect by checking if it's a valid rig name
|
|
return &SlingTarget{Kind: "polecat", Rig: rigName, Name: role}, nil
|
|
}
|
|
}
|
|
|
|
// isAgentRole returns true if the string is a known agent role.
|
|
func isAgentRole(s string) bool {
|
|
switch strings.ToLower(s) {
|
|
case "polecat", "polecats", "deacon", "witness", "refinery", "crew", "mayor":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// looksLikeIssueID returns true if the string looks like a beads issue ID.
|
|
func looksLikeIssueID(s string) bool {
|
|
// Issue IDs have a prefix followed by a dash
|
|
// Common prefixes: gt-, bd-, hq-
|
|
prefixes := []string{"gt-", "bd-", "hq-", "beads-"}
|
|
for _, prefix := range prefixes {
|
|
if strings.HasPrefix(s, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// slingToPolecat handles slinging work to a polecat.
|
|
func slingToPolecat(townRoot string, target *SlingTarget, thing *SlingThing) error {
|
|
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
|
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
|
if err != nil {
|
|
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
|
|
}
|
|
|
|
g := git.NewGit(townRoot)
|
|
rigMgr := rig.NewManager(townRoot, rigsConfig, g)
|
|
r, err := rigMgr.GetRig(target.Rig)
|
|
if err != nil {
|
|
return fmt.Errorf("rig '%s' not found", target.Rig)
|
|
}
|
|
|
|
// Get polecat manager
|
|
polecatGit := git.NewGit(r.Path)
|
|
polecatMgr := polecat.NewManager(r, polecatGit)
|
|
|
|
polecatName := target.Name
|
|
polecatAddress := fmt.Sprintf("%s/%s", target.Rig, polecatName)
|
|
|
|
// Router for mail operations
|
|
router := mail.NewRouter(r.Path)
|
|
|
|
// Check if polecat exists
|
|
existingPolecat, err := polecatMgr.Get(polecatName)
|
|
polecatExists := err == nil
|
|
|
|
if polecatExists {
|
|
// Check for existing work on hook
|
|
displacedID, err := checkHookCollision(polecatAddress, r.Path, slingForce)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if displacedID != "" {
|
|
fmt.Printf("%s Displaced %s back to ready pool\n", style.Warning.Render("⚠"), displacedID)
|
|
if err := releaseDisplacedWork(r.Path, displacedID); err != nil {
|
|
fmt.Printf(" %s could not release: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
}
|
|
|
|
// Check for uncommitted work
|
|
pGit := git.NewGit(existingPolecat.ClonePath)
|
|
workStatus, checkErr := pGit.CheckUncommittedWork()
|
|
if checkErr == nil && !workStatus.Clean() {
|
|
fmt.Printf("\n%s Polecat has uncommitted work:\n", style.Warning.Render("⚠"))
|
|
if workStatus.HasUncommittedChanges {
|
|
fmt.Printf(" • %d uncommitted change(s)\n", len(workStatus.ModifiedFiles)+len(workStatus.UntrackedFiles))
|
|
}
|
|
if workStatus.StashCount > 0 {
|
|
fmt.Printf(" • %d stash(es)\n", workStatus.StashCount)
|
|
}
|
|
if workStatus.UnpushedCommits > 0 {
|
|
fmt.Printf(" • %d unpushed commit(s)\n", workStatus.UnpushedCommits)
|
|
}
|
|
fmt.Println()
|
|
if !slingForce {
|
|
return fmt.Errorf("polecat '%s' has uncommitted work\nUse --force to proceed anyway", polecatName)
|
|
}
|
|
fmt.Printf("%s Proceeding with --force\n", style.Dim.Render("Warning:"))
|
|
}
|
|
|
|
// Check for unread mail
|
|
mailbox, mailErr := router.GetMailbox(polecatAddress)
|
|
if mailErr == nil {
|
|
_, unread, _ := mailbox.Count()
|
|
if unread > 0 && !slingForce {
|
|
return fmt.Errorf("polecat '%s' has %d unread message(s)\nUse --force to override", polecatName, unread)
|
|
} else if unread > 0 {
|
|
fmt.Printf("%s Polecat has %d unread message(s), proceeding with --force\n",
|
|
style.Dim.Render("Warning:"), unread)
|
|
}
|
|
}
|
|
|
|
// Recreate polecat with fresh worktree
|
|
fmt.Printf("Recreating polecat %s with fresh worktree...\n", polecatName)
|
|
if _, err = polecatMgr.Recreate(polecatName, slingForce); err != nil {
|
|
return fmt.Errorf("recreating polecat: %w", err)
|
|
}
|
|
fmt.Printf("%s Fresh worktree created\n", style.Bold.Render("✓"))
|
|
} else if err == polecat.ErrPolecatNotFound {
|
|
if !slingCreate {
|
|
suggestions := suggest.FindSimilar(polecatName, r.Polecats, 3)
|
|
hint := fmt.Sprintf("Or use --create to create: gt sling %s %s/%s --create",
|
|
thing.ID, target.Rig, polecatName)
|
|
return fmt.Errorf("%s", suggest.FormatSuggestion("Polecat", polecatName, suggestions, hint))
|
|
}
|
|
fmt.Printf("Creating polecat %s...\n", polecatName)
|
|
if _, err = polecatMgr.Add(polecatName); err != nil {
|
|
return fmt.Errorf("creating polecat: %w", err)
|
|
}
|
|
} else {
|
|
return fmt.Errorf("getting polecat: %w", err)
|
|
}
|
|
|
|
beadsPath := r.Path
|
|
|
|
// Sync beads
|
|
if err := syncBeads(beadsPath, true); err != nil {
|
|
fmt.Printf("%s beads sync: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
|
|
// Process the thing based on its kind
|
|
var issueID string
|
|
var moleculeCtx *MoleculeContext
|
|
|
|
switch thing.Kind {
|
|
case "proto":
|
|
// Spawn molecule from proto
|
|
issueID, moleculeCtx, err = spawnMoleculeFromProto(beadsPath, thing, polecatAddress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
case "issue":
|
|
issueID = thing.ID
|
|
if thing.Proto != "" {
|
|
// Sling issue with molecule workflow
|
|
issueID, moleculeCtx, err = spawnMoleculeOnIssue(beadsPath, thing, polecatAddress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
case "epic":
|
|
// Epics go to refinery, not polecats
|
|
return fmt.Errorf("epics should be slung at refinery/, not polecat/")
|
|
}
|
|
|
|
// Assign issue to polecat
|
|
if err := polecatMgr.AssignIssue(polecatName, issueID); err != nil {
|
|
return fmt.Errorf("assigning issue: %w", err)
|
|
}
|
|
fmt.Printf("%s Assigned %s to %s\n", style.Bold.Render("✓"), issueID, polecatAddress)
|
|
|
|
// Pin to hook (update handoff bead with attachment)
|
|
if err := pinToHook(beadsPath, polecatAddress, issueID, moleculeCtx); err != nil {
|
|
fmt.Printf("%s Could not pin to hook: %v\n", style.Dim.Render("Warning:"), err)
|
|
} else {
|
|
fmt.Printf("%s Pinned to hook\n", style.Bold.Render("✓"))
|
|
}
|
|
|
|
// Sync beads
|
|
if err := syncBeads(beadsPath, false); err != nil {
|
|
fmt.Printf("%s beads push: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
|
|
if slingNoStart {
|
|
fmt.Printf("\n %s\n", style.Dim.Render("Use 'gt session start' to start the session"))
|
|
return nil
|
|
}
|
|
|
|
// Fetch the issue for mail content
|
|
b := beads.New(beadsPath)
|
|
issue, _ := b.Show(issueID)
|
|
var beadsIssue *BeadsIssue
|
|
if issue != nil {
|
|
beadsIssue = &BeadsIssue{
|
|
ID: issue.ID,
|
|
Title: issue.Title,
|
|
Description: issue.Description,
|
|
Priority: issue.Priority,
|
|
Type: issue.Type,
|
|
Status: issue.Status,
|
|
}
|
|
}
|
|
|
|
// Send work assignment mail
|
|
workMsg := buildWorkAssignmentMail(beadsIssue, "", polecatAddress, moleculeCtx)
|
|
fmt.Printf("Sending work assignment to %s inbox...\n", polecatAddress)
|
|
if err := router.Send(workMsg); err != nil {
|
|
return fmt.Errorf("sending work assignment: %w", err)
|
|
}
|
|
fmt.Printf("%s Work assignment sent\n", style.Bold.Render("✓"))
|
|
|
|
// Start session
|
|
t := tmux.NewTmux()
|
|
sessMgr := session.NewManager(t, r)
|
|
|
|
running, _ := sessMgr.IsRunning(polecatName)
|
|
if running {
|
|
fmt.Printf("Session already running, notifying to check inbox...\n")
|
|
time.Sleep(500 * time.Millisecond)
|
|
} else {
|
|
fmt.Printf("Starting session for %s...\n", polecatAddress)
|
|
if err := sessMgr.Start(polecatName, session.StartOptions{}); err != nil {
|
|
return fmt.Errorf("starting session: %w", err)
|
|
}
|
|
time.Sleep(3 * time.Second)
|
|
}
|
|
|
|
fmt.Printf("%s Session started. Attach with: %s\n",
|
|
style.Bold.Render("✓"),
|
|
style.Dim.Render(fmt.Sprintf("gt session at %s", polecatAddress)))
|
|
|
|
// Nudge polecat
|
|
sessionName := sessMgr.SessionName(polecatName)
|
|
nudgeMsg := fmt.Sprintf("You have a work assignment. Run 'gt mail inbox' to see it, then start working on issue %s.", issueID)
|
|
if err := t.NudgeSession(sessionName, nudgeMsg); err != nil {
|
|
fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("Warning: could not nudge: %v", err)))
|
|
} else {
|
|
fmt.Printf(" %s\n", style.Dim.Render("Polecat nudged to start working"))
|
|
}
|
|
|
|
// Notify Witness
|
|
townRouter := mail.NewRouter(townRoot)
|
|
witnessAddr := fmt.Sprintf("%s/witness", target.Rig)
|
|
sender := detectSender()
|
|
spawnNotification := &mail.Message{
|
|
To: witnessAddr,
|
|
From: sender,
|
|
Subject: fmt.Sprintf("SLING: %s starting on %s", polecatName, issueID),
|
|
Body: fmt.Sprintf("Polecat slung.\n\nPolecat: %s\nIssue: %s\nSession: %s\nSlung by: %s", polecatName, issueID, sessionName, sender),
|
|
}
|
|
if err := townRouter.Send(spawnNotification); err != nil {
|
|
fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("Warning: could not notify witness: %v", err)))
|
|
} else {
|
|
fmt.Printf(" %s\n", style.Dim.Render("Witness notified"))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// slingToDeacon handles slinging work to the deacon.
|
|
func slingToDeacon(townRoot string, target *SlingTarget, thing *SlingThing) error {
|
|
// Deacon uses town-level beads for now (could be rig-specific in future)
|
|
beadsPath := townRoot
|
|
deaconAddress := "deacon/"
|
|
|
|
// --replace flag: use legacy behavior (replace hook with discrete work)
|
|
if slingReplace {
|
|
if thing.Kind != "proto" {
|
|
return fmt.Errorf("deacon --replace only accepts protos, not issues")
|
|
}
|
|
fmt.Printf("%s Using --replace: patrol will be terminated\n", style.Warning.Render("⚠"))
|
|
return slingToPatrolWithReplace(townRoot, beadsPath, deaconAddress, thing, "deacon")
|
|
}
|
|
|
|
// Check if patrol is currently running
|
|
patrolRunning, patrolID := isPatrolRunning(beadsPath, deaconAddress)
|
|
|
|
// Sync beads
|
|
if err := syncBeads(beadsPath, true); err != nil {
|
|
fmt.Printf("%s beads sync: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
|
|
// If no patrol running, start the default patrol first
|
|
if !patrolRunning {
|
|
patrolTitle := getDefaultPatrolMolecule("deacon")
|
|
fmt.Printf("No patrol running, starting %s...\n", patrolTitle)
|
|
|
|
// Resolve the patrol molecule title to its beads issue ID
|
|
patrolIssueID, err := resolvePatrolMoleculeID(beadsPath, patrolTitle)
|
|
if err != nil {
|
|
return fmt.Errorf("resolving patrol molecule: %w", err)
|
|
}
|
|
|
|
patrolThing := &SlingThing{
|
|
Kind: "proto",
|
|
ID: patrolIssueID, // Use the resolved beads issue ID
|
|
IsWisp: true, // Patrol cycles are ephemeral (gt-jsup)
|
|
}
|
|
patrolID, _, err = spawnMoleculeFromProto(beadsPath, patrolThing, deaconAddress)
|
|
if err != nil {
|
|
return fmt.Errorf("starting patrol: %w", err)
|
|
}
|
|
if err := pinToHook(beadsPath, deaconAddress, patrolID, nil); err != nil {
|
|
return fmt.Errorf("pinning patrol to hook: %w", err)
|
|
}
|
|
fmt.Printf("%s Started deacon patrol\n", style.Bold.Render("✓"))
|
|
} else {
|
|
fmt.Printf("Patrol running: %s\n", patrolID)
|
|
}
|
|
|
|
// Now queue the work via mail (don't touch hook - patrol stays pinned)
|
|
router := mail.NewRouter(townRoot)
|
|
|
|
// Build work assignment mail
|
|
b := beads.New(beadsPath)
|
|
var beadsIssue *BeadsIssue
|
|
issueID := thing.ID
|
|
|
|
// For protos, we need to spawn the molecule but NOT pin it
|
|
var moleculeCtx *MoleculeContext
|
|
var err error
|
|
if thing.Kind == "proto" {
|
|
issueID, moleculeCtx, err = spawnMoleculeFromProto(beadsPath, thing, deaconAddress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else if thing.Kind == "issue" {
|
|
if thing.Proto != "" {
|
|
issueID, moleculeCtx, err = spawnMoleculeOnIssue(beadsPath, thing, deaconAddress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Issues without molecule proto are queued directly
|
|
}
|
|
|
|
issue, _ := b.Show(issueID)
|
|
if issue != nil {
|
|
beadsIssue = &BeadsIssue{
|
|
ID: issue.ID,
|
|
Title: issue.Title,
|
|
Description: issue.Description,
|
|
Priority: issue.Priority,
|
|
Type: issue.Type,
|
|
Status: issue.Status,
|
|
}
|
|
}
|
|
|
|
workMsg := buildWorkAssignmentMail(beadsIssue, "", deaconAddress, moleculeCtx)
|
|
if slingUrgent {
|
|
workMsg.Subject = "🚨 URGENT: " + workMsg.Subject
|
|
}
|
|
if err := router.Send(workMsg); err != nil {
|
|
return fmt.Errorf("sending work assignment: %w", err)
|
|
}
|
|
fmt.Printf("%s Work assignment sent to %s\n", style.Bold.Render("✓"), deaconAddress)
|
|
|
|
// Sync beads
|
|
if err := syncBeads(beadsPath, false); err != nil {
|
|
fmt.Printf("%s beads push: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
|
|
if slingUrgent {
|
|
fmt.Printf("%s Queued as URGENT - will interrupt current patrol cycle\n",
|
|
style.Bold.Render("✓"))
|
|
} else {
|
|
fmt.Printf("%s Queued for next patrol cycle\n", style.Bold.Render("✓"))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// slingToCrew handles slinging work to a crew member.
|
|
// Crew members are persistent, human-managed workers - no session start.
|
|
func slingToCrew(townRoot string, target *SlingTarget, thing *SlingThing) error {
|
|
beadsPath := filepath.Join(townRoot, target.Rig)
|
|
crewAddress := fmt.Sprintf("%s/crew/%s", target.Rig, target.Name)
|
|
|
|
// Verify crew member exists
|
|
crewPath := filepath.Join(townRoot, target.Rig, "crew", target.Name)
|
|
if _, err := os.Stat(crewPath); os.IsNotExist(err) {
|
|
return fmt.Errorf("crew member '%s' not found at %s", target.Name, crewPath)
|
|
}
|
|
|
|
// Check for existing work on hook
|
|
displacedID, err := checkHookCollision(crewAddress, beadsPath, slingForce)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if displacedID != "" {
|
|
fmt.Printf("%s Displaced %s back to ready pool\n", style.Warning.Render("⚠"), displacedID)
|
|
if err := releaseDisplacedWork(beadsPath, displacedID); err != nil {
|
|
fmt.Printf(" %s could not release: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
}
|
|
|
|
// Sync beads
|
|
if err := syncBeads(beadsPath, true); err != nil {
|
|
fmt.Printf("%s beads sync: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
|
|
// Process the thing based on its kind
|
|
var issueID string
|
|
var moleculeCtx *MoleculeContext
|
|
|
|
switch thing.Kind {
|
|
case "proto":
|
|
issueID, moleculeCtx, err = spawnMoleculeFromProto(beadsPath, thing, crewAddress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
case "issue":
|
|
issueID = thing.ID
|
|
if thing.Proto != "" {
|
|
issueID, moleculeCtx, err = spawnMoleculeOnIssue(beadsPath, thing, crewAddress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case "epic":
|
|
// Epics can be slung to crew for manual processing
|
|
issueID = thing.ID
|
|
}
|
|
|
|
// Pin to hook
|
|
if err := pinToHook(beadsPath, crewAddress, issueID, moleculeCtx); err != nil {
|
|
fmt.Printf("%s Could not pin to hook: %v\n", style.Dim.Render("Warning:"), err)
|
|
} else {
|
|
fmt.Printf("%s Pinned to crew hook\n", style.Bold.Render("✓"))
|
|
}
|
|
|
|
// Sync beads
|
|
if err := syncBeads(beadsPath, false); err != nil {
|
|
fmt.Printf("%s beads push: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
|
|
// Send work assignment mail (crew will see it on next session start)
|
|
router := mail.NewRouter(townRoot)
|
|
b := beads.New(beadsPath)
|
|
issue, _ := b.Show(issueID)
|
|
var beadsIssue *BeadsIssue
|
|
if issue != nil {
|
|
beadsIssue = &BeadsIssue{
|
|
ID: issue.ID,
|
|
Title: issue.Title,
|
|
Description: issue.Description,
|
|
Priority: issue.Priority,
|
|
Type: issue.Type,
|
|
Status: issue.Status,
|
|
}
|
|
}
|
|
|
|
workMsg := buildWorkAssignmentMail(beadsIssue, "", crewAddress, moleculeCtx)
|
|
if err := router.Send(workMsg); err != nil {
|
|
fmt.Printf("%s Could not send mail: %v\n", style.Dim.Render("Warning:"), err)
|
|
} else {
|
|
fmt.Printf("%s Work assignment sent to %s\n", style.Bold.Render("✓"), crewAddress)
|
|
}
|
|
|
|
fmt.Printf("\n%s Crew member will see work on next session start\n",
|
|
style.Bold.Render("✓"))
|
|
fmt.Printf(" %s\n", style.Dim.Render("(Crew sessions are human-managed, not auto-started)"))
|
|
|
|
return nil
|
|
}
|
|
|
|
// slingToWitness handles slinging work to the witness.
|
|
func slingToWitness(townRoot string, target *SlingTarget, thing *SlingThing) error {
|
|
beadsPath := filepath.Join(townRoot, target.Rig)
|
|
witnessAddress := fmt.Sprintf("%s/witness", target.Rig)
|
|
|
|
// --replace flag: use legacy behavior (replace hook with discrete work)
|
|
if slingReplace {
|
|
fmt.Printf("%s Using --replace: patrol will be terminated\n", style.Warning.Render("⚠"))
|
|
return slingToPatrolWithReplace(townRoot, beadsPath, witnessAddress, thing, "witness")
|
|
}
|
|
|
|
// Check if patrol is currently running
|
|
patrolRunning, patrolID := isPatrolRunning(beadsPath, witnessAddress)
|
|
|
|
// Sync beads
|
|
if err := syncBeads(beadsPath, true); err != nil {
|
|
fmt.Printf("%s beads sync: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
|
|
// If no patrol running, start the default patrol first
|
|
if !patrolRunning {
|
|
patrolTitle := getDefaultPatrolMolecule("witness")
|
|
fmt.Printf("No patrol running, starting %s...\n", patrolTitle)
|
|
|
|
// Resolve the patrol molecule title to its beads issue ID
|
|
patrolIssueID, err := resolvePatrolMoleculeID(beadsPath, patrolTitle)
|
|
if err != nil {
|
|
return fmt.Errorf("resolving patrol molecule: %w", err)
|
|
}
|
|
|
|
patrolThing := &SlingThing{
|
|
Kind: "proto",
|
|
ID: patrolIssueID, // Use the resolved beads issue ID
|
|
IsWisp: true, // Patrol cycles are ephemeral (gt-jsup)
|
|
}
|
|
patrolID, _, err = spawnMoleculeFromProto(beadsPath, patrolThing, witnessAddress)
|
|
if err != nil {
|
|
return fmt.Errorf("starting patrol: %w", err)
|
|
}
|
|
if err := pinToHook(beadsPath, witnessAddress, patrolID, nil); err != nil {
|
|
return fmt.Errorf("pinning patrol to hook: %w", err)
|
|
}
|
|
fmt.Printf("%s Started witness patrol\n", style.Bold.Render("✓"))
|
|
} else {
|
|
fmt.Printf("Patrol running: %s\n", patrolID)
|
|
}
|
|
|
|
// Now queue the work via mail (don't touch hook - patrol stays pinned)
|
|
router := mail.NewRouter(townRoot)
|
|
|
|
// Build work assignment mail
|
|
b := beads.New(beadsPath)
|
|
var beadsIssue *BeadsIssue
|
|
issueID := thing.ID
|
|
|
|
// For protos, we need to spawn the molecule but NOT pin it
|
|
var moleculeCtx *MoleculeContext
|
|
var err error
|
|
if thing.Kind == "proto" {
|
|
// Spawn molecule without pinning
|
|
issueID, moleculeCtx, err = spawnMoleculeFromProto(beadsPath, thing, witnessAddress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else if thing.Kind == "issue" && thing.Proto != "" {
|
|
issueID, moleculeCtx, err = spawnMoleculeOnIssue(beadsPath, thing, witnessAddress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
issue, _ := b.Show(issueID)
|
|
if issue != nil {
|
|
beadsIssue = &BeadsIssue{
|
|
ID: issue.ID,
|
|
Title: issue.Title,
|
|
Description: issue.Description,
|
|
Priority: issue.Priority,
|
|
Type: issue.Type,
|
|
Status: issue.Status,
|
|
}
|
|
}
|
|
|
|
workMsg := buildWorkAssignmentMail(beadsIssue, "", witnessAddress, moleculeCtx)
|
|
if slingUrgent {
|
|
workMsg.Subject = "🚨 URGENT: " + workMsg.Subject
|
|
}
|
|
if err := router.Send(workMsg); err != nil {
|
|
return fmt.Errorf("sending work assignment: %w", err)
|
|
}
|
|
fmt.Printf("%s Work assignment sent to %s\n", style.Bold.Render("✓"), witnessAddress)
|
|
|
|
// Sync beads
|
|
if err := syncBeads(beadsPath, false); err != nil {
|
|
fmt.Printf("%s beads push: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
|
|
if slingUrgent {
|
|
fmt.Printf("%s Queued as URGENT - will interrupt current patrol cycle\n",
|
|
style.Bold.Render("✓"))
|
|
} else {
|
|
fmt.Printf("%s Queued for next patrol cycle\n", style.Bold.Render("✓"))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// slingToPatrolWithReplace implements legacy sling behavior for patrol roles.
|
|
// Used when --replace flag is set to explicitly terminate patrol.
|
|
func slingToPatrolWithReplace(townRoot, beadsPath, agentAddress string, thing *SlingThing, role string) error {
|
|
// Check for existing work on hook
|
|
displacedID, err := checkHookCollision(agentAddress, beadsPath, true) // force=true since we're replacing
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if displacedID != "" {
|
|
fmt.Printf("%s Displaced %s (patrol terminated)\n", style.Warning.Render("⚠"), displacedID)
|
|
if err := releaseDisplacedWork(beadsPath, displacedID); err != nil {
|
|
fmt.Printf(" %s could not release: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
}
|
|
|
|
// Sync beads
|
|
if err := syncBeads(beadsPath, true); err != nil {
|
|
fmt.Printf("%s beads sync: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
|
|
// Process the thing
|
|
var issueID string
|
|
var moleculeCtx *MoleculeContext
|
|
|
|
switch thing.Kind {
|
|
case "proto":
|
|
issueID, moleculeCtx, err = spawnMoleculeFromProto(beadsPath, thing, agentAddress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
case "issue":
|
|
issueID = thing.ID
|
|
if thing.Proto != "" {
|
|
issueID, moleculeCtx, err = spawnMoleculeOnIssue(beadsPath, thing, agentAddress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
default:
|
|
return fmt.Errorf("%s accepts protos or issues, not %s", role, thing.Kind)
|
|
}
|
|
|
|
// Pin to hook (replacing patrol)
|
|
if err := pinToHook(beadsPath, agentAddress, issueID, moleculeCtx); err != nil {
|
|
fmt.Printf("%s Could not pin to hook: %v\n", style.Dim.Render("Warning:"), err)
|
|
} else {
|
|
fmt.Printf("%s Pinned to %s hook (patrol replaced)\n", style.Bold.Render("✓"), role)
|
|
}
|
|
|
|
// Sync beads
|
|
if err := syncBeads(beadsPath, false); err != nil {
|
|
fmt.Printf("%s beads push: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
|
|
fmt.Printf("%s %s will run %s (discrete task, patrol stopped)\n",
|
|
style.Bold.Render("✓"), strings.Title(role), thing.ID)
|
|
|
|
return nil
|
|
}
|
|
|
|
// slingToRefinery handles slinging work to the refinery.
|
|
func slingToRefinery(townRoot string, target *SlingTarget, thing *SlingThing) error {
|
|
beadsPath := filepath.Join(townRoot, target.Rig)
|
|
refineryAddress := fmt.Sprintf("%s/refinery", target.Rig)
|
|
|
|
// --replace flag: use legacy behavior (replace hook with discrete work)
|
|
if slingReplace {
|
|
fmt.Printf("%s Using --replace: patrol will be terminated\n", style.Warning.Render("⚠"))
|
|
return slingToPatrolWithReplace(townRoot, beadsPath, refineryAddress, thing, "refinery")
|
|
}
|
|
|
|
// Check if patrol is currently running
|
|
patrolRunning, patrolID := isPatrolRunning(beadsPath, refineryAddress)
|
|
|
|
// Sync beads
|
|
if err := syncBeads(beadsPath, true); err != nil {
|
|
fmt.Printf("%s beads sync: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
|
|
// If no patrol running, start the default patrol first
|
|
if !patrolRunning {
|
|
patrolTitle := getDefaultPatrolMolecule("refinery")
|
|
fmt.Printf("No patrol running, starting %s...\n", patrolTitle)
|
|
|
|
// Resolve the patrol molecule title to its beads issue ID
|
|
patrolIssueID, err := resolvePatrolMoleculeID(beadsPath, patrolTitle)
|
|
if err != nil {
|
|
return fmt.Errorf("resolving patrol molecule: %w", err)
|
|
}
|
|
|
|
patrolThing := &SlingThing{
|
|
Kind: "proto",
|
|
ID: patrolIssueID, // Use the resolved beads issue ID
|
|
IsWisp: true, // Patrol cycles are ephemeral (gt-jsup)
|
|
}
|
|
patrolID, _, err = spawnMoleculeFromProto(beadsPath, patrolThing, refineryAddress)
|
|
if err != nil {
|
|
return fmt.Errorf("starting patrol: %w", err)
|
|
}
|
|
if err := pinToHook(beadsPath, refineryAddress, patrolID, nil); err != nil {
|
|
return fmt.Errorf("pinning patrol to hook: %w", err)
|
|
}
|
|
fmt.Printf("%s Started refinery patrol\n", style.Bold.Render("✓"))
|
|
} else {
|
|
fmt.Printf("Patrol running: %s\n", patrolID)
|
|
}
|
|
|
|
// Now queue the work via mail (don't touch hook - patrol stays pinned)
|
|
router := mail.NewRouter(townRoot)
|
|
|
|
// Build work assignment mail
|
|
b := beads.New(beadsPath)
|
|
var beadsIssue *BeadsIssue
|
|
issueID := thing.ID
|
|
|
|
// For protos, we need to spawn the molecule but NOT pin it
|
|
var moleculeCtx *MoleculeContext
|
|
var err error
|
|
if thing.Kind == "proto" {
|
|
issueID, moleculeCtx, err = spawnMoleculeFromProto(beadsPath, thing, refineryAddress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else if thing.Kind == "issue" && thing.Proto != "" {
|
|
issueID, moleculeCtx, err = spawnMoleculeOnIssue(beadsPath, thing, refineryAddress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Epics can be slung directly as issueID
|
|
|
|
issue, _ := b.Show(issueID)
|
|
if issue != nil {
|
|
beadsIssue = &BeadsIssue{
|
|
ID: issue.ID,
|
|
Title: issue.Title,
|
|
Description: issue.Description,
|
|
Priority: issue.Priority,
|
|
Type: issue.Type,
|
|
Status: issue.Status,
|
|
}
|
|
}
|
|
|
|
workMsg := buildWorkAssignmentMail(beadsIssue, "", refineryAddress, moleculeCtx)
|
|
if slingUrgent {
|
|
workMsg.Subject = "🚨 URGENT: " + workMsg.Subject
|
|
}
|
|
if err := router.Send(workMsg); err != nil {
|
|
return fmt.Errorf("sending work assignment: %w", err)
|
|
}
|
|
fmt.Printf("%s Work assignment sent to %s\n", style.Bold.Render("✓"), refineryAddress)
|
|
|
|
// Sync beads
|
|
if err := syncBeads(beadsPath, false); err != nil {
|
|
fmt.Printf("%s beads push: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
|
|
if slingUrgent {
|
|
fmt.Printf("%s Queued as URGENT - will interrupt current patrol cycle\n",
|
|
style.Bold.Render("✓"))
|
|
} else {
|
|
fmt.Printf("%s Queued for next patrol cycle\n", style.Bold.Render("✓"))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// slingToMayor handles slinging work to the mayor.
|
|
// Mayor is town-level, human-managed - no session start.
|
|
func slingToMayor(townRoot string, target *SlingTarget, thing *SlingThing) error {
|
|
// Mayor uses town-level beads
|
|
beadsPath := townRoot
|
|
mayorAddress := "mayor/"
|
|
|
|
// Check for existing work on hook
|
|
displacedID, err := checkHookCollision(mayorAddress, beadsPath, slingForce)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if displacedID != "" {
|
|
fmt.Printf("%s Displaced %s back to ready pool\n", style.Warning.Render("⚠"), displacedID)
|
|
if err := releaseDisplacedWork(beadsPath, displacedID); err != nil {
|
|
fmt.Printf(" %s could not release: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
}
|
|
|
|
// Sync beads
|
|
if err := syncBeads(beadsPath, true); err != nil {
|
|
fmt.Printf("%s beads sync: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
|
|
// Process the thing
|
|
var issueID string
|
|
var moleculeCtx *MoleculeContext
|
|
|
|
switch thing.Kind {
|
|
case "proto":
|
|
issueID, moleculeCtx, err = spawnMoleculeFromProto(beadsPath, thing, mayorAddress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
case "issue":
|
|
issueID = thing.ID
|
|
if thing.Proto != "" {
|
|
issueID, moleculeCtx, err = spawnMoleculeOnIssue(beadsPath, thing, mayorAddress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case "epic":
|
|
// Mayor can work epics directly
|
|
issueID = thing.ID
|
|
}
|
|
|
|
// Pin to mayor hook
|
|
if err := pinToHook(beadsPath, mayorAddress, issueID, moleculeCtx); err != nil {
|
|
fmt.Printf("%s Could not pin to hook: %v\n", style.Dim.Render("Warning:"), err)
|
|
} else {
|
|
fmt.Printf("%s Pinned to mayor hook\n", style.Bold.Render("✓"))
|
|
}
|
|
|
|
// Sync beads
|
|
if err := syncBeads(beadsPath, false); err != nil {
|
|
fmt.Printf("%s beads push: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
|
|
// Send work assignment mail
|
|
router := mail.NewRouter(townRoot)
|
|
b := beads.New(beadsPath)
|
|
issue, _ := b.Show(issueID)
|
|
var beadsIssue *BeadsIssue
|
|
if issue != nil {
|
|
beadsIssue = &BeadsIssue{
|
|
ID: issue.ID,
|
|
Title: issue.Title,
|
|
Description: issue.Description,
|
|
Priority: issue.Priority,
|
|
Type: issue.Type,
|
|
Status: issue.Status,
|
|
}
|
|
}
|
|
|
|
workMsg := buildWorkAssignmentMail(beadsIssue, "", mayorAddress, moleculeCtx)
|
|
if err := router.Send(workMsg); err != nil {
|
|
fmt.Printf("%s Could not send mail: %v\n", style.Dim.Render("Warning:"), err)
|
|
} else {
|
|
fmt.Printf("%s Work assignment sent to mayor\n", style.Bold.Render("✓"))
|
|
}
|
|
|
|
fmt.Printf("\n%s Mayor will see work on next session start\n",
|
|
style.Bold.Render("✓"))
|
|
|
|
return nil
|
|
}
|
|
|
|
// spawnMoleculeFromProto spawns a molecule from a proto template.
|
|
func spawnMoleculeFromProto(beadsPath string, thing *SlingThing, assignee string) (string, *MoleculeContext, error) {
|
|
moleculeType := "molecule"
|
|
if thing.IsWisp {
|
|
moleculeType = "wisp"
|
|
}
|
|
fmt.Printf("Spawning %s from proto %s...\n", moleculeType, thing.ID)
|
|
|
|
// Use bd mol run to spawn the molecule
|
|
args := []string{"--no-daemon", "mol", "run", thing.ID, "--json"}
|
|
if assignee != "" {
|
|
args = append(args, "--var", "assignee="+assignee)
|
|
}
|
|
|
|
// For wisps, use the ephemeral storage location
|
|
workDir := beadsPath
|
|
if thing.IsWisp {
|
|
wispPath := filepath.Join(beadsPath, ".beads-wisp")
|
|
// Check if wisp storage exists
|
|
if _, err := os.Stat(wispPath); err == nil {
|
|
// Use wisp storage - pass --db to point bd at the wisp directory
|
|
// bd mol run auto-discovers the main DB for templates when --db contains .beads-wisp (gt-jsup)
|
|
args = append([]string{"--db", filepath.Join(wispPath, "beads.db")}, args...)
|
|
fmt.Printf(" Using ephemeral storage: %s\n", style.Dim.Render(".beads-wisp/"))
|
|
} else {
|
|
fmt.Printf(" %s wisp storage not found, using regular storage\n",
|
|
style.Dim.Render("Note:"))
|
|
}
|
|
}
|
|
|
|
cmd := exec.Command("bd", args...)
|
|
cmd.Dir = workDir
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
errMsg := strings.TrimSpace(stderr.String())
|
|
if errMsg != "" {
|
|
return "", nil, fmt.Errorf("running molecule: %s", errMsg)
|
|
}
|
|
return "", nil, fmt.Errorf("running molecule: %w", err)
|
|
}
|
|
|
|
// Parse result
|
|
var molResult struct {
|
|
RootID string `json:"root_id"`
|
|
IDMapping map[string]string `json:"id_mapping"`
|
|
Created int `json:"created"`
|
|
Assignee string `json:"assignee"`
|
|
Pinned bool `json:"pinned"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &molResult); err != nil {
|
|
return "", nil, fmt.Errorf("parsing molecule result: %w", err)
|
|
}
|
|
|
|
fmt.Printf("%s %s spawned: %s (%d steps)\n",
|
|
style.Bold.Render("✓"), moleculeType, molResult.RootID, molResult.Created-1)
|
|
|
|
moleculeCtx := &MoleculeContext{
|
|
MoleculeID: thing.ID,
|
|
RootIssueID: molResult.RootID,
|
|
TotalSteps: molResult.Created - 1,
|
|
StepNumber: 1,
|
|
IsWisp: thing.IsWisp,
|
|
}
|
|
|
|
return molResult.RootID, moleculeCtx, nil
|
|
}
|
|
|
|
// spawnMoleculeOnIssue spawns a molecule workflow on an existing issue.
|
|
func spawnMoleculeOnIssue(beadsPath string, thing *SlingThing, assignee string) (string, *MoleculeContext, error) {
|
|
fmt.Printf("Running molecule %s on issue %s...\n", thing.Proto, thing.ID)
|
|
|
|
args := []string{"--no-daemon", "mol", "run", thing.Proto,
|
|
"--var", "issue=" + thing.ID, "--json"}
|
|
if assignee != "" {
|
|
args = append(args, "--var", "assignee="+assignee)
|
|
}
|
|
|
|
cmd := exec.Command("bd", args...)
|
|
cmd.Dir = beadsPath
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
errMsg := strings.TrimSpace(stderr.String())
|
|
if errMsg != "" {
|
|
return "", nil, fmt.Errorf("running molecule: %s", errMsg)
|
|
}
|
|
return "", nil, fmt.Errorf("running molecule: %w", err)
|
|
}
|
|
|
|
var molResult struct {
|
|
RootID string `json:"root_id"`
|
|
IDMapping map[string]string `json:"id_mapping"`
|
|
Created int `json:"created"`
|
|
Assignee string `json:"assignee"`
|
|
Pinned bool `json:"pinned"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &molResult); err != nil {
|
|
return "", nil, fmt.Errorf("parsing molecule result: %w", err)
|
|
}
|
|
|
|
fmt.Printf("%s Molecule %s applied to %s (%d steps)\n",
|
|
style.Bold.Render("✓"), thing.Proto, thing.ID, molResult.Created-1)
|
|
|
|
moleculeCtx := &MoleculeContext{
|
|
MoleculeID: thing.Proto,
|
|
RootIssueID: molResult.RootID,
|
|
TotalSteps: molResult.Created - 1,
|
|
StepNumber: 1,
|
|
IsWisp: thing.IsWisp,
|
|
}
|
|
|
|
return molResult.RootID, moleculeCtx, nil
|
|
}
|
|
|
|
// checkHookCollision checks if the agent's hook already has work.
|
|
// If force is true and hook is occupied, returns the displaced molecule ID.
|
|
// If force is false and hook is occupied, returns an error.
|
|
// Returns ("", nil) if hook is empty.
|
|
func checkHookCollision(agentAddress, beadsPath string, force bool) (string, error) {
|
|
// Parse agent address to get the role for handoff bead lookup
|
|
parts := strings.Split(agentAddress, "/")
|
|
var role string
|
|
if len(parts) >= 2 {
|
|
role = parts[len(parts)-1] // Last part is the name/role
|
|
} else {
|
|
role = parts[0]
|
|
}
|
|
|
|
b := beads.New(beadsPath)
|
|
handoff, err := b.FindHandoffBead(role)
|
|
if err != nil {
|
|
// Can't check, assume OK
|
|
return "", nil
|
|
}
|
|
|
|
if handoff == nil {
|
|
// No handoff bead exists, no collision
|
|
return "", nil
|
|
}
|
|
|
|
// Check if there's an attached molecule
|
|
attachment := beads.ParseAttachmentFields(handoff)
|
|
if attachment != nil && attachment.AttachedMolecule != "" {
|
|
if !force {
|
|
return "", fmt.Errorf("hook already occupied by %s\nUse --force to re-sling",
|
|
attachment.AttachedMolecule)
|
|
}
|
|
// Force mode: return the displaced molecule ID
|
|
return attachment.AttachedMolecule, nil
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
// releaseDisplacedWork returns displaced work to the ready pool.
|
|
// It unpins the molecule and sets status back to open with cleared assignee.
|
|
func releaseDisplacedWork(beadsPath, displacedID string) error {
|
|
b := beads.New(beadsPath)
|
|
|
|
// Unpin the molecule
|
|
if err := b.Unpin(displacedID); err != nil {
|
|
// Non-fatal, continue with release
|
|
fmt.Printf(" %s could not unpin %s: %v\n", style.Dim.Render("Note:"), displacedID, err)
|
|
}
|
|
|
|
// Release: set status=open, clear assignee
|
|
if err := b.ReleaseWithReason(displacedID, "displaced by new sling"); err != nil {
|
|
return fmt.Errorf("releasing displaced work: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// pinToHook pins work to an agent's hook by updating their handoff bead.
|
|
func pinToHook(beadsPath, agentAddress, issueID string, moleculeCtx *MoleculeContext) error {
|
|
// Parse agent address to get the role
|
|
parts := strings.Split(agentAddress, "/")
|
|
var role string
|
|
if len(parts) >= 2 {
|
|
role = parts[len(parts)-1]
|
|
} else {
|
|
role = parts[0]
|
|
}
|
|
|
|
b := beads.New(beadsPath)
|
|
|
|
// Get or create handoff bead
|
|
handoff, err := b.GetOrCreateHandoffBead(role)
|
|
if err != nil {
|
|
return fmt.Errorf("getting handoff bead: %w", err)
|
|
}
|
|
|
|
// Determine what to attach
|
|
attachedMolecule := issueID
|
|
if moleculeCtx != nil && moleculeCtx.RootIssueID != "" {
|
|
attachedMolecule = moleculeCtx.RootIssueID
|
|
}
|
|
|
|
// Attach molecule to handoff bead (stores in description)
|
|
_, err = b.AttachMolecule(handoff.ID, attachedMolecule)
|
|
if err != nil {
|
|
return fmt.Errorf("attaching molecule: %w", err)
|
|
}
|
|
|
|
// Also pin the work issue itself to the agent
|
|
// This sets the pinned boolean field AND assignee so bd hook can find it
|
|
// NOTE: There's a known issue (gt-o3is) where bd pin via subprocess doesn't
|
|
// actually set the pinned field, even though it reports success.
|
|
if err := b.Pin(attachedMolecule, role); err != nil {
|
|
// Non-fatal - the handoff bead attachment is the primary mechanism
|
|
// This just enables bd hook visibility
|
|
fmt.Printf(" %s pin work issue: %v\n", style.Dim.Render("Note: could not"), err)
|
|
}
|
|
|
|
return nil
|
|
}
|