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

793 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-15 17:27 -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 os 

15import random 

16import selectors 

17import signal 

18import subprocess 

19import time 

20from collections.abc import Callable 

21from dataclasses import dataclass 

22from datetime import UTC, datetime 

23from pathlib import Path 

24from typing import Any 

25 

26from little_loops.fsm.evaluators import ( 

27 EvaluationResult, 

28 evaluate, 

29 evaluate_blind_comparator, 

30 evaluate_exit_code, 

31 evaluate_llm_structured, 

32 evaluate_mcp_result, 

33) 

34from little_loops.fsm.handoff_handler import HandoffHandler 

35from little_loops.fsm.interpolation import ( 

36 InterpolationContext, 

37 InterpolationError, 

38 interpolate, 

39 interpolate_dict, 

40) 

41from little_loops.fsm.rate_limit_circuit import RateLimitCircuit 

42from little_loops.fsm.runners import ( 

43 ActionRunner, 

44 DefaultActionRunner, 

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

46 _now_ms, 

47) 

48from little_loops.fsm.schema import FSMLoop, StateConfig 

49from little_loops.fsm.signal_detector import DetectedSignal, SignalDetector 

50from little_loops.fsm.stall_detector import Stall, StallDetector 

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

52from little_loops.issue_lifecycle import FailureType, classify_failure 

53from little_loops.session_log import get_current_session_jsonl 

54from little_loops.subprocess_utils import ( 

55 UsageCallback, 

56 _kill_process_group, 

57 run_claude_command, 

58) 

59 

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

61_DEFAULT_RATE_LIMIT_RETRIES: int = 3 

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

63_DEFAULT_RATE_LIMIT_BACKOFF_BASE: int = 30 

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

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

66_DEFAULT_RATE_LIMIT_MAX_WAIT_SECONDS: int = 21600 

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

68# Mirrors RateLimitsConfig.long_wait_ladder default. 

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

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

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

72STALL_DETECTED_EVENT: str = "stall_detected" 

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

74RATE_LIMIT_EXHAUSTED_EVENT: str = "rate_limit_exhausted" 

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

76RATE_LIMIT_STORM_EVENT: str = "rate_limit_storm" 

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

78RATE_LIMIT_WAITING_EVENT: str = "rate_limit_waiting" 

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

80_RATE_LIMIT_HEARTBEAT_INTERVAL: float = 60.0 

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

82_RATE_LIMIT_STORM_THRESHOLD: int = 3 

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

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

85_DEFAULT_THROTTLE_NORMAL_MAX: int = 3 

86_DEFAULT_THROTTLE_WARN_MAX: int = 8 

87_DEFAULT_THROTTLE_HARD_MAX: int = 12 

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

89THROTTLE_WARN_EVENT: str = "throttle_warn" 

90THROTTLE_HARD_EVENT: str = "throttle_hard" 

91THROTTLE_STOP_EVENT: str = "throttle_stop" 

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

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

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

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

96_DEFAULT_API_ERROR_RETRIES: int = 2 

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

98_DEFAULT_API_ERROR_BACKOFF: int = 30 

99 

100 

101def _iso_now() -> str: 

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

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

104 

105 

106@dataclass 

107class RouteContext: 

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

109 

110 state_name: str 

111 state: StateConfig 

112 verdict: str 

113 action_result: ActionResult | None 

114 eval_result: EvaluationResult | None 

115 ctx: InterpolationContext 

116 iteration: int 

117 

118 

119@dataclass 

120class RouteDecision: 

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

122 

123 Return semantics for before_route: 

124 None (implicit) → passthrough, routing proceeds normally 

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

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

127 """ 

128 

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

130 

131 

132class FSMExecutor: 

133 """Execute an FSM loop. 

134 

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

136 - A terminal state is reached 

137 - max_iterations is exceeded 

138 - A timeout occurs 

139 - A shutdown signal is received 

140 - An unrecoverable error occurs 

141 

142 Events are emitted via the callback for observability. 

143 """ 

144 

145 def __init__( 

146 self, 

147 fsm: FSMLoop, 

148 event_callback: EventCallback | None = None, 

149 action_runner: ActionRunner | None = None, 

150 signal_detector: SignalDetector | None = None, 

151 handoff_handler: HandoffHandler | None = None, 

152 loops_dir: Path | None = None, 

153 circuit: RateLimitCircuit | None = None, 

154 ): 

155 """Initialize the executor. 

156 

157 Args: 

158 fsm: The FSM loop to execute 

159 event_callback: Optional callback for events 

160 action_runner: Optional custom action runner (for testing) 

161 signal_detector: Optional signal detector for output parsing 

162 handoff_handler: Optional handler for handoff signals 

163 loops_dir: Base directory for resolving sub-loop references 

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

165 """ 

166 self.fsm = fsm 

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

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

169 self.signal_detector = signal_detector 

170 self.handoff_handler = handoff_handler 

171 self.loops_dir = loops_dir 

172 self._circuit = circuit 

173 

174 # Runtime state 

175 self.current_state = fsm.initial 

176 self.iteration = 0 

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

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

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

180 self.started_at = "" 

181 self.start_time_ms = 0 

182 self.elapsed_offset_ms = ( 

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

184 ) 

185 

186 # Shutdown flag for graceful signal handling 

187 self._shutdown_requested = False 

188 

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

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

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

192 

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

194 self._pending_handoff: DetectedSignal | None = None 

195 

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

197 self._pending_error: str | None = None 

198 

199 # Per-state retry tracking for max_retries support. 

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

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

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

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

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

205 self._prev_state: str | None = None 

206 

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

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

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

210 self._just_routed: bool = False 

211 

212 # ENH-1631: true once the on_max_iterations summary state has been 

213 # dispatched. Prevents the cap guard from re-triggering before the 

214 # summary state completes. 

215 self._summary_state_executed: bool = False 

216 

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

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

219 # by _finish(). 

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

221 self._ab_item_index: int = 0 

222 

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

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

225 # { 

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

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

228 # "total_wait_seconds": float, 

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

230 # } 

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

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

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

234 

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

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

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

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

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

240 

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

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

243 # _RATE_LIMIT_STORM_THRESHOLD, a RATE_LIMIT_STORM event is emitted. 

244 self._consecutive_rate_limit_exhaustions: int = 0 

245 

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

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

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

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

250 

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

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

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

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

255 

256 # Per-edge revisit counter for cycle detection. 

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

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

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

260 

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

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

263 self._stall_detector: StallDetector | None = None 

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

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

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

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

268 self._pending_stall_abort: Stall | None = None 

269 

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

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

272 self._depth: int = 0 

273 

274 # Extension hook registries — populated by wire_extensions() 

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

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

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

278 

279 def request_shutdown(self) -> None: 

280 """Request graceful shutdown of the executor. 

