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

800 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-16 13:12 -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 selectors 

16import subprocess 

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_blind_comparator, 

28 evaluate_exit_code, 

29 evaluate_llm_structured, 

30 evaluate_mcp_result, 

31) 

32from little_loops.fsm.handoff_handler import HandoffHandler 

33from little_loops.fsm.interpolation import ( 

34 InterpolationContext, 

35 InterpolationError, 

36 interpolate, 

37 interpolate_dict, 

38) 

39from little_loops.fsm.rate_limit_circuit import RateLimitCircuit 

40from little_loops.fsm.runners import ( 

41 ActionRunner, 

42 DefaultActionRunner, 

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

44 _now_ms, 

45) 

46from little_loops.fsm.schema import FSMLoop, StateConfig 

47from little_loops.fsm.signal_detector import DetectedSignal, SignalDetector 

48from little_loops.fsm.stall_detector import Stall, StallDetector 

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

50from little_loops.issue_lifecycle import FailureType, classify_failure 

51from little_loops.session_log import get_current_session_jsonl 

52from little_loops.subprocess_utils import ( 

53 UsageCallback, 

54 _kill_process_group, 

55 run_claude_command, 

56) 

57 

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

59_DEFAULT_RATE_LIMIT_RETRIES: int = 3 

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

61_DEFAULT_RATE_LIMIT_BACKOFF_BASE: int = 30 

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

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

64_DEFAULT_RATE_LIMIT_MAX_WAIT_SECONDS: int = 21600 

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

66# Mirrors RateLimitsConfig.long_wait_ladder default. 

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

68# Event name emitted when the stall detector fires on N consecutive 

69# identical (state, exit_code, verdict) transitions. See FEAT-1637. 

70STALL_DETECTED_EVENT: str = "stall_detected" 

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

72RATE_LIMIT_EXHAUSTED_EVENT: str = "rate_limit_exhausted" 

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

74RATE_LIMIT_STORM_EVENT: str = "rate_limit_storm" 

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

76RATE_LIMIT_WAITING_EVENT: str = "rate_limit_waiting" 

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

78_RATE_LIMIT_HEARTBEAT_INTERVAL: float = 60.0 

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

80_RATE_LIMIT_STORM_THRESHOLD: int = 3 

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

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

83_DEFAULT_THROTTLE_NORMAL_MAX: int = 3 

84_DEFAULT_THROTTLE_WARN_MAX: int = 8 

85_DEFAULT_THROTTLE_HARD_MAX: int = 12 

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

87THROTTLE_WARN_EVENT: str = "throttle_warn" 

88THROTTLE_HARD_EVENT: str = "throttle_hard" 

89THROTTLE_STOP_EVENT: str = "throttle_stop" 

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

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

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

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

94_DEFAULT_API_ERROR_RETRIES: int = 2 

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

96_DEFAULT_API_ERROR_BACKOFF: int = 30 

97 

98 

99def _iso_now() -> str: 

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

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

102 

103 

104@dataclass 

105class RouteContext: 

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

107 

108 state_name: str 

109 state: StateConfig 

110 verdict: str 

111 action_result: ActionResult | None 

112 eval_result: EvaluationResult | None 

113 ctx: InterpolationContext 

114 iteration: int 

115 

116 

117@dataclass 

118class RouteDecision: 

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

120 

121 Return semantics for before_route: 

122 None (implicit) → passthrough, routing proceeds normally 

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

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

125 """ 

126 

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

128 

129 

130class FSMExecutor: 

131 """Execute an FSM loop. 

132 

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

134 - A terminal state is reached 

135 - max_iterations is exceeded 

136 - A timeout occurs 

137 - A shutdown signal is received 

138 - An unrecoverable error occurs 

139 

140 Events are emitted via the callback for observability. 

141 """ 

142 

143 def __init__( 

144 self, 

145 fsm: FSMLoop, 

146 event_callback: EventCallback | None = None, 

147 action_runner: ActionRunner | None = None, 

148 signal_detector: SignalDetector | None = None, 

149 handoff_handler: HandoffHandler | None = None, 

150 loops_dir: Path | None = None, 

151 circuit: RateLimitCircuit | None = None, 

152 ): 

153 """Initialize the executor. 

154 

155 Args: 

156 fsm: The FSM loop to execute 

157 event_callback: Optional callback for events 

158 action_runner: Optional custom action runner (for testing) 

159 signal_detector: Optional signal detector for output parsing 

160 handoff_handler: Optional handler for handoff signals 

161 loops_dir: Base directory for resolving sub-loop references 

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

163 """ 

164 self.fsm = fsm 

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

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

167 self.signal_detector = signal_detector 

168 self.handoff_handler = handoff_handler 

169 self.loops_dir = loops_dir 

170 self._circuit = circuit 

171 

172 # Runtime state 

173 self.current_state = fsm.initial 

174 self.iteration = 0 

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

176 self.messages: list[str] = [] 

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

178 self.started_at = "" 

179 self.start_time_ms = 0 

180 self.elapsed_offset_ms = ( 

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

182 ) 

183 

184 # Shutdown flag for graceful signal handling 

185 self._shutdown_requested = False 

186 

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

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

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

190 

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

192 self._pending_handoff: DetectedSignal | None = None 

193 

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

195 self._pending_error: str | None = None 

196 

197 # Per-state retry tracking for max_retries support. 

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

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

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

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

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

203 self._prev_state: str | None = None 

204 

205 # BUG-1226 / BUG-158: true between emitting a `route` event and 

206 # entering the target state. Gates the "flush one pending state on 

207 # timeout / max_iterations" behavior so we only flush when there is 

208 # actually a pending state. 

209 self._just_routed: bool = False 

210 

211 # ENH-1631 / BUG-158: true once the on_max_iterations summary state 

212 # has been dispatched. Prevents the cap guard from re-triggering 

213 # before the summary state completes. Also gates the terminal-check 

214 # short-circuit so the handler executes at least once before 

215 # terminating (BUG-158 dual-bug fix). 

216 self._summary_state_executed: bool = False 

217 

218 # FEAT-1822: Per-item A/B comparison results accumulated during baseline 

219 # execution. Populated by _execute_with_baseline(), written to ab.json 

220 # by _finish(). 

221 self._ab_results: list[dict[str, Any]] = [] 

222 self._ab_item_index: int = 0 

223 

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

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

226 # { 

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

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

229 # "total_wait_seconds": float, 

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

231 # } 

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

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

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

235 

236 # States currently mid rate-limit in-place retry. Populated by _route_next_state 

237 # when _handle_rate_limit returns an in-place target; consumed (discarded) by the 

238 # retry-counting block on the next same-state re-entry. Exempts infrastructure 

239 # pauses from the max_retries budget (BUG-2065 Fix 2). 

240 self._rate_limit_in_flight: set[str] = set() 

241 

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

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

244 # _RATE_LIMIT_STORM_THRESHOLD, a RATE_LIMIT_STORM event is emitted. 

245 self._consecutive_rate_limit_exhaustions: int = 0 

246 

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

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

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

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

251 

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

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

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

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

256 

257 # Per-edge revisit counter for cycle detection. 

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

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

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

261 

262 # Stall detector for repeated (state, exit_code, verdict) triples. 

263 # Enabled via fsm.circuit.repeated_failure (FEAT-1637); None when not configured. 

264 self._stall_detector: StallDetector | None = None 

265 if fsm.circuit is not None and fsm.circuit.repeated_failure is not None: 

266 self._stall_detector = StallDetector(window=fsm.circuit.repeated_failure.window) 

267 # Set by _execute_state when the detector fires with on_repeated_failure="abort"; 

268 # checked by run() to terminate via _finish("stall_detected", ...). 

269 self._pending_stall_abort: Stall | None = None 

270 

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

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

273 self._depth: int = 0 

274 

275 # Extension hook registries — populated by wire_extensions() 

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

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

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

279 

280 def request_shutdown(self) -> None: 

281 """Request graceful shutdown of the executor. 

