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

753 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-04 12:21 -0500

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

2 

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

4- Executes actions (shell commands or slash commands) 

5- Evaluates results using appropriate evaluators 

6- Routes to next states based on verdicts 

7- Tracks iteration count and enforces limits 

8- Manages captured variables and context 

9""" 

10 

11from __future__ import annotations 

12 

13import json 

14import random 

15import subprocess 

16import threading 

17import time 

18from collections.abc import Callable 

19from dataclasses import dataclass 

20from datetime import UTC, datetime 

21from pathlib import Path 

22from typing import Any 

23 

24from little_loops.fsm.evaluators import ( 

25 EvaluationResult, 

26 evaluate, 

27 evaluate_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 run_claude_command, 

55) 

56 

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

58_DEFAULT_RATE_LIMIT_RETRIES: int = 3 

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

60_DEFAULT_RATE_LIMIT_BACKOFF_BASE: int = 30 

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

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

63_DEFAULT_RATE_LIMIT_MAX_WAIT_SECONDS: int = 21600 

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

65# Mirrors RateLimitsConfig.long_wait_ladder default. 

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

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

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

69STALL_DETECTED_EVENT: str = "stall_detected" 

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

71RATE_LIMIT_EXHAUSTED_EVENT: str = "rate_limit_exhausted" 

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

73RATE_LIMIT_STORM_EVENT: str = "rate_limit_storm" 

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

75RATE_LIMIT_WAITING_EVENT: str = "rate_limit_waiting" 

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

77_RATE_LIMIT_HEARTBEAT_INTERVAL: float = 60.0 

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

79_RATE_LIMIT_STORM_THRESHOLD: int = 3 

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

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

82_DEFAULT_THROTTLE_NORMAL_MAX: int = 3 

83_DEFAULT_THROTTLE_WARN_MAX: int = 8 

84_DEFAULT_THROTTLE_HARD_MAX: int = 12 

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

86THROTTLE_WARN_EVENT: str = "throttle_warn" 

87THROTTLE_HARD_EVENT: str = "throttle_hard" 

88THROTTLE_STOP_EVENT: str = "throttle_stop" 

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

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

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

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

93_DEFAULT_API_ERROR_RETRIES: int = 2 

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

95_DEFAULT_API_ERROR_BACKOFF: int = 30 

96 

97 

98def _iso_now() -> str: 

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

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

101 

102 

103@dataclass 

104class RouteContext: 

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

106 

107 state_name: str 

108 state: StateConfig 

109 verdict: str 

110 action_result: ActionResult | None 

111 eval_result: EvaluationResult | None 

112 ctx: InterpolationContext 

113 iteration: int 

114 

115 

116@dataclass 

117class RouteDecision: 

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

119 

120 Return semantics for before_route: 

121 None (implicit) → passthrough, routing proceeds normally 

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

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

124 """ 

125 

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

127 

128 

129class FSMExecutor: 

130 """Execute an FSM loop. 

131 

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

133 - A terminal state is reached 

134 - max_iterations is exceeded 

135 - A timeout occurs 

136 - A shutdown signal is received 

137 - An unrecoverable error occurs 

138 

139 Events are emitted via the callback for observability. 

140 """ 

141 

142 def __init__( 

143 self, 

144 fsm: FSMLoop, 

145 event_callback: EventCallback | None = None, 

146 action_runner: ActionRunner | None = None, 

147 signal_detector: SignalDetector | None = None, 

148 handoff_handler: HandoffHandler | None = None, 

149 loops_dir: Path | None = None, 

150 circuit: RateLimitCircuit | None = None, 

151 ): 

152 """Initialize the executor. 

153 

154 Args: 

155 fsm: The FSM loop to execute 

156 event_callback: Optional callback for events 

157 action_runner: Optional custom action runner (for testing) 

158 signal_detector: Optional signal detector for output parsing 

159 handoff_handler: Optional handler for handoff signals 

160 loops_dir: Base directory for resolving sub-loop references 

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

162 """ 

163 self.fsm = fsm 

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

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

166 self.signal_detector = signal_detector 

167 self.handoff_handler = handoff_handler 

168 self.loops_dir = loops_dir 

169 self._circuit = circuit 

170 

171 # Runtime state 

172 self.current_state = fsm.initial 

173 self.iteration = 0 

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

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

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

177 self.started_at = "" 

178 self.start_time_ms = 0 

179 self.elapsed_offset_ms = ( 

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

181 ) 

182 

183 # Shutdown flag for graceful signal handling 

184 self._shutdown_requested = False 

185 

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

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

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

189 

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

191 self._pending_handoff: DetectedSignal | None = None 

192 

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

194 self._pending_error: str | None = None 

195 

196 # Per-state retry tracking for max_retries support. 

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

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

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

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

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

202 self._prev_state: str | None = None 

203 

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

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

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

207 self._just_routed: bool = False 

208 

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

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

211 # summary state completes. 

212 self._summary_state_executed: bool = False 

213 

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

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

216 # by _finish(). 

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

218 self._ab_item_index: int = 0 

219 

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

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

222 # { 

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

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

225 # "total_wait_seconds": float, 

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

227 # } 

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

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

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

231 

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

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

234 # _RATE_LIMIT_STORM_THRESHOLD, a RATE_LIMIT_STORM event is emitted. 

235 self._consecutive_rate_limit_exhaustions: int = 0 

236 

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

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

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

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

241 

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

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

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

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

246 

247 # Per-edge revisit counter for cycle detection. 

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

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

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

251 

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

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

254 self._stall_detector: StallDetector | None = None 

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

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

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

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

259 self._pending_stall_abort: Stall | None = None 

260 

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

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

263 self._depth: int = 0 

264 

265 # Extension hook registries — populated by wire_extensions() 

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

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

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

269 

270 def request_shutdown(self) -> None: 