281 

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

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

284 """ 

285 self._shutdown_requested = True 

286 

287 def run(self) -> ExecutionResult: 

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

289 

290 Returns: 

291 ExecutionResult with final state and execution metadata 

292 """ 

293 self.started_at = _iso_now() 

294 self.start_time_ms = _now_ms() 

295 

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

297 

298 try: 

299 while True: 

300 # Check shutdown request (signal handling) 

301 if self._shutdown_requested: 

302 return self._finish("signal") 

303 

304 # Check iteration limit 

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

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

307 self._emit( 

308 "max_iterations_summary", 

309 { 

310 "summary_state": self.fsm.on_max_iterations, 

311 "iterations": self.iteration, 

312 }, 

313 ) 

314 self._summary_state_executed = True 

315 self.current_state = self.fsm.on_max_iterations 

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

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

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

319 else: 

320 return self._finish("max_iterations") 

321 

322 # Check timeout 

323 if self.fsm.timeout: 

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

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

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

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

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

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

330 # silently lost. Bounded to shell actions — slash 

331 # commands and sub-loops would violate the timeout 

332 # budget. Single-step: no cascade. 

333 if self._just_routed: 

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

335 if ( 

336 pending is not None 

337 and not pending.terminal 

338 and pending.loop is None 

339 and pending.action is not None 

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

341 ): 

342 self._flush_pending_shell_state(pending) 

343 return self._finish("timeout") 

344 

345 # Get current state config 

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

347 

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

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

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

351 if self._prev_state is not None: 

352 if self.current_state == self._prev_state: 

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

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

355 if self.current_state not in self._rate_limit_in_flight: 

356 self._retry_counts[self.current_state] = ( 

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

358 ) 

359 self._rate_limit_in_flight.discard(self.current_state) 

360 else: 

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

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

363 self._rate_limit_in_flight.discard(self._prev_state) 

364 

365 # Check terminal 

366 if state_config.terminal: 

367 # Handle maintain mode - restart loop instead of terminating 

368 if self.fsm.maintain: 

369 self.iteration += 1 

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

371 self._emit( 

372 "route", 

373 { 

374 "from": self.current_state, 

375 "to": maintain_target, 

376 "reason": "maintain", 

377 }, 

378 ) 

379 self._prev_state = self.current_state 

380 self.current_state = maintain_target 

381 self._just_routed = True 

382 continue 

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

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

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

386 if self._summary_state_executed: 

387 return self._finish("max_iterations") 

388 return self._finish("terminal") 

389 

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

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

392 if state_config.max_retries is not None: 

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

394 if retry_count > state_config.max_retries: 

395 # on_retry_exhausted is guaranteed non-None by validation when 

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

397 exhausted_state: str = state_config.on_retry_exhausted or "" 

398 if not exhausted_state: 

399 return self._finish( 

400 "error", 

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

402 "but on_retry_exhausted is not set", 

403 ) 

404 self._emit( 

405 "retry_exhausted", 

406 { 

407 "state": self.current_state, 

408 "retries": retry_count, 

409 "next": exhausted_state, 

410 }, 

411 ) 

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

413 self._prev_state = self.current_state 

414 self.current_state = exhausted_state 

415 continue 

416 

417 self.iteration += 1 

418 self._just_routed = False 

419 self._emit( 

420 "state_enter", 

421 { 

422 "state": self.current_state, 

423 "iteration": self.iteration, 

424 }, 

425 ) 

426 

427 # Execute state 

428 next_state = self._execute_state(state_config) 

429 

430 # Check for pending error signal (FATAL_ERROR) 

431 if self._pending_error is not None: 

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

433 

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

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

436 # terminate cleanly via _finish (mirrors the cycle_detected 

437 # guard below at lines 397-416). 

438 if self._pending_stall_abort is not None: 

439 stall = self._pending_stall_abort 

440 s_state, s_exit, s_verdict = stall.triple 

441 return self._finish( 

442 "stall_detected", 

443 error=( 

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

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

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

447 ), 

448 ) 

449 

450 # Check for pending handoff signal 

451 if self._pending_handoff: 

452 return self._handle_handoff(self._pending_handoff) 

453 

454 # Handle maintain mode 

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

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

457 

458 # SIGKILL in _execute_state sets shutdown flag and returns None 

459 if next_state is None and self._shutdown_requested: 

460 return self._finish("signal") 

461 

462 if next_state is None: 

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

464 

465 # At this point next_state is guaranteed to be str 

466 resolved_next: str = next_state 

467 

468 self._emit( 

469 "route", 

470 { 

471 "from": self.current_state, 

472 "to": resolved_next, 

473 }, 

474 ) 

475 

476 # Per-edge revisit tracking for cycle detection. 

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

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

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

480 self._emit( 

481 "cycle_detected", 

482 { 

483 "edge": edge_key, 

484 "from": self.current_state, 

485 "to": resolved_next, 

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

487 "max": self.fsm.max_edge_revisits, 

488 }, 

489 ) 

490 return self._finish( 

491 "cycle_detected", 

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

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

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

495 ) 

496 

497 self._prev_state = self.current_state 

498 self.current_state = resolved_next 

499 self._just_routed = True 

500 

501 # Interruptible backoff sleep between iterations 

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

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

504 while time.time() < deadline: 

505 if self._shutdown_requested: 

506 break 

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

508 

509 except InterpolationError as exc: 

510 return self._finish( 

511 "error", 

512 error=( 

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

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

515 ), 

516 ) 

517 except Exception as exc: 

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

519 

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

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

522 

523 Args: 

