gt sling: use mail queue for patrol roles (gt-afn0)

When slinging work to patrol agents (witness, refinery, deacon), queue via
mail instead of replacing the hook. This preserves patrol continuity.

New behavior:
- Default: Check if patrol is running, start default patrol if not, send
  work via mail. Patrol processes queued work during its cycle.
- --urgent: Marks mail as urgent (🚨 URGENT prefix)
- --replace: Legacy behavior, explicitly terminates patrol (break-glass)

New helper functions:
- isPatrolRole(kind): Returns true for witness/refinery/deacon
- getDefaultPatrolMolecule(role): Returns patrol template name
- resolvePatrolMoleculeID(path, title): Looks up beads issue ID by title
- isPatrolRunning(path, addr): Checks if patrol molecule is attached

Note: Patrol spawning currently uses main DB (not wisp storage) because
templates must be looked up from the main database. Wisp support for
patrol instances is a future enhancement.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-23 16:24:44 -08:00
parent 7b619b75d6
commit 58c0fae614

View File

@@ -32,6 +32,8 @@ var (
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{
@@ -92,10 +94,104 @@ func init() {
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"
@@ -546,44 +642,113 @@ func slingToPolecat(townRoot string, target *SlingTarget, thing *SlingThing) err
// slingToDeacon handles slinging work to the deacon.
func slingToDeacon(townRoot string, target *SlingTarget, thing *SlingThing) error {
if thing.Kind != "proto" {
return fmt.Errorf("deacon only accepts protos (like 'patrol'), not issues")
// 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")
}
if !thing.IsWisp {
fmt.Printf("%s Deacon work should be ephemeral. Consider using --wisp\n",
style.Dim.Render("Note:"))
}
// For deacon, we just need to update its hook and send mail
beadsPath := filepath.Join(townRoot, target.Rig)
// 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)
}
// Spawn the molecule from proto
deaconAddress := fmt.Sprintf("%s/deacon", target.Rig)
issueID, moleculeCtx, err := spawnMoleculeFromProto(beadsPath, thing, deaconAddress)
if err != nil {
return 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: false, // Templates are in main DB, spawn there for now
}
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)
}
// Pin to deacon's hook
if err := pinToHook(beadsPath, deaconAddress, 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 deacon hook\n", style.Bold.Render("✓"))
// 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)
}
fmt.Printf("%s Deacon will run %s on next patrol\n",
style.Bold.Render("✓"), thing.ID)
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
}
@@ -687,18 +852,118 @@ func slingToWitness(townRoot string, target *SlingTarget, thing *SlingThing) err
beadsPath := filepath.Join(townRoot, target.Rig)
witnessAddress := fmt.Sprintf("%s/witness", target.Rig)
if !thing.IsWisp {
fmt.Printf("%s Witness work should be ephemeral. Consider using --wisp\n",
style.Dim.Render("Note:"))
// --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: false, // Templates are in main DB, spawn there for now
}
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(witnessAddress, beadsPath, slingForce)
displacedID, err := checkHookCollision(agentAddress, beadsPath, true) // force=true since we're replacing
if err != nil {
return err
}
if displacedID != "" {
fmt.Printf("%s Displaced %s back to ready pool\n", style.Warning.Render("⚠"), 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)
}
@@ -715,27 +980,27 @@ func slingToWitness(townRoot string, target *SlingTarget, thing *SlingThing) err
switch thing.Kind {
case "proto":
issueID, moleculeCtx, err = spawnMoleculeFromProto(beadsPath, thing, witnessAddress)
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, witnessAddress)
issueID, moleculeCtx, err = spawnMoleculeOnIssue(beadsPath, thing, agentAddress)
if err != nil {
return err
}
}
default:
return fmt.Errorf("witness accepts protos or issues, not %s", thing.Kind)
return fmt.Errorf("%s accepts protos or issues, not %s", role, thing.Kind)
}
// Pin to witness hook
if err := pinToHook(beadsPath, witnessAddress, issueID, moleculeCtx); err != nil {
// 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 witness hook\n", style.Bold.Render("✓"))
fmt.Printf("%s Pinned to %s hook (patrol replaced)\n", style.Bold.Render("✓"), role)
}
// Sync beads
@@ -743,8 +1008,8 @@ func slingToWitness(townRoot string, target *SlingTarget, thing *SlingThing) err
fmt.Printf("%s beads push: %v\n", style.Dim.Render("Warning:"), err)
}
fmt.Printf("%s Witness will run %s on next patrol\n",
style.Bold.Render("✓"), thing.ID)
fmt.Printf("%s %s will run %s (discrete task, patrol stopped)\n",
style.Bold.Render("✓"), strings.Title(role), thing.ID)
return nil
}
@@ -754,60 +1019,104 @@ func slingToRefinery(townRoot string, target *SlingTarget, thing *SlingThing) er
beadsPath := filepath.Join(townRoot, target.Rig)
refineryAddress := fmt.Sprintf("%s/refinery", target.Rig)
// Check for existing work on hook
displacedID, err := checkHookCollision(refineryAddress, 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)
}
// --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)
}
// Process the thing
var issueID string
var moleculeCtx *MoleculeContext
// If no patrol running, start the default patrol first
if !patrolRunning {
patrolTitle := getDefaultPatrolMolecule("refinery")
fmt.Printf("No patrol running, starting %s...\n", patrolTitle)
switch thing.Kind {
case "proto":
// 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: false, // Templates are in main DB, spawn there for now
}
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
}
case "issue":
issueID = thing.ID
if thing.Proto != "" {
issueID, moleculeCtx, err = spawnMoleculeOnIssue(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,
}
case "epic":
// Epics go to refinery for batch dispatch to polecats
issueID = thing.ID
}
// Pin to refinery hook
if err := pinToHook(beadsPath, refineryAddress, 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 refinery hook\n", style.Bold.Render("✓"))
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)
}
fmt.Printf("%s Refinery will process %s on next cycle\n",
style.Bold.Render("✓"), thing.ID)
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
}