271 """Request graceful shutdown of the executor. 

272 

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

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

275 """ 

276 self._shutdown_requested = True 

277 

278 def run(self) -> ExecutionResult: 

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

280 

281 Returns: 

282 ExecutionResult with final state and execution metadata 

283 """ 

284 self.started_at = _iso_now() 

285 self.start_time_ms = _now_ms() 

286 

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

288 

289 try: 

290 while True: 

291 # Check shutdown request (signal handling) 

292 if self._shutdown_requested: 

293 return self._finish("signal") 

294 

295 # Check iteration limit 

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

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

298 self._emit( 

299 "max_iterations_summary", 

300 { 

301 "summary_state": self.fsm.on_max_iterations, 

302 "iterations": self.iteration, 

303 }, 

304 ) 

305 self._summary_state_executed = True 

306 self.current_state = self.fsm.on_max_iterations 

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

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

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

310 else: 

311 return self._finish("max_iterations") 

312 

313 # Check timeout 

314 if self.fsm.timeout: 

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

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

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

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

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

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

321 # silently lost. Bounded to shell actions — slash 

322 # commands and sub-loops would violate the timeout 

323 # budget. Single-step: no cascade. 

324 if self._just_routed: 

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

326 if ( 

327 pending is not None 

328 and not pending.terminal 

329 and pending.loop is None 

330 and pending.action is not None 

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

332 ): 

333 self._flush_pending_shell_state(pending) 

334 return self._finish("timeout") 

335 

336 # Get current state config 

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

338 

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

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

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

342 if self._prev_state is not None: 

343 if self.current_state == self._prev_state: 

344 self._retry_counts[self.current_state] = ( 

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

346 ) 

347 else: 

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

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

350 

351 # Check terminal 

352 if state_config.terminal: 

353 # Handle maintain mode - restart loop instead of terminating 

354 if self.fsm.maintain: 

355 self.iteration += 1 

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

357 self._emit( 

358 "route", 

359 { 

360 "from": self.current_state, 

361 "to": maintain_target, 

362 "reason": "maintain", 

363 }, 

364 ) 

365 self._prev_state = self.current_state 

366 self.current_state = maintain_target 

367 self._just_routed = True 

368 continue 

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

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

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

372 if self._summary_state_executed: 

373 return self._finish("max_iterations") 

374 return self._finish("terminal") 

375 

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

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

378 if state_config.max_retries is not None: 

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

380 if retry_count > state_config.max_retries: 

381 # on_retry_exhausted is guaranteed non-None by validation when 

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

383 exhausted_state: str = state_config.on_retry_exhausted or "" 

384 if not exhausted_state: 

385 return self._finish( 

386 "error", 

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

388 "but on_retry_exhausted is not set", 

389 ) 

390 self._emit( 

391 "retry_exhausted", 

392 { 

393 "state": self.current_state, 

394 "retries": retry_count, 

395 "next": exhausted_state, 

396 }, 

397 ) 

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

399 self._prev_state = self.current_state 

400 self.current_state = exhausted_state 

401 continue 

402 

403 self.iteration += 1 

404 self._just_routed = False 

405 self._emit( 

406 "state_enter", 

407 { 

408 "state": self.current_state, 

409 "iteration": self.iteration, 

410 }, 

411 ) 

412 

413 # Execute state 

414 next_state = self._execute_state(state_config) 

415 

416 # Check for pending error signal (FATAL_ERROR) 

417 if self._pending_error is not None: 

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

419 

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

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

422 # terminate cleanly via _finish (mirrors the cycle_detected 

423 # guard below at lines 397-416). 

424 if self._pending_stall_abort is not None: 

425 stall = self._pending_stall_abort 

426 s_state, s_exit, s_verdict = stall.triple 

427 return self._finish( 

428 "stall_detected", 

429 error=( 

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

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

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

433 ), 

434 ) 

435 

436 # Check for pending handoff signal 

437 if self._pending_handoff: 

438 return self._handle_handoff(self._pending_handoff) 

439 

440 # Handle maintain mode 

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

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

443 

444 # SIGKILL in _execute_state sets shutdown flag and returns None 

445 if next_state is None and self._shutdown_requested: 

446 return self._finish("signal") 

447 

448 if next_state is None: 

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

450 

451 # At this point next_state is guaranteed to be str 

452 resolved_next: str = next_state 

453 

454 self._emit( 

455 "route", 

456 { 

457 "from": self.current_state, 

458 "to": resolved_next, 

459 }, 

460 ) 

461 

462 # Per-edge revisit tracking for cycle detection. 

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

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

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

466 self._emit( 

467 "cycle_detected", 

468 { 

469 "edge": edge_key, 

470 "from": self.current_state, 

471 "to": resolved_next, 

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

473 "max": self.fsm.max_edge_revisits, 

474 }, 

475 ) 

476 return self._finish( 

477 "cycle_detected", 

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

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

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

481 ) 

482 

483 self._prev_state = self.current_state 

484 self.current_state = resolved_next 

485 self._just_routed = True 

486 

487 # Interruptible backoff sleep between iterations 

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

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

490 while time.time() < deadline: 

491 if self._shutdown_requested: 

492 break 

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

494 

495 except InterpolationError as exc: 

496 return self._finish( 

497 "error", 

498 error=( 

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

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

501 ), 

502 ) 

503 except Exception as exc: 

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

505 

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

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

508 

509 Args: 

510 state: The state configuration with loop field set 

511 ctx: Interpolation context for routing 

512 

513 Returns: 

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

515 """ 

516 from little_loops.cli.loop._helpers import resolve_loop_path 

517 from little_loops.fsm.validation import load_and_validate 

518 

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

520 loop_name = interpolate(state.loop, ctx) 

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

522 child_fsm, _ = load_and_validate(loop_path) 

523 

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

525 if state.with_: 

526 from little_loops.fsm.interpolation import interpolate_dict 