524 state: The state configuration with loop field set 

525 ctx: Interpolation context for routing 

526 

527 Returns: 

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

529 """ 

530 from little_loops.cli.loop._helpers import resolve_loop_path 

531 from little_loops.fsm.validation import load_and_validate 

532 

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

534 

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

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

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

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

539 if isinstance(self.action_runner, SimulationActionRunner): 

540 try: 

541 display_name = interpolate(state.loop, ctx) 

542 except InterpolationError: 

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

544 sim_result = self.action_runner.run( 

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

546 timeout=0, 

547 is_slash_command=False, 

548 ) 

549 if sim_result.exit_code == 0: 

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

551 elif sim_result.exit_code == 2: 

552 if state.on_error: 

553 return interpolate(state.on_error, ctx) 

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

555 else: 

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

557 

558 loop_name = interpolate(state.loop, ctx) 

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

560 child_fsm, _ = load_and_validate(loop_path) 

561 

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

563 if state.with_: 

564 from little_loops.fsm.interpolation import interpolate_dict 

565 

566 resolved = interpolate_dict(state.with_, ctx) 

567 # Apply declared defaults for unbound optional parameters 

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

569 if ( 

570 param_name not in resolved 

571 and not param_spec.required 

572 and param_spec.default is not None 

573 ): 

574 resolved[param_name] = param_spec.default 

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

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

577 if param_spec.required and param_name not in resolved: 

578 raise ValueError( 

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

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

581 ) 

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

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

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

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

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

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

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

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

590 # winning if a caller ever sets one. 

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

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

593 elif state.context_passthrough: 

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

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

596 captured_as_context = { 

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

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

599 } 

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

601 

602 depth = self._depth + 1 

603 child_events: list[dict] = [] 

604 

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

606 child_events.append(event) 

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

608 if "depth" not in event: 

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

610 else: 

611 self.event_callback(event) 

612 

613 child_executor = FSMExecutor( 

614 child_fsm, 

615 action_runner=self.action_runner, 

616 loops_dir=self.loops_dir, 

617 event_callback=_sub_event_callback, 

618 circuit=self._circuit, 

619 ) 

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

621 

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

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

624 if self.fsm.timeout: 

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

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

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

628 child_fsm.timeout = remaining_s 

629 

630 child_result = child_executor.run() 

631 

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

633 if state.capture and child_events: 

634 import json as _json 

635 

636 self.captured[state.capture] = { 

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

638 "exit_code": None, 

639 } 

640 

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

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

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

644 

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

646 if child_result.terminated_by == "terminal": 

647 if child_result.final_state == "done": 

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

649 else: 

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

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

652 elif child_result.terminated_by == "error": 

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

654 if state.on_error: 

655 return interpolate(state.on_error, ctx) 

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

657 else: 

658 # max_iterations, timeout, signal — all are failure 

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

660 

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

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

663 

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

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

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

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

668 (default 2) is used. 

669 

670 For each target: 

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

672 2. If proven → continue. 

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

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

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

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

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

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

679 routing to ``on_blocked``/``on_no``. 

680 

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

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

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

684 """ 

685 from little_loops.learning_tests import check_learning_test 

686 

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

688 

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

690 if state.learning.targets_csv is not None: 

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

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

693 else: 

694 targets = list(state.learning.targets) 

695 

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

697 if state.learning.max_retries_expr is not None: 

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

699 else: 

700 max_retries = state.learning.max_retries 

701 

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

703 self._emit( 

704 "learning_blocked", 

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

706 ) 

707 route = state.on_blocked or state.on_no 

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

709 

710 for target in targets: 

711 record = check_learning_test(target) 

712 

713 attempts = 0 

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

715 if attempts >= max_retries: 

716 return _blocked_target("retries_exhausted", target) 

717 

718 if record is None: 

719 self._emit( 

720 "learning_target_stale", 

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

722 ) 

723 else: 

724 self._emit( 

725 "learning_target_stale", 

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

727 ) 

728 

729 self._emit( 

730 "learning_explore_invoked", 

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

732 ) 

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

734 attempts += 1 

735 record = check_learning_test(target) 

736 

737 if record.status == "refuted": 

738 self._emit( 

739 "learning_target_refuted", 

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

741 ) 

742 return _blocked_target("refuted", target) 

743 

744 self._emit( 

745 "learning_target_proven", 

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

747 ) 

748 

749 self._emit( 

750 "learning_complete", 

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

752 ) 

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

754 

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

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

757 

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

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

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

761 

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

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

764 cannot reset the stall window on every cycle. 

765 """ 

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

767 return None 

768 rf = self.fsm.circuit.repeated_failure 

769 paths = rf.progress_paths 

770 if not paths: 

771 return None 

772 

773 excluded: set[str] = set() 

774 for raw_excl in rf.exclude_paths: 

775 try: 

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

777 except Exception: 

778 pass 

779 

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

781 for raw_path in paths: 

782 try: 

783 resolved = interpolate(raw_path, ctx) 

784 except Exception: 

785 continue 

786 if resolved in excluded: 

787 continue 

788 p = Path(resolved) 

789 if p.exists(): 

790 st = p.stat() 

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

792 else: 

793 entries.append((0.0, 0)) 

794 return tuple(entries) if entries else None 

795 

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

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

798 

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

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

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

802 

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

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

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

806 """ 

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

808 self._throttle_counts[state_name] = count 

809 

810 throttle = state.throttle 

811 normal_max = ( 

812 throttle.normal_max 

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

814 else _DEFAULT_THROTTLE_NORMAL_MAX 

815 ) 

816 warn_max = ( 

817 throttle.warn_max 

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

819 else _DEFAULT_THROTTLE_WARN_MAX 

820 ) 

821 hard_max = ( 

822 throttle.hard_max 

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

824 else _DEFAULT_THROTTLE_HARD_MAX 

825 ) 

826 

827 if count == warn_max: 

828 self._emit( 

829 THROTTLE_WARN_EVENT, 

830 { 

831 "state": state_name, 

832 "count": count, 

833 "normal_max": normal_max, 

834 "warn_max": warn_max, 

835 "hard_max": hard_max, 

836 }, 

837 ) 