282 

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

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

285 """ 

286 self._shutdown_requested = True 

287 

288 def run(self) -> ExecutionResult: 

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

290 

291 Returns: 

292 ExecutionResult with final state and execution metadata 

293 """ 

294 self.started_at = _iso_now() 

295 self.start_time_ms = _now_ms() 

296 

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

298 

299 try: 

300 while True: 

301 # Check shutdown request (signal handling) 

302 if self._shutdown_requested: 

303 return self._finish("signal") 

304 

305 # Check iteration limit 

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

307 if self.fsm.on_max_iterations is not None and not self._summary_state_executed: 

308 # BUG-158: if we just routed from a sub-loop state 

309 # (e.g. its on_error fired at the iteration cap), 

310 # flush one pending non-sub-loop state before 

311 # entering the summary handler so its action isn't 

312 # silently lost. Mirrors the BUG-1226 timeout guard 

313 # at lines 320-341. Single-step: no cascade. 

314 # Gate: only flush when the route originated from a 

315 # sub-loop (checked via _prev_state.loop). Normal 

316 # self-routes are not sub-loop–originated and their 

317 # action already executed — flushing them would 

318 # double-count the iteration. 

319 # Gate uses `pending.action is not None` (broader 

320 # than the timeout guard's shell-only gate) so both 

321 # shell-action and prompt-action intermediate states 

322 # are flushed. 

323 if self._just_routed: 

324 prev_config = self.fsm.states.get(self._prev_state or "") 

325 if prev_config is not None and prev_config.loop is not None: 

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

327 if ( 

328 pending is not None 

329 and not pending.terminal 

330 and pending.loop is None 

331 and pending.action is not None 

332 ): 

333 self._flush_pending_shell_state(pending) 

334 self._emit( 

335 "max_iterations_summary", 

336 { 

337 "summary_state": self.fsm.on_max_iterations, 

338 "iterations": self.iteration, 

339 }, 

340 ) 

341 self._summary_state_executed = True 

342 self.current_state = self.fsm.on_max_iterations 

343 # Fall through — let the summary state run in this iteration. 

344 # (do not `continue`; that would re-trigger the cap check 

345 # before the state executes since self.iteration is unchanged.) 

346 else: 

347 return self._finish("max_iterations") 

348 

349 # Check timeout 

350 if self.fsm.timeout: 

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

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

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

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

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

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

357 # silently lost. Bounded to shell actions — slash 

358 # commands and sub-loops would violate the timeout 

359 # budget. Single-step: no cascade. 

360 if self._just_routed: 

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

362 if ( 

363 pending is not None 

364 and not pending.terminal 

365 and pending.loop is None 

366 and pending.action is not None 

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

368 ): 

369 self._flush_pending_shell_state(pending) 

370 return self._finish("timeout") 

371 

372 # Get current state config 

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

374 

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

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

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

378 if self._prev_state is not None: 

379 if self.current_state == self._prev_state: 

380 # Rate-limit in-place retries are infrastructure pauses, not action 

381 # failures — exempt them from max_retries budget (BUG-2065 Fix 2). 

382 if self.current_state not in self._rate_limit_in_flight: 

383 self._retry_counts[self.current_state] = ( 

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

385 ) 

386 self._rate_limit_in_flight.discard(self.current_state) 

387 else: 

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

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

390 self._rate_limit_in_flight.discard(self._prev_state) 

391 

392 # Check terminal 

393 if state_config.terminal: 

394 # Handle maintain mode - restart loop instead of terminating 

395 if self.fsm.maintain: 

396 self.iteration += 1 

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

398 self._emit( 

399 "route", 

400 { 

401 "from": self.current_state, 

402 "to": maintain_target, 

403 "reason": "maintain", 

404 }, 

405 ) 

406 self._prev_state = self.current_state 

407 self.current_state = maintain_target 

408 self._just_routed = True 

409 continue 

410 # ENH-1631: if we arrived here via the on_max_iterations summary 

411 # state, preserve terminated_by="max_iterations" so audit tooling 

412 # and PersistentExecutor see "interrupted" rather than "completed". 

413 if self._summary_state_executed: 

414 # BUG-158: when current_state IS the on_max_iterations 

415 # handler, it hasn't executed yet — let it run once before 

416 # terminating. The _summary_state_executed flag prevents the 

417 # cap guard from re-triggering; it shouldn't prevent the 

418 # handler from executing its action. 

419 if self.current_state != self.fsm.on_max_iterations: 

420 return self._finish("max_iterations") 

421 # Fall through — execute the handler's action this iteration. 

422 else: 

423 return self._finish("terminal") 

424 

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

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

427 if state_config.max_retries is not None: 

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

429 if retry_count > state_config.max_retries: 

430 # on_retry_exhausted is guaranteed non-None by validation when 

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

432 exhausted_state: str = state_config.on_retry_exhausted or "" 

433 if not exhausted_state: 

434 return self._finish( 

435 "error", 

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

437 "but on_retry_exhausted is not set", 

438 ) 

439 self._emit( 

440 "retry_exhausted", 

441 { 

442 "state": self.current_state, 

443 "retries": retry_count, 

444 "next": exhausted_state, 

445 }, 

446 ) 

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

448 self._prev_state = self.current_state 

449 self.current_state = exhausted_state 

450 continue 

451 

452 self.iteration += 1 

453 self._just_routed = False 

454 self._emit( 

455 "state_enter", 

456 { 

457 "state": self.current_state, 

458 "iteration": self.iteration, 

459 }, 

460 ) 

461 

462 # Execute state 

463 next_state = self._execute_state(state_config) 

464 

465 # Check for pending error signal (FATAL_ERROR) 

466 if self._pending_error is not None: 

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

468 

469 # Check for pending stall abort (FEAT-1637). The detector 

470 # fired with on_repeated_failure="abort" inside _execute_state; 

471 # terminate cleanly via _finish (mirrors the cycle_detected 

472 # guard below at lines 397-416). 

473 if self._pending_stall_abort is not None: 

474 stall = self._pending_stall_abort 

475 s_state, s_exit, s_verdict = stall.triple 

476 return self._finish( 

477 "stall_detected", 

478 error=( 

479 f"Stall detected: state '{s_state}' produced " 

480 f"(exit_code={s_exit}, verdict='{s_verdict}') " 

481 f"for {stall.count} consecutive iterations" 

482 ), 

483 ) 

484 

485 # Check for pending handoff signal 

486 if self._pending_handoff: 

487 return self._handle_handoff(self._pending_handoff) 

488 

489 # Handle maintain mode 

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

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

492 

493 # SIGKILL in _execute_state sets shutdown flag and returns None 

494 if next_state is None and self._shutdown_requested: 

495 return self._finish("signal") 

496 

497 if next_state is None: 

498 # BUG-158: if the on_max_iterations summary handler executed 

499 # and returned None (no routing targets — typical for terminal 

500 # handlers that produce diagnostic output), terminate with the 

501 # max_iterations reason instead of an error. 

502 if self._summary_state_executed: 

503 return self._finish("max_iterations") 

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

505 

506 # At this point next_state is guaranteed to be str 

507 resolved_next: str = next_state 

508 

509 self._emit( 

510 "route", 

511 { 

512 "from": self.current_state, 

513 "to": resolved_next, 

514 }, 

515 ) 

516 

517 # Per-edge revisit tracking for cycle detection. 

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

519 self._edge_revisit_counts[edge_key] = self._edge_revisit_counts.get(edge_key, 0) + 1 

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

521 self._emit( 

522 "cycle_detected", 

523 { 

524 "edge": edge_key, 

525 "from": self.current_state, 

526 "to": resolved_next, 

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

528 "max": self.fsm.max_edge_revisits, 

529 }, 

530 ) 