527 

528 resolved = interpolate_dict(state.with_, ctx) 

529 # Apply declared defaults for unbound optional parameters 

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

531 if ( 

532 param_name not in resolved 

533 and not param_spec.required 

534 and param_spec.default is not None 

535 ): 

536 resolved[param_name] = param_spec.default 

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

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

539 if param_spec.required and param_name not in resolved: 

540 raise ValueError( 

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

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

543 ) 

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

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

546 elif state.context_passthrough: 

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

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

549 captured_as_context = { 

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

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

552 } 

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

554 

555 depth = self._depth + 1 

556 child_events: list[dict] = [] 

557 

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

559 child_events.append(event) 

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

561 if "depth" not in event: 

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

563 else: 

564 self.event_callback(event) 

565 

566 child_executor = FSMExecutor( 

567 child_fsm, 

568 action_runner=self.action_runner, 

569 loops_dir=self.loops_dir, 

570 event_callback=_sub_event_callback, 

571 circuit=self._circuit, 

572 ) 

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

574 

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

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

577 if self.fsm.timeout: 

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

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

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

581 child_fsm.timeout = remaining_s 

582 

583 child_result = child_executor.run() 

584 

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

586 if state.capture and child_events: 

587 import json as _json 

588 

589 self.captured[state.capture] = { 

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

591 "exit_code": None, 

592 } 

593 

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

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

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

597 

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

599 if child_result.terminated_by == "terminal": 

600 if child_result.final_state == "done": 

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

602 else: 

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

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

605 elif child_result.terminated_by == "error": 

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

607 if state.on_error: 

608 return interpolate(state.on_error, ctx) 

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

610 else: 

611 # max_iterations, timeout, signal — all are failure 

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

613 

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

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

616 

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

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

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

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

621 (default 2) is used. 

622 

623 For each target: 

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

625 2. If proven → continue. 

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

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

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

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

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

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

632 routing to ``on_blocked``/``on_no``. 

633 

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

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

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

637 """ 

638 from little_loops.learning_tests import check_learning_test 

639 

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

641 

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

643 if state.learning.targets_csv is not None: 

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

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

646 else: 

647 targets = list(state.learning.targets) 

648 

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

650 if state.learning.max_retries_expr is not None: 

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

652 else: 

653 max_retries = state.learning.max_retries 

654 

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

656 self._emit( 

657 "learning_blocked", 

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

659 ) 

660 route = state.on_blocked or state.on_no 

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

662 

663 for target in targets: 

664 record = check_learning_test(target) 

665 

666 attempts = 0 

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

668 if attempts >= max_retries: 

669 return _blocked_target("retries_exhausted", target) 

670 

671 if record is None: 

672 self._emit( 

673 "learning_target_stale", 

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

675 ) 

676 else: 

677 self._emit( 

678 "learning_target_stale", 

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

680 ) 

681 

682 self._emit( 

683 "learning_explore_invoked", 

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

685 ) 

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

687 attempts += 1 

688 record = check_learning_test(target) 

689 

690 if record.status == "refuted": 

691 self._emit( 

692 "learning_target_refuted", 

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

694 ) 

695 return _blocked_target("refuted", target) 

696 

697 self._emit( 

698 "learning_target_proven", 

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

700 ) 

701 

702 self._emit( 

703 "learning_complete", 

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

705 ) 

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

707 

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

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

710 

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

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

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

714 

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

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

717 cannot reset the stall window on every cycle. 

718 """ 

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

720 return None 

721 rf = self.fsm.circuit.repeated_failure 

722 paths = rf.progress_paths 

723 if not paths: 

724 return None 

725 

726 excluded: set[str] = set() 

727 for raw_excl in rf.exclude_paths: 

728 try: 

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

730 except Exception: 

731 pass 

732 

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

734 for raw_path in paths: 

735 try: 

736 resolved = interpolate(raw_path, ctx) 

737 except Exception: 

738 continue 

739 if resolved in excluded: 

740 continue 

741 p = Path(resolved) 

742 if p.exists(): 

743 st = p.stat() 

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

745 else: 

746 entries.append((0.0, 0)) 

747 return tuple(entries) if entries else None 

748 

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

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

751 

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

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

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

755 

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

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

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

759 """ 

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

761 self._throttle_counts[state_name] = count 

762 

763 throttle = state.throttle 

764 normal_max = ( 

765 throttle.normal_max 

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

767 else _DEFAULT_THROTTLE_NORMAL_MAX 

768 ) 

769 warn_max = ( 

770 throttle.warn_max 

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

772 else _DEFAULT_THROTTLE_WARN_MAX 

773 ) 

774 hard_max = ( 

775 throttle.hard_max 

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

777 else _DEFAULT_THROTTLE_HARD_MAX 

778 ) 

779 

780 if count == warn_max: 

781 self._emit( 

782 THROTTLE_WARN_EVENT, 

783 { 

784 "state": state_name, 

785 "count": count, 

786 "normal_max": normal_max, 

787 "warn_max": warn_max, 

788 "hard_max": hard_max, 

789 }, 

790 ) 

791 

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

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

794 if state.type == "learning": 

795 return None 

796 

797 if count == hard_max: 

798 next_target = state.on_throttle_hard or state.on_error 

799 self._emit( 

800 THROTTLE_HARD_EVENT, 

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

802 ) 

803 return next_target 

804 

805 if count > hard_max: 

806 self._emit( 

807 THROTTLE_STOP_EVENT, 

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

809 ) 

810 self._pending_error = ( 

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

812 "tool calls with no on_throttle_hard target" 

813 ) 

814 return "__STOP__" 

815 

816 return None 

817 

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

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

820 

821 Args: 

822 state: The state configuration to execute 

823 

824 Returns: 

825 Next state name, or None if no valid transition 

826 """ 

827 # Build interpolation context 

