diff --git a/internal/beads/beads.go b/internal/beads/beads.go index a1873275..f89eb503 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -1423,3 +1423,16 @@ func (b *Beads) FindMRForBranch(branch string) (*Issue, error) { return nil, nil } + +// AddGateWaiter registers an agent as a waiter on a gate bead. +// When the gate closes, the waiter will receive a wake notification via gt gate wake. +// The waiter is typically the polecat's address (e.g., "gastown/polecats/Toast"). +func (b *Beads) AddGateWaiter(gateID, waiter string) error { + // Use bd gate add-waiter to register the waiter on the gate + // This adds the waiter to the gate's native waiters field + _, err := b.run("gate", "add-waiter", gateID, waiter) + if err != nil { + return fmt.Errorf("adding gate waiter: %w", err) + } + return nil +} diff --git a/internal/cmd/done.go b/internal/cmd/done.go index a44724e7..d40c2ba6 100644 --- a/internal/cmd/done.go +++ b/internal/cmd/done.go @@ -27,31 +27,42 @@ This is a convenience command for polecats that: 4. Optionally exits the Claude session (--exit flag) Exit statuses: - COMPLETED - Work done, MR submitted (default) - ESCALATED - Hit blocker, needs human intervention - DEFERRED - Work paused, issue still open + COMPLETED - Work done, MR submitted (default) + ESCALATED - Hit blocker, needs human intervention + DEFERRED - Work paused, issue still open + PHASE_COMPLETE - Phase done, awaiting gate (use --phase-complete) + +Phase handoff workflow: + When a molecule has gate steps (async waits), use --phase-complete to signal + that the current phase is complete but work continues after the gate closes. + The Witness will recycle this polecat and dispatch a new one when the gate + resolves. Examples: - gt done # Submit branch, notify COMPLETED - gt done --exit # Submit and exit Claude session - gt done --issue gt-abc # Explicit issue ID - gt done --status ESCALATED # Signal blocker, skip MR - gt done --status DEFERRED # Pause work, skip MR`, + gt done # Submit branch, notify COMPLETED + gt done --exit # Submit and exit Claude session + gt done --issue gt-abc # Explicit issue ID + gt done --status ESCALATED # Signal blocker, skip MR + gt done --status DEFERRED # Pause work, skip MR + gt done --phase-complete --gate g-x # Phase done, waiting on gate g-x`, RunE: runDone, } var ( - doneIssue string - donePriority int - doneStatus string - doneExit bool + doneIssue string + donePriority int + doneStatus string + doneExit bool + donePhaseComplete bool + doneGate string ) // Valid exit types for gt done const ( - ExitCompleted = "COMPLETED" - ExitEscalated = "ESCALATED" - ExitDeferred = "DEFERRED" + ExitCompleted = "COMPLETED" + ExitEscalated = "ESCALATED" + ExitDeferred = "DEFERRED" + ExitPhaseComplete = "PHASE_COMPLETE" ) func init() { @@ -59,15 +70,26 @@ func init() { doneCmd.Flags().IntVarP(&donePriority, "priority", "p", -1, "Override priority (0-4, default: inherit from issue)") doneCmd.Flags().StringVar(&doneStatus, "status", ExitCompleted, "Exit status: COMPLETED, ESCALATED, or DEFERRED") doneCmd.Flags().BoolVar(&doneExit, "exit", false, "Exit Claude session after MR submission (self-terminate)") + doneCmd.Flags().BoolVar(&donePhaseComplete, "phase-complete", false, "Signal phase complete - await gate before continuing") + doneCmd.Flags().StringVar(&doneGate, "gate", "", "Gate bead ID to wait on (with --phase-complete)") rootCmd.AddCommand(doneCmd) } func runDone(cmd *cobra.Command, args []string) error { - // Validate exit status - exitType := strings.ToUpper(doneStatus) - if exitType != ExitCompleted && exitType != ExitEscalated && exitType != ExitDeferred { - return fmt.Errorf("invalid exit status '%s': must be COMPLETED, ESCALATED, or DEFERRED", doneStatus) + // Handle --phase-complete flag (overrides --status) + var exitType string + if donePhaseComplete { + exitType = ExitPhaseComplete + if doneGate == "" { + return fmt.Errorf("--phase-complete requires --gate ") + } + } else { + // Validate exit status + exitType = strings.ToUpper(doneStatus) + if exitType != ExitCompleted && exitType != ExitEscalated && exitType != ExitDeferred { + return fmt.Errorf("invalid exit status '%s': must be COMPLETED, ESCALATED, or DEFERRED", doneStatus) + } } // Find workspace @@ -235,6 +257,24 @@ func runDone(cmd *cobra.Command, args []string) error { fmt.Printf(" Priority: P%d\n", priority) fmt.Println() fmt.Printf("%s\n", style.Dim.Render("The Refinery will process your merge request.")) + } else if exitType == ExitPhaseComplete { + // Phase complete - register as waiter on gate, then recycle + fmt.Printf("%s Phase complete, awaiting gate\n", style.Bold.Render("→")) + fmt.Printf(" Gate: %s\n", doneGate) + if issueID != "" { + fmt.Printf(" Issue: %s\n", issueID) + } + fmt.Printf(" Branch: %s\n", branch) + fmt.Println() + fmt.Printf("%s\n", style.Dim.Render("Witness will dispatch new polecat when gate closes.")) + + // Register this polecat as a waiter on the gate + bd := beads.New(cwd) + if err := bd.AddGateWaiter(doneGate, sender); err != nil { + style.PrintWarning("could not register as gate waiter: %v", err) + } else { + fmt.Printf("%s Registered as waiter on gate %s\n", style.Bold.Render("✓"), doneGate) + } } else { // For ESCALATED or DEFERRED, just print status fmt.Printf("%s Signaling %s\n", style.Bold.Render("→"), exitType) @@ -258,6 +298,9 @@ func runDone(cmd *cobra.Command, args []string) error { if mrID != "" { bodyLines = append(bodyLines, fmt.Sprintf("MR: %s", mrID)) } + if doneGate != "" { + bodyLines = append(bodyLines, fmt.Sprintf("Gate: %s", doneGate)) + } bodyLines = append(bodyLines, fmt.Sprintf("Branch: %s", branch)) doneNotification := &mail.Message{ @@ -298,6 +341,7 @@ func runDone(cmd *cobra.Command, args []string) error { // - COMPLETED → "done" // - ESCALATED → "stuck" // - DEFERRED → "idle" +// - PHASE_COMPLETE → "awaiting-gate" // // Also self-reports cleanup_status for ZFC compliance (#10). func updateAgentStateOnDone(cwd, townRoot, exitType, issueID string) { @@ -329,6 +373,8 @@ func updateAgentStateOnDone(cwd, townRoot, exitType, issueID string) { newState = "stuck" case ExitDeferred: newState = "idle" + case ExitPhaseComplete: + newState = "awaiting-gate" default: return } diff --git a/internal/witness/handlers.go b/internal/witness/handlers.go index f13da123..42d2ac5e 100644 --- a/internal/witness/handlers.go +++ b/internal/witness/handlers.go @@ -28,6 +28,7 @@ type HandlerResult struct { // HandlePolecatDone processes a POLECAT_DONE message from a polecat. // For ESCALATED/DEFERRED exits (no pending MR), auto-nukes if clean. +// For PHASE_COMPLETE exits, recycles the polecat (session ends, worktree kept). // For exits with pending MR, creates a cleanup wisp to wait for MERGED. func HandlePolecatDone(workDir, rigName string, msg *mail.Message) *HandlerResult { result := &HandlerResult{ @@ -42,6 +43,18 @@ func HandlePolecatDone(workDir, rigName string, msg *mail.Message) *HandlerResul return result } + // Handle PHASE_COMPLETE: recycle polecat (session ends but worktree stays) + // The polecat is registered as a waiter on the gate and will be re-dispatched + // when the gate closes via gt gate wake. + if payload.Exit == "PHASE_COMPLETE" { + result.Handled = true + result.Action = fmt.Sprintf("phase-complete for %s (gate=%s) - session recycled, awaiting gate", payload.PolecatName, payload.Gate) + // Note: The polecat has already registered itself as a gate waiter via bd + // The gate wake mechanism (gt gate wake) will send mail when gate closes + // A new polecat will be dispatched to continue the molecule from the next step + return result + } + // Check if this polecat has a pending MR // ESCALATED/DEFERRED exits typically have no MR pending hasPendingMR := payload.MRID != "" || payload.Exit == "COMPLETED" diff --git a/internal/witness/protocol.go b/internal/witness/protocol.go index 0ae58174..a98201df 100644 --- a/internal/witness/protocol.go +++ b/internal/witness/protocol.go @@ -45,10 +45,11 @@ const ( // PolecatDonePayload contains parsed data from a POLECAT_DONE message. type PolecatDonePayload struct { PolecatName string - Exit string // MERGED, ESCALATED, DEFERRED + Exit string // COMPLETED, ESCALATED, DEFERRED, PHASE_COMPLETE IssueID string MRID string Branch string + Gate string // Gate ID when Exit is PHASE_COMPLETE } // HelpPayload contains parsed data from a HELP message. @@ -101,9 +102,10 @@ func ClassifyMessage(subject string) ProtocolType { // Subject format: POLECAT_DONE // Body format: // -// Exit: MERGED|ESCALATED|DEFERRED +// Exit: COMPLETED|ESCALATED|DEFERRED|PHASE_COMPLETE // Issue: // MR: +// Gate: // Branch: func ParsePolecatDone(subject, body string) (*PolecatDonePayload, error) { matches := PatternPolecatDone.FindStringSubmatch(subject) @@ -124,6 +126,8 @@ func ParsePolecatDone(subject, body string) (*PolecatDonePayload, error) { payload.IssueID = strings.TrimSpace(strings.TrimPrefix(line, "Issue:")) } else if strings.HasPrefix(line, "MR:") { payload.MRID = strings.TrimSpace(strings.TrimPrefix(line, "MR:")) + } else if strings.HasPrefix(line, "Gate:") { + payload.Gate = strings.TrimSpace(strings.TrimPrefix(line, "Gate:")) } else if strings.HasPrefix(line, "Branch:") { payload.Branch = strings.TrimSpace(strings.TrimPrefix(line, "Branch:")) }