From a6ae2c611625e66a2acc054565ffec5291d5f5e1 Mon Sep 17 00:00:00 2001 From: gastown/polecats/nux Date: Thu, 1 Jan 2026 18:50:07 -0800 Subject: [PATCH] feat(backoff): Add gt agent state command and await-signal idle tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 1: Implements gt agent state command for managing agent bead labels: - gt agent state - Get all state labels - gt agent state --set idle=0 - Set label value - gt agent state --incr idle - Increment numeric label - gt agent state --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 --- internal/cmd/agent_state.go | 296 +++++++++++++++++++++ internal/cmd/agent_state_test.go | 219 +++++++++++++++ internal/cmd/molecule_await_signal.go | 154 +++++++++-- internal/cmd/molecule_await_signal_test.go | 30 ++- 4 files changed, 672 insertions(+), 27 deletions(-) create mode 100644 internal/cmd/agent_state.go create mode 100644 internal/cmd/agent_state_test.go diff --git a/internal/cmd/agent_state.go b/internal/cmd/agent_state.go new file mode 100644 index 00000000..b36a1245 --- /dev/null +++ b/internal/cmd/agent_state.go @@ -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 ", + 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 + + Set a label: + gt agent state --set idle=0 + gt agent state --set idle=0 --set backoff=30s + + Increment a numeric label: + gt agent state --incr idle + (Creates label with value 1 if not present) + + Delete a label: + gt agent state --del idle + +COMMON LABELS: + idle: - Consecutive idle patrol cycles + backoff: - Current backoff interval + last_activity: - 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 +} diff --git a/internal/cmd/agent_state_test.go b/internal/cmd/agent_state_test.go new file mode 100644 index 00000000..38781277 --- /dev/null +++ b/internal/cmd/agent_state_test.go @@ -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 +} diff --git a/internal/cmd/molecule_await_signal.go b/internal/cmd/molecule_await_signal.go index 67569325..3da75116 100644 --- a/internal/cmd/molecule_await_signal.go +++ b/internal/cmd/molecule_await_signal.go @@ -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 +} diff --git a/internal/cmd/molecule_await_signal_test.go b/internal/cmd/molecule_await_signal_test.go index e7e6c5c5..4f570505 100644 --- a/internal/cmd/molecule_await_signal_test.go +++ b/internal/cmd/molecule_await_signal_test.go @@ -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