531 return self._finish( 

532 "cycle_detected", 

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

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

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

536 ) 

537 

538 self._prev_state = self.current_state 

539 self.current_state = resolved_next 

540 self._just_routed = True 

541 

542 # Interruptible backoff sleep between iterations 

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

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

545 while time.time() < deadline: 

546 if self._shutdown_requested: 

547 break 

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

549 

550 except InterpolationError as exc: 

551 return self._finish( 

552 "error", 

553 error=( 

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

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

556 ), 

557 ) 

558 except Exception as exc: 

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

560 

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

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

563 

564 Args: 

565 state: The state configuration with loop field set 

566 ctx: Interpolation context for routing 

567 

568 Returns: 

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

570 """ 

571 from little_loops.cli.loop._helpers import resolve_loop_path 

572 from little_loops.fsm.validation import load_and_validate 

573 

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

575 

576 # Simulation mode: stub dispatch instead of loading/running the real child FSM. 

577 # Mirrors the ENH-1164 treatment of parallel: states — no side effects, no real child 

578 # execution. Dynamic loop names that can't resolve in simulation yield a display label 

579 # from the raw template rather than aborting with InterpolationError. 

580 if isinstance(self.action_runner, SimulationActionRunner): 

581 try: 

582 display_name = interpolate(state.loop, ctx) 

583 except InterpolationError: 

584 display_name = state.loop # raw template as label when context not populated 

585 sim_result = self.action_runner.run( 

586 action=f"[sub-loop: {display_name}]", 

587 timeout=0, 

588 is_slash_command=False, 

589 ) 

590 if sim_result.exit_code == 0: 

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

592 elif sim_result.exit_code == 2: 

593 if state.on_error: 

594 return interpolate(state.on_error, ctx) 

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

596 else: 

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

598 

599 loop_name = interpolate(state.loop, ctx) 

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

601 child_fsm, _ = load_and_validate(loop_path) 

602 

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

604 if state.with_: 

605 from little_loops.fsm.interpolation import interpolate_dict 

606 

607 resolved = interpolate_dict(state.with_, ctx) 

608 # Apply declared defaults for unbound optional parameters 

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

610 if ( 

611 param_name not in resolved 

612 and not param_spec.required 

613 and param_spec.default is not None 

614 ): 

615 resolved[param_name] = param_spec.default 

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

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

618 if param_spec.required and param_name not in resolved: 

619 raise ValueError( 

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

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

622 ) 

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

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

625 # Runner-managed runtime invariants must survive explicit `with:` binding. run_dir is 

626 # injected into the parent context by the runner (cli/loop/run.py) and every loop 

627 # assumes its presence (writes goals.json, batch-plan.json, etc.). The 

628 # context_passthrough branch inherits it via **self.fsm.context; the with: branch 

629 # must re-inject it explicitly or the child's first os.makedirs('${context.run_dir}') 

630 # -> os.makedirs('') crashes. setdefault keeps an explicit `with: run_dir:` override 

631 # winning if a caller ever sets one. 

632 if "run_dir" in self.fsm.context: 

633 child_fsm.context.setdefault("run_dir", self.fsm.context["run_dir"]) 

634 elif state.context_passthrough: 

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

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

637 captured_as_context = { 

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

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

640 } 

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

642 

643 depth = self._depth + 1 

644 child_events: list[dict] = [] 

645 

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

647 child_events.append(event) 

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

649 if "depth" not in event: 

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

651 else: 

652 self.event_callback(event) 

653 

654 child_executor = FSMExecutor( 

655 child_fsm, 

656 action_runner=self.action_runner, 

657 loops_dir=self.loops_dir, 

658 event_callback=_sub_event_callback, 

659 circuit=self._circuit, 

660 ) 

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

662 

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

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

665 if self.fsm.timeout: 

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

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

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

669 child_fsm.timeout = remaining_s 

670 

671 child_result = child_executor.run() 

672 

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

674 if state.capture and child_events: 

675 import json as _json 

676 

677 self.captured[state.capture] = { 

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

679 "exit_code": None, 

680 } 

681 

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

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

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

685 

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

687 if child_result.terminated_by == "terminal": 

688 if child_result.final_state == "done": 

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

690 else: 

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

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

693 elif child_result.terminated_by == "error": 

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

695 if state.on_error: 

696 return interpolate(state.on_error, ctx) 

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

698 else: 

699 # max_iterations, timeout, signal — all are failure 

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

701 

702 def _execute_learning_state(self, state: StateConfig, ctx: InterpolationContext) -> str | None: 

703 """Execute a FEAT-1283 ``type: learning`` state. 

704 

705 Resolves the target list at runtime: if ``learning.targets_csv`` is set, 

706 it is interpolated and CSV-split; otherwise ``learning.targets`` is used 

707 directly. The retry limit is resolved similarly: ``learning.max_retries_expr`` 

708 (if set) is interpolated and int()-cast; otherwise ``learning.max_retries`` 

709 (default 2) is used. 

710 

711 For each target: 

712 1. Look up its record in the learning-tests registry (ENH-1282). 

713 2. If proven → continue. 

714 3. If refuted → emit ``learning_target_refuted`` + ``learning_blocked`` 

715 and route to ``on_blocked`` (preferred) or ``on_no``. 

716 4. If missing or stale → emit ``learning_target_stale`` and invoke 

717 ``/ll:explore-api <target>`` via the executor's action_runner; 

718 re-check the registry; repeat up to ``max_retries`` times before 

719 emitting ``learning_blocked`` (reason ``retries_exhausted``) and 

720 routing to ``on_blocked``/``on_no``. 

721 

722 When every target ends up proven, emit ``learning_complete`` and route 

723 to ``on_yes``. Returns the resolved next-state name (or ``None`` when no 

724 route is configured for the terminal verdict, mirroring ``_route``). 

725 """ 

726 from little_loops.learning_tests import check_learning_test 

727 

728 assert state.learning is not None # guarded by caller 

729 

730 # Resolve target list at runtime (ENH-1741: targets_csv support). 

731 if state.learning.targets_csv is not None: 

732 raw_csv = interpolate(state.learning.targets_csv, ctx) 

733 targets = [t.strip() for t in raw_csv.split(",") if t.strip()] 

734 else: 

735 targets = list(state.learning.targets) 

736 

737 # Resolve retry limit at runtime (ENH-1741: max_retries_expr support). 

738 if state.learning.max_retries_expr is not None: 

739 max_retries = int(interpolate(state.learning.max_retries_expr, ctx)) 

740 else: 

741 max_retries = state.learning.max_retries 

742 

743 def _blocked_target(reason: str, target: str) -> str | None: 

744 self._emit( 

745 "learning_blocked", 

746 {"state": self.current_state, "target": target, "reason": reason}, 

747 ) 

748 route = state.on_blocked or state.on_no 

749 return interpolate(route, ctx) if route else None 

750 

751 for target in targets: 

752 record = check_learning_test(target) 

753 

754 attempts = 0 

755 while record is None or record.status == "stale": 

756 if attempts >= max_retries: 

757 return _blocked_target("retries_exhausted", target) 

758 

759 if record is None: 

760 self._emit( 

761 "learning_target_stale", 

762 {"state": self.current_state, "target": target, "cause": "missing"}, 

763 ) 

764 else: 

765 self._emit( 

766 "learning_target_stale", 

767 {"state": self.current_state, "target": target, "cause": "stale"}, 

768 ) 

769 

770 self._emit( 

771 "learning_explore_invoked", 

772 {"state": self.current_state, "target": target, "attempt": attempts + 1}, 

773 ) 

774 self._run_action(f"/ll:explore-api {target}", state, ctx) 

775 attempts += 1 

776 record = check_learning_test(target) 

777 

778 if record.status == "refuted": 

779 self._emit( 

780 "learning_target_refuted", 

781 {"state": self.current_state, "target": target}, 

782 ) 

783 return _blocked_target("refuted", target) 

784 

785 self._emit( 

786 "learning_target_proven", 

787 {"state": self.current_state, "target": target}, 

788 ) 

789 

790 self._emit( 

791 "learning_complete", 

792 {"state": self.current_state, "targets": targets}, 

793 ) 

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

795 

796 def _compute_progress_fingerprint(self, ctx: InterpolationContext) -> tuple[object, ...] | None: 

797 """Return an (mtime, size) fingerprint for configured progress_paths, or None. 