828 ctx = self._build_context() 

829 

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

831 if state.loop is not None: 

832 try: 

833 return self._execute_sub_loop(state, ctx) 

834 except (FileNotFoundError, ValueError): 

835 if state.on_error: 

836 return interpolate(state.on_error, ctx) 

837 raise 

838 

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

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

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

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

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

844 return self._execute_learning_state(state, ctx) 

845 

846 # Handle unconditional transition 

847 if state.next: 

848 if state.action: 

849 self._maybe_wait_for_circuit(state) 

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

851 if routed is not None: 

852 return routed 

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

854 if throttle_next == "__STOP__": 

855 return None 

856 if throttle_next is not None: 

857 return throttle_next 

858 assert result is not None 

859 self.prev_result = { 

860 "output": result.output, 

861 "exit_code": result.exit_code, 

862 "state": self.current_state, 

863 } 

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

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

866 if state.on_error: 

867 return interpolate(state.on_error, ctx) 

868 self.request_shutdown() 

869 return None 

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

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

872 error_target = interpolate(state.on_error, ctx) 

873 if ( 

874 state.retryable_exit_codes is not None 

875 and result.exit_code is not None 

876 and result.exit_code not in state.retryable_exit_codes 

877 ): 

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

879 # on_retry_exhausted (if set) or on_error directly. 

880 if state.on_retry_exhausted: 

881 return state.on_retry_exhausted 

882 return error_target 

883 return error_target 

884 return interpolate(state.next, ctx) 

885 

886 # Execute action if present 

887 action_result = None 

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

889 self._maybe_wait_for_circuit(state) 

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

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

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

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

894 if routed is not None: 

895 return routed 

896 else: 

897 action_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 

906 # Evaluate 

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

908 self.prev_result = { 

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

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

911 "state": self.current_state, 

912 } 

913 

914 # Update context with result for routing interpolation 

915 if eval_result: 

916 ctx.result = { 

917 "verdict": eval_result.verdict, 

918 "details": eval_result.details, 

919 } 

920 

921 # Route based on verdict 

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

923 

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

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

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

927 # override next_state to the configured recovery target. 

928 stall_route_target: str | None = None 

929 if self._stall_detector is not None: 

930 stall_exit_code = action_result.exit_code if action_result else 0 

931 stall_fingerprint = self._compute_progress_fingerprint(ctx) 

932 self._stall_detector.record( 

933 self.current_state, stall_exit_code, verdict, stall_fingerprint 

934 ) 

935 stall = self._stall_detector.check() 

936 if stall is not None: 

937 assert self.fsm.circuit is not None 

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

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

940 self._emit( 

941 STALL_DETECTED_EVENT, 

942 { 

943 "state": self.current_state, 

944 "exit_code": stall_exit_code, 

945 "verdict": verdict, 

946 "consecutive": stall.count, 

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

948 }, 

949 ) 

950 if cfg_action == "abort": 

951 self._pending_stall_abort = stall 

952 return None 

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

954 # eval verdict does not pull us elsewhere first. 

955 stall_route_target = cfg_action 

956 

957 route_ctx = RouteContext( 

958 state_name=self.current_state, 

959 state=state, 

960 verdict=verdict, 

961 action_result=action_result, 

962 eval_result=eval_result, 

963 ctx=ctx, 

964 iteration=self.iteration, 

965 ) 

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

967 # returns early without dispatching to registered before_route hooks. 

968 if action_result is not None: 

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

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

971 if _failure_type == FailureType.TRANSIENT and ( 

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

973 ): 

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

975 if _handled: 

976 return _target 

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

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

979 if _handled: 

980 return _target 

981 # exhausted — fall through to normal verdict routing 

982 else: 

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

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

985 self._consecutive_rate_limit_exhaustions = 0 

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

987 

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

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

990 # configured target wins over the eval verdict. 

991 if stall_route_target is not None: 

992 return stall_route_target 

993 

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

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

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

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

998 if ( 

999 verdict == "error" 

1000 and state.retryable_exit_codes is not None 

1001 and action_result is not None 

1002 and action_result.exit_code is not None 

1003 and action_result.exit_code not in state.retryable_exit_codes 

1004 ): 

1005 if state.on_retry_exhausted: 

1006 return state.on_retry_exhausted 

1007 if state.on_error: 

1008 return interpolate(state.on_error, ctx) 

1009 

1010 for interceptor in self._interceptors: 

1011 if hasattr(interceptor, "before_route"): 

1012 decision = interceptor.before_route(route_ctx) 

1013 if isinstance(decision, RouteDecision): 

1014 if decision.next_state is None: 

1015 return None # veto 

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

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

1018 for interceptor in self._interceptors: 

1019 if hasattr(interceptor, "after_route"): 

1020 interceptor.after_route(route_ctx) 

1021 return next_state 

1022 

1023 def _run_action( 

1024 self, 

1025 action_template: str, 

1026 state: StateConfig, 

1027 ctx: InterpolationContext, 

1028 on_usage: UsageCallback | None = None, 

1029 ) -> ActionResult: 

1030 """Execute action and optionally capture result. 

1031 

1032 Args: 

1033 action_template: Action string (may contain variables) 

1034 state: State configuration 

1035 ctx: Interpolation context 

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

1037 

1038 Returns: 

1039 ActionResult with output and exit code 

1040 """ 

1041 action = interpolate(action_template, ctx) 

1042 action_mode = self._action_mode(state) 

1043 

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

1045 

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

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

1048 

1049 if action_mode == "mcp_tool": 

1050 # Direct MCP tool call — bypass action_runner entirely 

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

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

1053 result = self._run_subprocess( 

1054 cmd, 

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

1056 on_output_line=_on_line, 

1057 ) 

1058 elif action_mode == "contributed": 

1059 assert ( 

1060 state.action_type is not None 

1061 ) # guaranteed by _action_mode returning "contributed" 

