Layer 1: Implements gt agent state command for managing agent bead labels: - gt agent state <bead> - Get all state labels - gt agent state <bead> --set idle=0 - Set label value - gt agent state <bead> --incr idle - Increment numeric label - gt agent state <bead> --del idle - Delete label Layer 2: Fixes await-signal iteration tracking: - Adds --agent-bead flag to read/write idle:N label - Implements exponential backoff: base * mult^idle_cycles - Auto-increments idle counter on timeout - Returns idle_cycles in result for caller to reset on signal This enables patrol agents to back off during quiet periods while staying responsive to signals. Part of epic gt-srm3y. (gt-srm3y) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
297 lines
7.5 KiB
Go
297 lines
7.5 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
"github.com/steveyegge/gastown/internal/style"
|
|
)
|
|
|
|
var (
|
|
agentStateSet []string
|
|
agentStateIncr string
|
|
agentStateDel []string
|
|
agentStateJSON bool
|
|
)
|
|
|
|
var agentStateCmd = &cobra.Command{
|
|
Use: "state <agent-bead>",
|
|
Short: "Get or set operational state on agent beads",
|
|
Long: `Get or set label-based operational state on agent beads.
|
|
|
|
Agent beads store operational state (like idle cycle counts) as labels.
|
|
This command provides a convenient interface for reading and modifying
|
|
these labels without affecting other bead properties.
|
|
|
|
LABEL FORMAT:
|
|
Labels are stored as key:value pairs (e.g., idle:3, backoff:2m).
|
|
|
|
OPERATIONS:
|
|
Get all labels (default):
|
|
gt agent state <agent-bead>
|
|
|
|
Set a label:
|
|
gt agent state <agent-bead> --set idle=0
|
|
gt agent state <agent-bead> --set idle=0 --set backoff=30s
|
|
|
|
Increment a numeric label:
|
|
gt agent state <agent-bead> --incr idle
|
|
(Creates label with value 1 if not present)
|
|
|
|
Delete a label:
|
|
gt agent state <agent-bead> --del idle
|
|
|
|
COMMON LABELS:
|
|
idle:<n> - Consecutive idle patrol cycles
|
|
backoff:<duration> - Current backoff interval
|
|
last_activity:<ts> - Last activity timestamp
|
|
|
|
EXAMPLES:
|
|
# Check current idle count
|
|
gt agent state gt-gastown-witness
|
|
|
|
# Reset idle counter after finding work
|
|
gt agent state gt-gastown-witness --set idle=0
|
|
|
|
# Increment idle counter on timeout
|
|
gt agent state gt-gastown-witness --incr idle
|
|
|
|
# Get state as JSON
|
|
gt agent state gt-gastown-witness --json`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runAgentState,
|
|
}
|
|
|
|
func init() {
|
|
agentStateCmd.Flags().StringArrayVar(&agentStateSet, "set", nil,
|
|
"Set label value (format: key=value, repeatable)")
|
|
agentStateCmd.Flags().StringVar(&agentStateIncr, "incr", "",
|
|
"Increment numeric label (creates with value 1 if missing)")
|
|
agentStateCmd.Flags().StringArrayVar(&agentStateDel, "del", nil,
|
|
"Delete label (repeatable)")
|
|
agentStateCmd.Flags().BoolVar(&agentStateJSON, "json", false,
|
|
"Output as JSON")
|
|
|
|
// Add as subcommand of agents
|
|
agentsCmd.AddCommand(agentStateCmd)
|
|
}
|
|
|
|
// agentStateResult holds the state query result.
|
|
type agentStateResult struct {
|
|
AgentBead string `json:"agent_bead"`
|
|
Labels map[string]string `json:"labels"`
|
|
}
|
|
|
|
func runAgentState(cmd *cobra.Command, args []string) error {
|
|
agentBead := args[0]
|
|
|
|
// Find beads directory
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return fmt.Errorf("getting working directory: %w", err)
|
|
}
|
|
|
|
beadsDir := beads.ResolveBeadsDir(cwd)
|
|
if beadsDir == "" {
|
|
return fmt.Errorf("not in a beads workspace")
|
|
}
|
|
|
|
// Determine operation mode
|
|
hasSet := len(agentStateSet) > 0
|
|
hasIncr := agentStateIncr != ""
|
|
hasDel := len(agentStateDel) > 0
|
|
|
|
if hasSet || hasIncr || hasDel {
|
|
// Modification mode
|
|
return modifyAgentState(agentBead, beadsDir, hasIncr)
|
|
}
|
|
|
|
// Query mode
|
|
return queryAgentState(agentBead, beadsDir)
|
|
}
|
|
|
|
// queryAgentState retrieves and displays labels from an agent bead.
|
|
func queryAgentState(agentBead, beadsDir string) error {
|
|
labels, err := getAgentLabels(agentBead, beadsDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
result := &agentStateResult{
|
|
AgentBead: agentBead,
|
|
Labels: labels,
|
|
}
|
|
|
|
if agentStateJSON {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(result)
|
|
}
|
|
|
|
// Human-readable output
|
|
fmt.Printf("%s Agent: %s\n\n", style.Bold.Render("📊"), agentBead)
|
|
|
|
if len(labels) == 0 {
|
|
fmt.Printf(" %s\n", style.Dim.Render("(no operational state labels)"))
|
|
return nil
|
|
}
|
|
|
|
for key, value := range labels {
|
|
fmt.Printf(" %s: %s\n", key, value)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// modifyAgentState modifies labels on an agent bead.
|
|
// Uses read-modify-write pattern: read current labels, apply changes, write back all.
|
|
func modifyAgentState(agentBead, beadsDir string, hasIncr bool) error {
|
|
// Read current labels
|
|
labels, err := getAgentLabels(agentBead, beadsDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Also get non-state labels (ones without : separator) to preserve them
|
|
allLabels, err := getAllAgentLabels(agentBead, beadsDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Apply increment operation
|
|
if hasIncr {
|
|
currentValue := 0
|
|
if valStr, ok := labels[agentStateIncr]; ok {
|
|
if v, err := strconv.Atoi(valStr); err == nil {
|
|
currentValue = v
|
|
}
|
|
}
|
|
labels[agentStateIncr] = strconv.Itoa(currentValue + 1)
|
|
}
|
|
|
|
// Apply set operations
|
|
for _, setOp := range agentStateSet {
|
|
parts := strings.SplitN(setOp, "=", 2)
|
|
if len(parts) != 2 {
|
|
return fmt.Errorf("invalid set format: %s (expected key=value)", setOp)
|
|
}
|
|
labels[parts[0]] = parts[1]
|
|
}
|
|
|
|
// Apply delete operations
|
|
for _, delKey := range agentStateDel {
|
|
delete(labels, delKey)
|
|
}
|
|
|
|
// Build final label list: non-state labels + state labels (key:value format)
|
|
var finalLabels []string
|
|
|
|
// First, keep non-state labels (those without : separator)
|
|
for _, label := range allLabels {
|
|
if !strings.Contains(label, ":") {
|
|
finalLabels = append(finalLabels, label)
|
|
}
|
|
}
|
|
|
|
// Add state labels from modified map
|
|
for key, value := range labels {
|
|
finalLabels = append(finalLabels, key+":"+value)
|
|
}
|
|
|
|
// Build update command with --set-labels to replace all
|
|
args := []string{"update", agentBead}
|
|
for _, label := range finalLabels {
|
|
args = append(args, "--set-labels="+label)
|
|
}
|
|
|
|
// If no labels, clear all
|
|
if len(finalLabels) == 0 {
|
|
args = append(args, "--set-labels=")
|
|
}
|
|
|
|
// Execute bd update
|
|
cmd := exec.Command("bd", args...)
|
|
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
|
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
errMsg := strings.TrimSpace(stderr.String())
|
|
if errMsg != "" {
|
|
return fmt.Errorf("%s", errMsg)
|
|
}
|
|
return fmt.Errorf("updating agent state: %w", err)
|
|
}
|
|
|
|
fmt.Printf("%s Updated agent state for %s\n", style.Bold.Render("✓"), agentBead)
|
|
|
|
return nil
|
|
}
|
|
|
|
// getAgentLabels retrieves state labels from an agent bead.
|
|
// Returns only labels in key:value format, parsed into a map.
|
|
// State labels are those with a : separator (e.g., idle:3, backoff:2m).
|
|
func getAgentLabels(agentBead, beadsDir string) (map[string]string, error) {
|
|
allLabels, err := getAllAgentLabels(agentBead, beadsDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Parse state labels (those with : separator) into key:value map
|
|
labels := make(map[string]string)
|
|
for _, label := range allLabels {
|
|
parts := strings.SplitN(label, ":", 2)
|
|
if len(parts) == 2 {
|
|
labels[parts[0]] = parts[1]
|
|
}
|
|
}
|
|
|
|
return labels, nil
|
|
}
|
|
|
|
// getAllAgentLabels retrieves all labels (including non-state) from an agent bead.
|
|
func getAllAgentLabels(agentBead, beadsDir string) ([]string, error) {
|
|
args := []string{"show", agentBead, "--json"}
|
|
|
|
cmd := exec.Command("bd", args...)
|
|
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
errMsg := strings.TrimSpace(stderr.String())
|
|
if strings.Contains(errMsg, "not found") {
|
|
return nil, fmt.Errorf("agent bead not found: %s", agentBead)
|
|
}
|
|
if errMsg != "" {
|
|
return nil, fmt.Errorf("%s", errMsg)
|
|
}
|
|
return nil, fmt.Errorf("querying agent bead: %w", err)
|
|
}
|
|
|
|
// Parse JSON output - bd show --json returns an array
|
|
var issues []struct {
|
|
Labels []string `json:"labels"`
|
|
}
|
|
|
|
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
|
|
return nil, fmt.Errorf("parsing agent bead: %w", err)
|
|
}
|
|
|
|
if len(issues) == 0 {
|
|
return nil, fmt.Errorf("agent bead not found: %s", agentBead)
|
|
}
|
|
|
|
return issues[0].Labels, nil
|
|
}
|