838 

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

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

841 if state.type == "learning": 

842 return None 

843 

844 if count == hard_max: 

845 next_target = state.on_throttle_hard or state.on_error 

846 self._emit( 

847 THROTTLE_HARD_EVENT, 

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

849 ) 

850 return next_target 

851 

852 if count > hard_max: 

853 self._emit( 

854 THROTTLE_STOP_EVENT, 

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

856 ) 

857 self._pending_error = ( 

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

859 "tool calls with no on_throttle_hard target" 

860 ) 

861 return "__STOP__" 

862 

863 return None 

864 

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

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

867 

868 Args: 

869 state: The state configuration to execute 

870 

871 Returns: 

872 Next state name, or None if no valid transition 

873 """ 

874 # Build interpolation context 

875 ctx = self._build_context() 

876 

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

878 if state.loop is not None: 

879 try: 

880 return self._execute_sub_loop(state, ctx) 

881 except (FileNotFoundError, ValueError, InterpolationError): 

882 if state.on_error: 

883 return interpolate(state.on_error, ctx) 

884 raise 

885 

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

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

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

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

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

891 return self._execute_learning_state(state, ctx) 

892 

893 # Handle unconditional transition 

894 if state.next: 

895 if state.action: 

896 self._maybe_wait_for_circuit(state) 

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

898 if routed is not None: 

899 return routed 

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

901 if throttle_next == "__STOP__": 

902 return None 

903 if throttle_next is not None: 

904 return throttle_next 

905 assert result is not None 

906 self.prev_result = { 

907 "output": result.output, 

908 "exit_code": result.exit_code, 

909 "state": self.current_state, 

910 } 

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

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

913 if state.on_error: 

914 return interpolate(state.on_error, ctx) 

915 self.request_shutdown() 

916 return None 

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

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

919 error_target = interpolate(state.on_error, ctx) 

920 if ( 

921 state.retryable_exit_codes is not None 

922 and result.exit_code is not None 

923 and result.exit_code not in state.retryable_exit_codes 

924 ): 

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

926 # on_retry_exhausted (if set) or on_error directly. 

927 if state.on_retry_exhausted: 

928 return state.on_retry_exhausted 

929 return error_target 

930 return error_target 

931 return interpolate(state.next, ctx) 

932 

933 # Execute action if present 

934 action_result = None 

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

936 self._maybe_wait_for_circuit(state) 

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

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

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

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

941 if routed is not None: 

942 return routed 

943 else: 

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

945 if routed is not None: 

946 return routed 

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

948 if throttle_next == "__STOP__": 

949 return None 

950 if throttle_next is not None: 

951 return throttle_next 

952 

953 # Evaluate 

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

955 self.prev_result = { 

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

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

958 "state": self.current_state, 

959 } 

960 

961 # Update context with result for routing interpolation 

962 if eval_result: 

963 ctx.result = { 

964 "verdict": eval_result.verdict, 

965 "details": eval_result.details, 

966 } 

967 

968 # Route based on verdict 

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

970 

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

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

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

974 # override next_state to the configured recovery target. 

975 stall_route_target: str | None = None 

976 if self._stall_detector is not None: 

977 stall_exit_code = action_result.exit_code if action_result else 0 

978 stall_fingerprint = self._compute_progress_fingerprint(ctx) 

979 self._stall_detector.record( 

980 self.current_state, stall_exit_code, verdict, stall_fingerprint 

981 ) 

982 stall = self._stall_detector.check() 

983 if stall is not None: 

984 assert self.fsm.circuit is not None 

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

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

987 self._emit( 

988 STALL_DETECTED_EVENT, 

989 { 

990 "state": self.current_state, 

991 "exit_code": stall_exit_code, 

992 "verdict": verdict, 

993 "consecutive": stall.count, 

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

995 }, 

996 ) 

997 if cfg_action == "abort": 

998 self._pending_stall_abort = stall 

999 return None 

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

1001 # eval verdict does not pull us elsewhere first. 

1002 stall_route_target = cfg_action 

1003 

1004 route_ctx = RouteContext( 

1005 state_name=self.current_state, 

1006 state=state, 

1007 verdict=verdict, 

1008 action_result=action_result, 

1009 eval_result=eval_result, 

1010 ctx=ctx, 

1011 iteration=self.iteration, 

1012 ) 

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

1014 # returns early without dispatching to registered before_route hooks. 

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

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

1017 if action_result is not None: 

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

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

1020 if ( 

1021 action_result.exit_code != 0 

1022 and _failure_type == FailureType.TRANSIENT 

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

1024 ): 

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

1026 if _handled: 

1027 self._rate_limit_in_flight.add(route_ctx.state_name) 

1028 return _target 

1029 elif ( 

1030 action_result.exit_code != 0 

1031 and _failure_type == FailureType.TRANSIENT 

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

1033 ): 

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

1035 if _handled: 

1036 return _target 

1037 # exhausted — fall through to normal verdict routing 

1038 else: 

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

1040 # future transients start fresh. 

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

1042 self._consecutive_rate_limit_exhaustions = 0 

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

1044 

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

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

1047 # configured target wins over the eval verdict. 

1048 if stall_route_target is not None: 

1049 return stall_route_target 

1050 

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

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

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

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

1055 if ( 

1056 verdict == "error" 

1057 and state.retryable_exit_codes is not None 

1058 and action_result is not None 

1059 and action_result.exit_code is not None 

1060 and action_result.exit_code not in state.retryable_exit_codes 

1061 ): 

1062 if state.on_retry_exhausted: 

1063 return state.on_retry_exhausted 

1064 if state.on_error: 

1065 return interpolate(state.on_error, ctx) 

1066 

1067 for interceptor in self._interceptors: 

1068 if hasattr(interceptor, "before_route"): 

1069 decision = interceptor.before_route(route_ctx) 

1070 if isinstance(decision, RouteDecision): 

1071 if decision.next_state is None: 

1072 return None # veto 

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

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

1075 for interceptor in self._interceptors: 

1076 if hasattr(interceptor, "after_route"): 

1077 interceptor.after_route(route_ctx) 

1078 return next_state 

1079 

1080 def _run_action( 

1081 self, 

1082 action_template: str, 

1083 state: StateConfig, 

1084 ctx: InterpolationContext, 

1085 on_usage: UsageCallback | None = None, 

1086 ) -> ActionResult: 

1087 """Execute action and optionally capture result. 