1062 runner = self._contributed_actions[state.action_type] 

1063 result = runner.run( 

1064 action, 

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

1066 is_slash_command=False, 

1067 on_output_line=_on_line, 

1068 on_usage=on_usage, 

1069 ) 

1070 else: 

1071 result = self.action_runner.run( 

1072 action, 

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

1074 is_slash_command=action_mode == "prompt", 

1075 on_output_line=_on_line, 

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

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

1078 on_usage=on_usage, 

1079 ) 

1080 

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

1082 payload: dict[str, Any] = { 

1083 "exit_code": result.exit_code, 

1084 "duration_ms": result.duration_ms, 

1085 "output_preview": preview, 

1086 "is_prompt": action_mode == "prompt", 

1087 } 

1088 if action_mode == "prompt": 

1089 session_jsonl = get_current_session_jsonl() 

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

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

1092 if result.usage_events: 

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

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

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

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

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

1098 payload["input_tokens"] = total_input 

1099 payload["output_tokens"] = total_output 

1100 payload["cache_read_tokens"] = total_cache_read 

1101 payload["cache_creation_tokens"] = total_cache_creation 

1102 payload["model"] = model 

1103 self._emit("action_complete", payload) 

1104 

1105 # Capture if requested 

1106 if state.capture: 

1107 self.captured[state.capture] = { 

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

1109 "stderr": result.stderr, 

1110 "exit_code": result.exit_code, 

1111 "duration_ms": result.duration_ms, 

1112 } 

1113 

1114 # Append to shared messages log if requested 

1115 if state.append_to_messages: 

1116 post_ctx = self._build_context() 

1117 message = interpolate(state.append_to_messages, post_ctx) 

1118 self.messages.append(message) 

1119 self._emit( 

1120 "messages_append", 

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

1122 ) 

1123 

1124 # Check for signals in output 

1125 if self.signal_detector: 

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

1127 if signal: 

1128 if signal.signal_type == "handoff": 

1129 self._pending_handoff = signal 

1130 elif signal.signal_type == "error": 

1131 self._pending_error = signal.payload 

1132 elif signal.signal_type == "stop": 

1133 self.request_shutdown() 

1134 

1135 return result 

1136 

1137 def _run_subprocess( 

1138 self, 

1139 cmd: list[str], 

1140 timeout: int, 

1141 on_output_line: Any | None = None, 

1142 ) -> ActionResult: 

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

1144 

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

1146 

1147 Args: 

1148 cmd: Command and arguments to execute 

1149 timeout: Timeout in seconds 

1150 on_output_line: Optional callback for each stdout line 

1151 

1152 Returns: 

1153 ActionResult with output, stderr, exit_code, duration_ms 

1154 """ 

1155 start = _now_ms() 

1156 process = subprocess.Popen( 

1157 cmd, 

1158 stdout=subprocess.PIPE, 

1159 stderr=subprocess.PIPE, 

1160 text=True, 

1161 ) 

1162 self._current_process = process 

1163 output_chunks: list[str] = [] 

1164 stderr_chunks: list[str] = [] 

1165 

1166 def _drain_stderr() -> None: 

1167 assert process.stderr is not None 

1168 for line in process.stderr: 

1169 stderr_chunks.append(line) 

1170 

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

1172 stderr_thread.start() 

1173 

1174 try: 

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

1176 output_chunks.append(line) 

1177 if on_output_line: 

1178 on_output_line(line.rstrip()) 

1179 process.wait(timeout=timeout) 

1180 except subprocess.TimeoutExpired: 

1181 process.kill() 

1182 process.wait() 

1183 stderr_thread.join(timeout=5) 

1184 return ActionResult( 

1185 output="".join(output_chunks), 

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

1187 exit_code=124, 

1188 duration_ms=timeout * 1000, 

1189 ) 

1190 finally: 

1191 self._current_process = None 

1192 stderr_thread.join(timeout=5) 

1193 return ActionResult( 

1194 output="".join(output_chunks), 

1195 stderr="".join(stderr_chunks), 

1196 exit_code=process.returncode, 

1197 duration_ms=_now_ms() - start, 

1198 ) 

1199 

1200 def _evaluate( 

1201 self, 

1202 state: StateConfig, 

1203 action_result: ActionResult | None, 

1204 ctx: InterpolationContext, 

1205 ) -> EvaluationResult | None: 

1206 """Evaluate action result. 

1207 

1208 Args: 

1209 state: State configuration 

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

1211 ctx: Interpolation context 

1212 

1213 Returns: 

1214 EvaluationResult, or None if no evaluation needed 

1215 """ 

1216 if state.evaluate is None: 

1217 # Default evaluation based on action type 

1218 if action_result: 

1219 action_mode = self._action_mode(state) 

1220 

1221 if action_mode == "mcp_tool": 

1222 # MCP tool call: use mcp_result evaluator 

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

1224 elif action_mode == "prompt": 

1225 # Slash command or prompt: use LLM evaluation 

1226 if not self.fsm.llm.enabled: 

1227 result = EvaluationResult( 

1228 verdict="error", 

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

1230 ) 

1231 else: 

1232 result = evaluate_llm_structured( 

1233 action_result.output, 

1234 model=self.fsm.llm.model, 

1235 max_tokens=self.fsm.llm.max_tokens, 

1236 timeout=self.fsm.llm.timeout, 

1237 ) 

1238 else: 

1239 # Shell command: use exit code 

1240 result = evaluate_exit_code(action_result.exit_code) 

1241 

1242 self._emit( 

1243 "evaluate", 

1244 { 

1245 "type": "default", 

1246 "verdict": result.verdict, 

1247 **result.details, 

1248 }, 

1249 ) 

1250 return result 

1251 return None 

1252 

1253 # Explicit evaluation config 

1254 raw_output = action_result.output if action_result else "" 

1255 if state.evaluate.source: 

1256 try: 

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

1258 except InterpolationError: 

1259 eval_input = raw_output 

1260 else: 

1261 eval_input = raw_output 

1262 

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

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

1265 state.evaluate, 

1266 eval_input, 

1267 action_result.exit_code if action_result else 0, 

1268 ctx, 

1269 ) 

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

1271 result = EvaluationResult( 

1272 verdict="error", 

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

1274 ) 

1275 else: 

1276 result = evaluate( 

1277 config=state.evaluate, 

1278 output=eval_input, 

1279 exit_code=action_result.exit_code if action_result else 0, 

1280 context=ctx, 

1281 ) 

1282 

1283 self._emit( 

1284 "evaluate", 

1285 { 

1286 "type": state.evaluate.type, 

1287 "verdict": result.verdict, 

1288 **result.details, 

1289 }, 

1290 ) 

1291 

1292 return result 

1293 

1294 def _route( 

1295 self, 

1296 state: StateConfig, 

1297 verdict: str, 

1298 ctx: InterpolationContext, 

1299 ) -> str | None: 

1300 """Determine next state from verdict. 

