Files
gastown/internal/cmd/gate.go
Steve Yegge 9f14db8f76 Add gt park/resume commands for async gate coordination (gt-twjr5.5)
Implements agent parking and resumption on gates:
- gt park <gate-id>: Parks work on a gate, saves context
- gt resume: Checks for cleared gates and restores work
- gt gate wake: Sends wake mail to waiters when gate closes

These commands enable agents to safely suspend work while waiting
for external conditions (timers, CI, human approval) and resume
when gates clear.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 23:53:34 -08:00

187 lines
4.9 KiB
Go

package cmd
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
// Gate command provides gt wrappers for gate operations.
// Most gate commands are in beads (bd gate ...), but gt provides
// integration with the Gas Town mail system for wake notifications.
var gateCmd = &cobra.Command{
Use: "gate",
GroupID: GroupWork,
Short: "Gate coordination commands",
Long: `Gate commands for async coordination.
Most gate commands are in beads:
bd gate create - Create a gate (timer, gh:run, human, mail)
bd gate show - Show gate details
bd gate list - List open gates
bd gate close - Close a gate
bd gate approve - Approve a human gate
bd gate eval - Evaluate and close elapsed gates
The gt gate command provides Gas Town integration:
gt gate wake - Send wake mail to gate waiters after close`,
}
var gateWakeCmd = &cobra.Command{
Use: "wake <gate-id>",
Short: "Send wake mail to gate waiters",
Long: `Send wake mail to all waiters on a gate.
This command should be called after a gate closes to notify waiting agents.
Typically called by Deacon after 'bd gate eval' or after manual gate close.
The wake mail includes:
- Gate ID and close reason
- Instructions to run 'gt resume'
Examples:
# After manual gate close
bd gate close gt-xxx --reason "Approved"
gt gate wake gt-xxx
# In Deacon patrol after gate eval
for gate in $(bd gate eval --json | jq -r '.closed[]'); do
gt gate wake $gate
done`,
Args: cobra.ExactArgs(1),
RunE: runGateWake,
}
var (
gateWakeJSON bool
gateWakeDryRun bool
)
func init() {
gateWakeCmd.Flags().BoolVar(&gateWakeJSON, "json", false, "Output as JSON")
gateWakeCmd.Flags().BoolVarP(&gateWakeDryRun, "dry-run", "n", false, "Show what would be done")
gateCmd.AddCommand(gateWakeCmd)
rootCmd.AddCommand(gateCmd)
}
// GateWakeResult represents the result of sending wake mail.
type GateWakeResult struct {
GateID string `json:"gate_id"`
CloseReason string `json:"close_reason"`
Waiters []string `json:"waiters"`
Notified []string `json:"notified"`
Failed []string `json:"failed,omitempty"`
}
func runGateWake(cmd *cobra.Command, args []string) error {
gateID := args[0]
// Get gate info
gateCheck := exec.Command("bd", "gate", "show", gateID, "--json")
gateOutput, err := gateCheck.Output()
if err != nil {
return fmt.Errorf("gate '%s' not found or not accessible", gateID)
}
var gateInfo struct {
ID string `json:"id"`
Status string `json:"status"`
CloseReason string `json:"close_reason"`
Waiters []string `json:"waiters"`
}
if err := json.Unmarshal(gateOutput, &gateInfo); err != nil {
return fmt.Errorf("parsing gate info: %w", err)
}
if gateInfo.Status != "closed" {
return fmt.Errorf("gate '%s' is not closed (status: %s) - wake mail only sent for closed gates", gateID, gateInfo.Status)
}
if len(gateInfo.Waiters) == 0 {
if gateWakeJSON {
result := GateWakeResult{
GateID: gateID,
CloseReason: gateInfo.CloseReason,
Waiters: []string{},
Notified: []string{},
}
return outputGateWakeResult(result)
}
fmt.Printf("%s Gate %s has no waiters to notify\n", style.Dim.Render("○"), gateID)
return nil
}
if gateWakeDryRun {
fmt.Printf("Would send wake mail for gate %s to:\n", gateID)
for _, w := range gateInfo.Waiters {
fmt.Printf(" - %s\n", w)
}
return nil
}
// Find town root for mail routing
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding town root: %w", err)
}
router := mail.NewRouter(townRoot)
result := GateWakeResult{
GateID: gateID,
CloseReason: gateInfo.CloseReason,
Waiters: gateInfo.Waiters,
Notified: []string{},
Failed: []string{},
}
subject := fmt.Sprintf("🚦 GATE CLEARED: %s", gateID)
body := fmt.Sprintf("Gate %s has closed.\n\nReason: %s\n\nRun 'gt resume' to continue your parked work.",
gateID, gateInfo.CloseReason)
for _, waiter := range gateInfo.Waiters {
msg := &mail.Message{
From: "deacon/",
To: waiter,
Subject: subject,
Body: body,
Type: mail.TypeNotification,
Priority: mail.PriorityHigh,
Wisp: true,
}
if err := router.Send(msg); err != nil {
result.Failed = append(result.Failed, waiter)
} else {
result.Notified = append(result.Notified, waiter)
}
}
if gateWakeJSON {
return outputGateWakeResult(result)
}
fmt.Printf("%s Sent wake mail for gate %s\n", style.Bold.Render("🚦"), gateID)
if len(result.Notified) > 0 {
fmt.Printf(" Notified: %v\n", result.Notified)
}
if len(result.Failed) > 0 {
fmt.Printf(" Failed: %v\n", result.Failed)
}
return nil
}
func outputGateWakeResult(result GateWakeResult) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(result)
}