feat(backoff): Add gt agent state command and await-signal idle tracking
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>
This commit is contained in:
committed by
Steve Yegge
parent
5a4a691dfd
commit
a6ae2c6116
296
internal/cmd/agent_state.go
Normal file
296
internal/cmd/agent_state.go
Normal file
@@ -0,0 +1,296 @@
|
||||
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
|
||||
}
|
||||
219
internal/cmd/agent_state_test.go
Normal file
219
internal/cmd/agent_state_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseStateLabels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labels []string
|
||||
wantKeys []string
|
||||
}{
|
||||
{
|
||||
name: "empty labels",
|
||||
labels: []string{},
|
||||
wantKeys: []string{},
|
||||
},
|
||||
{
|
||||
name: "only non-state labels",
|
||||
labels: []string{"role_type", "urgent"},
|
||||
wantKeys: []string{},
|
||||
},
|
||||
{
|
||||
name: "only state labels",
|
||||
labels: []string{"idle:3", "backoff:2m"},
|
||||
wantKeys: []string{"idle", "backoff"},
|
||||
},
|
||||
{
|
||||
name: "mixed labels",
|
||||
labels: []string{"role_type", "idle:5", "urgent", "backoff:30s"},
|
||||
wantKeys: []string{"idle", "backoff"},
|
||||
},
|
||||
{
|
||||
name: "label with multiple colons",
|
||||
labels: []string{"last_activity:2025-01-01T12:00:00Z"},
|
||||
wantKeys: []string{"last_activity"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
labels := parseStateLabels(tt.labels)
|
||||
if len(labels) != len(tt.wantKeys) {
|
||||
t.Errorf("got %d labels, want %d", len(labels), len(tt.wantKeys))
|
||||
return
|
||||
}
|
||||
for _, key := range tt.wantKeys {
|
||||
if _, ok := labels[key]; !ok {
|
||||
t.Errorf("missing expected key: %s", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLabelOperations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
initial map[string]string
|
||||
setOps []string
|
||||
incrKey string
|
||||
delKeys []string
|
||||
wantKeys map[string]string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "set new label",
|
||||
initial: map[string]string{},
|
||||
setOps: []string{"idle=0"},
|
||||
wantKeys: map[string]string{"idle": "0"},
|
||||
},
|
||||
{
|
||||
name: "set overwrites existing",
|
||||
initial: map[string]string{"idle": "5"},
|
||||
setOps: []string{"idle=0"},
|
||||
wantKeys: map[string]string{"idle": "0"},
|
||||
},
|
||||
{
|
||||
name: "increment missing key creates with 1",
|
||||
initial: map[string]string{},
|
||||
incrKey: "idle",
|
||||
wantKeys: map[string]string{"idle": "1"},
|
||||
},
|
||||
{
|
||||
name: "increment existing key",
|
||||
initial: map[string]string{"idle": "3"},
|
||||
incrKey: "idle",
|
||||
wantKeys: map[string]string{"idle": "4"},
|
||||
},
|
||||
{
|
||||
name: "delete existing key",
|
||||
initial: map[string]string{"idle": "3", "backoff": "2m"},
|
||||
delKeys: []string{"idle"},
|
||||
wantKeys: map[string]string{"backoff": "2m"},
|
||||
},
|
||||
{
|
||||
name: "delete non-existent key is noop",
|
||||
initial: map[string]string{"idle": "3"},
|
||||
delKeys: []string{"nonexistent"},
|
||||
wantKeys: map[string]string{"idle": "3"},
|
||||
},
|
||||
{
|
||||
name: "invalid set format",
|
||||
initial: map[string]string{},
|
||||
setOps: []string{"invalid"},
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
labels := copyMap(tt.initial)
|
||||
err := applyLabelOperations(labels, tt.setOps, tt.incrKey, tt.delKeys)
|
||||
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(labels) != len(tt.wantKeys) {
|
||||
t.Errorf("got %d labels, want %d", len(labels), len(tt.wantKeys))
|
||||
return
|
||||
}
|
||||
|
||||
for key, wantVal := range tt.wantKeys {
|
||||
if gotVal, ok := labels[key]; !ok {
|
||||
t.Errorf("missing expected key: %s", key)
|
||||
} else if gotVal != wantVal {
|
||||
t.Errorf("labels[%s] = %s, want %s", key, gotVal, wantVal)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// parseStateLabels extracts state labels (key:value format) from all labels.
|
||||
// This is a helper for testing that mirrors the logic in getAgentLabels.
|
||||
func parseStateLabels(allLabels []string) map[string]string {
|
||||
labels := make(map[string]string)
|
||||
for _, label := range allLabels {
|
||||
if idx := indexOf(label, ":"); idx > 0 {
|
||||
labels[label[:idx]] = label[idx+1:]
|
||||
}
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
// indexOf returns the index of the first occurrence of substr in s, or -1 if not found.
|
||||
func indexOf(s, substr string) int {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// applyLabelOperations applies set, increment, and delete operations to a label map.
|
||||
// This mirrors the logic in modifyAgentState.
|
||||
func applyLabelOperations(labels map[string]string, setOps []string, incrKey string, delKeys []string) error {
|
||||
// Apply increment
|
||||
if incrKey != "" {
|
||||
currentValue := 0
|
||||
if valStr, ok := labels[incrKey]; ok {
|
||||
for i := 0; i < len(valStr); i++ {
|
||||
if valStr[i] >= '0' && valStr[i] <= '9' {
|
||||
currentValue = currentValue*10 + int(valStr[i]-'0')
|
||||
}
|
||||
}
|
||||
}
|
||||
labels[incrKey] = intToString(currentValue + 1)
|
||||
}
|
||||
|
||||
// Apply set operations
|
||||
for _, setOp := range setOps {
|
||||
idx := indexOf(setOp, "=")
|
||||
if idx <= 0 {
|
||||
return errors.New("invalid set format: " + setOp)
|
||||
}
|
||||
labels[setOp[:idx]] = setOp[idx+1:]
|
||||
}
|
||||
|
||||
// Apply delete operations
|
||||
for _, delKey := range delKeys {
|
||||
delete(labels, delKey)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyMap creates a shallow copy of a string map.
|
||||
func copyMap(m map[string]string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
for k, v := range m {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// intToString converts an int to a string without using strconv.
|
||||
func intToString(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
result := ""
|
||||
for n > 0 {
|
||||
result = string(rune('0'+n%10)) + result
|
||||
n /= 10
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -20,6 +20,7 @@ var (
|
||||
awaitSignalBackoffMult int
|
||||
awaitSignalBackoffMax string
|
||||
awaitSignalQuiet bool
|
||||
awaitSignalAgentBead string
|
||||
)
|
||||
|
||||
var moleculeAwaitSignalCmd = &cobra.Command{
|
||||
@@ -39,9 +40,11 @@ exponential wait patterns.
|
||||
|
||||
BACKOFF MODE:
|
||||
When backoff parameters are provided, the effective timeout is calculated as:
|
||||
min(base * multiplier^iteration, max)
|
||||
min(base * multiplier^idle_cycles, max)
|
||||
|
||||
This is useful for patrol loops where you want to back off during quiet periods.
|
||||
The idle_cycles value is read from the agent bead's "idle" label, enabling
|
||||
exponential backoff that persists across invocations. When a signal is
|
||||
received, the caller should reset idle:0 on the agent bead.
|
||||
|
||||
EXIT CODES:
|
||||
0 - Signal received or timeout (check output for which)
|
||||
@@ -51,8 +54,12 @@ EXAMPLES:
|
||||
# Simple wait with 60s timeout
|
||||
gt mol await-signal --timeout 60s
|
||||
|
||||
# Backoff mode: start at 30s, double each iteration, max 10m
|
||||
gt mol await-signal --backoff-base 30s --backoff-mult 2 --backoff-max 10m
|
||||
# Backoff mode with agent bead tracking:
|
||||
gt mol await-signal --agent-bead gt-gastown-witness \
|
||||
--backoff-base 30s --backoff-mult 2 --backoff-max 5m
|
||||
|
||||
# On timeout, the agent bead's idle:N label is auto-incremented
|
||||
# On signal, caller should reset: gt agent state gt-gastown-witness --set idle=0
|
||||
|
||||
# Quiet mode (no output, for scripting)
|
||||
gt mol await-signal --timeout 30s --quiet`,
|
||||
@@ -61,9 +68,10 @@ EXAMPLES:
|
||||
|
||||
// AwaitSignalResult is the result of an await-signal operation.
|
||||
type AwaitSignalResult struct {
|
||||
Reason string `json:"reason"` // "signal" or "timeout"
|
||||
Elapsed time.Duration `json:"elapsed"` // how long we waited
|
||||
Signal string `json:"signal"` // the line that woke us (if signal)
|
||||
Reason string `json:"reason"` // "signal" or "timeout"
|
||||
Elapsed time.Duration `json:"elapsed"` // how long we waited
|
||||
Signal string `json:"signal,omitempty"` // the line that woke us (if signal)
|
||||
IdleCycles int `json:"idle_cycles,omitempty"` // current idle cycle count (after update)
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -75,6 +83,8 @@ func init() {
|
||||
"Multiplier for exponential backoff (default: 2)")
|
||||
moleculeAwaitSignalCmd.Flags().StringVar(&awaitSignalBackoffMax, "backoff-max", "",
|
||||
"Maximum interval cap for backoff (e.g., 10m)")
|
||||
moleculeAwaitSignalCmd.Flags().StringVar(&awaitSignalAgentBead, "agent-bead", "",
|
||||
"Agent bead ID for tracking idle cycles (reads/writes idle:N label)")
|
||||
moleculeAwaitSignalCmd.Flags().BoolVar(&awaitSignalQuiet, "quiet", false,
|
||||
"Suppress output (for scripting)")
|
||||
moleculeAwaitSignalCmd.Flags().BoolVar(&moleculeJSON, "json", false,
|
||||
@@ -84,21 +94,45 @@ func init() {
|
||||
}
|
||||
|
||||
func runMoleculeAwaitSignal(cmd *cobra.Command, args []string) error {
|
||||
// Calculate effective timeout
|
||||
timeout, err := calculateEffectiveTimeout()
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid timeout configuration: %w", err)
|
||||
}
|
||||
|
||||
// Find beads directory
|
||||
workDir, err := findLocalBeadsDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a beads workspace: %w", err)
|
||||
}
|
||||
|
||||
beadsDir := beads.ResolveBeadsDir(workDir)
|
||||
|
||||
// Read current idle cycles from agent bead (if specified)
|
||||
var idleCycles int
|
||||
if awaitSignalAgentBead != "" {
|
||||
labels, err := getAgentLabels(awaitSignalAgentBead, beadsDir)
|
||||
if err != nil {
|
||||
// Agent bead might not exist yet - that's OK, start at 0
|
||||
if !awaitSignalQuiet {
|
||||
fmt.Printf("%s Could not read agent bead (starting at idle=0): %v\n",
|
||||
style.Dim.Render("⚠"), err)
|
||||
}
|
||||
} else if idleStr, ok := labels["idle"]; ok {
|
||||
if n, err := parseIntSimple(idleStr); err == nil {
|
||||
idleCycles = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate effective timeout (uses idle cycles if backoff mode)
|
||||
timeout, err := calculateEffectiveTimeout(idleCycles)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid timeout configuration: %w", err)
|
||||
}
|
||||
|
||||
if !awaitSignalQuiet && !moleculeJSON {
|
||||
fmt.Printf("%s Awaiting signal (timeout: %v)...\n",
|
||||
style.Dim.Render("⏳"), timeout)
|
||||
if awaitSignalAgentBead != "" {
|
||||
fmt.Printf("%s Awaiting signal (timeout: %v, idle: %d)...\n",
|
||||
style.Dim.Render("⏳"), timeout, idleCycles)
|
||||
} else {
|
||||
fmt.Printf("%s Awaiting signal (timeout: %v)...\n",
|
||||
style.Dim.Render("⏳"), timeout)
|
||||
}
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
@@ -114,6 +148,22 @@ func runMoleculeAwaitSignal(cmd *cobra.Command, args []string) error {
|
||||
|
||||
result.Elapsed = time.Since(startTime)
|
||||
|
||||
// On timeout, increment idle cycles on agent bead
|
||||
if result.Reason == "timeout" && awaitSignalAgentBead != "" {
|
||||
newIdleCycles := idleCycles + 1
|
||||
if err := setAgentIdleCycles(awaitSignalAgentBead, beadsDir, newIdleCycles); err != nil {
|
||||
if !awaitSignalQuiet {
|
||||
fmt.Printf("%s Failed to update agent bead idle count: %v\n",
|
||||
style.Dim.Render("⚠"), err)
|
||||
}
|
||||
} else {
|
||||
result.IdleCycles = newIdleCycles
|
||||
}
|
||||
} else if result.Reason == "signal" && awaitSignalAgentBead != "" {
|
||||
// On signal, report current idle cycles (caller should reset)
|
||||
result.IdleCycles = idleCycles
|
||||
}
|
||||
|
||||
// Output result
|
||||
if moleculeJSON {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
@@ -135,8 +185,13 @@ func runMoleculeAwaitSignal(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf(" %s\n", style.Dim.Render(sig))
|
||||
}
|
||||
case "timeout":
|
||||
fmt.Printf("%s Timeout after %v (no activity)\n",
|
||||
style.Dim.Render("⏱"), result.Elapsed.Round(time.Millisecond))
|
||||
if awaitSignalAgentBead != "" {
|
||||
fmt.Printf("%s Timeout after %v (idle cycle: %d)\n",
|
||||
style.Dim.Render("⏱"), result.Elapsed.Round(time.Millisecond), result.IdleCycles)
|
||||
} else {
|
||||
fmt.Printf("%s Timeout after %v (no activity)\n",
|
||||
style.Dim.Render("⏱"), result.Elapsed.Round(time.Millisecond))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,9 +199,10 @@ func runMoleculeAwaitSignal(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// calculateEffectiveTimeout determines the timeout based on flags.
|
||||
// If backoff parameters are provided, uses backoff calculation.
|
||||
// If backoff parameters are provided, uses exponential backoff formula:
|
||||
// min(base * multiplier^idleCycles, max)
|
||||
// Otherwise uses the simple --timeout value.
|
||||
func calculateEffectiveTimeout() (time.Duration, error) {
|
||||
func calculateEffectiveTimeout(idleCycles int) (time.Duration, error) {
|
||||
// If backoff base is set, use backoff mode
|
||||
if awaitSignalBackoffBase != "" {
|
||||
base, err := time.ParseDuration(awaitSignalBackoffBase)
|
||||
@@ -154,10 +210,11 @@ func calculateEffectiveTimeout() (time.Duration, error) {
|
||||
return 0, fmt.Errorf("invalid backoff-base: %w", err)
|
||||
}
|
||||
|
||||
// For now, use base as timeout
|
||||
// A more sophisticated implementation would track iteration count
|
||||
// and apply exponential backoff
|
||||
// Apply exponential backoff: base * multiplier^idleCycles
|
||||
timeout := base
|
||||
for i := 0; i < idleCycles; i++ {
|
||||
timeout *= time.Duration(awaitSignalBackoffMult)
|
||||
}
|
||||
|
||||
// Apply max cap if specified
|
||||
if awaitSignalBackoffMax != "" {
|
||||
@@ -246,3 +303,56 @@ func GetCurrentStepBackoff(workDir string) (*beads.BackoffConfig, error) {
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// parseIntSimple parses a string to int without using strconv.
|
||||
func parseIntSimple(s string) (int, error) {
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("empty string")
|
||||
}
|
||||
n := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return 0, fmt.Errorf("invalid integer: %s", s)
|
||||
}
|
||||
n = n*10 + int(s[i]-'0')
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// setAgentIdleCycles sets the idle:N label on an agent bead.
|
||||
// Uses read-modify-write pattern to update only the idle label.
|
||||
func setAgentIdleCycles(agentBead, beadsDir string, cycles int) error {
|
||||
// Read all current labels
|
||||
allLabels, err := getAllAgentLabels(agentBead, beadsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build new label list: keep non-idle labels, add new idle value
|
||||
var newLabels []string
|
||||
for _, label := range allLabels {
|
||||
// Skip any existing idle:* label
|
||||
if len(label) > 5 && label[:5] == "idle:" {
|
||||
continue
|
||||
}
|
||||
newLabels = append(newLabels, label)
|
||||
}
|
||||
|
||||
// Add new idle value
|
||||
newLabels = append(newLabels, fmt.Sprintf("idle:%d", cycles))
|
||||
|
||||
// Use bd update with --set-labels to replace all labels
|
||||
args := []string{"update", agentBead}
|
||||
for _, label := range newLabels {
|
||||
args = append(args, "--set-labels="+label)
|
||||
}
|
||||
|
||||
cmd := exec.Command("bd", args...)
|
||||
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("setting idle label: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ func TestCalculateEffectiveTimeout(t *testing.T) {
|
||||
backoffBase string
|
||||
backoffMult int
|
||||
backoffMax string
|
||||
idleCycles int
|
||||
want time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
@@ -26,17 +27,36 @@ func TestCalculateEffectiveTimeout(t *testing.T) {
|
||||
want: 5 * time.Minute,
|
||||
},
|
||||
{
|
||||
name: "backoff base only",
|
||||
name: "backoff base only, idle=0",
|
||||
timeout: "60s",
|
||||
backoffBase: "30s",
|
||||
idleCycles: 0,
|
||||
want: 30 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "backoff with max",
|
||||
name: "backoff with idle=1, mult=2",
|
||||
timeout: "60s",
|
||||
backoffBase: "30s",
|
||||
backoffMax: "10m",
|
||||
want: 30 * time.Second,
|
||||
backoffMult: 2,
|
||||
idleCycles: 1,
|
||||
want: 60 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "backoff with idle=2, mult=2",
|
||||
timeout: "60s",
|
||||
backoffBase: "30s",
|
||||
backoffMult: 2,
|
||||
idleCycles: 2,
|
||||
want: 2 * time.Minute,
|
||||
},
|
||||
{
|
||||
name: "backoff with max cap",
|
||||
timeout: "60s",
|
||||
backoffBase: "30s",
|
||||
backoffMult: 2,
|
||||
backoffMax: "5m",
|
||||
idleCycles: 10, // Would be 30s * 2^10 = ~8.5h but capped at 5m
|
||||
want: 5 * time.Minute,
|
||||
},
|
||||
{
|
||||
name: "backoff base exceeds max",
|
||||
@@ -76,7 +96,7 @@ func TestCalculateEffectiveTimeout(t *testing.T) {
|
||||
}
|
||||
awaitSignalBackoffMax = tt.backoffMax
|
||||
|
||||
got, err := calculateEffectiveTimeout()
|
||||
got, err := calculateEffectiveTimeout(tt.idleCycles)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("calculateEffectiveTimeout() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user