Coverage for little_loops / fsm / executor.py: 14%

548 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-11 00:29 -0500

1"""FSM Executor - Runtime engine for FSM loop execution. 

2 

3This module provides the execution engine that runs FSM loops: 

4- Executes actions (shell commands or slash commands) 

5- Evaluates results using appropriate evaluators 

6- Routes to next states based on verdicts 

7- Tracks iteration count and enforces limits 

8- Manages captured variables and context 

9""" 

10 

11from __future__ import annotations 

12 

13import json 

14import random 

15import subprocess 

16import threading 

17import time 

18from collections.abc import Callable 

19from dataclasses import dataclass 

20from datetime import UTC, datetime 

21from pathlib import Path 

22from typing import Any 

23 

24from little_loops.fsm.evaluators import ( 

25 EvaluationResult, 

26 evaluate, 

27 evaluate_exit_code, 

28 evaluate_llm_structured, 

29 evaluate_mcp_result, 

30) 

31from little_loops.fsm.handoff_handler import HandoffHandler 

32from little_loops.fsm.interpolation import ( 

33 InterpolationContext, 

34 InterpolationError, 

35 interpolate, 

36 interpolate_dict, 

37) 

38from little_loops.fsm.rate_limit_circuit import RateLimitCircuit 

39from little_loops.fsm.runners import ( 

40 ActionRunner, 

41 DefaultActionRunner, 

42 SimulationActionRunner, # noqa: F401 — re-exported for backward compatibility 

43 _now_ms, 

44) 

45from little_loops.fsm.schema import FSMLoop, StateConfig 

46from little_loops.fsm.signal_detector import DetectedSignal, SignalDetector 

47from little_loops.fsm.types import ActionResult, Evaluator, EventCallback, ExecutionResult 

48from little_loops.issue_lifecycle import FailureType, classify_failure 

49from little_loops.session_log import get_current_session_jsonl 

50 

51# Maximum number of per-state rate-limit retries before emitting rate_limit_exhausted. 

52_DEFAULT_RATE_LIMIT_RETRIES: int = 3 

53# Base backoff in seconds; actual sleep = base * 2^(attempt-1) + uniform(0, base). 

54_DEFAULT_RATE_LIMIT_BACKOFF_BASE: int = 30 

55# Total wall-clock budget (seconds) across short + long tiers before routing to 

56# on_rate_limit_exhausted. Mirrors RateLimitsConfig.max_wait_seconds default (6h). 

57_DEFAULT_RATE_LIMIT_MAX_WAIT_SECONDS: int = 21600 

58# Long-wait tier ladder (seconds), walked once the short-tier budget is spent. 

59# Mirrors RateLimitsConfig.long_wait_ladder default. 

60_DEFAULT_RATE_LIMIT_LONG_WAIT_LADDER: list[int] = [300, 900, 1800, 3600] 

61# Event name emitted when rate-limit retries are exhausted. 

62RATE_LIMIT_EXHAUSTED_EVENT: str = "rate_limit_exhausted" 

63# Event name emitted when consecutive rate-limit exhaustions reach the storm threshold. 

64RATE_LIMIT_STORM_EVENT: str = "rate_limit_storm" 

65# Event name emitted every ~60s during a long-wait rate-limit sleep so UIs can show live progress. 

66RATE_LIMIT_WAITING_EVENT: str = "rate_limit_waiting" 

67# Interval (seconds) between rate_limit_waiting heartbeat emissions during long-wait sleeps. 

68_RATE_LIMIT_HEARTBEAT_INTERVAL: float = 60.0 

69# Number of consecutive rate_limit_exhausted events that constitute a storm. 

70_RATE_LIMIT_STORM_THRESHOLD: int = 3 

71# Progressive throttle defaults: calls 1..normal_max pass through, at warn_max emit warning, 

72# at hard_max route to on_throttle_hard, beyond hard_max hard-stop. 

73_DEFAULT_THROTTLE_NORMAL_MAX: int = 3 

74_DEFAULT_THROTTLE_WARN_MAX: int = 8 

75_DEFAULT_THROTTLE_HARD_MAX: int = 12 

76# Event names for progressive tool-call throttling within a single state visit. 

77THROTTLE_WARN_EVENT: str = "throttle_warn" 

78THROTTLE_HARD_EVENT: str = "throttle_hard" 

79THROTTLE_STOP_EVENT: str = "throttle_stop" 

80# Action types that consume LLM quota and are gated by the shared circuit breaker. 

81# `_action_mode()` collapses both to "prompt"; the frozenset documents intent. 

82LLM_ACTION_TYPES: frozenset[str] = frozenset({"slash_command", "prompt"}) 

83# Maximum per-state API server error retries before falling through to normal routing. 

84_DEFAULT_API_ERROR_RETRIES: int = 2 

85# Flat backoff in seconds between API server error retries (no exponential ladder). 

86_DEFAULT_API_ERROR_BACKOFF: int = 30 

87 

88 

89def _iso_now() -> str: 

90 """Get current time as ISO 8601 string.""" 

91 return datetime.now(UTC).isoformat() 

92 

93 

94@dataclass 

95class RouteContext: 

96 """Context passed to before_route / after_route interceptors.""" 

97 

98 state_name: str 

99 state: StateConfig 

100 verdict: str 

101 action_result: ActionResult | None 

102 eval_result: EvaluationResult | None 

103 ctx: InterpolationContext 

104 iteration: int 

105 

106 

107@dataclass 

108class RouteDecision: 

109 """Returned by before_route to redirect or veto a routing transition. 

110 

111 Return semantics for before_route: 

112 None (implicit) → passthrough, routing proceeds normally 

113 RouteDecision("state") → redirect, bypass _route() and use "state" directly 

114 RouteDecision(None) → veto, _execute_state() returns None → _finish("error") 

115 """ 

116 

117 next_state: str | None # str → redirect; None → veto 

118 

119 

120class FSMExecutor: 

121 """Execute an FSM loop. 

122 

123 The executor runs an FSM from its initial state until: 

124 - A terminal state is reached 

125 - max_iterations is exceeded 

126 - A timeout occurs 

127 - A shutdown signal is received 

128 - An unrecoverable error occurs 

129 

130 Events are emitted via the callback for observability. 

131 """ 

132 

133 def __init__( 

134 self, 

135 fsm: FSMLoop, 

136 event_callback: EventCallback | None = None, 

137 action_runner: ActionRunner | None = None, 

138 signal_detector: SignalDetector | None = None, 

139 handoff_handler: HandoffHandler | None = None, 

140 loops_dir: Path | None = None, 

141 circuit: RateLimitCircuit | None = None, 

142 ): 

143 """Initialize the executor. 

144 

145 Args: 

146 fsm: The FSM loop to execute 

147 event_callback: Optional callback for events 

148 action_runner: Optional custom action runner (for testing) 

149 signal_detector: Optional signal detector for output parsing 

150 handoff_handler: Optional handler for handoff signals 

151 loops_dir: Base directory for resolving sub-loop references 

152 circuit: Optional shared rate-limit circuit breaker for 429 coordination 

153 """ 

154 self.fsm = fsm 