1301 

1302 Resolution order (from design doc): 

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

1304 2. route (full routing table) 

1305 3. on_success/on_failure/on_error (shorthand) 

1306 4. terminal - handled in main loop 

1307 5. error 

1308 

1309 Args: 

1310 state: State configuration 

1311 verdict: Verdict string from evaluation 

1312 ctx: Interpolation context 

1313 

1314 Returns: 

1315 Next state name, or None if no valid route 

1316 """ 

1317 if state.route: 

1318 routes = state.route.routes 

1319 if verdict in routes: 

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

1321 if state.route.default: 

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

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

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

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

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

1327 return None 

1328 

1329 # Shorthand routing 

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

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

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

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

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

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

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

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

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

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

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

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

1342 

1343 # Dynamic on_<verdict> shorthands from extra_routes 

1344 if verdict in state.extra_routes: 

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

1346 

1347 return None 

1348 

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

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

1351 

1352 Args: 

1353 route: Route target string 

1354 ctx: Interpolation context 

1355 

1356 Returns: 

1357 Resolved state name 

1358 """ 

1359 if route == "$current": 

1360 return self.current_state 

1361 return interpolate(route, ctx) 

1362 

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

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

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

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

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

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

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

1370 """ 

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

1372 self.iteration += 1 

1373 self._just_routed = False 

1374 self._emit( 

1375 "state_enter", 

1376 { 

1377 "state": self.current_state, 

1378 "iteration": self.iteration, 

1379 "flushed": True, 

1380 }, 

1381 ) 

1382 ctx = self._build_context() 

1383 try: 

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

1385 except Exception: 

1386 # Deliberately swallow — the timeout is being honored regardless 

1387 # of whether the flushed action succeeded. 

1388 pass 

1389 

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

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

1392 if state.action_type == "contract": 

1393 return "contract" 

1394 if state.action_type == "mcp_tool": 

1395 return "mcp_tool" 

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

1397 return "prompt" 

1398 if state.action_type == "shell": 

1399 return "shell" 

1400 if state.action_type in self._contributed_actions: 

1401 return "contributed" 

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

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

1404 return "prompt" 

1405 return "shell" 

1406 

1407 def _execute_with_baseline( 

1408 self, 

1409 state: StateConfig, 

1410 ctx: InterpolationContext, 

1411 baseline_cfg: dict[str, Any], 

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

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

1414 

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

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

1417 

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

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

1420 """ 

1421 from concurrent.futures import ThreadPoolExecutor 

1422 

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

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

1425 

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

1427 harness_tokens.append((input_tokens, output_tokens)) 

1428 

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

1430 baseline_tokens.append((input_tokens, output_tokens)) 

1431 

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

1433 baseline_skill_name = baseline_cfg.get("skill") 

1434 if baseline_skill_name is None: 

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

1436 baseline_skill_name = _extract_skill_from_action(action_text) 

1437 

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

1439 with ThreadPoolExecutor(max_workers=2) as pool: 

1440 harness_future = pool.submit( 

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

1442 ) 

1443 baseline_future = pool.submit( 

1444 self._run_baseline_arm, baseline_skill_name, state, _on_baseline_usage 

1445 ) 

1446 harness_result: ActionResult = harness_future.result() 

1447 baseline_result: ActionResult = baseline_future.result() 

1448 

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

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

1451 

1452 self._emit( 

1453 "baseline_complete", 

1454 { 

1455 "harness_duration_ms": harness_result.duration_ms, 

1456 "baseline_duration_ms": baseline_result.duration_ms, 

1457 "harness_tokens": harness_total_tokens, 

1458 "baseline_tokens": baseline_total_tokens, 

1459 }, 

1460 ) 

1461 

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

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

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

1465 try: 

1466 comparison = evaluate_blind_comparator( 

1467 output_harness=harness_result.output, 

1468 output_baseline=baseline_result.output, 

1469 ) 

1470 item: dict[str, Any] = { 

1471 "index": self._ab_item_index, 

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

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

1474 "harness_tokens": harness_total_tokens, 

1475 "baseline_tokens": baseline_total_tokens, 

1476 "harness_duration_ms": harness_result.duration_ms, 

1477 "baseline_duration_ms": baseline_result.duration_ms, 

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

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

1480 } 

1481 self._ab_results.append(item) 

1482 self._ab_item_index += 1 

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

1484 except Exception: 

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

1486 item = { 

1487 "index": self._ab_item_index, 

1488 "harness_pass": False, 

1489 "baseline_pass": False, 

1490 "harness_tokens": harness_total_tokens, 

1491 "baseline_tokens": baseline_total_tokens, 

1492 "harness_duration_ms": harness_result.duration_ms, 

1493 "baseline_duration_ms": baseline_result.duration_ms, 

1494 "confidence": 0.0, 

1495 "reason": "Blind evaluation failed", 

1496 "error": "evaluation_error", 

1497 } 