798 

799 Called just before stall_detector.record() so that file changes made by 

800 intermediate next:-only states are visible to the detector. Returns None 

801 when no progress_paths are configured (preserves existing semantics). 

802 

803 Paths listed in exclude_paths (BUG-1767) are resolved and removed before 

804 building the fingerprint tuple so that a loop's internal bookkeeping files 

805 cannot reset the stall window on every cycle. 

806 """ 

807 if self.fsm.circuit is None or self.fsm.circuit.repeated_failure is None: 

808 return None 

809 rf = self.fsm.circuit.repeated_failure 

810 paths = rf.progress_paths 

811 if not paths: 

812 return None 

813 

814 excluded: set[str] = set() 

815 for raw_excl in rf.exclude_paths: 

816 try: 

817 excluded.add(interpolate(raw_excl, ctx)) 

818 except Exception: 

819 pass 

820 

821 entries: list[tuple[float, int]] = [] 

822 for raw_path in paths: 

823 try: 

824 resolved = interpolate(raw_path, ctx) 

825 except Exception: 

826 continue 

827 if resolved in excluded: 

828 continue 

829 p = Path(resolved) 

830 if p.exists(): 

831 st = p.stat() 

832 entries.append((st.st_mtime, st.st_size)) 

833 else: 

834 entries.append((0.0, 0)) 

835 return tuple(entries) if entries else None 

836 

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

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

839 

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

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

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

843 

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

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

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

847 """ 

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

849 self._throttle_counts[state_name] = count 

850 

851 throttle = state.throttle 

852 normal_max = ( 

853 throttle.normal_max 

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

855 else _DEFAULT_THROTTLE_NORMAL_MAX 

856 ) 

857 warn_max = ( 

858 throttle.warn_max 

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

860 else _DEFAULT_THROTTLE_WARN_MAX 

861 ) 

862 hard_max = ( 

863 throttle.hard_max 

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

865 else _DEFAULT_THROTTLE_HARD_MAX 

866 ) 

867 

868 if count == warn_max: 

869 self._emit( 

870 THROTTLE_WARN_EVENT, 

871 { 

872 "state": state_name, 

873 "count": count, 

874 "normal_max": normal_max, 

875 "warn_max": warn_max, 

876 "hard_max": hard_max, 

877 }, 

878 ) 

879 

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

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

882 if state.type == "learning": 

883 return None 

884 

885 if count == hard_max: 

886 next_target = state.on_throttle_hard or state.on_error 

887 self._emit( 

888 THROTTLE_HARD_EVENT, 

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

890 ) 

891 return next_target 

892 

893 if count > hard_max: 

894 self._emit( 

895 THROTTLE_STOP_EVENT, 

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

897 ) 

898 self._pending_error = ( 

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

900 "tool calls with no on_throttle_hard target" 

901 ) 

902 return "__STOP__" 

903 

904 return None 

905 

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

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

908 

909 Args: 

910 state: The state configuration to execute 

911 

912 Returns: 

913 Next state name, or None if no valid transition 

914 """ 

915 # Build interpolation context 

916 ctx = self._build_context() 

917 

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

919 if state.loop is not None: 

920 try: 

921 return self._execute_sub_loop(state, ctx) 

922 except (FileNotFoundError, ValueError, InterpolationError): 

923 if state.on_error: 

924 return interpolate(state.on_error, ctx) 

925 raise 

926 

927 # FEAT-1283: dispatch to learning-state handler when both type="learning" 

928 # AND a LearningConfig is present. The bare `type="learning"` marker 

929 # (used pre-FEAT-1283 only as a throttle hard_max exemption hint, see 

930 # ThrottleConfig docstring) falls through to normal action execution. 

931 if state.type == "learning" and state.learning is not None: 

932 return self._execute_learning_state(state, ctx) 

933 

934 # Handle unconditional transition 

935 if state.next: 

936 if state.action: 

937 self._maybe_wait_for_circuit(state) 

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

939 if routed is not None: 

940 return routed 

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

942 if throttle_next == "__STOP__": 

943 return None 

944 if throttle_next is not None: 

945 return throttle_next 

946 assert result is not None 

947 self.prev_result = { 

948 "output": result.output, 

949 "exit_code": result.exit_code, 

950 "state": self.current_state, 

951 } 

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

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

954 if state.on_error: 

955 return interpolate(state.on_error, ctx) 

956 self.request_shutdown() 

957 return None 

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

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

960 error_target = interpolate(state.on_error, ctx) 

961 if ( 

962 state.retryable_exit_codes is not None 

963 and result.exit_code is not None 

964 and result.exit_code not in state.retryable_exit_codes 

965 ): 

966 # Non-retryable exit code — bypass retry, route to 

967 # on_retry_exhausted (if set) or on_error directly. 

968 if state.on_retry_exhausted: 

969 return state.on_retry_exhausted 

970 return error_target 

971 return error_target 

972 return interpolate(state.next, ctx) 

973 

974 # Execute action if present 

975 action_result = None 

976 if state.action and self._action_mode(state) != "contract": 

977 self._maybe_wait_for_circuit(state) 

978 baseline_cfg = ctx.context.get("_baseline") 

979 if baseline_cfg and isinstance(baseline_cfg, dict) and baseline_cfg.get("enabled"): 

980 # Baseline mode: spawn parallel arms, harness drives routing 

981 action_result, routed = self._execute_with_baseline(state, ctx, baseline_cfg) 

982 if routed is not None: 

983 return routed 

984 else: 

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

986 if routed is not None: 

987 return routed 

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

989 if throttle_next == "__STOP__": 

990 return None 

991 if throttle_next is not None: 

992 return throttle_next 

993 

994 # Evaluate 

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

996 self.prev_result = { 

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

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

999 "state": self.current_state, 

1000 } 

1001 

1002 # Update context with result for routing interpolation 

1003 if eval_result: 

1004 ctx.result = { 

1005 "verdict": eval_result.verdict, 

1006 "details": eval_result.details, 

1007 } 

1008 

1009 # Route based on verdict 

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

1011 

1012 # Stall detection (FEAT-1637). Record this transition's triple and 

1013 # check whether the last `window` triples are identical. On stall, 

1014 # either abort (set _pending_stall_abort for run() to catch) or 

1015 # override next_state to the configured recovery target. 

1016 stall_route_target: str | None = None 

1017 if self._stall_detector is not None: 

1018 stall_exit_code = action_result.exit_code if action_result else 0 

1019 stall_fingerprint = self._compute_progress_fingerprint(ctx) 

1020 self._stall_detector.record( 

1021 self.current_state, stall_exit_code, verdict, stall_fingerprint 

1022 ) 

1023 stall = self._stall_detector.check() 

1024 if stall is not None: 

1025 assert self.fsm.circuit is not None 

1026 assert self.fsm.circuit.repeated_failure is not None 

1027 cfg_action = self.fsm.circuit.repeated_failure.on_repeated_failure 

1028 self._emit( 

1029 STALL_DETECTED_EVENT, 

1030 { 

1031 "state": self.current_state, 

1032 "exit_code": stall_exit_code, 

1033 "verdict": verdict, 

1034 "consecutive": stall.count, 

1035 "action": "abort" if cfg_action == "abort" else f"route:{cfg_action}", 

1036 }, 

1037 ) 

1038 if cfg_action == "abort": 

1039 self._pending_stall_abort = stall 

1040 return None 

1041 # Route to recovery target; bypass _route() entirely so the 

1042 # eval verdict does not pull us elsewhere first. 

1043 stall_route_target = cfg_action 

1044 

1045 route_ctx = RouteContext( 

1046 state_name=self.current_state, 

1047 state=state, 

1048 verdict=verdict, 

1049 action_result=action_result, 

1050 eval_result=eval_result, 

1051 ctx=ctx, 

1052 iteration=self.iteration, 

1053 ) 

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

1055 # returns early without dispatching to registered before_route hooks. 

1056 # BUG-2065 Fix 1: guard on exit_code != 0 so that successful actions whose 

1057 # output incidentally contains "rate limit" text are never intercepted. 

1058 if action_result is not None: 

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

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

1061 if ( 

1062 action_result.exit_code != 0 

1063 and _failure_type == FailureType.TRANSIENT 

1064 and ("rate limit" in _reason.lower() or "quota" in _reason.lower()) 

1065 ): 

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

1067 if _handled: 

1068 self._rate_limit_in_flight.add(route_ctx.state_name) 

1069 return _target 

1070 elif ( 

1071 action_result.exit_code != 0 

1072 and _failure_type == FailureType.TRANSIENT 

1073 and "api server error" in _reason.lower() 

1074 ): 

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

1076 if _handled: 

1077 return _target 

1078 # exhausted — fall through to normal verdict routing 

1079 else: 

1080 # Not rate-limited or server-error (or exit_code=0): reset counters so 

1081 # future transients start fresh. 

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

1083 self._consecutive_rate_limit_exhaustions = 0 

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

1085 

1086 # Stall-route override: if the detector elected to route to a recovery 

1087 # state, honor it now (bypass interceptors and _route) so the 

1088 # configured target wins over the eval verdict. 

1089 if stall_route_target is not None: 

1090 return stall_route_target 

1091 

1092 # Non-retryable exit code filter for eval-based error routing. When a 

1093 # state uses retryable_exit_codes and the action fails with a code that 

1094 # is NOT retryable, bypass the normal error route (which may be a 

1095 # self-retry) and go directly to on_retry_exhausted (or on_error). 

1096 if ( 

1097 verdict == "error" 

1098 and state.retryable_exit_codes is not None 

1099 and action_result is not None 

1100 and action_result.exit_code is not None 

1101 and action_result.exit_code not in state.retryable_exit_codes 

1102 ): 

1103 if state.on_retry_exhausted: 

1104 return state.on_retry_exhausted 

1105 if state.on_error: 

1106 return interpolate(state.on_error, ctx) 

1107 

1108 for interceptor in self._interceptors: 

1109 if hasattr(interceptor, "before_route"): 

1110 decision = interceptor.before_route(route_ctx) 

1111 if isinstance(decision, RouteDecision): 

1112 if decision.next_state is None: 

1113 return None # veto 

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

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

1116 for interceptor in self._interceptors: 

1117 if hasattr(interceptor, "after_route"): 

1118 interceptor.after_route(route_ctx) 

1119 return next_state 

1120 

1121 def _run_action( 

1122 self, 

1123 action_template: str, 

1124 state: StateConfig, 

1125 ctx: InterpolationContext, 

1126 on_usage: UsageCallback | None = None, 

1127 ) -> ActionResult: 

1128 """Execute action and optionally capture result. 

