Merge pull request #39 from cvsloane/fix/polecat-dispatcher-notification
feat: notify dispatcher when polecat work completes
This commit is contained in:
@@ -20,6 +20,7 @@ type AttachmentFields struct {
|
|||||||
AttachedMolecule string // Root issue ID of the attached molecule
|
AttachedMolecule string // Root issue ID of the attached molecule
|
||||||
AttachedAt string // ISO 8601 timestamp when attached
|
AttachedAt string // ISO 8601 timestamp when attached
|
||||||
AttachedArgs string // Natural language args passed via gt sling --args (no-tmux mode)
|
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.
|
// ParseAttachmentFields extracts attachment fields from an issue's description.
|
||||||
@@ -61,6 +62,9 @@ func ParseAttachmentFields(issue *Issue) *AttachmentFields {
|
|||||||
case "attached_args", "attached-args", "attachedargs":
|
case "attached_args", "attached-args", "attachedargs":
|
||||||
fields.AttachedArgs = value
|
fields.AttachedArgs = value
|
||||||
hasFields = true
|
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 != "" {
|
if fields.AttachedArgs != "" {
|
||||||
lines = append(lines, "attached_args: "+fields.AttachedArgs)
|
lines = append(lines, "attached_args: "+fields.AttachedArgs)
|
||||||
}
|
}
|
||||||
|
if fields.DispatchedBy != "" {
|
||||||
|
lines = append(lines, "dispatched_by: "+fields.DispatchedBy)
|
||||||
|
}
|
||||||
|
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
@@ -107,6 +114,9 @@ func SetAttachmentFields(issue *Issue, fields *AttachmentFields) string {
|
|||||||
"attached_args": true,
|
"attached_args": true,
|
||||||
"attached-args": true,
|
"attached-args": true,
|
||||||
"attachedargs": true,
|
"attachedargs": true,
|
||||||
|
"dispatched_by": true,
|
||||||
|
"dispatched-by": true,
|
||||||
|
"dispatchedby": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect non-attachment lines from existing description
|
// Collect non-attachment lines from existing description
|
||||||
|
|||||||
@@ -330,6 +330,23 @@ func runDone(cmd *cobra.Command, args []string) error {
|
|||||||
fmt.Printf("%s Witness notified of %s\n", style.Bold.Render("✓"), exitType)
|
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)
|
// Log done event (townlog and activity feed)
|
||||||
_ = LogDone(townRoot, sender, issueID)
|
_ = LogDone(townRoot, sender, issueID)
|
||||||
_ = events.LogFeed(events.TypeDone, sender, events.DonePayload(issueID, branch))
|
_ = events.LogFeed(events.TypeDone, sender, events.DonePayload(issueID, branch))
|
||||||
@@ -421,6 +438,27 @@ func updateAgentStateOnDone(cwd, townRoot, exitType, _ string) { // issueID unus
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// computeCleanupStatus checks git state and returns the cleanup status.
|
||||||
// Returns the most critical issue: has_unpushed > has_stash > has_uncommitted > clean
|
// Returns the most critical issue: has_unpushed > has_stash > has_uncommitted > clean
|
||||||
func computeCleanupStatus(cwd string) string {
|
func computeCleanupStatus(cwd string) string {
|
||||||
|
|||||||
@@ -454,6 +454,12 @@ func runSling(cmd *cobra.Command, args []string) error {
|
|||||||
// Update agent bead's hook_bead field (ZFC: agents track their current work)
|
// Update agent bead's hook_bead field (ZFC: agents track their current work)
|
||||||
updateAgentHookBead(targetAgent, beadID, hookWorkDir, townBeadsDir)
|
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)
|
// Store args in bead description (no-tmux mode: beads as data plane)
|
||||||
if slingArgs != "" {
|
if slingArgs != "" {
|
||||||
if err := storeArgsInBead(beadID, slingArgs); err != nil {
|
if err := storeArgsInBead(beadID, slingArgs); err != nil {
|
||||||
@@ -520,6 +526,52 @@ func storeArgsInBead(beadID, args string) error {
|
|||||||
return nil
|
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.
|
// injectStartPrompt sends a prompt to the target pane to start working.
|
||||||
// Uses the reliable nudge pattern: literal mode + 500ms debounce + separate Enter.
|
// Uses the reliable nudge pattern: literal mode + 500ms debounce + separate Enter.
|
||||||
func injectStartPrompt(pane, beadID, subject, args string) error {
|
func injectStartPrompt(pane, beadID, subject, args string) error {
|
||||||
@@ -862,6 +914,12 @@ func runSlingFormula(args []string) error {
|
|||||||
// Note: formula slinging uses town root as workDir (no polecat-specific path)
|
// Note: formula slinging uses town root as workDir (no polecat-specific path)
|
||||||
updateAgentHookBead(targetAgent, wispResult.RootID, "", townBeadsDir)
|
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)
|
// Store args in wisp bead if provided (no-tmux mode: beads as data plane)
|
||||||
if slingArgs != "" {
|
if slingArgs != "" {
|
||||||
if err := storeArgsInBead(wispResult.RootID, slingArgs); err != nil {
|
if err := storeArgsInBead(wispResult.RootID, slingArgs); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user