1088 

1089 Args: 

1090 action_template: Action string (may contain variables) 

1091 state: State configuration 

1092 ctx: Interpolation context 

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

1094 

1095 Returns: 

1096 ActionResult with output and exit code 

1097 """ 

1098 action = interpolate(action_template, ctx) 

1099 action_mode = self._action_mode(state) 

1100 

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

1102 

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

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

1105 

1106 if action_mode == "mcp_tool": 

1107 # Direct MCP tool call — bypass action_runner entirely 

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

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

1110 result = self._run_subprocess( 

1111 cmd, 

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

1113 on_output_line=_on_line, 

1114 ) 

1115 elif action_mode == "contributed": 

1116 assert ( 

1117 state.action_type is not None 

1118 ) # guaranteed by _action_mode returning "contributed" 

1119 runner = self._contributed_actions[state.action_type] 

1120 result = runner.run( 

1121 action, 

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

1123 is_slash_command=False, 

1124 on_output_line=_on_line, 

1125 on_usage=on_usage, 

1126 ) 

1127 else: 

1128 result = self.action_runner.run( 

1129 action, 

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

1131 is_slash_command=action_mode == "prompt", 

1132 on_output_line=_on_line, 

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

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

1135 on_usage=on_usage, 

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

1137 ) 

1138 

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

1140 payload: dict[str, Any] = { 

1141 "exit_code": result.exit_code, 

1142 "duration_ms": result.duration_ms, 

1143 "output_preview": preview, 

1144 "is_prompt": action_mode == "prompt", 

1145 } 

1146 if action_mode == "prompt": 

1147 session_jsonl = get_current_session_jsonl() 

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

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

1150 if result.usage_events: 

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

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

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

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

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

1156 payload["input_tokens"] = total_input 

1157 payload["output_tokens"] = total_output 

1158 payload["cache_read_tokens"] = total_cache_read 

1159 payload["cache_creation_tokens"] = total_cache_creation 

1160 payload["model"] = model 

1161 self._emit("action_complete", payload) 

1162 

1163 # Capture if requested 

1164 if state.capture: 

1165 self.captured[state.capture] = { 

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

1167 "stderr": result.stderr, 

1168 "exit_code": result.exit_code, 

1169 "duration_ms": result.duration_ms, 

1170 } 

1171 

1172 # Append to shared messages log if requested 

1173 if state.append_to_messages: 

1174 post_ctx = self._build_context() 

1175 message = interpolate(state.append_to_messages, post_ctx) 

1176 self.messages.append(message) 

1177 self._emit( 

1178 "messages_append", 

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

1180 ) 

1181 

1182 # Check for signals in output 

1183 if self.signal_detector: 

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

1185 if signal: 

1186 if signal.signal_type == "handoff": 

1187 self._pending_handoff = signal 

1188 elif signal.signal_type == "error": 

1189 self._pending_error = signal.payload 

1190 elif signal.signal_type == "stop": 

1191 self.request_shutdown() 

1192 

1193 return result 

1194 

1195 def _run_subprocess( 

1196 self, 

1197 cmd: list[str], 

1198 timeout: int, 

1199 on_output_line: Any | None = None, 

1200 ) -> ActionResult: 

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

1202 

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

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

1205 """ 

1206 start = _now_ms() 

1207 process = subprocess.Popen( 

1208 cmd, 

1209 stdout=subprocess.PIPE, 

1210 stderr=subprocess.PIPE, 

1211 text=True, 

1212 ) 

1213 self._current_process = process 

1214 deadline = time.time() + timeout 

1215 

1216 output_chunks: list[str] = [] 

1217 stderr_chunks: list[str] = [] 

1218 

1219 sel = selectors.DefaultSelector() 

1220 if process.stdout is not None: 

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

1222 if process.stderr is not None: 

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

1224 

1225 timed_out = False 

1226 try: 

1227 while sel.get_map(): 

1228 remaining = deadline - time.time() 

1229 if remaining <= 0: 

1230 timed_out = True 

1231 break 

1232 poll_timeout = min(1.0, remaining) 

1233 ready = sel.select(timeout=poll_timeout) 

1234 if not ready: 

1235 continue 

1236 for key, _mask in ready: 

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

1238 if line: 

1239 if key.data == "stdout": 

1240 output_chunks.append(line) 

1241 if on_output_line: 

1242 on_output_line(line.rstrip()) 

1243 else: 

1244 stderr_chunks.append(line) 

1245 else: 

1246 sel.unregister(key.fileobj) 

1247 finally: 

1248 sel.close() 

1249 self._current_process = None 

1250 

1251 if timed_out: 

1252 _kill_process_group(process) 

1253 try: 

1254 process.wait(timeout=5) 

1255 except subprocess.TimeoutExpired: 

1256 process.kill() 

1257 process.wait() 

1258 return ActionResult( 

1259 output="".join(output_chunks), 

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

1261 exit_code=124, 

1262 duration_ms=timeout * 1000, 

1263 ) 

1264 

1265 process.wait(timeout=5) 

1266 return ActionResult( 

1267 output="".join(output_chunks), 

1268 stderr="".join(stderr_chunks), 

1269 exit_code=process.returncode, 

1270 duration_ms=_now_ms() - start, 

1271 ) 

1272 

1273 def _evaluate( 

1274 self, 

1275 state: StateConfig, 

1276 action_result: ActionResult | None, 

1277 ctx: InterpolationContext, 

1278 ) -> EvaluationResult | None: 

1279 """Evaluate action result. 

1280 

1281 Args: 

1282 state: State configuration 

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

1284 ctx: Interpolation context 

1285 

1286 Returns: 

1287 EvaluationResult, or None if no evaluation needed 

1288 """ 

1289 if state.evaluate is None: 

1290 # Default evaluation based on action type 

1291 if action_result: 

1292 action_mode = self._action_mode(state) 

1293 

1294 if action_mode == "mcp_tool": 

1295 # MCP tool call: use mcp_result evaluator 

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