1498 self._ab_results.append(item) 

1499 self._ab_item_index += 1 

1500 

1501 return harness_result, None 

1502 

1503 def _run_baseline_arm( 

1504 self, 

1505 skill_command: str, 

1506 state: StateConfig, 

1507 on_usage: UsageCallback | None = None, 

1508 ) -> ActionResult: 

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

1510 

1511 Args: 

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

1513 state: State configuration (for timeout) 

1514 on_usage: Optional callback for token capture 

1515 

1516 Returns: 

1517 ActionResult with output, exit code, and duration 

1518 """ 

1519 start = _now_ms() 

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

1521 try: 

1522 completed = run_claude_command( 

1523 command=skill_command, 

1524 timeout=timeout, 

1525 on_usage=on_usage, 

1526 ) 

1527 return ActionResult( 

1528 output=completed.stdout, 

1529 stderr=completed.stderr, 

1530 exit_code=completed.returncode, 

1531 duration_ms=_now_ms() - start, 

1532 ) 

1533 except subprocess.TimeoutExpired: 

1534 return ActionResult( 

1535 output="", 

1536 stderr="Baseline action timed out", 

1537 exit_code=124, 

1538 duration_ms=timeout * 1000, 

1539 ) 

1540 

1541 def _run_action_or_route( 

1542 self, state: StateConfig, ctx: InterpolationContext 

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

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

1545 

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

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

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

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

1550 """ 

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

1552 try: 

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

1554 except Exception as exc: 

1555 if state.on_error: 

1556 self._emit( 

1557 "action_error", 

1558 { 

1559 "state": self.current_state, 

1560 "error": str(exc), 

1561 "route": "on_error", 

1562 }, 

1563 ) 

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

1565 raise 

1566 

1567 def _build_context(self) -> InterpolationContext: 

1568 """Build interpolation context for current state. 

1569 

1570 Returns: 

1571 InterpolationContext with all runtime values 

1572 """ 

1573 from little_loops.fsm.interpolation import interpolate_dict 

1574 

1575 ctx = InterpolationContext( 

1576 context=self.fsm.context, 

1577 captured=self.captured, 

1578 prev=self.prev_result, 

1579 result=None, 

1580 state_name=self.current_state, 

1581 iteration=self.iteration, 

1582 loop_name=self.fsm.name, 

1583 started_at=self.started_at, 

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

1585 messages=list(self.messages), 

1586 ) 

1587 

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

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

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

1591 resolved = interpolate_dict(state.fragment_bindings, ctx) 

1592 # Apply declared defaults for unbound optional parameters 

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

1594 if ( 

1595 param_name not in resolved 

1596 and not param_spec.required 

1597 and param_spec.default is not None 

1598 ): 

1599 resolved[param_name] = param_spec.default 

1600 # Runtime enforcement: required parameters must be bound 

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

1602 if param_spec.required and param_name not in resolved: 

1603 raise ValueError( 

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

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

1606 ) 

1607 ctx.param = resolved 

1608 

1609 return ctx 

1610 

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

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

1613 self.event_callback( 

1614 { 

1615 "event": event, 

1616 "ts": _iso_now(), 

1617 **data, 

1618 } 

1619 ) 

1620 

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

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

1623 

1624 Implements the two-tier retry ladder: 

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

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

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

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

1629 

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

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

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

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

1634 

1635 Returns: 

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

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

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

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

1640 extensions. 

1641 """ 

1642 _short_max = ( 

1643 state.max_rate_limit_retries 

1644 if state.max_rate_limit_retries is not None 

1645 else _DEFAULT_RATE_LIMIT_RETRIES 

1646 ) 

1647 _backoff_base = ( 

1648 state.rate_limit_backoff_base_seconds 

1649 if state.rate_limit_backoff_base_seconds is not None 

1650 else _DEFAULT_RATE_LIMIT_BACKOFF_BASE 

1651 ) 

1652 _max_wait = ( 

1653 state.rate_limit_max_wait_seconds 

1654 if state.rate_limit_max_wait_seconds is not None 

1655 else _DEFAULT_RATE_LIMIT_MAX_WAIT_SECONDS 

1656 ) 

1657 _ladder = ( 

1658 state.rate_limit_long_wait_ladder 

1659 if state.rate_limit_long_wait_ladder is not None 

1660 else _DEFAULT_RATE_LIMIT_LONG_WAIT_LADDER 

1661 ) 

1662 

1663 record = self._rate_limit_retries.get(state_name) 

1664 if record is None: 

1665 record = { 

1666 "short_retries": 0, 

1667 "long_retries": 0, 

1668 "total_wait_seconds": 0.0, 

1669 "first_seen_at": time.time(), 

1670 } 

1671 self._rate_limit_retries[state_name] = record 

1672 

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

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

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

1676 

1677 if short_retries < _short_max: 

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

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

1680 short_retries += 1 

1681 record["short_retries"] = short_retries 

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

1683 if self._circuit is not None: 

1684 self._circuit.record_rate_limit(_sleep) 

1685 total_wait += self._interruptible_sleep(_sleep) 

1686 record["total_wait_seconds"] = total_wait 

1687 return True, state_name # retry in place 

1688 

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

1690 long_retries += 1 

1691 record["long_retries"] = long_retries 

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

1693 _wait = float(_ladder[_idx]) 

1694 if self._circuit is not None: 

1695 self._circuit.record_rate_limit(_wait) 

1696 _tier_start = time.time() 

1697 _deadline = _tier_start + _wait 

1698 _total_wait_before_tier = total_wait 

1699 total_wait += self._interruptible_sleep( 

1700 _wait, 

1701 on_heartbeat=lambda elapsed: self._emit( 

1702 RATE_LIMIT_WAITING_EVENT, 

1703 { 

1704 "state": state_name, 

1705 "elapsed_seconds": elapsed, 

1706 "next_attempt_at": _deadline, 

1707 "total_waited_seconds": _total_wait_before_tier + elapsed, 

1708 "budget_seconds": _max_wait, 

1709 "tier": "long_wait", 

1710 }, 

1711 ), 

1712 ) 

1713 record["total_wait_seconds"] = total_wait 

1714 if total_wait >= _max_wait: 

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

1716 return True, state_name # retry in place 

1717 

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

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

1720 

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

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

1723 """ 

