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>
This commit is contained in:
Steve Yegge
2025-12-25 23:53:17 -08:00
parent 70c7bedefd
commit 9f14db8f76
3 changed files with 639 additions and 0 deletions

186
internal/cmd/gate.go Normal file
View File

@@ -0,0 +1,186 @@
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)
}

249
internal/cmd/park.go Normal file
View File

@@ -0,0 +1,249 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/wisp"
"github.com/steveyegge/gastown/internal/workspace"
)
// Park command parks work on a gate, allowing agent to exit safely.
// When the gate closes, waiters are notified and can resume.
var parkCmd = &cobra.Command{
Use: "park <gate-id>",
GroupID: GroupWork,
Short: "Park work on a gate for async resumption",
Long: `Park current work on a gate, allowing the agent to exit safely.
When you need to wait for an external condition (timer, CI, human approval),
park your work on a gate. When the gate closes, you'll receive wake mail.
The park command:
1. Saves your current hook state (molecule/bead you're working on)
2. Adds you as a waiter on the gate
3. Stores context notes in the parked state
After parking, you can exit the session safely. Use 'gt resume' to check
for cleared gates and continue work.
Examples:
# Create a timer gate and park work on it
bd gate create --await timer:30m --title "Coffee break"
gt park <gate-id> -m "Taking a break, will resume auth work"
# Park on a human approval gate
bd gate create --await human:deploy-approval --notify ops/
gt park <gate-id> -m "Deploy staged, awaiting approval"
# Park on a GitHub Actions gate
bd gate create --await gh:run:123456789
gt park <gate-id> -m "Waiting for CI to complete"`,
Args: cobra.ExactArgs(1),
RunE: runPark,
}
var (
parkMessage string
parkDryRun bool
)
func init() {
parkCmd.Flags().StringVarP(&parkMessage, "message", "m", "", "Context notes for resumption")
parkCmd.Flags().BoolVarP(&parkDryRun, "dry-run", "n", false, "Show what would be done without executing")
rootCmd.AddCommand(parkCmd)
}
// ParkedWork represents work state saved when parking on a gate.
type ParkedWork struct {
// AgentID is the agent that parked (e.g., "gastown/crew/max")
AgentID string `json:"agent_id"`
// GateID is the gate we're parked on
GateID string `json:"gate_id"`
// BeadID is the bead/molecule we were working on
BeadID string `json:"bead_id,omitempty"`
// Formula is the formula attached to the work (if any)
Formula string `json:"formula,omitempty"`
// Context is additional context notes from the agent
Context string `json:"context,omitempty"`
// ParkedAt is when the work was parked
ParkedAt time.Time `json:"parked_at"`
}
func runPark(cmd *cobra.Command, args []string) error {
gateID := args[0]
// Verify gate exists and is open
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)
}
// Parse gate info to verify it's open
var gateInfo struct {
ID string `json:"id"`
Status string `json:"status"`
}
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 already closed - nothing to park on", gateID)
}
// Detect agent identity
agentID, _, cloneRoot, err := resolveSelfTarget()
if err != nil {
return fmt.Errorf("detecting agent identity: %w", err)
}
// Read current hook state (if any)
var beadID, formula, hookContext string
if hook, err := wisp.ReadHook(cloneRoot, agentID); err == nil {
beadID = hook.BeadID
formula = hook.Formula
hookContext = hook.Context
}
// Build context combining hook context and new message
context := ""
if hookContext != "" && parkMessage != "" {
context = hookContext + "\n---\n" + parkMessage
} else if hookContext != "" {
context = hookContext
} else if parkMessage != "" {
context = parkMessage
}
// Create parked work record
parked := &ParkedWork{
AgentID: agentID,
GateID: gateID,
BeadID: beadID,
Formula: formula,
Context: context,
ParkedAt: time.Now(),
}
if parkDryRun {
fmt.Printf("Would park on gate %s\n", gateID)
fmt.Printf(" Agent: %s\n", agentID)
if beadID != "" {
fmt.Printf(" Bead: %s\n", beadID)
}
if formula != "" {
fmt.Printf(" Formula: %s\n", formula)
}
if context != "" {
fmt.Printf(" Context: %s\n", context)
}
fmt.Printf("Would add %s as waiter on gate\n", agentID)
return nil
}
// Add agent as waiter on the gate
waitCmd := exec.Command("bd", "gate", "wait", gateID, "--notify", agentID)
if err := waitCmd.Run(); err != nil {
// Not fatal - might already be a waiter
fmt.Printf("%s Note: could not add as waiter (may already be registered)\n", style.Dim.Render("⚠"))
}
// Store parked work in a file (alongside hook files)
parkedPath := wisp.WispPath(cloneRoot, fmt.Sprintf("parked-%s.json", strings.ReplaceAll(agentID, "/", "_")))
parkedJSON, err := json.MarshalIndent(parked, "", " ")
if err != nil {
return fmt.Errorf("marshaling parked work: %w", err)
}
if err := os.WriteFile(parkedPath, parkedJSON, 0644); err != nil {
return fmt.Errorf("writing parked state: %w", err)
}
fmt.Printf("%s Parked work on gate %s\n", style.Bold.Render("🅿️"), gateID)
if beadID != "" {
fmt.Printf(" Working on: %s\n", beadID)
}
if context != "" {
// Truncate for display
displayContext := context
if len(displayContext) > 80 {
displayContext = displayContext[:77] + "..."
}
fmt.Printf(" Context: %s\n", displayContext)
}
fmt.Printf("\n%s You can now safely exit. Run 'gt resume' to check for cleared gates.\n",
style.Dim.Render("→"))
return nil
}
// readParkedWork reads the parked work state for an agent.
func readParkedWork(cloneRoot, agentID string) (*ParkedWork, error) {
parkedPath := wisp.WispPath(cloneRoot, fmt.Sprintf("parked-%s.json", strings.ReplaceAll(agentID, "/", "_")))
data, err := os.ReadFile(parkedPath)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var parked ParkedWork
if err := json.Unmarshal(data, &parked); err != nil {
return nil, err
}
return &parked, nil
}
// clearParkedWork removes the parked work state for an agent.
func clearParkedWork(cloneRoot, agentID string) error {
parkedPath := wisp.WispPath(cloneRoot, fmt.Sprintf("parked-%s.json", strings.ReplaceAll(agentID, "/", "_")))
err := os.Remove(parkedPath)
if os.IsNotExist(err) {
return nil
}
return err
}
// sendGateWakeMail sends wake mail to all waiters when a gate closes.
// This should be called after gate close (from Deacon or gate eval).
func sendGateWakeMail(gateID, closeReason string, waiters []string) error {
// 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)
for _, waiter := range waiters {
msg := &mail.Message{
From: "deacon/",
To: waiter,
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, closeReason),
Type: mail.TypeNotification,
Priority: mail.PriorityHigh,
Wisp: true,
}
if err := router.Send(msg); err != nil {
// Log but don't fail on individual send errors
fmt.Fprintf(os.Stderr, "Warning: failed to send wake mail to %s: %v\n", waiter, err)
}
}
return nil
}