1297 elif action_mode == "prompt": 

1298 # Slash command or prompt: use LLM evaluation 

1299 if not self.fsm.llm.enabled: 

1300 result = EvaluationResult( 

1301 verdict="error", 

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

1303 ) 

1304 else: 

1305 result = evaluate_llm_structured( 

1306 action_result.output, 

1307 model=self.fsm.llm.model, 

1308 max_tokens=self.fsm.llm.max_tokens, 

1309 timeout=self.fsm.llm.timeout, 

1310 ) 

1311 else: 

1312 # Shell command: use exit code 

1313 result = evaluate_exit_code(action_result.exit_code) 

1314 

1315 self._emit( 

1316 "evaluate", 

1317 { 

1318 "type": "default", 

1319 "verdict": result.verdict, 

1320 **result.details, 

1321 }, 

1322 ) 

1323 return result 

1324 return None 

1325 

1326 # Explicit evaluation config 

1327 raw_output = action_result.output if action_result else "" 

1328 if state.evaluate.source: 

1329 try: 

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

1331 except InterpolationError: 

1332 eval_input = raw_output 

1333 else: 

1334 eval_input = raw_output 

1335 

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

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

1338 state.evaluate, 

1339 eval_input, 

1340 action_result.exit_code if action_result else 0, 

1341 ctx, 

1342 ) 

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

1344 result = EvaluationResult( 

1345 verdict="error", 

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

1347 ) 

1348 else: 

1349 result = evaluate( 

1350 config=state.evaluate, 

1351 output=eval_input, 

1352 exit_code=action_result.exit_code if action_result else 0, 

1353 context=ctx, 

1354 ) 

1355 

1356 self._emit( 

1357 "evaluate", 

1358 { 

1359 "type": state.evaluate.type, 

1360 "verdict": result.verdict, 

1361 **result.details, 

1362 }, 

1363 ) 

1364 

1365 return result 

1366 

1367 def _route( 

1368 self, 

1369 state: StateConfig, 

1370 verdict: str, 

1371 ctx: InterpolationContext, 

1372 ) -> str | None: 

1373 """Determine next state from verdict. 

1374 

1375 Resolution order (from design doc): 

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

1377 2. route (full routing table) 

1378 3. on_success/on_failure/on_error (shorthand) 

1379 4. terminal - handled in main loop 

1380 5. error 

1381 

1382 Args: 

1383 state: State configuration 

1384 verdict: Verdict string from evaluation 

1385 ctx: Interpolation context 

1386 

1387 Returns: 

1388 Next state name, or None if no valid route 

1389 """ 

1390 if state.route: 

1391 routes = state.route.routes 

1392 if verdict in routes: 

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

1394 if state.route.default: 

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

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

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

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

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

1400 return None 

1401 

1402 # Shorthand routing 

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

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

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

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

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

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

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

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

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

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

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

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

1415 

1416 # Dynamic on_<verdict> shorthands from extra_routes 

1417 if verdict in state.extra_routes: 

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

1419 

1420 return None 

1421 

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

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

1424 

1425 Args: 

1426 route: Route target string 

1427 ctx: Interpolation context 

1428 

1429 Returns: 

1430 Resolved state name 

1431 """ 

1432 if route == "$current": 

1433 return self.current_state 

1434 return interpolate(route, ctx) 

1435 

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

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

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

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

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

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

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

1443 """ 

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

1445 self.iteration += 1 

1446 self._just_routed = False 

1447 self._emit( 

1448 "state_enter", 

1449 { 

1450 "state": self.current_state, 

1451 "iteration": self.iteration, 

1452 "flushed": True, 

1453 }, 

1454 ) 

1455 ctx = self._build_context() 

1456 try: 

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

1458 except Exception: 

1459 # Deliberately swallow — the timeout is being honored regardless 

1460 # of whether the flushed action succeeded. 

1461 pass 

1462 

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

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

1465 if state.action_type == "contract": 

1466 return "contract" 

1467 if state.action_type == "mcp_tool": 

1468 return "mcp_tool" 

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

1470 return "prompt" 

1471 if state.action_type == "shell": 

1472 return "shell" 

1473 if state.action_type in self._contributed_actions: 

1474 return "contributed" 

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

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

1477 return "prompt" 

1478 return "shell" 

1479 