155 self.event_callback = event_callback or (lambda _: None) 

156 self.action_runner: ActionRunner = action_runner or DefaultActionRunner() 

157 self.signal_detector = signal_detector 

158 self.handoff_handler = handoff_handler 

159 self.loops_dir = loops_dir 

160 self._circuit = circuit 

161 

162 # Runtime state 

163 self.current_state = fsm.initial 

164 self.iteration = 0 

165 self.captured: dict[str, dict[str, Any]] = {} 

166 self.prev_result: dict[str, Any] | None = None 

167 self.started_at = "" 

168 self.start_time_ms = 0 

169 self.elapsed_offset_ms = ( 

170 0 # milliseconds from segments before current run (set by PersistentExecutor on resume) 

171 ) 

172 

173 # Shutdown flag for graceful signal handling 

174 self._shutdown_requested = False 

175 

176 # Currently running MCP subprocess (set by _run_subprocess, cleared in finally). 

177 # Enables external shutdown code to kill the process on SIGTERM. 

178 self._current_process: subprocess.Popen[str] | None = None 

179 

180 # Pending handoff signal (set by _run_action, checked by main loop) 

181 self._pending_handoff: DetectedSignal | None = None 

182 

183 # Pending error payload from FATAL_ERROR signal (set by _run_action, checked by main loop) 

184 self._pending_error: str | None = None 

185 

186 # Per-state retry tracking for max_retries support. 

187 # _retry_counts[state_name] = number of consecutive re-entries into that state. 

188 # Incremented each time we enter the same state as the previous iteration. 

189 # Reset when a different state is entered, or after retry exhaustion. 

190 self._retry_counts: dict[str, int] = {} 

191 # State entered in the previous iteration (None on first iteration or after resume). 

192 self._prev_state: str | None = None 

193 

194 # BUG-1226: true between emitting a `route` event and entering the 

195 # target state. Gates the "flush one pending shell state on timeout" 

196 # behavior so we only flush when there is actually a pending state. 

197 self._just_routed: bool = False 

198 

199 # Per-state rate-limit retry tracking (parallel to _retry_counts). 

200 # _rate_limit_retries[state_name] = dict-of-record: 

201 # { 

202 # "short_retries": int, # attempts in short-burst tier 

203 # "long_retries": int, # attempts in long-wait tier 

204 # "total_wait_seconds": float, 

205 # "first_seen_at": float | None, # epoch timestamp of first 429 

206 # } 

207 # Incremented inside _handle_rate_limit on each detected rate-limit response. 

208 # Reset when the state completes without a rate-limit, or after exhaustion. 

209 self._rate_limit_retries: dict[str, dict[str, Any]] = {} 

210 

211 # Consecutive rate_limit_exhausted emissions across all states. Reset on any 

212 # successful non-rate-limited state transition. When this reaches 

213 # _RATE_LIMIT_STORM_THRESHOLD, a RATE_LIMIT_STORM event is emitted. 

214 self._consecutive_rate_limit_exhaustions: int = 0 

215 

216 # Per-state API server error retry tracking (parallel to _rate_limit_retries). 

217 # _api_error_retries[state_name] = {"retries": int, "total_wait": float} 

218 # Reset when the state completes without a server error, or after exhaustion. 

219 self._api_error_retries: dict[str, dict[str, Any]] = {} 

220 

221 # Per-state tool-call throttle counter. Counts successive action executions within 

222 # a single continuous state visit. Reset on state exit; NOT serialized to LoopState 

223 # (throttle counts measure instantaneous visit-level activity, not cumulative retries). 

224 self._throttle_counts: dict[str, int] = {} 

225 

226 # Per-edge revisit counter for cycle detection. 

227 # _edge_revisit_counts["from_state->to_state"] = number of times that edge has fired. 

228 # When any edge exceeds max_edge_revisits, the loop terminates with cycle_detected. 

229 self._edge_revisit_counts: dict[str, int] = {} 

230 

231 # Nesting depth for sub-loop event forwarding (0 = top-level, 1+ = sub-loop). 

232 # Set by the parent executor when constructing child executors. 

233 self._depth: int = 0 

234 

235 # Extension hook registries — populated by wire_extensions() 

236 self._contributed_actions: dict[str, ActionRunner] = {} 

237 self._contributed_evaluators: dict[str, Evaluator] = {} 

238 self._interceptors: list[Any] = [] 

239 

240 def request_shutdown(self) -> None: 

241 """Request graceful shutdown of the executor. 

242 

243 Sets a flag that will be checked at the start of each iteration, 

244 allowing the loop to exit cleanly after the current state completes. 

245 """ 

246 self._shutdown_requested = True 

247 

248 def run(self) -> ExecutionResult: 

249 """Execute the FSM until terminal state or limits reached. 

250 

251 Returns: 

252 ExecutionResult with final state and execution metadata 

253 """ 

254 self.started_at = _iso_now() 

255 self.start_time_ms = _now_ms() 

256 

257 self._emit("loop_start", {"loop": self.fsm.name}) 

258 

259 try: 

260 while True: 

261 # Check shutdown request (signal handling) 

262 if self._shutdown_requested: 

263 return self._finish("signal") 

264 

265 # Check iteration limit 

266 if self.iteration >= self.fsm.max_iterations: 

267 return self._finish("max_iterations") 

268 

269 # Check timeout 

270 if self.fsm.timeout: 

271 elapsed = _now_ms() - self.start_time_ms + self.elapsed_offset_ms 

272 if elapsed > self.fsm.timeout * 1000: 

273 # BUG-1226: if timeout fires in the race window between 

274 # a `route` event and `state_enter`, flush one pending 

275 # shell-action state before honoring the timeout so its 

276 # side effect (e.g. copying a handshake flag) is not 

277 # silently lost. Bounded to shell actions — slash 

278 # commands and sub-loops would violate the timeout 

279 # budget. Single-step: no cascade. 

280 if self._just_routed: 

281 pending = self.fsm.states.get(self.current_state) 

282 if ( 

283 pending is not None 

284 and not pending.terminal 

285 and pending.loop is None 

286 and pending.action is not None 

287 and self._action_mode(pending) == "shell" 

288 ): 

289 self._flush_pending_shell_state(pending) 

290 return self._finish("timeout") 

291 

292 # Get current state config 

293 state_config = self.fsm.states[self.current_state] 

294 

295 # Update per-state retry tracking based on transition from previous iteration. 

296 # If re-entering the same state consecutively, increment retry count. 

297 # If entering a different state, clear the previous state's retry count. 

298 if self._prev_state is not None: 

299 if self.current_state == self._prev_state: 

300 self._retry_counts[self.current_state] = ( 

301 self._retry_counts.get(self.current_state, 0) + 1 

302 ) 

303 else: 

304 self._retry_counts.pop(self._prev_state, None) 

305 self._throttle_counts.pop(self._prev_state, None) 

306 

307 # Check terminal 

308 if state_config.terminal: 

309 # Handle maintain mode - restart loop instead of terminating 

310 if self.fsm.maintain: 

311 self.iteration += 1 

312 maintain_target = state_config.on_maintain or self.fsm.initial 