204
internal/cmd/resume.go Normal file
View File

@@ -0,0 +1,204 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/wisp"
)
// Resume command checks for cleared gates and resumes parked work.
var resumeCmd = &cobra.Command{
Use: "resume",
GroupID: GroupWork,
Short: "Resume from parked work when gate clears",
Long: `Resume work that was parked on a gate.
This command checks if you have parked work and whether its gate has cleared.
If the gate is closed, it restores your work context so you can continue.
The resume command:
1. Checks for parked work state
2. Verifies the gate has closed
3. Restores the hook with your previous work
4. Displays context notes to help you continue
Examples:
gt resume # Check for and resume parked work
gt resume --status # Just show parked work status without resuming`,
RunE: runResume,
}
var (
resumeStatusOnly bool
resumeJSON bool
)
func init() {
resumeCmd.Flags().BoolVar(&resumeStatusOnly, "status", false, "Just show parked work status")
resumeCmd.Flags().BoolVar(&resumeJSON, "json", false, "Output as JSON")
rootCmd.AddCommand(resumeCmd)
}
// ResumeStatus represents the current resume state.
type ResumeStatus struct {
HasParkedWork bool `json:"has_parked_work"`
ParkedWork *ParkedWork `json:"parked_work,omitempty"`
GateClosed bool `json:"gate_closed"`
CloseReason string `json:"close_reason,omitempty"`
CanResume bool `json:"can_resume"`
}
func runResume(cmd *cobra.Command, args []string) error {
// Detect agent identity
agentID, _, cloneRoot, err := resolveSelfTarget()
if err != nil {
return fmt.Errorf("detecting agent identity: %w", err)
}
// Check for parked work
parked, err := readParkedWork(cloneRoot, agentID)
if err != nil {
return fmt.Errorf("reading parked work: %w", err)
}
status := ResumeStatus{
HasParkedWork: parked != nil,
ParkedWork: parked,
}
if parked == nil {
if resumeJSON {
return outputResumeStatus(status)
}
fmt.Printf("%s No parked work found\n", style.Dim.Render("○"))
fmt.Printf(" Use 'gt park <gate-id>' to park work on a gate\n")
return nil
}
// Check gate status
gateCheck := exec.Command("bd", "gate", "show", parked.GateID, "--json")
gateOutput, err := gateCheck.Output()
if err != nil {
// Gate might have been deleted or is inaccessible
status.GateClosed = false
status.CloseReason = "Gate not accessible"
} else {
var gateInfo struct {
ID string `json:"id"`
Status string `json:"status"`
CloseReason string `json:"close_reason"`
}
if err := json.Unmarshal(gateOutput, &gateInfo); err == nil {
status.GateClosed = gateInfo.Status == "closed"
status.CloseReason = gateInfo.CloseReason
}
}
status.CanResume = status.GateClosed
// Status-only mode
if resumeStatusOnly {
if resumeJSON {
return outputResumeStatus(status)
}
return displayResumeStatus(status, parked)
}
// JSON output
if resumeJSON {
return outputResumeStatus(status)
}
// If gate not closed yet, show status and exit
if !status.GateClosed {
fmt.Printf("%s Work parked on gate %s (still open)\n",
style.Bold.Render("🅿️"), parked.GateID)
if parked.BeadID != "" {
fmt.Printf(" Working on: %s\n", parked.BeadID)
}
fmt.Printf(" Parked at: %s\n", parked.ParkedAt.Format("2006-01-02 15:04:05"))
fmt.Printf("\n%s Gate still open. Check back later or run 'bd gate show %s'\n",
style.Dim.Render("⏳"), parked.GateID)
return nil
}
// Gate closed - resume work!
fmt.Printf("%s Gate %s has cleared!\n", style.Bold.Render("🚦"), parked.GateID)
if status.CloseReason != "" {
fmt.Printf(" Reason: %s\n", status.CloseReason)
}
// Restore hook if we have a bead
if parked.BeadID != "" {
hook := wisp.NewSlungWork(parked.BeadID, agentID)
hook.Formula = parked.Formula
hook.Context = parked.Context
if err := wisp.WriteSlungWork(cloneRoot, agentID, hook); err != nil {
return fmt.Errorf("restoring hook: %w", err)
}
fmt.Printf("\n%s Restored work: %s\n", style.Bold.Render("🪝"), parked.BeadID)
if parked.Formula != "" {
fmt.Printf(" Formula: %s\n", parked.Formula)
}
}
// Show context
if parked.Context != "" {
fmt.Printf("\n%s Context:\n", style.Bold.Render("📝"))
fmt.Println(parked.Context)
}
// Clear parked work state
if err := clearParkedWork(cloneRoot, agentID); err != nil {
// Non-fatal
fmt.Printf("%s Warning: could not clear parked state: %v\n", style.Dim.Render("⚠"), err)
}
fmt.Printf("\n%s Ready to continue!\n", style.Bold.Render("✓"))
return nil
}
func outputResumeStatus(status ResumeStatus) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(status)
}
func displayResumeStatus(status ResumeStatus, parked *ParkedWork) error {
if !status.HasParkedWork {
fmt.Printf("%s No parked work\n", style.Dim.Render("○"))
return nil
}
gateStatus := "open"
gateIcon := "⏳"
if status.GateClosed {
gateStatus = "closed"
gateIcon = "✓"
}
fmt.Printf("%s Parked work status:\n", style.Bold.Render("🅿️"))
fmt.Printf(" Gate: %s %s (%s)\n", gateIcon, parked.GateID, gateStatus)
if parked.BeadID != "" {
fmt.Printf(" Bead: %s\n", parked.BeadID)
}
if parked.Formula != "" {
fmt.Printf(" Formula: %s\n", parked.Formula)
}
fmt.Printf(" Parked: %s\n", parked.ParkedAt.Format("2006-01-02 15:04:05"))
if status.GateClosed {
fmt.Printf("\n%s Gate cleared! Run 'gt resume' (without --status) to restore work.\n",
style.Bold.Render("→"))
}
return nil
}