From b8eca6c04afc1629a71c45a764f221956de8fc3e Mon Sep 17 00:00:00 2001 From: nux Date: Fri, 2 Jan 2026 22:24:48 -0500 Subject: [PATCH] feat: notify dispatcher when polecat work completes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a crew or other agent dispatches work to a polecat using `gt sling`, the polecat now tracks who dispatched the work and sends them a completion notification when running `gt done`. Changes: - Add DispatchedBy field to AttachmentFields in beads/fields.go - Store dispatcher agent ID in bead when slinging (both direct and formula) - Check for dispatcher in done.go and send WORK_DONE notification to them This fixes the orchestration issue where crews were left waiting because polecats only notified the Witness on completion, not the dispatcher. Fixes: id-c17 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/beads/fields.go | 10 +++++++ internal/cmd/done.go | 38 ++++++++++++++++++++++++++ internal/cmd/sling.go | 58 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/internal/beads/fields.go b/internal/beads/fields.go index b0cf2b15..0b39f915 100644 --- a/internal/beads/fields.go +++ b/internal/beads/fields.go @@ -20,6 +20,7 @@ type AttachmentFields struct { AttachedMolecule string // Root issue ID of the attached molecule AttachedAt string // ISO 8601 timestamp when attached AttachedArgs string // Natural language args passed via gt sling --args (no-tmux mode) + DispatchedBy string // Agent ID that dispatched this work (for completion notification) } // ParseAttachmentFields extracts attachment fields from an issue's description. @@ -61,6 +62,9 @@ func ParseAttachmentFields(issue *Issue) *AttachmentFields { case "attached_args", "attached-args", "attachedargs": fields.AttachedArgs = value hasFields = true + case "dispatched_by", "dispatched-by", "dispatchedby": + fields.DispatchedBy = value + hasFields = true } } @@ -88,6 +92,9 @@ func FormatAttachmentFields(fields *AttachmentFields) string { if fields.AttachedArgs != "" { lines = append(lines, "attached_args: "+fields.AttachedArgs) } + if fields.DispatchedBy != "" { + lines = append(lines, "dispatched_by: "+fields.DispatchedBy) + } return strings.Join(lines, "\n") } @@ -107,6 +114,9 @@ func SetAttachmentFields(issue *Issue, fields *AttachmentFields) string { "attached_args": true, "attached-args": true, "attachedargs": true, + "dispatched_by": true, + "dispatched-by": true, + "dispatchedby": true, } // Collect non-attachment lines from existing description diff --git a/internal/cmd/done.go b/internal/cmd/done.go index 45f55f77..b0b7ec00 100644 --- a/internal/cmd/done.go +++ b/internal/cmd/done.go @@ -322,6 +322,23 @@ func runDone(cmd *cobra.Command, args []string) error { fmt.Printf("%s Witness notified of %s\n", style.Bold.Render("✓"), exitType) } + // Notify dispatcher if work was dispatched by another agent + if issueID != "" { + if dispatcher := getDispatcherFromBead(cwd, issueID); dispatcher != "" && dispatcher != sender { + dispatcherNotification := &mail.Message{ + To: dispatcher, + From: sender, + Subject: fmt.Sprintf("WORK_DONE: %s", issueID), + Body: strings.Join(bodyLines, "\n"), + } + if err := townRouter.Send(dispatcherNotification); err != nil { + style.PrintWarning("could not notify dispatcher %s: %v", dispatcher, err) + } else { + fmt.Printf("%s Dispatcher %s notified of %s\n", style.Bold.Render("✓"), dispatcher, exitType) + } + } + } + // Log done event (townlog and activity feed) LogDone(townRoot, sender, issueID) _ = events.LogFeed(events.TypeDone, sender, events.DonePayload(issueID, branch)) @@ -406,6 +423,27 @@ func updateAgentStateOnDone(cwd, townRoot, exitType, issueID string) { } } +// getDispatcherFromBead retrieves the dispatcher agent ID from the bead's attachment fields. +// Returns empty string if no dispatcher is recorded. +func getDispatcherFromBead(cwd, issueID string) string { + if issueID == "" { + return "" + } + + bd := beads.New(cwd) + issue, err := bd.Show(issueID) + if err != nil { + return "" + } + + fields := beads.ParseAttachmentFields(issue) + if fields == nil { + return "" + } + + return fields.DispatchedBy +} + // computeCleanupStatus checks git state and returns the cleanup status. // Returns the most critical issue: has_unpushed > has_stash > has_uncommitted > clean func computeCleanupStatus(cwd string) string { diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index 4ebfb83f..7227f04b 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -453,6 +453,12 @@ func runSling(cmd *cobra.Command, args []string) error { // Update agent bead's hook_bead field (ZFC: agents track their current work) updateAgentHookBead(targetAgent, beadID, hookWorkDir, townBeadsDir) + // Store dispatcher in bead description (enables completion notification to dispatcher) + if err := storeDispatcherInBead(beadID, actor); err != nil { + // Warn but don't fail - polecat will still complete work + fmt.Printf("%s Could not store dispatcher in bead: %v\n", style.Dim.Render("Warning:"), err) + } + // Store args in bead description (no-tmux mode: beads as data plane) if slingArgs != "" { if err := storeArgsInBead(beadID, slingArgs); err != nil { @@ -519,6 +525,52 @@ func storeArgsInBead(beadID, args string) error { return nil } +// storeDispatcherInBead stores the dispatcher agent ID in the bead's description. +// This enables polecats to notify the dispatcher when work is complete. +func storeDispatcherInBead(beadID, dispatcher string) error { + if dispatcher == "" { + return nil + } + + // Get the bead to preserve existing description content + showCmd := exec.Command("bd", "show", beadID, "--json") + out, err := showCmd.Output() + if err != nil { + return fmt.Errorf("fetching bead: %w", err) + } + + // Parse the bead + var issues []beads.Issue + if err := json.Unmarshal(out, &issues); err != nil { + return fmt.Errorf("parsing bead: %w", err) + } + if len(issues) == 0 { + return fmt.Errorf("bead not found") + } + issue := &issues[0] + + // Get or create attachment fields + fields := beads.ParseAttachmentFields(issue) + if fields == nil { + fields = &beads.AttachmentFields{} + } + + // Set the dispatcher + fields.DispatchedBy = dispatcher + + // Update the description + newDesc := beads.SetAttachmentFields(issue, fields) + + // Update the bead + updateCmd := exec.Command("bd", "update", beadID, "--description="+newDesc) + updateCmd.Stderr = os.Stderr + if err := updateCmd.Run(); err != nil { + return fmt.Errorf("updating bead description: %w", err) + } + + return nil +} + // injectStartPrompt sends a prompt to the target pane to start working. // Uses the reliable nudge pattern: literal mode + 500ms debounce + separate Enter. func injectStartPrompt(pane, beadID, subject, args string) error { @@ -861,6 +913,12 @@ func runSlingFormula(args []string) error { // Note: formula slinging uses town root as workDir (no polecat-specific path) updateAgentHookBead(targetAgent, wispResult.RootID, "", townBeadsDir) + // Store dispatcher in bead description (enables completion notification to dispatcher) + if err := storeDispatcherInBead(wispResult.RootID, actor); err != nil { + // Warn but don't fail - polecat will still complete work + fmt.Printf("%s Could not store dispatcher in bead: %v\n", style.Dim.Render("Warning:"), err) + } + // Store args in wisp bead if provided (no-tmux mode: beads as data plane) if slingArgs != "" { if err := storeArgsInBead(wispResult.RootID, slingArgs); err != nil {