313 self._emit( 

314 "route", 

315 { 

316 "from": self.current_state, 

317 "to": maintain_target, 

318 "reason": "maintain", 

319 }, 

320 ) 

321 self._prev_state = self.current_state 

322 self.current_state = maintain_target 

323 self._just_routed = True 

324 continue 

325 return self._finish("terminal") 

326 

327 # Check per-state retry limit. If the consecutive re-entry count exceeds 

328 # max_retries, skip execution and route to on_retry_exhausted instead. 

329 if state_config.max_retries is not None: 

330 retry_count = self._retry_counts.get(self.current_state, 0) 

331 if retry_count > state_config.max_retries: 

332 # on_retry_exhausted is guaranteed non-None by validation when 

333 # max_retries is set, but we fall back to an error if misconfigured. 

334 exhausted_state: str = state_config.on_retry_exhausted or "" 

335 if not exhausted_state: 

336 return self._finish( 

337 "error", 

338 error=f"State '{self.current_state}' exceeded max_retries " 

339 "but on_retry_exhausted is not set", 

340 ) 

341 self._emit( 

342 "retry_exhausted", 

343 { 

344 "state": self.current_state, 

345 "retries": retry_count, 

346 "next": exhausted_state, 

347 }, 

348 ) 

349 self._retry_counts.pop(self.current_state, None) 

350 self._prev_state = self.current_state 

351 self.current_state = exhausted_state 

352 continue 

353 

354 self.iteration += 1 

355 self._just_routed = False 

356 self._emit( 

357 "state_enter", 

358 { 

359 "state": self.current_state, 

360 "iteration": self.iteration, 

361 }, 

362 ) 

363 

364 # Execute state 

365 next_state = self._execute_state(state_config) 

366 

367 # Check for pending error signal (FATAL_ERROR) 

368 if self._pending_error is not None: 

369 return self._finish("error", error=self._pending_error) 

370 

371 # Check for pending handoff signal 

372 if self._pending_handoff: 

373 return self._handle_handoff(self._pending_handoff) 

374 

375 # Handle maintain mode 

376 if next_state is None and self.fsm.maintain: 

377 next_state = state_config.on_maintain or self.fsm.initial 

378 

379 # SIGKILL in _execute_state sets shutdown flag and returns None 

380 if next_state is None and self._shutdown_requested: 

381 return self._finish("signal") 

382 

383 if next_state is None: 

384 return self._finish("error", error="No valid transition") 

385 

386 # At this point next_state is guaranteed to be str 

387 resolved_next: str = next_state 

388 

389 self._emit( 

390 "route", 

391 { 

392 "from": self.current_state, 

393 "to": resolved_next, 

394 }, 

395 ) 

396 

397 # Per-edge revisit tracking for cycle detection. 

398 edge_key = f"{self.current_state}->{resolved_next}" 

399 self._edge_revisit_counts[edge_key] = ( 

400 self._edge_revisit_counts.get(edge_key, 0) + 1 

401 ) 

402 if self._edge_revisit_counts[edge_key] > self.fsm.max_edge_revisits: 

403 self._emit("cycle_detected", { 

404 "edge": edge_key, 

405 "from": self.current_state, 

406 "to": resolved_next, 

407 "count": self._edge_revisit_counts[edge_key], 

408 "max": self.fsm.max_edge_revisits, 

409 }) 

410 return self._finish( 

411 "cycle_detected", 

412 error=f"Cycle detected: edge {edge_key} traversed " 

413 f"{self._edge_revisit_counts[edge_key]} times " 

414 f"(limit: {self.fsm.max_edge_revisits})", 

415 ) 

416 

417 self._prev_state = self.current_state 

418 self.current_state = resolved_next 

419 self._just_routed = True 

420 

421 # Interruptible backoff sleep between iterations 

422 if self.fsm.backoff and self.fsm.backoff > 0: 

423 deadline = time.time() + self.fsm.backoff 

424 while time.time() < deadline: 

425 if self._shutdown_requested: 

426 break 

427 time.sleep(min(0.1, deadline - time.time())) 

428 

429 except InterpolationError as exc: 

430 return self._finish( 

431 "error", 

432 error=( 

433 f"Missing context variable in state '{self.current_state}': {exc}. " 

434 f"Run with: ll-loop run {self.fsm.name} --context KEY=VALUE" 

435 ), 

436 ) 

437 except Exception as exc: 

438 return self._finish("error", error=str(exc)) 

439 

440 def _execute_sub_loop(self, state: StateConfig, ctx: InterpolationContext) -> str | None: 

441 """Execute a sub-loop state by loading and running a child FSM. 

442 

443 Args: 

444 state: The state configuration with loop field set 

445 ctx: Interpolation context for routing 

446 

447 Returns: 

448 Next state name based on child loop verdict, or None 

449 """ 

450 from little_loops.cli.loop._helpers import resolve_loop_path 

451 from little_loops.fsm.validation import load_and_validate 

452 

453 assert state.loop is not None # guarded by caller 

454 loop_name = interpolate(state.loop, ctx) 

455 loop_path = resolve_loop_path(loop_name, self.loops_dir or Path(".loops")) 

456 child_fsm, _ = load_and_validate(loop_path) 

457 

458 # Bind child context: explicit with: bindings take precedence over legacy passthrough 

459 if state.with_: 

460 from little_loops.fsm.interpolation import interpolate_dict 

461 

462 resolved = interpolate_dict(state.with_, ctx) 

463 # Apply declared defaults for unbound optional parameters 

464 for param_name, param_spec in child_fsm.parameters.items(): 

465 if ( 

466 param_name not in resolved 

467 and not param_spec.required 

468 and param_spec.default is not None 

469 ): 

470 resolved[param_name] = param_spec.default 

471 # Runtime check: required parameters must be present after interpolation 

472 for param_name, param_spec in child_fsm.parameters.items(): 

473 if param_spec.required and param_name not in resolved: 

474 raise ValueError( 

475 f"Sub-loop '{state.loop}' requires parameter '{param_name}' " 

476 f"but it is not bound in 'with'" 

477 ) 

478 # Merge: child's own context block provides base; with: bindings override 

479 child_fsm.context = {**child_fsm.context, **resolved} 

480 elif state.context_passthrough: 

481 # Extract .output strings from capture result dicts so ${context.key} resolves 

482 # to the plain output string (e.g. "ENH-123") rather than the full capture object. 

483 captured_as_context = { 

484 k: v["output"] if isinstance(v, dict) and "exit_code" in v else v 

485 for k, v in self.captured.items() 

486 } 

487 child_fsm.context = {**self.fsm.context, **captured_as_context, **child_fsm.context} 

488 

489 depth = self._depth + 1 

490 child_events: list[dict] = [] 

491 

492 def _sub_event_callback(event: dict) -> None: 

493 child_events.append(event) 

494 # Only inject depth if not already set by a deeper nested sub-loop 

495 if "depth" not in event: 

496 self.event_callback({**event, "depth": depth}) 

497 else: 

498 self.event_callback(event) 

499 

