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>
187 lines
4.9 KiB
Go
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)
|
|
}
|