1724 if self._circuit is None: 

1725 return 

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

1727 return 

1728 recovery = self._circuit.get_estimated_recovery() 

1729 if recovery is None: 

1730 return 

1731 wait = recovery - time.time() 

1732 if wait > 0: 

1733 self._interruptible_sleep(wait) 

1734 

1735 def _interruptible_sleep( 

1736 self, 

1737 duration: float, 

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

1739 ) -> float: 

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

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

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

1743 

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

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

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

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

1748 """ 

1749 if duration <= 0: 

1750 return 0.0 

1751 _start = time.time() 

1752 _deadline = _start + duration 

1753 last_heartbeat = _start 

1754 while time.time() < _deadline: 

1755 if self._shutdown_requested: 

1756 break 

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

1758 if on_heartbeat is not None: 

1759 _now = time.time() 

1760 if _now - last_heartbeat >= _RATE_LIMIT_HEARTBEAT_INTERVAL: 

1761 on_heartbeat(_now - _start) 

1762 last_heartbeat = _now 

1763 return time.time() - _start 

1764 

1765 def _exhaust_rate_limit( 

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

1767 ) -> str | None: 

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

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

1770 once the wall-clock budget is spent. 

1771 """ 

1772 self._rate_limit_retries.pop(state_name, None) 

1773 target = state.on_rate_limit_exhausted or state.on_error 

1774 self._emit( 

1775 RATE_LIMIT_EXHAUSTED_EVENT, 

1776 { 

1777 "state": state_name, 

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

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

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

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

1782 "next": target, 

1783 }, 

1784 ) 

1785 self._consecutive_rate_limit_exhaustions += 1 

1786 if self._consecutive_rate_limit_exhaustions >= _RATE_LIMIT_STORM_THRESHOLD: 

1787 self._emit( 

1788 RATE_LIMIT_STORM_EVENT, 

1789 { 

1790 "state": state_name, 

1791 "count": self._consecutive_rate_limit_exhaustions, 

1792 }, 

1793 ) 

1794 return target 

1795 

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

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

1798 

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

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

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

1802 

1803 Returns: 

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

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

1806 through to normal verdict routing). 

1807 """ 

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

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

1810 self._api_error_retries.pop(state_name, None) 

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

1812 return False, None 

1813 record["retries"] += 1 

1814 slept = self._interruptible_sleep(_DEFAULT_API_ERROR_BACKOFF) 

1815 record["total_wait"] += slept 

1816 self._emit( 

1817 "api_error_retry", 

1818 { 

1819 "state": state_name, 

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

1821 "backoff": _DEFAULT_API_ERROR_BACKOFF, 

1822 }, 

1823 ) 

1824 return True, state_name 

1825 

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

1827 """Finalize execution and return result.""" 

1828 self._emit( 

1829 "loop_complete", 

1830 { 

1831 "final_state": self.current_state, 

1832 "iterations": self.iteration, 

1833 "terminated_by": terminated_by, 

1834 }, 

1835 ) 

1836 

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

1838 if self._ab_results: 

1839 try: 

1840 from little_loops.ab_writer import calculate_ab_summary, write_ab_json 

1841 

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

1843 if run_dir: 

1844 summary = calculate_ab_summary(self._ab_results) 

1845 write_ab_json(summary, run_dir) 

1846 self._emit( 

1847 "ab_summary", 

1848 { 

1849 "harness_pass_rate": summary.harness_pass_rate, 

1850 "baseline_pass_rate": summary.baseline_pass_rate, 

1851 "delta": summary.delta, 

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

1853 }, 

1854 ) 

1855 except Exception: 

1856 pass # Non-fatal: loop still completes 

1857 

1858 return ExecutionResult( 

1859 final_state=self.current_state, 

1860 iterations=self.iteration, 

1861 terminated_by=terminated_by, 

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

1863 captured=self.captured, 

1864 error=error, 

1865 messages=list(self.messages), 

1866 ) 

1867 

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

1869 """Handle a detected handoff signal. 

1870 

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

1872 

1873 Args: 

1874 signal: The detected handoff signal 

1875 

1876 Returns: 

1877 ExecutionResult with handoff information 

1878 """ 

1879 self._emit( 

1880 "handoff_detected", 

1881 { 

1882 "state": self.current_state, 

1883 "iteration": self.iteration, 

1884 "continuation": signal.payload, 

1885 }, 

1886 ) 

1887 

1888 # Invoke handler if configured 

1889 if self.handoff_handler: 

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

1891 if result.spawned_process is not None: 

1892 self._emit( 

1893 "handoff_spawned", 

1894 { 

1895 "pid": result.spawned_process.pid, 

1896 "state": self.current_state, 

1897 }, 

1898 ) 

1899 

1900 return ExecutionResult( 

1901 final_state=self.current_state, 

1902 iterations=self.iteration, 

1903 terminated_by="handoff", 

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

1905 captured=self.captured, 

1906 handoff=True, 

1907 continuation_prompt=signal.payload, 

1908 ) 

1909 

1910 

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

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

1913 

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

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

1916 """ 

1917 stripped = action.strip() 

1918 if stripped.startswith("/"): 

1919 parts = stripped.split(maxsplit=1) 

1920 return parts[0] 

1921 return stripped