500 child_executor = FSMExecutor( 

501 child_fsm, 

502 action_runner=self.action_runner, 

503 loops_dir=self.loops_dir, 

504 event_callback=_sub_event_callback, 

505 circuit=self._circuit, 

506 ) 

507 child_executor._depth = depth # propagate depth for further nesting 

508 

509 # Clamp child timeout to parent's remaining wall-clock budget so a slow sub-loop 

510 # can't silently consume the parent's deadline with no recourse for the parent FSM. 

511 if self.fsm.timeout: 

512 elapsed_ms = _now_ms() - self.start_time_ms + self.elapsed_offset_ms 

513 remaining_s = max(1, int((self.fsm.timeout * 1000 - elapsed_ms) // 1000)) 

514 if child_fsm.timeout is None or child_fsm.timeout > remaining_s: 

515 child_fsm.timeout = remaining_s 

516 

517 child_result = child_executor.run() 

518 

519 # Capture child event stream as a JSON-lines string if the state declares a capture key 

520 if state.capture and child_events: 

521 import json as _json 

522 

523 self.captured[state.capture] = { 

524 "output": "\n".join(_json.dumps(e) for e in child_events), 

525 "exit_code": None, 

526 } 

527 

528 # Merge child captures back into parent under the state name 

529 if (state.context_passthrough or state.with_) and child_executor.captured: 

530 self.captured[self.current_state] = child_executor.captured 

531 

532 # Route based on child termination reason and terminal state name 

533 if child_result.terminated_by == "terminal": 

534 if child_result.final_state == "done": 

535 return interpolate(state.on_yes, ctx) if state.on_yes else None 

536 else: 

537 # Reached a non-done terminal (e.g. "failed") → failure 

538 return interpolate(state.on_no, ctx) if state.on_no else None 

539 elif child_result.terminated_by == "error": 

540 # Runtime child failure (not a YAML load error) 

541 if state.on_error: 

542 return interpolate(state.on_error, ctx) 

543 return interpolate(state.on_no, ctx) if state.on_no else None 

544 else: 

545 # max_iterations, timeout, signal — all are failure 

546 return interpolate(state.on_no, ctx) if state.on_no else None 

547 

548 def _check_throttle(self, state: StateConfig, state_name: str) -> str | None: 

549 """Increment the per-state tool-call counter and enforce throttle thresholds. 

550 

551 Called after every action execution within a state visit. Returns the forced 

552 next-state name when the hard threshold is reached, or None when execution 

553 should continue normally (warn events are emitted but do not redirect). 

554 

555 Sets self._pending_error and returns "__STOP__" when the call count exceeds 

556 hard_max with no on_throttle_hard target — the caller must propagate this as 

557 a None return from _execute_state so the main loop detects _pending_error. 

558 """ 

559 count = self._throttle_counts.get(state_name, 0) + 1 

560 self._throttle_counts[state_name] = count 

561 

562 throttle = state.throttle 

563 normal_max = ( 

564 throttle.normal_max 

565 if (throttle and throttle.normal_max is not None) 

566 else _DEFAULT_THROTTLE_NORMAL_MAX 

567 ) 

568 warn_max = ( 

569 throttle.warn_max 

570 if (throttle and throttle.warn_max is not None) 

571 else _DEFAULT_THROTTLE_WARN_MAX 

572 ) 

573 hard_max = ( 

574 throttle.hard_max 

575 if (throttle and throttle.hard_max is not None) 

576 else _DEFAULT_THROTTLE_HARD_MAX 

577 ) 

578 

579 if count == warn_max: 

580 self._emit( 

581 THROTTLE_WARN_EVENT, 

582 { 

583 "state": state_name, 

584 "count": count, 

585 "normal_max": normal_max, 

586 "warn_max": warn_max, 

587 "hard_max": hard_max, 

588 }, 

589 ) 

590 

591 # States with type="learning" (FEAT-1283) are exempt from hard_max — they 

592 # legitimately make N calls per visit (one per unproven target). 

593 if state.type == "learning": 

594 return None 

595 

596 if count == hard_max: 

597 next_target = state.on_throttle_hard or state.on_error 

598 self._emit( 

599 THROTTLE_HARD_EVENT, 

600 {"state": state_name, "count": count, "hard_max": hard_max, "next": next_target}, 

601 ) 

602 return next_target 

603 

604 if count > hard_max: 

605 self._emit( 

606 THROTTLE_STOP_EVENT, 

607 {"state": state_name, "count": count, "hard_max": hard_max}, 

608 ) 

609 self._pending_error = ( 

610 f"Throttle stop: state '{state_name}' exceeded hard_max={hard_max} " 

611 "tool calls with no on_throttle_hard target" 

612 ) 

613 return "__STOP__" 

614 

615 return None 

616 

617 def _execute_state(self, state: StateConfig) -> str | None: 

618 """Execute a single state and return next state name. 

619 

620 Args: 

621 state: The state configuration to execute 

622 

623 Returns: 

624 Next state name, or None if no valid transition 

625 """ 

626 # Build interpolation context 

627 ctx = self._build_context() 

628 

629 # Dispatch to sub-loop handler if this is a sub-loop state 

630 if state.loop is not None: 

631 try: 

632 return self._execute_sub_loop(state, ctx) 

633 except (FileNotFoundError, ValueError): 

634 if state.on_error: 

635 return interpolate(state.on_error, ctx) 

636 raise 

637 

638 # Handle unconditional transition 

639 if state.next: 

640 if state.action: 

641 self._maybe_wait_for_circuit(state) 

642 result, routed = self._run_action_or_route(state, ctx) 

643 if routed is not None: 

644 return routed 

645 throttle_next = self._check_throttle(state, self.current_state) 

646 if throttle_next == "__STOP__": 

647 return None 

648 if throttle_next is not None: 

649 return throttle_next 

650 assert result is not None 

651 self.prev_result = { 

652 "output": result.output, 

653 "exit_code": result.exit_code, 

654 "state": self.current_state, 

655 } 

656 if result.exit_code is not None and result.exit_code < 0: 

657 # Process killed by signal — do not silently advance via next 

658 if state.on_error: 

659 return interpolate(state.on_error, ctx) 

660 self.request_shutdown() 

661 return None 

662 # Non-zero exit: if on_error is defined, treat next as success path only 

663 if result.exit_code != 0 and state.on_error: 

664 return interpolate(state.on_error, ctx) 

665 return interpolate(state.next, ctx) 

666 

667 # Execute action if present 

668 action_result = None 

669 if state.action: 

670 self._maybe_wait_for_circuit(state) 

671 action_result, routed = self._run_action_or_route(state, ctx) 

672 if routed is not None: 

673 return routed 

674 throttle_next = self._check_throttle(state, self.current_state) 

675 if throttle_next == "__STOP__": 

676 return None 

677 if throttle_next is not None: 

678 return throttle_next 

679 

680 # Evaluate 

681 eval_result = self._evaluate(state, action_result, ctx) 

682 self.prev_result = { 

683 "output": action_result.output if action_result else "", 

684 "exit_code": action_result.exit_code if action_result else 0, 

685 "state": self.current_state, 

686 } 

687 

688 # Update context with result for routing interpolation 

689 if eval_result: 

690 ctx.result = { 

691 "verdict": eval_result.verdict, 

692 "details": eval_result.details, 

693 } 

694 

695 # Route based on verdict 

696 verdict = eval_result.verdict if eval_result else "yes" 

697 route_ctx = RouteContext( 

698 state_name=self.current_state, 

699 state=state, 

700 verdict=verdict, 

701 action_result=action_result, 

702 eval_result=eval_result, 

703 ctx=ctx, 

704 iteration=self.iteration, 

705 ) 

706 # 429 / rate-limit detection — runs before interceptors so an in-place retry 

707 # returns early without dispatching to registered before_route hooks. 

708 if action_result is not None: 

709 _combined = (action_result.output or "") + "\n" + (action_result.stderr or "") 

710 _failure_type, _reason = classify_failure(_combined, action_result.exit_code) 

711 if _failure_type == FailureType.TRANSIENT and ( 

712 "rate limit" in _reason.lower() or "quota" in _reason.lower() 

713 ): 

714 _handled, _target = self._handle_rate_limit(state, route_ctx.state_name) 

715 if _handled: 

716 return _target 

717 elif _failure_type == FailureType.TRANSIENT and "api server error" in _reason.lower(): 

718 _handled, _target = self._handle_api_error(state, route_ctx.state_name) 

719 if _handled: 

720 return _target 

721 # exhausted — fall through to normal verdict routing 

722 else: 

723 # Not rate-limited or server-error: reset counters so future transients start fresh. 

724 self._rate_limit_retries.pop(route_ctx.state_name, None) 

725 self._consecutive_rate_limit_exhaustions = 0 

726 self._api_error_retries.pop(route_ctx.state_name, None) 

727 

728 for interceptor in self._interceptors: 

729 if hasattr(interceptor, "before_route"): 

730 decision = interceptor.before_route(route_ctx) 

731 if isinstance(decision, RouteDecision): 

732 if decision.next_state is None: 

733 return None # veto 

734 return decision.next_state # redirect — bypass _route() 

735 next_state = self._route(state, verdict, ctx) 

736 for interceptor in self._interceptors: 

737 if hasattr(interceptor, "after_route"): 

738 interceptor.after_route(route_ctx) 

739 return next_state 

740 

741 def _run_action( 

742 self, 

743 action_template: str, 

744 state: StateConfig, 

745 ctx: InterpolationContext, 

746 ) -> ActionResult: 

747 """Execute action and optionally capture result. 

748 

749 Args: 

750 action_template: Action string (may contain variables) 

751 state: State configuration 

752 ctx: Interpolation context 

753 

754 Returns: 

755 ActionResult with output and exit code 

756 """ 

757 action = interpolate(action_template, ctx) 

758 action_mode = self._action_mode(state) 

759 

760 self._emit("action_start", {"action": action, "is_prompt": action_mode == "prompt"}) 

761 

762 def _on_line(line: str) -> None: 

763 self._emit("action_output", {"line": line}) 

764 

765 if action_mode == "mcp_tool": 

766 # Direct MCP tool call — bypass action_runner entirely 

767 interpolated_params = interpolate_dict(state.params, ctx) if state.params else {} 

768 cmd = ["mcp-call", action, json.dumps(interpolated_params)] 

769 result = self._run_subprocess( 

770 cmd, 

771 timeout=state.timeout or self.fsm.default_timeout or 30, 

772 on_output_line=_on_line, 

773 ) 

774 elif action_mode == "contributed": 

775 assert ( 

776 state.action_type is not None 

777 ) # guaranteed by _action_mode returning "contributed" 

778 runner = self._contributed_actions[state.action_type] 

779 result = runner.run( 

780 action, 

781 timeout=state.timeout or self.fsm.default_timeout or 3600, 

782 is_slash_command=False, 

783 on_output_line=_on_line, 

784 ) 

785 else: 

786 result = self.action_runner.run( 

787 action, 

788 timeout=state.timeout or self.fsm.default_timeout or 3600, 

789 is_slash_command=action_mode == "prompt", 

790 on_output_line=_on_line, 

791 agent=state.agent if action_mode == "prompt" else None, 

792 tools=state.tools if action_mode == "prompt" else None, 

793 ) 

794 

795 preview = result.output[-2000:].strip() if result.output else None 

796 payload: dict[str, Any] = { 

797 "exit_code": result.exit_code, 

798 "duration_ms": result.duration_ms, 

799 "output_preview": preview, 

800 "is_prompt": action_mode == "prompt", 

801 } 

802 if action_mode == "prompt": 

803 session_jsonl = get_current_session_jsonl() 

804 payload["session_jsonl"] = str(session_jsonl) if session_jsonl else None 

805 self._emit("action_complete", payload) 

806 

807 # Capture if requested 

808 if state.capture: 

809 self.captured[state.capture] = { 

810 "output": result.output.rstrip("\n\r"), 

811 "stderr": result.stderr, 

812 "exit_code": result.exit_code, 

813 "duration_ms": result.duration_ms, 

814 } 

815 

816 # Check for signals in output 

817 if self.signal_detector: 

818 signal = self.signal_detector.detect_first(result.output) 

819 if signal: 

820 if signal.signal_type == "handoff": 

821 self._pending_handoff = signal 

822 elif signal.signal_type == "error": 

823 self._pending_error = signal.payload 

824 elif signal.signal_type == "stop": 

825 self.request_shutdown() 

826 

827 return result 

828 

829 def _run_subprocess( 

830 self, 

831 cmd: list[str], 

832 timeout: int, 

833 on_output_line: Any | None = None, 

834 ) -> ActionResult: 

835 """Run a subprocess directly and return ActionResult. 

836 

837 Follows the same Popen + stderr-drain-thread pattern as DefaultActionRunner. 

838 

839 Args: 

840 cmd: Command and arguments to execute 

841 timeout: Timeout in seconds 

842 on_output_line: Optional callback for each stdout line 

843 

844 Returns: 

845 ActionResult with output, stderr, exit_code, duration_ms 

846 """ 

847 start = _now_ms() 

848 process = subprocess.Popen( 

849 cmd, 

850 stdout=subprocess.PIPE, 

851 stderr=subprocess.PIPE, 

852 text=True, 

853 ) 

854 self._current_process = process 

855 output_chunks: list[str] = [] 

856 stderr_chunks: list[str] = [] 

857 

858 def _drain_stderr() -> None: 

859 assert process.stderr is not None 

860 for line in process.stderr: 

861 stderr_chunks.append(line) 

862 

863 stderr_thread = threading.Thread(target=_drain_stderr, daemon=True) 

864 stderr_thread.start() 

865 

866 try: 

867 for line in process.stdout: # type: ignore[union-attr] 

868 output_chunks.append(line) 

869 if on_output_line: 

870 on_output_line(line.rstrip()) 

871 process.wait(timeout=timeout) 

872 except subprocess.TimeoutExpired: 

873 process.kill() 

874 process.wait() 

875 stderr_thread.join(timeout=5) 

876 return ActionResult( 

877 output="".join(output_chunks), 

878 stderr="".join(stderr_chunks) or "MCP call timed out", 

879 exit_code=124, 

880 duration_ms=timeout * 1000, 

881 ) 

882 finally: 

883 self._current_process = None 

884 stderr_thread.join(timeout=5) 

885 return ActionResult( 

886 output="".join(output_chunks), 

887 stderr="".join(stderr_chunks), 

888 exit_code=process.returncode, 

889 duration_ms=_now_ms() - start, 

890 ) 

891 

892 def _evaluate( 

893 self, 

894 state: StateConfig, 

895 action_result: ActionResult | None, 

896 ctx: InterpolationContext, 

897 ) -> EvaluationResult | None: 

898 """Evaluate action result. 

899 

900 Args: 

901 state: State configuration 

902 action_result: Result from action execution (may be None) 

903 ctx: Interpolation context 

904 

905 Returns: 

906 EvaluationResult, or None if no evaluation needed 

907 """ 

908 if state.evaluate is None: 

909 # Default evaluation based on action type 

910 if action_result: 

911 action_mode = self._action_mode(state) 

912 

913 if action_mode == "mcp_tool": 

914 # MCP tool call: use mcp_result evaluator 

915 result = evaluate_mcp_result(action_result.output, action_result.exit_code) 

916 elif action_mode == "prompt": 

917 # Slash command or prompt: use LLM evaluation 

918 if not self.fsm.llm.enabled: 

919 result = EvaluationResult( 

920 verdict="error", 

921 details={"error": "LLM evaluation disabled via --no-llm"}, 

922 ) 

923 else: 

924 result = evaluate_llm_structured( 

925 action_result.output, 

926 model=self.fsm.llm.model, 

927 max_tokens=self.fsm.llm.max_tokens, 

928 timeout=self.fsm.llm.timeout, 

929 ) 

930 else: 

931 # Shell command: use exit code 

932 result = evaluate_exit_code(action_result.exit_code) 

933 

934 self._emit( 

935 "evaluate", 

936 { 

937 "type": "default", 

938 "verdict": result.verdict, 

939 **result.details, 

940 }, 

941 ) 

942 return result 

943 return None 

944 

945 # Explicit evaluation config 

946 raw_output = action_result.output if action_result else "" 

947 if state.evaluate.source: 

948 try: 

949 eval_input = interpolate(state.evaluate.source, ctx) 

950 except InterpolationError: 

951 eval_input = raw_output 

952 else: 

953 eval_input = raw_output 

954 

955 if state.evaluate.type in self._contributed_evaluators: 

956 result = self._contributed_evaluators[state.evaluate.type]( 

957 state.evaluate, 

958 eval_input, 

959 action_result.exit_code if action_result else 0, 

960 ctx, 

961 ) 

962 elif state.evaluate.type == "llm_structured" and not self.fsm.llm.enabled: 

963 result = EvaluationResult( 

964 verdict="error", 

965 details={"error": "LLM evaluation disabled via --no-llm"}, 

966 ) 

967 else: 

968 result = evaluate( 

969 config=state.evaluate, 

970 output=eval_input, 

971 exit_code=action_result.exit_code if action_result else 0, 

972 context=ctx, 

973 ) 

974 

975 self._emit( 

976 "evaluate", 

977 { 

978 "type": state.evaluate.type, 

979 "verdict": result.verdict, 

980 **result.details, 

981 }, 

982 ) 

983 

984 return result 

985 

986 def _route( 

987 self, 

988 state: StateConfig, 

989 verdict: str, 

990 ctx: InterpolationContext, 

991 ) -> str | None: 

992 """Determine next state from verdict. 

993 

994 Resolution order (from design doc): 

995 1. next (unconditional) - handled before this method 

996 2. route (full routing table) 

997 3. on_success/on_failure/on_error (shorthand) 

998 4. terminal - handled in main loop 

999 5. error 

1000 

1001 Args: 

1002 state: State configuration 

1003 verdict: Verdict string from evaluation 

1004 ctx: Interpolation context 

1005 

1006 Returns: 

1007 Next state name, or None if no valid route 

1008 """ 

1009 if state.route: 

1010 routes = state.route.routes 

1011 if verdict in routes: 

1012 return self._resolve_route(routes[verdict], ctx) 

1013 if state.route.default: 

1014 return self._resolve_route(state.route.default, ctx) 

1015 if verdict == "error" and state.route.error: 

1016 return self._resolve_route(state.route.error, ctx) 

1017 return None 

1018 

1019 # Shorthand routing 

1020 if verdict == "yes" and state.on_yes: 

1021 return self._resolve_route(state.on_yes, ctx) 

1022 if verdict == "no" and state.on_no: 

1023 return self._resolve_route(state.on_no, ctx) 

1024 if verdict == "error" and state.on_error: 

1025 return self._resolve_route(state.on_error, ctx) 

1026 if verdict == "partial" and state.on_partial: 

1027 return self._resolve_route(state.on_partial, ctx) 

1028 if verdict == "blocked" and state.on_blocked: 

1029 return self._resolve_route(state.on_blocked, ctx) 

1030 

1031 # Dynamic on_<verdict> shorthands from extra_routes 

1032 if verdict in state.extra_routes: 

1033 return self._resolve_route(state.extra_routes[verdict], ctx) 

1034 

1035 return None 

1036 

1037 def _resolve_route(self, route: str, ctx: InterpolationContext) -> str: 

1038 """Resolve route target, handling special tokens. 

1039 

1040 Args: 

1041 route: Route target string 

1042 ctx: Interpolation context 

1043 

1044 Returns: 

1045 Resolved state name 

1046 """ 

1047 if route == "$current": 

1048 return self.current_state 

1049 return interpolate(route, ctx) 

1050 

1051 def _flush_pending_shell_state(self, state: StateConfig) -> None: 

1052 """Execute a pending shell-action state's action before honoring a 

1053 wall-clock timeout. BUG-1226: closes the narrow race between emitting 

1054 a `route` event and `state_enter` so handshake states (e.g. autodev's 

1055 ``copy_broke_down``) do not silently drop their side effect when the 

1056 timeout fires in that window. Single-step: we run the action but do 

1057 not follow its routing — ``final_state`` stays as the flushed state. 

1058 """ 

1059 assert state.action is not None # guarded by caller 

1060 self.iteration += 1 

1061 self._just_routed = False 

1062 self._emit( 

1063 "state_enter", 

1064 { 

1065 "state": self.current_state, 

1066 "iteration": self.iteration, 

1067 "flushed": True, 

1068 }, 

1069 ) 

1070 ctx = self._build_context() 

1071 try: 

1072 self._run_action(state.action, state, ctx) 

1073 except Exception: 

1074 # Deliberately swallow — the timeout is being honored regardless 

1075 # of whether the flushed action succeeded. 

1076 pass 

1077 

1078 def _action_mode(self, state: StateConfig) -> str: 

1079 """Return execution mode for the state: 'prompt', 'shell', or 'mcp_tool'.""" 

1080 if state.action_type == "mcp_tool": 

1081 return "mcp_tool" 

1082 if state.action_type in ("prompt", "slash_command"): 

1083 return "prompt" 

1084 if state.action_type == "shell": 

1085 return "shell" 

1086 if state.action_type in self._contributed_actions: 

1087 return "contributed" 

1088 # Heuristic: / prefix = slash_command (prompt mode) 

1089 if state.action is not None and state.action.startswith("/"): 

1090 return "prompt" 

1091 return "shell" 

1092 

1093 def _run_action_or_route( 

1094 self, state: StateConfig, ctx: InterpolationContext 

1095 ) -> tuple[ActionResult | None, str | None]: 

1096 """Run the state action, routing unhandled exceptions to on_error. 

1097 

1098 Returns (action_result, routed_target). ``routed_target`` is a non-None 

1099 next-state string only when an exception was raised AND ``state.on_error`` 

1100 is defined; in that case ``action_result`` is None. When no on_error is 

1101 set, the exception is re-raised for the top-level ``run()`` handler. 

1102 """ 

1103 assert state.action is not None # caller-guarded 

1104 try: 

1105 return self._run_action(state.action, state, ctx), None 

1106 except Exception as exc: 

1107 if state.on_error: 

1108 self._emit( 

1109 "action_error", 

1110 { 

1111 "state": self.current_state, 

1112 "error": str(exc), 

1113 "route": "on_error", 

1114 }, 

1115 ) 

1116 return None, interpolate(state.on_error, ctx) 

1117 raise 

1118 

1119 def _build_context(self) -> InterpolationContext: 

1120 """Build interpolation context for current state. 

1121 

1122 Returns: 

1123 InterpolationContext with all runtime values 

1124 """ 

1125 return InterpolationContext( 

1126 context=self.fsm.context, 

1127 captured=self.captured, 

1128 prev=self.prev_result, 

1129 result=None, 

1130 state_name=self.current_state, 

1131 iteration=self.iteration, 

1132 loop_name=self.fsm.name, 

1133 started_at=self.started_at, 

1134 elapsed_ms=_now_ms() - self.start_time_ms + self.elapsed_offset_ms, 

1135 ) 

1136 

1137 def _emit(self, event: str, data: dict[str, Any]) -> None: 

1138 """Emit an event via the callback.""" 

1139 self.event_callback( 

1140 { 

1141 "event": event, 

1142 "ts": _iso_now(), 

1143 **data, 

1144 } 

1145 ) 

1146 

1147 def _handle_rate_limit(self, state: StateConfig, state_name: str) -> tuple[bool, str | None]: 

1148 """Handle a detected 429/rate-limit action outcome. 

1149 

1150 Implements the two-tier retry ladder: 

1151 1. Short-burst tier: up to ``max_rate_limit_retries`` attempts with 

1152 exponential backoff (``rate_limit_backoff_base_seconds * 2^n + jitter``). 

1153 2. Long-wait tier: walks ``rate_limit_long_wait_ladder`` with index capped 

1154 at the last entry, accumulating ``total_wait_seconds``. 

1155 

1156 Routes to ``on_rate_limit_exhausted`` (falling back to ``on_error``) only 

1157 once ``total_wait_seconds >= rate_limit_max_wait_seconds``. Emits 

1158 ``rate_limit_exhausted`` on routing (including tier counters) and 

1159 ``rate_limit_storm`` when consecutive exhaustions reach the threshold. 

1160 

1161 Returns: 

1162 (handled, target). ``handled=True`` means the caller should return 

1163 ``target`` directly (in-place retry uses ``state_name``; exhaustion 

1164 uses the routed target). ``handled=False`` should not occur for the 

1165 current rate-limit classification path but is reserved for future 

1166 extensions. 

1167 """ 

1168 _short_max = ( 

1169 state.max_rate_limit_retries 

1170 if state.max_rate_limit_retries is not None 

1171 else _DEFAULT_RATE_LIMIT_RETRIES 

1172 ) 

1173 _backoff_base = ( 

1174 state.rate_limit_backoff_base_seconds 

1175 if state.rate_limit_backoff_base_seconds is not None 

1176 else _DEFAULT_RATE_LIMIT_BACKOFF_BASE 

1177 ) 

1178 _max_wait = ( 

1179 state.rate_limit_max_wait_seconds 

1180 if state.rate_limit_max_wait_seconds is not None 

1181 else _DEFAULT_RATE_LIMIT_MAX_WAIT_SECONDS 

1182 ) 

1183 _ladder = ( 

1184 state.rate_limit_long_wait_ladder 

1185 if state.rate_limit_long_wait_ladder is not None 

1186 else _DEFAULT_RATE_LIMIT_LONG_WAIT_LADDER 

1187 ) 

1188 

1189 record = self._rate_limit_retries.get(state_name) 

1190 if record is None: 

1191 record = { 

1192 "short_retries": 0, 

1193 "long_retries": 0, 

1194 "total_wait_seconds": 0.0, 

1195 "first_seen_at": time.time(), 

1196 } 

1197 self._rate_limit_retries[state_name] = record 

1198 

1199 short_retries = int(record.get("short_retries", 0)) 

1200 long_retries = int(record.get("long_retries", 0)) 

1201 total_wait = float(record.get("total_wait_seconds", 0.0)) 

1202 

1203 if short_retries < _short_max: 

1204 # Short-burst tier — exponential backoff with jitter. Budget is not 

1205 # checked here; short-tier always advances to long-wait on exhaustion. 

1206 short_retries += 1 

1207 record["short_retries"] = short_retries 

1208 _sleep = _backoff_base * (2 ** (short_retries - 1)) + random.uniform(0, _backoff_base) 

1209 if self._circuit is not None: 

1210 self._circuit.record_rate_limit(_sleep) 

1211 total_wait += self._interruptible_sleep(_sleep) 

1212 record["total_wait_seconds"] = total_wait 

1213 return True, state_name # retry in place 

1214 

1215 # Long-wait tier — walk ladder with capped index. 

1216 long_retries += 1 

1217 record["long_retries"] = long_retries 

1218 _idx = min(long_retries - 1, len(_ladder) - 1) 

1219 _wait = float(_ladder[_idx]) 

1220 if self._circuit is not None: 

1221 self._circuit.record_rate_limit(_wait) 

1222 _tier_start = time.time() 

1223 _deadline = _tier_start + _wait 

1224 _total_wait_before_tier = total_wait 

1225 total_wait += self._interruptible_sleep( 

1226 _wait, 

1227 on_heartbeat=lambda elapsed: self._emit( 

1228 RATE_LIMIT_WAITING_EVENT, 

1229 { 

1230 "state": state_name, 

1231 "elapsed_seconds": elapsed, 

1232 "next_attempt_at": _deadline, 

1233 "total_waited_seconds": _total_wait_before_tier + elapsed, 

1234 "budget_seconds": _max_wait, 

1235 "tier": "long_wait", 

1236 }, 

1237 ), 

1238 ) 

1239 record["total_wait_seconds"] = total_wait 

1240 if total_wait >= _max_wait: 

1241 return True, self._exhaust_rate_limit(state, state_name, record) 

1242 return True, state_name # retry in place 

1243 

1244 def _maybe_wait_for_circuit(self, state: StateConfig) -> None: 

1245 """Pre-action circuit-breaker check: sleep until shared 429 recovery. 

1246 

1247 Skips quietly when no circuit is injected, when the state's action is not 

1248 an LLM-quota consumer, or when the circuit has no active recovery window. 

1249 """ 

1250 if self._circuit is None: 

1251 return 

1252 if self._action_mode(state) != "prompt": 

1253 return 

1254 recovery = self._circuit.get_estimated_recovery() 

1255 if recovery is None: 

1256 return 

1257 wait = recovery - time.time() 

1258 if wait > 0: 

1259 self._interruptible_sleep(wait) 

1260 

1261 def _interruptible_sleep( 

1262 self, 

1263 duration: float, 

1264 on_heartbeat: Callable[[float], None] | None = None, 

1265 ) -> float: 

1266 """Sleep for up to ``duration`` seconds in 100ms ticks, exiting promptly 

1267 on ``_shutdown_requested``. Returns the actual elapsed seconds so callers 

1268 can accumulate wall-clock time spent in rate-limit waits. 

1269 

1270 If ``on_heartbeat`` is provided, it is invoked with the elapsed seconds 

1271 roughly every ``_RATE_LIMIT_HEARTBEAT_INTERVAL`` seconds so UIs can show 

1272 live progress during long waits. The short-tier call site intentionally 

1273 omits the callback to preserve backward-compatible silent behavior. 

1274 """ 

1275 if duration <= 0: 

1276 return 0.0 

1277 _start = time.time() 

1278 _deadline = _start + duration 

1279 last_heartbeat = _start 

1280 while time.time() < _deadline: 

1281 if self._shutdown_requested: 

1282 break 

1283 time.sleep(min(0.1, _deadline - time.time())) 

1284 if on_heartbeat is not None: 

1285 _now = time.time() 

1286 if _now - last_heartbeat >= _RATE_LIMIT_HEARTBEAT_INTERVAL: 

1287 on_heartbeat(_now - _start) 

1288 last_heartbeat = _now 

1289 return time.time() - _start 

1290 

1291 def _exhaust_rate_limit( 

1292 self, state: StateConfig, state_name: str, record: dict[str, Any] 

1293 ) -> str | None: 

1294 """Finalize rate-limit exhaustion: emit event, storm detection, and 

1295 return the routed target. Pops the per-state record and is called only 

1296 once the wall-clock budget is spent. 

1297 """ 

1298 self._rate_limit_retries.pop(state_name, None) 

1299 target = state.on_rate_limit_exhausted or state.on_error 

1300 self._emit( 

1301 RATE_LIMIT_EXHAUSTED_EVENT, 

1302 { 

1303 "state": state_name, 

1304 "retries": int(record.get("short_retries", 0)) + int(record.get("long_retries", 0)), 

1305 "short_retries": int(record.get("short_retries", 0)), 

1306 "long_retries": int(record.get("long_retries", 0)), 

1307 "total_wait_seconds": float(record.get("total_wait_seconds", 0.0)), 

1308 "next": target, 

1309 }, 

1310 ) 

1311 self._consecutive_rate_limit_exhaustions += 1 

1312 if self._consecutive_rate_limit_exhaustions >= _RATE_LIMIT_STORM_THRESHOLD: 

1313 self._emit( 

1314 RATE_LIMIT_STORM_EVENT, 

1315 { 

1316 "state": state_name, 

1317 "count": self._consecutive_rate_limit_exhaustions, 

1318 }, 

1319 ) 

1320 return target 

1321 

1322 def _handle_api_error(self, state: StateConfig, state_name: str) -> tuple[bool, str | None]: 

1323 """Handle a detected API server error with short-burst flat backoff. 

1324 

1325 Unlike ``_handle_rate_limit``, uses a flat backoff with no long-wait tier and 

1326 falls through to normal FSM routing after ``_DEFAULT_API_ERROR_RETRIES`` attempts 

1327 so transient infrastructure hiccups don't permanently misdirect the loop. 

1328 

1329 Returns: 

1330 ``(True, state_name)`` to retry the state in place, or 

1331 ``(False, None)`` when the retry budget is exhausted (caller falls 

1332 through to normal verdict routing). 

1333 """ 

1334 record = self._api_error_retries.setdefault(state_name, {"retries": 0, "total_wait": 0.0}) 

1335 if record["retries"] >= _DEFAULT_API_ERROR_RETRIES: 

1336 self._api_error_retries.pop(state_name, None) 

1337 self._emit("api_error_exhausted", {"state": state_name, "retries": record["retries"]}) 

1338 return False, None 

1339 record["retries"] += 1 

1340 slept = self._interruptible_sleep(_DEFAULT_API_ERROR_BACKOFF) 

1341 record["total_wait"] += slept 

1342 self._emit( 

1343 "api_error_retry", 

1344 { 

1345 "state": state_name, 

1346 "attempt": record["retries"], 

1347 "backoff": _DEFAULT_API_ERROR_BACKOFF, 

1348 }, 

1349 ) 

1350 return True, state_name 

1351 

1352 def _finish(self, terminated_by: str, error: str | None = None) -> ExecutionResult: 

1353 """Finalize execution and return result.""" 

1354 self._emit( 

1355 "loop_complete", 

1356 { 

1357 "final_state": self.current_state, 

1358 "iterations": self.iteration, 

1359 "terminated_by": terminated_by, 

1360 }, 

1361 ) 

1362 

1363 return ExecutionResult( 

1364 final_state=self.current_state, 

1365 iterations=self.iteration, 

1366 terminated_by=terminated_by, 

1367 duration_ms=_now_ms() - self.start_time_ms + self.elapsed_offset_ms, 

1368 captured=self.captured, 

1369 error=error, 

1370 ) 

1371 

1372 def _handle_handoff(self, signal: DetectedSignal) -> ExecutionResult: 

1373 """Handle a detected handoff signal. 

1374 

1375 Emits a handoff_detected event and optionally invokes the handoff handler. 

1376 

1377 Args: 

1378 signal: The detected handoff signal 

1379 

1380 Returns: 

1381 ExecutionResult with handoff information 

1382 """ 

1383 self._emit( 

1384 "handoff_detected", 

1385 { 

1386 "state": self.current_state, 

1387 "iteration": self.iteration, 

1388 "continuation": signal.payload, 

1389 }, 

1390 ) 

1391 

1392 # Invoke handler if configured 

1393 if self.handoff_handler: 

1394 result = self.handoff_handler.handle(self.fsm.name, signal.payload) 

1395 if result.spawned_process is not None: 

1396 self._emit( 

1397 "handoff_spawned", 

1398 { 

1399 "pid": result.spawned_process.pid, 

1400 "state": self.current_state, 

1401 }, 

1402 ) 

1403 

1404 return ExecutionResult( 

1405 final_state=self.current_state, 

1406 iterations=self.iteration, 

1407 terminated_by="handoff", 

1408 duration_ms=_now_ms() - self.start_time_ms + self.elapsed_offset_ms, 

1409 captured=self.captured, 

1410 handoff=True, 

1411 continuation_prompt=signal.payload, 

1412 )