1129 

1130 Args: 

1131 action_template: Action string (may contain variables) 

1132 state: State configuration 

1133 ctx: Interpolation context 

1134 on_usage: Optional callback invoked with (input_tokens, output_tokens) on completion 

1135 

1136 Returns: 

1137 ActionResult with output and exit code 

1138 """ 

1139 action = interpolate(action_template, ctx) 

1140 action_mode = self._action_mode(state) 

1141 

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

1143 

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

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

1146 

1147 if action_mode == "mcp_tool": 

1148 # Direct MCP tool call — bypass action_runner entirely 

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

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

1151 result = self._run_subprocess( 

1152 cmd, 

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

1154 on_output_line=_on_line, 

1155 ) 

1156 elif action_mode == "contributed": 

1157 assert ( 

1158 state.action_type is not None 

1159 ) # guaranteed by _action_mode returning "contributed" 

1160 runner = self._contributed_actions[state.action_type] 

1161 result = runner.run( 

1162 action, 

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

1164 is_slash_command=False, 

1165 on_output_line=_on_line, 

1166 on_usage=on_usage, 

1167 ) 

1168 else: 

1169 result = self.action_runner.run( 

1170 action, 

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

1172 is_slash_command=action_mode == "prompt", 

1173 on_output_line=_on_line, 

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

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

1176 on_usage=on_usage, 

1177 model=state.model if action_mode == "prompt" else None, 

1178 ) 

1179 

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

1181 payload: dict[str, Any] = { 

1182 "exit_code": result.exit_code, 

1183 "duration_ms": result.duration_ms, 

1184 "output_preview": preview, 

1185 "is_prompt": action_mode == "prompt", 

1186 } 

1187 if action_mode == "prompt": 

1188 session_jsonl = get_current_session_jsonl() 

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

1190 # Aggregate token usage from host-CLI invocations (prompt / slash_command only) 

1191 if result.usage_events: 

1192 total_input = sum(u.input_tokens for u in result.usage_events) 

1193 total_output = sum(u.output_tokens for u in result.usage_events) 

1194 total_cache_read = sum(u.cache_read_tokens for u in result.usage_events) 

1195 total_cache_creation = sum(u.cache_creation_tokens for u in result.usage_events) 

1196 model = result.usage_events[-1].model 

1197 payload["input_tokens"] = total_input 

1198 payload["output_tokens"] = total_output 

1199 payload["cache_read_tokens"] = total_cache_read 

1200 payload["cache_creation_tokens"] = total_cache_creation 

1201 payload["model"] = model 

1202 self._emit("action_complete", payload) 

1203 

1204 # Capture if requested 

1205 if state.capture: 

1206 self.captured[state.capture] = { 

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

1208 "stderr": result.stderr, 

1209 "exit_code": result.exit_code, 

1210 "duration_ms": result.duration_ms, 

1211 } 

1212 

1213 # Append to shared messages log if requested 

1214 if state.append_to_messages: 

1215 post_ctx = self._build_context() 

1216 message = interpolate(state.append_to_messages, post_ctx) 

1217 self.messages.append(message) 

1218 self._emit( 

1219 "messages_append", 

1220 {"message": message, "state": self.current_state}, 

1221 ) 

1222 

1223 # Check for signals in output 

1224 if self.signal_detector: 

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

1226 if signal: 

1227 if signal.signal_type == "handoff": 

1228 self._pending_handoff = signal 

1229 elif signal.signal_type == "error": 

1230 self._pending_error = signal.payload 

1231 elif signal.signal_type == "stop": 

1232 self.request_shutdown() 

1233 

1234 return result 

1235 

1236 def _run_subprocess( 

1237 self, 

1238 cmd: list[str], 

1239 timeout: int, 

1240 on_output_line: Any | None = None, 

1241 ) -> ActionResult: 

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

1243 

1244 Uses selector-based I/O with wall-clock timeout enforcement so the 

1245 timeout is honoured even when the process hangs before producing output. 

1246 """ 

1247 start = _now_ms() 

1248 process = subprocess.Popen( 

1249 cmd, 

1250 stdout=subprocess.PIPE, 

1251 stderr=subprocess.PIPE, 

1252 text=True, 

1253 ) 

1254 self._current_process = process 

1255 deadline = time.time() + timeout 

1256 

1257 output_chunks: list[str] = [] 

1258 stderr_chunks: list[str] = [] 

1259 

1260 sel = selectors.DefaultSelector() 

1261 if process.stdout is not None: 

1262 sel.register(process.stdout, selectors.EVENT_READ, data="stdout") 