1480 def _execute_with_baseline( 

1481 self, 

1482 state: StateConfig, 

1483 ctx: InterpolationContext, 

1484 baseline_cfg: dict[str, Any], 

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

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

1487 

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

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

1490 

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

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

1493 """ 

1494 from concurrent.futures import ThreadPoolExecutor 

1495 

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

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

1498 

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

1500 harness_tokens.append((input_tokens, output_tokens)) 

1501 

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

1503 baseline_tokens.append((input_tokens, output_tokens)) 

1504 

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

1506 baseline_skill_name = baseline_cfg.get("skill") 

1507 if baseline_skill_name is None: 

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

1509 baseline_skill_name = _extract_skill_from_action(action_text) 

1510 

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

1512 with ThreadPoolExecutor(max_workers=2) as pool: 

1513 harness_future = pool.submit( 

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

1515 ) 

1516 baseline_future = pool.submit( 

1517 self._run_baseline_arm, baseline_skill_name, state, _on_baseline_usage 

1518 ) 

1519 harness_result: ActionResult = harness_future.result() 

1520 baseline_result: ActionResult = baseline_future.result() 

1521 

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

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

1524 

1525 self._emit( 

1526 "baseline_complete", 

1527 { 

1528 "harness_duration_ms": harness_result.duration_ms, 

1529 "baseline_duration_ms": baseline_result.duration_ms, 

1530 "harness_tokens": harness_total_tokens, 

1531 "baseline_tokens": baseline_total_tokens, 

1532 }, 

1533 ) 

1534 

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

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

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

1538 try: 

1539 comparison = evaluate_blind_comparator( 

1540 output_harness=harness_result.output, 

1541 output_baseline=baseline_result.output, 

1542 ) 

1543 item: dict[str, Any] = { 

1544 "index": self._ab_item_index, 

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

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

1547 "harness_tokens": harness_total_tokens, 

1548 "baseline_tokens": baseline_total_tokens, 

1549 "harness_duration_ms": harness_result.duration_ms, 

1550 "baseline_duration_ms": baseline_result.duration_ms, 

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

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

1553 } 

1554 self._ab_results.append(item) 

1555 self._ab_item_index += 1 

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

1557 except Exception: 

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

1559 item = { 

1560 "index": self._ab_item_index, 

1561 "harness_pass": False, 

1562 "baseline_pass": False, 

1563 "harness_tokens": harness_total_tokens, 

1564 "baseline_tokens": baseline_total_tokens, 

1565 "harness_duration_ms": harness_result.duration_ms, 

1566 "baseline_duration_ms": baseline_result.duration_ms, 

1567 "confidence": 0.0, 

1568 "reason": "Blind evaluation failed", 

1569 "error": "evaluation_error", 

1570 } 

1571 self._ab_results.append(item) 

1572 self._ab_item_index += 1 

1573 

1574 return harness_result, None 

1575 

1576 def _run_baseline_arm( 

1577 self, 

1578 skill_command: str, 

1579 state: StateConfig, 

1580 on_usage: UsageCallback | None = None, 

1581 ) -> ActionResult: 

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

1583 

1584 Args: 

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

1586 state: State configuration (for timeout) 

1587 on_usage: Optional callback for token capture 

1588 

1589 Returns: 

1590 ActionResult with output, exit code, and duration 

1591 """ 

1592 start = _now_ms() 

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

1594 try: 

1595 completed = run_claude_command( 

1596 command=skill_command, 

1597 timeout=timeout, 

1598 on_usage=on_usage, 

1599 ) 

1600 return ActionResult( 

1601 output=completed.stdout, 

1602 stderr=completed.stderr, 

1603 exit_code=completed.returncode, 

1604 duration_ms=_now_ms() - start, 

1605 ) 

1606 except subprocess.TimeoutExpired: 

1607 return ActionResult( 

1608 output="", 

1609 stderr="Baseline action timed out", 

1610 exit_code=124, 

1611 duration_ms=timeout * 1000, 

1612 ) 

1613 

1614 def _run_action_or_route( 

1615 self, state: StateConfig, ctx: InterpolationContext 

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

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

1618 

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

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

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

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

1623 """ 

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

1625 try: 

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

1627 except Exception as exc: 

1628 if state.on_error: 

1629 self._emit( 

1630 "action_error", 

1631 { 

1632 "state": self.current_state, 

1633 "error": str(exc), 

1634 "route": "on_error", 

1635 }, 

1636 ) 

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

1638 raise 

1639 

1640 def _build_context(self) -> InterpolationContext: 

1641 """Build interpolation context for current state. 

1642 

1643 Returns: 

1644 InterpolationContext with all runtime values 

1645 """ 

1646 from little_loops.fsm.interpolation import interpolate_dict 

1647 

1648 ctx = InterpolationContext( 

1649 context=self.fsm.context, 

1650 captured=self.captured, 

1651 prev=self.prev_result, 

1652 result=None, 

1653 state_name=self.current_state, 

1654 iteration=self.iteration, 

1655 loop_name=self.fsm.name, 

1656 started_at=self.started_at, 

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

1658 messages=list(self.messages), 

1659 ) 

1660 

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

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

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

1664 resolved = interpolate_dict(state.fragment_bindings, ctx) 

1665 # Apply declared defaults for unbound optional parameters 

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

1667 if ( 

1668 param_name not in resolved 

1669 and not param_spec.required 

1670 and param_spec.default is not None 

1671 ): 

1672 resolved[param_name] = param_spec.default 

1673 # Runtime enforcement: required parameters must be bound 

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

1675 if param_spec.required and param_name not in resolved: 

1676 raise ValueError( 

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

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

1679 ) 

1680 ctx.param = resolved 

1681 

1682 return ctx 

1683 

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

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

1686 self.event_callback( 

1687 { 

1688 "event": event, 

1689 "ts": _iso_now(), 

1690 **data, 

1691 } 

1692 ) 

1693 

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

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

1696 

1697 Implements the two-tier retry ladder: 

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

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

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

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

1702 

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

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

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

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

1707 

1708 Returns: 

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

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

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

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

1713 extensions. 

1714 """ 

1715 _short_max = ( 

1716 state.max_rate_limit_retries 

1717 if state.max_rate_limit_retries is not None 

1718 else _DEFAULT_RATE_LIMIT_RETRIES 

1719 ) 

1720 _backoff_base = ( 

1721 state.rate_limit_backoff_base_seconds 

1722 if state.rate_limit_backoff_base_seconds is not None 

1723 else _DEFAULT_RATE_LIMIT_BACKOFF_BASE 

1724 ) 

1725 _max_wait = ( 

1726 state.rate_limit_max_wait_seconds 

1727 if state.rate_limit_max_wait_seconds is not None 

1728 else _DEFAULT_RATE_LIMIT_MAX_WAIT_SECONDS 

1729 ) 

1730 _ladder = ( 

1731 state.rate_limit_long_wait_ladder 

1732 if state.rate_limit_long_wait_ladder is not None 

1733 else _DEFAULT_RATE_LIMIT_LONG_WAIT_LADDER 

1734 ) 

1735 

1736 record = self._rate_limit_retries.get(state_name) 

1737 if record is None: 

1738 record = { 

1739 "short_retries": 0, 

1740 "long_retries": 0, 

1741 "total_wait_seconds": 0.0, 

1742 "first_seen_at": time.time(), 

1743 } 

1744 self._rate_limit_retries[state_name] = record 

1745 

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

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

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

1749 

1750 if short_retries < _short_max: 

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

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

1753 short_retries += 1 

1754 record["short_retries"] = short_retries 

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

1756 if self._circuit is not None: 

1757 self._circuit.record_rate_limit(_sleep) 

1758 total_wait += self._interruptible_sleep(_sleep) 

1759 record["total_wait_seconds"] = total_wait 

1760 return True, state_name # retry in place 

1761 

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

1763 long_retries += 1 

1764 record["long_retries"] = long_retries 

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

1766 _wait = float(_ladder[_idx]) 

1767 if self._circuit is not None: 

1768 self._circuit.record_rate_limit(_wait) 

1769 _tier_start = time.time() 

1770 _deadline = _tier_start + _wait 

1771 _total_wait_before_tier = total_wait 

1772 total_wait += self._interruptible_sleep( 

1773 _wait, 

1774 on_heartbeat=lambda elapsed: self._emit( 

1775 RATE_LIMIT_WAITING_EVENT, 

1776 { 

1777 "state": state_name, 

1778 "elapsed_seconds": elapsed, 

1779 "next_attempt_at": _deadline, 

1780 "total_waited_seconds": _total_wait_before_tier + elapsed, 

1781 "budget_seconds": _max_wait, 

1782 "tier": "long_wait", 

1783 }, 

1784 ), 

1785 ) 

1786 record["total_wait_seconds"] = total_wait 

1787 if total_wait >= _max_wait: 

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

1789 return True, state_name # retry in place 

1790 

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

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

1793 

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

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

1796 """ 

1797 if self._circuit is None: 

1798 return 

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

1800 return 

1801 recovery = self._circuit.get_estimated_recovery() 

1802 if recovery is None: 

1803 return 

1804 wait = recovery - time.time() 

1805 if wait > 0: 

1806 self._interruptible_sleep(wait) 

1807 

1808 def _interruptible_sleep( 

1809 self, 

1810 duration: float, 

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

1812 ) -> float: 

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

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

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

1816 

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

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

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

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

1821 """ 

1822 if duration <= 0: 

1823 return 0.0 

1824 _start = time.time() 

1825 _deadline = _start + duration 

1826 last_heartbeat = _start 

1827 while time.time() < _deadline: 

1828 if self._shutdown_requested: 

1829 break 

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

1831 if on_heartbeat is not None: 

1832 _now = time.time() 

1833 if _now - last_heartbeat >= _RATE_LIMIT_HEARTBEAT_INTERVAL: 

1834 on_heartbeat(_now - _start) 

1835 last_heartbeat = _now 

1836 return time.time() - _start 

1837 

1838 def _exhaust_rate_limit( 

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

1840 ) -> str | None: 

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

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

1843 once the wall-clock budget is spent. 

1844 """ 

1845 self._rate_limit_retries.pop(state_name, None) 

1846 target = state.on_rate_limit_exhausted or state.on_error 

1847 self._emit( 

1848 RATE_LIMIT_EXHAUSTED_EVENT, 

1849 { 

1850 "state": state_name, 

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

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

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

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

1855 "next": target, 

1856 }, 

1857 ) 

1858 self._consecutive_rate_limit_exhaustions += 1 

1859 if self._consecutive_rate_limit_exhaustions >= _RATE_LIMIT_STORM_THRESHOLD: 

1860 self._emit( 

1861 RATE_LIMIT_STORM_EVENT, 

1862 { 

1863 "state": state_name, 

1864 "count": self._consecutive_rate_limit_exhaustions, 

1865 }, 

1866 ) 

1867 return target 

1868 

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

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

1871 

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

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

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

1875 

1876 Returns: 

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

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

1879 through to normal verdict routing). 

1880 """ 

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

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

1883 self._api_error_retries.pop(state_name, None) 

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

1885 return False, None 

1886 record["retries"] += 1 

1887 slept = self._interruptible_sleep(_DEFAULT_API_ERROR_BACKOFF) 

1888 record["total_wait"] += slept 

1889 self._emit( 

1890 "api_error_retry", 

1891 { 

1892 "state": state_name, 

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

1894 "backoff": _DEFAULT_API_ERROR_BACKOFF, 

1895 }, 

1896 ) 

1897 return True, state_name 

1898 

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

1900 """Finalize execution and return result.""" 

1901 self._emit( 

1902 "loop_complete", 

1903 { 

1904 "final_state": self.current_state, 

1905 "iterations": self.iteration, 

1906 "terminated_by": terminated_by, 

1907 }, 

1908 ) 

1909 

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

1911 if self._ab_results: 

1912 try: 

1913 from little_loops.ab_writer import calculate_ab_summary, write_ab_json 

1914 

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

1916 if run_dir: 

1917 summary = calculate_ab_summary(self._ab_results) 

1918 write_ab_json(summary, run_dir) 

1919 self._emit( 

1920 "ab_summary", 

1921 { 

1922 "harness_pass_rate": summary.harness_pass_rate, 

1923 "baseline_pass_rate": summary.baseline_pass_rate, 

1924 "delta": summary.delta, 

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

1926 }, 

1927 ) 

1928 except Exception: 

1929 pass # Non-fatal: loop still completes 

1930 

1931 return ExecutionResult( 

1932 final_state=self.current_state, 

1933 iterations=self.iteration, 

1934 terminated_by=terminated_by, 

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

1936 captured=self.captured, 

1937 error=error, 

1938 messages=list(self.messages), 

1939 ) 

1940 

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

1942 """Handle a detected handoff signal. 

1943 

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

1945 

1946 Args: 

1947 signal: The detected handoff signal 

1948 

1949 Returns: 

1950 ExecutionResult with handoff information 

1951 """ 

1952 self._emit( 

1953 "handoff_detected", 

1954 { 

1955 "state": self.current_state, 

1956 "iteration": self.iteration, 

1957 "continuation": signal.payload, 

1958 }, 

1959 ) 

1960 

1961 # Invoke handler if configured 

1962 if self.handoff_handler: 

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

1964 if result.spawned_process is not None: 

1965 self._emit( 

1966 "handoff_spawned", 

1967 { 

1968 "pid": result.spawned_process.pid, 

1969 "state": self.current_state, 

1970 }, 

1971 ) 

1972 

1973 return ExecutionResult( 

1974 final_state=self.current_state, 

1975 iterations=self.iteration, 

1976 terminated_by="handoff", 

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

1978 captured=self.captured, 

1979 handoff=True, 

1980 continuation_prompt=signal.payload, 

1981 ) 

1982 

1983 

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

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

1986 

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

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

1989 """ 

1990 stripped = action.strip() 

1991 if stripped.startswith("/"): 

1992 parts = stripped.split(maxsplit=1) 

1993 return parts[0] 

1994 return stripped