1263 if process.stderr is not None: 

1264 sel.register(process.stderr, selectors.EVENT_READ, data="stderr") 

1265 

1266 timed_out = False 

1267 try: 

1268 while sel.get_map(): 

1269 remaining = deadline - time.time() 

1270 if remaining <= 0: 

1271 timed_out = True 

1272 break 

1273 poll_timeout = min(1.0, remaining) 

1274 ready = sel.select(timeout=poll_timeout) 

1275 if not ready: 

1276 continue 

1277 for key, _mask in ready: 

1278 line = key.fileobj.readline() # type: ignore[union-attr] 

1279 if line: 

1280 if key.data == "stdout": 

1281 output_chunks.append(line) 

1282 if on_output_line: 

1283 on_output_line(line.rstrip()) 

1284 else: 

1285 stderr_chunks.append(line) 

1286 else: 

1287 sel.unregister(key.fileobj) 

1288 finally: 

1289 sel.close() 

1290 self._current_process = None 

1291 

1292 if timed_out: 

1293 _kill_process_group(process) 

1294 try: 

1295 process.wait(timeout=5) 

1296 except subprocess.TimeoutExpired: 

1297 process.kill() 

1298 process.wait() 

1299 return ActionResult( 

1300 output="".join(output_chunks), 

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

1302 exit_code=124, 

1303 duration_ms=timeout * 1000, 

1304 ) 

1305 

1306 process.wait(timeout=5) 

1307 return ActionResult( 

1308 output="".join(output_chunks), 

1309 stderr="".join(stderr_chunks), 

1310 exit_code=process.returncode, 

1311 duration_ms=_now_ms() - start, 

1312 ) 

1313 

1314 def _evaluate( 

1315 self, 

1316 state: StateConfig, 

1317 action_result: ActionResult | None, 

1318 ctx: InterpolationContext, 

1319 ) -> EvaluationResult | None: 

1320 """Evaluate action result. 

1321 

1322 Args: 

1323 state: State configuration 

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

1325 ctx: Interpolation context 

1326 

1327 Returns: 

1328 EvaluationResult, or None if no evaluation needed 

1329 """ 

1330 if state.evaluate is None: 

1331 # Default evaluation based on action type 

1332 if action_result: 

1333 action_mode = self._action_mode(state) 

1334 

1335 if action_mode == "mcp_tool": 

1336 # MCP tool call: use mcp_result evaluator 

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

1338 elif action_mode == "prompt": 

1339 # Slash command or prompt: use LLM evaluation 

1340 if not self.fsm.llm.enabled: 

1341 result = EvaluationResult( 

1342 verdict="error", 

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

1344 ) 

1345 else: 

1346 result = evaluate_llm_structured( 

1347 action_result.output, 

1348 model=self.fsm.llm.model, 

1349 max_tokens=self.fsm.llm.max_tokens, 

1350 timeout=self.fsm.llm.timeout, 

1351 ) 

1352 else: 

1353 # Shell command: use exit code 

1354 result = evaluate_exit_code(action_result.exit_code) 

1355 

1356 self._emit( 

1357 "evaluate", 

1358 { 

1359 "type": "default", 

1360 "verdict": result.verdict, 

1361 **result.details, 

1362 }, 

1363 ) 

1364 return result 

1365 return None 

1366 

1367 # Explicit evaluation config 

1368 raw_output = action_result.output if action_result else "" 

1369 if state.evaluate.source: 

1370 try: 

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

1372 except InterpolationError: 

1373 eval_input = raw_output 

1374 else: 

1375 eval_input = raw_output 

1376 

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

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

1379 state.evaluate, 

1380 eval_input, 

1381 action_result.exit_code if action_result else 0, 

1382 ctx, 

1383 ) 

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

1385 result = EvaluationResult( 

1386 verdict="error", 

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

1388 ) 

1389 else: 

1390 result = evaluate( 

1391 config=state.evaluate, 

1392 output=eval_input, 

1393 exit_code=action_result.exit_code if action_result else 0, 

1394 context=ctx, 

1395 ) 

1396 

1397 self._emit( 

1398 "evaluate", 

1399 { 

1400 "type": state.evaluate.type, 

1401 "verdict": result.verdict, 

1402 **result.details, 

1403 }, 

1404 ) 

1405 

1406 return result 

1407 

1408 def _route( 

1409 self, 

1410 state: StateConfig, 

1411 verdict: str, 

1412 ctx: InterpolationContext, 

1413 ) -> str | None: 

1414 """Determine next state from verdict. 

1415 

1416 Resolution order (from design doc): 

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

1418 2. route (full routing table) 

1419 3. on_success/on_failure/on_error (shorthand) 

1420 4. terminal - handled in main loop 

1421 5. error 

1422 

1423 Args: 

1424 state: State configuration 

1425 verdict: Verdict string from evaluation 

1426 ctx: Interpolation context 

1427 

1428 Returns: 

1429 Next state name, or None if no valid route 

1430 """ 

1431 if state.route: 

1432 routes = state.route.routes 

1433 if verdict in routes: 

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

1435 if state.route.default: 

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

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

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

1439 if verdict == "no" and state.route.error: 

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

1441 return None 

1442 

1443 # Shorthand routing 

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

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

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

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

1448 if verdict == "no" and not state.on_no and state.on_error: 

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

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

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

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

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

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

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

1456 

1457 # Dynamic on_<verdict> shorthands from extra_routes 

1458 if verdict in state.extra_routes: 

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

1460 

1461 return None 

1462 

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

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

1465 

1466 Args: 

1467 route: Route target string 

1468 ctx: Interpolation context 

1469 

1470 Returns: 

1471 Resolved state name 

1472 """ 

1473 if route == "$current": 

1474 return self.current_state 

1475 return interpolate(route, ctx) 

1476 

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

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

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

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

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

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

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

1484 """ 

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

1486 self.iteration += 1 

1487 self._just_routed = False 

1488 self._emit( 

1489 "state_enter", 

1490 { 

1491 "state": self.current_state, 

1492 "iteration": self.iteration, 

1493 "flushed": True, 

1494 }, 

1495 ) 

1496 ctx = self._build_context() 

1497 try: 

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

1499 except Exception: 

1500 # Deliberately swallow — the timeout is being honored regardless 

1501 # of whether the flushed action succeeded. 

1502 pass 

1503 

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

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

1506 if state.action_type == "contract": 

1507 return "contract" 

1508 if state.action_type == "mcp_tool": 

1509 return "mcp_tool" 

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

1511 return "prompt" 

1512 if state.action_type == "shell": 

1513 return "shell" 

1514 if state.action_type in self._contributed_actions: 

1515 return "contributed" 

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

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

1518 return "prompt" 

1519 return "shell" 

1520 

1521 def _execute_with_baseline( 

1522 self, 

1523 state: StateConfig, 

1524 ctx: InterpolationContext, 

1525 baseline_cfg: dict[str, Any], 

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

1527 """Execute harness arm + baseline arm in parallel. 

1528 

1529 The harness arm drives FSM routing; the baseline arm runs a single-shot 

1530 skill invocation with no eval gates for data collection only. 

1531 

1532 Returns (action_result, routed_target) where action_result is from the 

1533 harness arm and routed_target is None (routing happens in _execute_state). 

1534 """ 

1535 from concurrent.futures import ThreadPoolExecutor 

1536 

1537 harness_tokens: list[tuple[int, int]] = [] 

1538 baseline_tokens: list[tuple[int, int]] = [] 

1539 

1540 def _on_harness_usage(input_tokens: int, output_tokens: int) -> None: 

1541 harness_tokens.append((input_tokens, output_tokens)) 

1542 

1543 def _on_baseline_usage(input_tokens: int, output_tokens: int) -> None: 

1544 baseline_tokens.append((input_tokens, output_tokens)) 

1545 

1546 # Determine baseline skill: use --baseline-skill override or extract from action 

1547 baseline_skill_name = baseline_cfg.get("skill") 

1548 if baseline_skill_name is None: 

1549 action_text = interpolate(state.action, ctx) # type: ignore[arg-type] 

1550 baseline_skill_name = _extract_skill_from_action(action_text) 

1551 

1552 assert state.action is not None # caller-guarded by `if state.action:` 

1553 with ThreadPoolExecutor(max_workers=2) as pool: 

1554 harness_future = pool.submit( 

1555 self._run_action, state.action, state, ctx, _on_harness_usage 

1556 ) 

1557 baseline_future = pool.submit( 

1558 self._run_baseline_arm, baseline_skill_name, state, _on_baseline_usage 

1559 ) 

1560 harness_result: ActionResult = harness_future.result() 

1561 baseline_result: ActionResult = baseline_future.result() 

1562 

1563 harness_total_tokens = sum(t[0] + t[1] for t in harness_tokens) 

1564 baseline_total_tokens = sum(t[0] + t[1] for t in baseline_tokens) 

1565 

1566 self._emit( 

1567 "baseline_complete", 

1568 { 

1569 "harness_duration_ms": harness_result.duration_ms, 

1570 "baseline_duration_ms": baseline_result.duration_ms, 

1571 "harness_tokens": harness_total_tokens, 

1572 "baseline_tokens": baseline_total_tokens, 

1573 }, 

1574 ) 

1575 

1576 # FEAT-1822: Run blind comparison and accumulate per-item results. 

1577 # The blind comparator is non-fatal: if it errors, we record a 

1578 # degraded result but let the harness routing proceed unchanged. 

1579 try: 

1580 comparison = evaluate_blind_comparator( 

1581 output_harness=harness_result.output, 

1582 output_baseline=baseline_result.output, 

1583 ) 

1584 item: dict[str, Any] = { 

1585 "index": self._ab_item_index, 

1586 "harness_pass": comparison.get("harness_pass", False), 

1587 "baseline_pass": comparison.get("baseline_pass", False), 

1588 "harness_tokens": harness_total_tokens, 

1589 "baseline_tokens": baseline_total_tokens, 

1590 "harness_duration_ms": harness_result.duration_ms, 

1591 "baseline_duration_ms": baseline_result.duration_ms, 

1592 "confidence": comparison.get("confidence", 0.0), 

1593 "reason": comparison.get("reason", ""), 

1594 } 

1595 self._ab_results.append(item) 

1596 self._ab_item_index += 1 

1597 self._emit("ab_comparison", {**item, "raw": comparison.get("raw", {})}) 

1598 except Exception: 

1599 # Blind evaluation failure is non-fatal — record degraded result 

1600 item = { 

1601 "index": self._ab_item_index, 

1602 "harness_pass": False, 

1603 "baseline_pass": False, 

1604 "harness_tokens": harness_total_tokens, 

1605 "baseline_tokens": baseline_total_tokens, 

1606 "harness_duration_ms": harness_result.duration_ms, 

1607 "baseline_duration_ms": baseline_result.duration_ms, 

1608 "confidence": 0.0, 

1609 "reason": "Blind evaluation failed", 

1610 "error": "evaluation_error", 

1611 } 

1612 self._ab_results.append(item) 

1613 self._ab_item_index += 1 

1614 

1615 return harness_result, None 

1616 

1617 def _run_baseline_arm( 

1618 self, 

1619 skill_command: str, 

1620 state: StateConfig, 

1621 on_usage: UsageCallback | None = None, 

1622 ) -> ActionResult: 

1623 """Run a single-shot baseline skill invocation with no eval gates. 

1624 

1625 Args: 

1626 skill_command: Slash command for the baseline skill (e.g., "/ll:some-skill") 

1627 state: State configuration (for timeout) 

1628 on_usage: Optional callback for token capture 

1629 

1630 Returns: 

1631 ActionResult with output, exit code, and duration 

1632 """ 

1633 start = _now_ms() 

1634 timeout = state.timeout or self.fsm.default_timeout or 3600 

1635 try: 

1636 completed = run_claude_command( 

1637 command=skill_command, 

1638 timeout=timeout, 

1639 on_usage=on_usage, 

1640 ) 

1641 return ActionResult( 

1642 output=completed.stdout, 

1643 stderr=completed.stderr, 

1644 exit_code=completed.returncode, 

1645 duration_ms=_now_ms() - start, 

1646 ) 

1647 except subprocess.TimeoutExpired: 

1648 return ActionResult( 

1649 output="", 

1650 stderr="Baseline action timed out", 

1651 exit_code=124, 

1652 duration_ms=timeout * 1000, 

1653 ) 

1654 

1655 def _run_action_or_route( 

1656 self, state: StateConfig, ctx: InterpolationContext 

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

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

1659 

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

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

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

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

1664 """ 

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

1666 try: 

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

1668 except Exception as exc: 

1669 if state.on_error: 

1670 self._emit( 

1671 "action_error", 

1672 { 

1673 "state": self.current_state, 

1674 "error": str(exc), 

1675 "route": "on_error", 

1676 }, 

1677 ) 

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

1679 raise 

1680 

1681 def _build_context(self) -> InterpolationContext: 

1682 """Build interpolation context for current state. 

1683 

1684 Returns: 

1685 InterpolationContext with all runtime values 

1686 """ 

1687 from little_loops.fsm.interpolation import interpolate_dict 

1688 

1689 ctx = InterpolationContext( 

1690 context=self.fsm.context, 

1691 captured=self.captured, 

1692 prev=self.prev_result, 

1693 result=None, 

1694 state_name=self.current_state, 

1695 iteration=self.iteration, 

1696 loop_name=self.fsm.name, 

1697 started_at=self.started_at, 

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

1699 messages=list(self.messages), 

1700 ) 

1701 

1702 # Populate param namespace from fragment bindings (if this state uses a fragment) 

1703 state = self.fsm.states.get(self.current_state) 

1704 if state is not None and (state.fragment_bindings or state.fragment_parameters): 

1705 resolved = interpolate_dict(state.fragment_bindings, ctx) 

1706 # Apply declared defaults for unbound optional parameters 

1707 for param_name, param_spec in state.fragment_parameters.items(): 

1708 if ( 

1709 param_name not in resolved 

1710 and not param_spec.required 

1711 and param_spec.default is not None 

1712 ): 

1713 resolved[param_name] = param_spec.default 

1714 # Runtime enforcement: required parameters must be bound 

1715 for param_name, param_spec in state.fragment_parameters.items(): 

1716 if param_spec.required and param_name not in resolved: 

1717 raise ValueError( 

1718 f"Fragment '{state.fragment_name}' requires parameter '{param_name}' " 

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

1720 ) 

1721 ctx.param = resolved 

1722 

1723 return ctx 

1724 

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

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

1727 self.event_callback( 

1728 { 

1729 "event": event, 

1730 "ts": _iso_now(), 

1731 **data, 

1732 } 

1733 ) 

1734 

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

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

1737 

1738 Implements the two-tier retry ladder: 

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

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

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

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

1743 

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

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

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

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

1748 

1749 Returns: 

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

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

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

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

1754 extensions. 

1755 """ 

1756 _short_max = ( 

1757 state.max_rate_limit_retries 

1758 if state.max_rate_limit_retries is not None 

1759 else _DEFAULT_RATE_LIMIT_RETRIES 

1760 ) 

1761 _backoff_base = ( 

1762 state.rate_limit_backoff_base_seconds 

1763 if state.rate_limit_backoff_base_seconds is not None 

1764 else _DEFAULT_RATE_LIMIT_BACKOFF_BASE 

1765 ) 

1766 _max_wait = ( 

1767 state.rate_limit_max_wait_seconds 

1768 if state.rate_limit_max_wait_seconds is not None 

1769 else _DEFAULT_RATE_LIMIT_MAX_WAIT_SECONDS 

1770 ) 

1771 _ladder = ( 

1772 state.rate_limit_long_wait_ladder 

1773 if state.rate_limit_long_wait_ladder is not None 

1774 else _DEFAULT_RATE_LIMIT_LONG_WAIT_LADDER 

1775 ) 

1776 

1777 record = self._rate_limit_retries.get(state_name) 

1778 if record is None: 

1779 record = { 

1780 "short_retries": 0, 

1781 "long_retries": 0, 

1782 "total_wait_seconds": 0.0, 

1783 "first_seen_at": time.time(), 

1784 } 

1785 self._rate_limit_retries[state_name] = record 

1786 

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

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

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

1790 

1791 if short_retries < _short_max: 

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

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

1794 short_retries += 1 

1795 record["short_retries"] = short_retries 

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

1797 if self._circuit is not None: 

1798 self._circuit.record_rate_limit(_sleep) 

1799 total_wait += self._interruptible_sleep(_sleep) 

1800 record["total_wait_seconds"] = total_wait 

1801 return True, state_name # retry in place 

1802 

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

1804 long_retries += 1 

1805 record["long_retries"] = long_retries 

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

1807 _wait = float(_ladder[_idx]) 

1808 if self._circuit is not None: 

1809 self._circuit.record_rate_limit(_wait) 

1810 _tier_start = time.time() 

1811 _deadline = _tier_start + _wait 

1812 _total_wait_before_tier = total_wait 

1813 total_wait += self._interruptible_sleep( 

1814 _wait, 

1815 on_heartbeat=lambda elapsed: self._emit( 

1816 RATE_LIMIT_WAITING_EVENT, 

1817 { 

1818 "state": state_name, 

1819 "elapsed_seconds": elapsed, 

1820 "next_attempt_at": _deadline, 

1821 "total_waited_seconds": _total_wait_before_tier + elapsed, 

1822 "budget_seconds": _max_wait, 

1823 "tier": "long_wait", 

1824 }, 

1825 ), 

1826 ) 

1827 record["total_wait_seconds"] = total_wait 

1828 if total_wait >= _max_wait: 

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

1830 return True, state_name # retry in place 

1831 

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

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

1834 

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

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

1837 """ 

1838 if self._circuit is None: 

1839 return 

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

1841 return 

1842 recovery = self._circuit.get_estimated_recovery() 

1843 if recovery is None: 

1844 return 

1845 wait = recovery - time.time() 

1846 if wait > 0: 

1847 self._interruptible_sleep(wait) 

1848 

1849 def _interruptible_sleep( 

1850 self, 

1851 duration: float, 

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

1853 ) -> float: 

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

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

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

1857 

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

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

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

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

1862 """ 

1863 if duration <= 0: 

1864 return 0.0 

1865 _start = time.time() 

1866 _deadline = _start + duration 

1867 last_heartbeat = _start 

1868 while time.time() < _deadline: 

1869 if self._shutdown_requested: 

1870 break 

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

1872 if on_heartbeat is not None: 

1873 _now = time.time() 

1874 if _now - last_heartbeat >= _RATE_LIMIT_HEARTBEAT_INTERVAL: 

1875 on_heartbeat(_now - _start) 

1876 last_heartbeat = _now 

1877 return time.time() - _start 

1878 

1879 def _exhaust_rate_limit( 

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

1881 ) -> str | None: 

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

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

1884 once the wall-clock budget is spent. 

1885 """ 

1886 self._rate_limit_retries.pop(state_name, None) 

1887 target = state.on_rate_limit_exhausted or state.on_error 

1888 self._emit( 

1889 RATE_LIMIT_EXHAUSTED_EVENT, 

1890 { 

1891 "state": state_name, 

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

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

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

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

1896 "next": target, 

1897 }, 

1898 ) 

1899 self._consecutive_rate_limit_exhaustions += 1 

1900 if self._consecutive_rate_limit_exhaustions >= _RATE_LIMIT_STORM_THRESHOLD: 

1901 self._emit( 

1902 RATE_LIMIT_STORM_EVENT, 

1903 { 

1904 "state": state_name, 

1905 "count": self._consecutive_rate_limit_exhaustions, 

1906 }, 

1907 ) 

1908 return target 

1909 

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

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

1912 

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

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

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

1916 

1917 Returns: 

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

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

1920 through to normal verdict routing). 

1921 """ 

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

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

1924 self._api_error_retries.pop(state_name, None) 

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

1926 return False, None 

1927 record["retries"] += 1 

1928 slept = self._interruptible_sleep(_DEFAULT_API_ERROR_BACKOFF) 

1929 record["total_wait"] += slept 

1930 self._emit( 

1931 "api_error_retry", 

1932 { 

1933 "state": state_name, 

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

1935 "backoff": _DEFAULT_API_ERROR_BACKOFF, 

1936 }, 

1937 ) 

1938 return True, state_name 

1939 

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

1941 """Finalize execution and return result.""" 

1942 self._emit( 

1943 "loop_complete", 

1944 { 

1945 "final_state": self.current_state, 

1946 "iterations": self.iteration, 

1947 "terminated_by": terminated_by, 

1948 }, 

1949 ) 

1950 

1951 # FEAT-1822: Write ab.json if baseline comparison results exist 

1952 if self._ab_results: 

1953 try: 

1954 from little_loops.ab_writer import calculate_ab_summary, write_ab_json 

1955 

1956 run_dir = self.fsm.context.get("run_dir", "") 

1957 if run_dir: 

1958 summary = calculate_ab_summary(self._ab_results) 

1959 write_ab_json(summary, run_dir) 

1960 self._emit( 

1961 "ab_summary", 

1962 { 

1963 "harness_pass_rate": summary.harness_pass_rate, 

1964 "baseline_pass_rate": summary.baseline_pass_rate, 

1965 "delta": summary.delta, 

1966 "item_count": len(summary.per_item), 

1967 }, 

1968 ) 

1969 except Exception: 

1970 pass # Non-fatal: loop still completes 

1971 

1972 return ExecutionResult( 

1973 final_state=self.current_state, 

1974 iterations=self.iteration, 

1975 terminated_by=terminated_by, 

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

1977 captured=self.captured, 

1978 error=error, 

1979 messages=list(self.messages), 

1980 ) 

1981 

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

1983 """Handle a detected handoff signal. 

1984 

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

1986 

1987 Args: 

1988 signal: The detected handoff signal 

1989 

1990 Returns: 

1991 ExecutionResult with handoff information 

1992 """ 

1993 self._emit( 

1994 "handoff_detected", 

1995 { 

1996 "state": self.current_state, 

1997 "iteration": self.iteration, 

1998 "continuation": signal.payload, 

1999 }, 

2000 ) 

2001 

2002 # Invoke handler if configured 

2003 if self.handoff_handler: 

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

2005 if result.spawned_process is not None: 

2006 self._emit( 

2007 "handoff_spawned", 

2008 { 

2009 "pid": result.spawned_process.pid, 

2010 "state": self.current_state, 

2011 }, 

2012 ) 

2013 

2014 return ExecutionResult( 

2015 final_state=self.current_state, 

2016 iterations=self.iteration, 

2017 terminated_by="handoff", 

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

2019 captured=self.captured, 

2020 handoff=True, 

2021 continuation_prompt=signal.payload, 

2022 ) 

2023 

2024 

2025def _extract_skill_from_action(action: str) -> str: 

2026 """Extract the base skill name from an action string. 

2027 

2028 For slash commands like "/ll:some-skill --arg value", returns "/ll:some-skill". 

2029 For other actions, returns the action as-is. 

2030 """ 

2031 stripped = action.strip() 

2032 if stripped.startswith("/"): 

2033 parts = stripped.split(maxsplit=1) 

2034 return parts[0] 

2035 return stripped