Coverage for little_loops / cli / loop / _helpers.py: 9%

783 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-08 15:34 -0500

1"""Shared helpers for ll-loop CLI subcommands.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import json 

7import signal 

8import subprocess 

9import sys 

10import time 

11from datetime import datetime 

12from pathlib import Path 

13from types import FrameType 

14from typing import TYPE_CHECKING, Any 

15 

16from little_loops.cli.loop.diagram_modes import ( 

17 TOPOLOGY_TO_DETAIL, 

18 DiagramFacets, 

19 resolve_facets, 

20) 

21from little_loops.cli.output import colorize, strip_ansi, terminal_size, terminal_width 

22from little_loops.fsm.concurrency import LockManager, _process_alive, resolve_scope 

23from little_loops.logger import Logger 

24from little_loops.pricing import estimate_cost_usd 

25 

26if TYPE_CHECKING: 

27 from little_loops.fsm.schema import FSMLoop 

28 

29# Exit code mapping for terminated_by values 

30EXIT_CODES: dict[str, int] = { 

31 "terminal": 0, 

32 "signal": 0, 

33 "handoff": 0, 

34 "max_iterations": 1, 

35 "timeout": 1, 

36 "cycle_detected": 1, 

37 "stall_detected": 1, 

38} 

39 

40# Minimum number of action-output rows reserved beneath the pinned pane in 

41# alt-screen mode. When the pinned pane plus this margin would exceed the 

42# terminal height, the layout falls back to a more compact diagram variant. 

43MIN_ACTION_ROWS = 6 

44 

45# Module-level shutdown state for signal handling 

46_loop_shutdown_requested: bool = False 

47_loop_executor: Any = None 

48_loop_pid_file: Path | None = None 

49_using_alt_screen: bool = False 

50# Set by SIGWINCH handler when the terminal is resized; consumed by the 

51# display_progress callback to trigger a pinned-pane redraw on the next event. 

52_needs_redraw: bool = False 

53# Previous SIGWINCH handler, stashed when we install our own so it can be 

54# restored in run_foreground's finally block. ``None`` means "not installed". 

55_original_sigwinch: Any = None 

56 

57 

58class _TeeWriter: 

59 """Wraps a stream, writing to both the original and a log file (ANSI stripped on log).""" 

60 

61 def __init__(self, stream: Any, log_fh: Any) -> None: 

62 self._stream = stream 

63 self._log_fh = log_fh 

64 

65 def write(self, data: str) -> int: 

66 n = self._stream.write(data) 

67 self._log_fh.write(strip_ansi(data)) 

68 return n 

69 

70 def flush(self) -> None: 

71 self._stream.flush() 

72 self._log_fh.flush() 

73 

74 def __getattr__(self, name: str) -> Any: 

75 return getattr(self._stream, name) 

76 

77 

78def _loop_signal_handler(signum: int, frame: FrameType | None) -> None: 

79 """Handle shutdown signals gracefully for ll-loop. 

80 

81 First signal: Set shutdown flag for graceful exit after current state. 

82 Second signal: Force immediate exit. 

83 """ 

84 global _loop_shutdown_requested, _using_alt_screen 

85 if _loop_shutdown_requested: 

86 # Second signal - force exit 

87 if _loop_pid_file is not None: 

88 _loop_pid_file.unlink(missing_ok=True) 

89 if _using_alt_screen: 

90 # Reset the DECSTBM scroll region BEFORE exiting the alt screen, 

91 # otherwise the main buffer is left with a restricted scroll 

92 # region — visible to the user as `seq 1 50` failing to scroll 

93 # past the previous pinned-pane height. 

94 print("\033[r", end="", file=sys.stderr, flush=True) 

95 print("\033[?1049l", end="", file=sys.stderr, flush=True) 

96 print(colorize("\nForce shutdown requested", "38;5;208"), file=sys.stderr) 

97 sys.exit(1) 

98 _loop_shutdown_requested = True 

99 print(colorize("\nShutdown requested, will exit after current state...", "33"), file=sys.stderr) 

100 if _loop_executor is not None: 

101 _loop_executor.request_shutdown() 

102 # Kill any child subprocess currently blocking in the action runner 

103 inner = getattr(_loop_executor, "_executor", None) 

104 if inner is not None: 

105 runner = getattr(inner, "action_runner", None) 

106 if runner is not None: 

107 proc = getattr(runner, "_current_process", None) 

108 if proc is not None: 

109 proc.kill() 

110 # Also kill MCP subprocesses tracked directly on FSMExecutor (_run_subprocess path) 

111 fsm_proc = getattr(inner, "_current_process", None) 

112 if fsm_proc is not None: 

113 fsm_proc.kill() 

114 

115 

116def _is_earliest_waiter(entry_id: str, queue_dir: Path) -> bool: 

117 """Return True if entry_id is the earliest-enqueued waiter in queue_dir. 

118 

119 Returns True when this waiter is first or the queue is empty/unreadable, 

120 allowing it to proceed with acquire(). Non-first waiters return False and 

121 should back off to yield to the earlier waiter (ENH-1332). 

122 

123 Stale entries (dead PIDs) are removed on the fly so orphaned queue files 

124 from crashed processes do not block live waiters indefinitely (BUG-1360). 

125 """ 

126 if not queue_dir.exists(): 

127 return True 

128 entries: list[dict] = [] 

129 for f in queue_dir.glob("*.json"): 

130 try: 

131 with open(f) as fh: 

132 data = json.load(fh) 

133 pid = data.get("context", {}).get("pid") 

134 if pid is not None and not _process_alive(pid): 

135 f.unlink(missing_ok=True) 

136 continue 

137 entries.append(data) 

138 except (json.JSONDecodeError, KeyError, FileNotFoundError, OSError): 

139 continue 

140 if not entries: 

141 return True 

142 entries.sort(key=lambda d: d.get("enqueuedAt", "")) 

143 return entries[0].get("id") == entry_id 

144 

145 

146def register_loop_signal_handlers(executor: Any, pid_file: Path | None = None) -> None: 

147 """Register SIGINT/SIGTERM handlers for graceful loop shutdown. 

148 

149 Sets up signal handling so that Ctrl-C triggers a graceful shutdown 

150 (calls executor.request_shutdown()) rather than raising KeyboardInterrupt. 

151 A second Ctrl-C forces immediate exit with PID file cleanup. 

152 

153 Args: 

154 executor: The PersistentExecutor instance to request shutdown on. 

155 pid_file: Optional path to PID file to clean up on forced exit. 

156 """ 

157 global _loop_shutdown_requested, _loop_executor, _loop_pid_file 

158 _loop_shutdown_requested = False 

159 _loop_executor = executor 

160 _loop_pid_file = pid_file 

161 signal.signal(signal.SIGINT, _loop_signal_handler) 

162 signal.signal(signal.SIGTERM, _loop_signal_handler) 

163 

164 

165# --------------------------------------------------------------------------- 

166# SIGWINCH handler (alt-screen pinned-pane mode only) 

167# --------------------------------------------------------------------------- 

168 

169 

170def _sigwinch_handler(signum: int, frame: FrameType | None) -> None: 

171 """Mark the pinned pane as needing a redraw after a terminal resize. 

172 

173 The handler does the minimum amount of work safe to perform in a signal 

174 context: it just sets a flag. ``display_progress`` consumes the flag 

175 before processing the next FSM event and triggers the actual redraw. 

176 """ 

177 global _needs_redraw 

178 _needs_redraw = True 

179 

180 

181def _install_sigwinch_handler() -> None: 

182 """Install ``_sigwinch_handler`` for SIGWINCH, stashing the prior handler. 

183 

184 Idempotent — re-calling while installed is a no-op so we never leak the 

185 chain by overwriting our own stash. No-op on platforms without SIGWINCH 

186 (e.g. Windows). 

187 """ 

188 global _original_sigwinch 

189 if not hasattr(signal, "SIGWINCH"): 

190 return 

191 if _original_sigwinch is not None: 

192 return 

193 _original_sigwinch = signal.signal(signal.SIGWINCH, _sigwinch_handler) 

194 

195 

196def _restore_sigwinch_handler() -> None: 

197 """Restore the SIGWINCH handler stashed by ``_install_sigwinch_handler``. 

198 

199 Safe to call when no handler was installed. After this returns, 

200 ``_original_sigwinch`` is reset to ``None`` so the install is repeatable. 

201 """ 

202 global _original_sigwinch, _needs_redraw 

203 if not hasattr(signal, "SIGWINCH"): 

204 _original_sigwinch = None 

205 _needs_redraw = False 

206 return 

207 if _original_sigwinch is None: 

208 return 

209 signal.signal(signal.SIGWINCH, _original_sigwinch) 

210 _original_sigwinch = None 

211 _needs_redraw = False 

212 

213 

214# --------------------------------------------------------------------------- 

215# Pinned-pane layout (alt-screen mode) 

216# --------------------------------------------------------------------------- 

217 

218 

219def _count_display_lines(text: str) -> int: 

220 """Return the number of terminal rows a string occupies. 

221 

222 Treats each ``\\n`` as a row boundary; a trailing newline is *not* 

223 counted as an extra empty row. ANSI escape sequences are assumed not to 

224 contain newlines (true for SGR / cursor / scroll-region codes). 

225 """ 

226 if not text: 

227 return 0 

228 return text.count("\n") + (0 if text.endswith("\n") else 1) 

229 

230 

231def _choose_pinned_layout( 

232 rows: int, 

233 variants: list[str], 

234 min_action_rows: int = MIN_ACTION_ROWS, 

235) -> tuple[str, int]: 

236 """Pick the most detailed pinned-pane variant that leaves room for action output. 

237 

238 ``variants`` is an ordered list from most-detailed to least-detailed 

239 (e.g. ``[full, neighborhood, single_line]``). Returns 

240 ``(pinned_str, line_count)`` for the first variant whose height plus 

241 ``min_action_rows`` fits within ``rows``. If none fit, returns the 

242 last (smallest) variant unchanged — a degenerate terminal still gets 

243 *something* pinned. 

244 """ 

245 last_text = "" 

246 last_h = 0 

247 for variant in variants: 

248 last_text = variant 

249 last_h = _count_display_lines(variant) 

250 if last_h + min_action_rows <= rows: 

251 return variant, last_h 

252 return last_text, last_h 

253 

254 

255def _render_single_line_status(fsm: FSMLoop, active_state: str | None) -> str: 

256 """Render the single-line fallback: ``fsm: <preds> → [<active>] → <succs>``.""" 

257 from little_loops.cli.loop.layout import _collect_edges 

258 

259 if active_state is None or active_state not in fsm.states: 

260 return f"fsm: · → [{active_state or '?'}] → ·" 

261 edges = _collect_edges(fsm) 

262 preds = sorted({s for (s, t, _lbl) in edges if t == active_state and s != active_state}) 

263 succs = sorted({t for (s, t, _lbl) in edges if s == active_state and t != active_state}) 

264 preds_s = ",".join(preds) if preds else "·" 

265 succs_s = ",".join(succs) if succs else "·" 

266 return f"fsm: {preds_s} → [{active_state}] → {succs_s}" 

267 

268 

269def _build_pinned_pane( 

270 detail: str, 

271 fsm: FSMLoop, 

272 parent_highlight: str | None, 

273 child_fsm_stack: dict[int, FSMLoop | None], 

274 last_state_at_depth: dict[int, str], 

275 iteration_line: str, 

276 cols: int, 

277 *, 

278 facets: DiagramFacets, 

279 highlight_color: str, 

280 edge_label_colors: dict[str, str] | None, 

281 badges: dict[str, str] | None, 

282 prev_highlight: str | None = None, 

283 prev_state_at_depth: dict[int, str] | None = None, 

284 loop_path: Path | None = None, 

285 model: str | None = None, 

286) -> str: 

287 """Compose the pinned pane (header + diagram(s) + state line + separator). 

288 

289 ``detail`` selects the diagram variant: ``"full"`` (layered 

290 ``_render_fsm_diagram``), ``"neighborhood"`` (1-hop pred/active/succ), or 

291 ``"single"`` (one-line ``fsm:`` status). The returned string is intended 

292 to be printed with ``flush=True`` and is terminated by a horizontal 

293 separator (no trailing newline). 

294 """ 

295 from little_loops.cli.loop.layout import ( 

296 _collect_edges, 

297 _filter_main_path_graph, 

298 _render_fsm_diagram, 

299 _render_neighborhood_diagram, 

300 ) 

301 

302 verbose = facets.scope == "full" and facets.state_detail == "full" 

303 

304 def _render_one(target: FSMLoop, highlight: str | None, prev: str | None) -> str: 

305 if detail == "single": 

306 return _render_single_line_status(target, highlight) 

307 if detail == "neighborhood": 

308 return _render_neighborhood_diagram( 

309 target, 

310 highlight or target.initial, 

311 edge_label_colors=edge_label_colors, 

312 badges=badges, 

313 highlight_color=highlight_color, 

314 mode=facets.scope, 

315 prev_state=prev, 

316 ) 

317 # "full" (layered) 

318 scope = facets.scope 

319 if scope == "main" and highlight is not None: 

320 _filtered_edges, reachable = _filter_main_path_graph(target, _collect_edges(target)) 

321 if highlight not in reachable: 

322 scope = "full" 

323 return _render_fsm_diagram( 

324 target, 

325 verbose=verbose, 

326 highlight_state=highlight, 

327 highlight_color=highlight_color, 

328 edge_label_colors=edge_label_colors, 

329 badges=badges, 

330 mode=scope, 

331 suppress_labels=not facets.edge_labels, 

332 title_only=facets.state_detail == "title", 

333 ) 

334 

335 prev_map = prev_state_at_depth or {} 

336 lines: list[str] = [] 

337 

338 # Find deepest active loop — show only that one instead of stacking all levels. 

339 active_fsm = fsm 

340 active_state = parent_highlight 

341 active_prev = prev_highlight 

342 active_depth = 0 

343 for d in sorted(child_fsm_stack.keys()): 

344 child = child_fsm_stack[d] 

345 if child is not None and (d + 1) in last_state_at_depth: 

346 active_fsm = child 

347 active_state = last_state_at_depth.get(d + 1) 

348 active_prev = prev_map.get(d + 1) 

349 active_depth = d + 1 

350 

351 # Header: breadcrumb shows immediate parent when inside a sub-loop. 

352 if active_depth > 0: 

353 imm_parent_name = ( 

354 fsm.name if active_depth == 1 else (child_fsm_stack.get(active_depth - 2) or fsm).name 

355 ) 

356 imm_parent_state = last_state_at_depth.get(active_depth - 1, "") 

357 header_text = f"== loop: {active_fsm.name} ({imm_parent_name}{imm_parent_state}) " 

358 else: 

359 header_text = f"== loop: {fsm.name} " 

360 lines.append(header_text + "=" * max(0, cols - len(header_text))) 

361 for key, value in _artifact_lines(fsm, loop_path): 

362 lines.append(f" {key}: {colorize(value, '2')}") 

363 if model is not None: 

364 lines.append(f" model: {colorize(model, '2')}") 

365 diagram = _render_one(active_fsm, active_state, active_prev) 

366 if diagram: 

367 lines.extend(diagram.split("\n")) 

368 

369 lines.append(iteration_line) 

370 lines.append("─" * cols) 

371 return "\n".join(lines) 

372 

373 

374def _render_pinned_pane( 

375 fsm: FSMLoop, 

376 parent_highlight: str | None, 

377 child_fsm_stack: dict[int, FSMLoop | None], 

378 last_state_at_depth: dict[int, str], 

379 iteration_line: str, 

380 *, 

381 facets: DiagramFacets, 

382 highlight_color: str, 

383 edge_label_colors: dict[str, str] | None, 

384 badges: dict[str, str] | None, 

385 min_action_rows: int = MIN_ACTION_ROWS, 

386 prev_state_at_depth: dict[int, str] | None = None, 

387 loop_path: Path | None = None, 

388 model: str | None = None, 

389) -> int: 

390 """Render the pinned pane to stdout and set the scroll region beneath it. 

391 

392 Performs (in order): reset scroll region, clear+home cursor, build all 

393 pinned-pane variants, pick the largest that fits, print it, set the 

394 DECSTBM scroll region to start one row below the pinned pane, and 

395 position the cursor at the top of that scroll region. Returns the 

396 pinned-pane height in rows so the caller can track it across events. 

397 """ 

398 cols, rows = terminal_size() 

399 # 1. Reset any existing scroll region so the clear covers the full screen. 

400 print("\033[r", end="", flush=True) 

401 # 2. Clear + cursor home. 

402 print("\033[2J\033[H", end="", flush=True) 

403 

404 prev_map = prev_state_at_depth or {} 

405 

406 def _build(detail: str) -> str: 

407 return _build_pinned_pane( 

408 detail, 

409 fsm, 

410 parent_highlight, 

411 child_fsm_stack, 

412 last_state_at_depth, 

413 iteration_line, 

414 cols, 

415 facets=facets, 

416 highlight_color=highlight_color, 

417 edge_label_colors=edge_label_colors, 

418 badges=badges, 

419 prev_highlight=prev_map.get(0), 

420 prev_state_at_depth=prev_map, 

421 loop_path=loop_path, 

422 model=model, 

423 ) 

424 

425 # Build the fallback ladder based on facets source and topology. 

426 # Explicit topology (source="topology"): render exactly once, no degradation. 

427 # Preset/default with layered topology: skip neighborhood — layered→neighborhood 

428 # looks like a broken diagram and confuses users. Go straight to single-line. 

429 # Explicit neighborhood topology: neighborhood→single. 

430 topo_detail = TOPOLOGY_TO_DETAIL[facets.topology] 

431 if facets.source == "topology": 

432 variants = [_build(topo_detail)] 

433 elif topo_detail == "full": 

434 variants = [_build("full"), _build("single")] 

435 elif topo_detail == "neighborhood": 

436 variants = [_build("neighborhood"), _build("single")] 

437 else: # inline / single 

438 variants = [_build("single")] 

439 

440 # compact presets (clean/slim) set state_detail="title" and already sacrifice 

441 # action body lines; tolerate fewer rows below the diagram to avoid the 

442 # single-line fsm: fallback on larger loops. 

443 effective_min_action_rows = 3 if facets.state_detail == "title" else min_action_rows 

444 pinned, pinned_height = _choose_pinned_layout( 

445 rows, 

446 variants, 

447 min_action_rows=effective_min_action_rows, 

448 ) 

449 print(pinned, flush=True) 

450 

451 # Guard against degenerate terminals: scroll region must have at least 

452 # 1 row beneath the pinned pane. If pinned_height >= rows, skip the 

453 # scroll-region setup entirely — output will append normally and the 

454 # caller can rely on the alt-screen exit to clean up. 

455 if pinned_height < rows: 

456 # DECSTBM uses 1-indexed inclusive rows. 

457 print(f"\033[{pinned_height + 1};{rows}r", end="", flush=True) 

458 # Move cursor to the top of the scroll region. 

459 print(f"\033[{pinned_height + 1};1H", end="", flush=True) 

460 return pinned_height 

461 

462 

463class StateFeedRenderer: 

464 """Renders loop-state events as terminal output for foreground runs and monitor attach. 

465 

466 Extracted from ``run_foreground()`` so both the foreground run path and the 

467 ``cmd_monitor`` attach path (FEAT-1764) can share the same rendering logic. 

468 """ 

469 

470 def __init__( 

471 self, 

472 fsm: FSMLoop, 

473 args: argparse.Namespace, 

474 highlight_color: str = "32", 

475 edge_label_colors: dict[str, str] | None = None, 

476 badges: dict[str, str] | None = None, 

477 loops_dir: Path | None = None, 

478 loop_path: Path | None = None, 

479 model: str | None = None, 

480 ) -> None: 

481 self.fsm = fsm 

482 self.args = args 

483 self.highlight_color = highlight_color 

484 self.edge_label_colors = edge_label_colors 

485 self.badges = badges 

486 self.loops_dir = loops_dir or Path(".") 

487 self.loop_path = loop_path 

488 self.model = model 

489 

490 # Derived from args 

491 self.quiet: bool = getattr(args, "quiet", False) 

492 self.verbose: bool = getattr(args, "verbose", False) 

493 self.facets: DiagramFacets | None = resolve_facets(args) 

494 self.show_diagrams: bool = self.facets is not None 

495 self.clear_screen: bool = getattr(args, "clear", False) 

496 self.in_pinned_mode: bool = self.show_diagrams and self.clear_screen and sys.stdout.isatty() 

497 

498 # Mutable state (was closure-captured in run_foreground) 

499 self.current_iteration: list[int] = [0] 

500 self.last_state_at_depth: dict[int, str] = {} 

501 self.prev_state_at_depth: dict[int, str] = {} 

502 self.child_fsm_stack: dict[int, FSMLoop | None] = {} 

503 self.pinned_height: list[int] = [0] 

504 self.loop_start_time: float = time.monotonic() 

505 

506 def _elapsed_str(self) -> str: 

507 elapsed_int = int(time.monotonic() - self.loop_start_time) 

508 if elapsed_int < 60: 

509 return f"{elapsed_int}s" 

510 return f"{elapsed_int // 60}m {elapsed_int % 60}s" 

511 

512 def _redraw_pinned(self, state0: str) -> None: 

513 """Redraw the pinned pane in place using the current depth-0 state.""" 

514 assert self.facets is not None 

515 iter_line = ( 

516 f"[{self.current_iteration[0]}/{self.fsm.max_iterations}] " 

517 f"{colorize(state0, '1')} ({colorize(self._elapsed_str(), '2')})" 

518 ) 

519 self.pinned_height[0] = _render_pinned_pane( 

520 self.fsm, 

521 state0, 

522 self.child_fsm_stack, 

523 self.last_state_at_depth, 

524 iter_line, 

525 facets=self.facets, 

526 highlight_color=self.highlight_color, 

527 edge_label_colors=self.edge_label_colors, 

528 badges=self.badges, 

529 prev_state_at_depth=self.prev_state_at_depth, 

530 loop_path=self.loop_path, 

531 model=self.model, 

532 ) 

533 

534 def handle_event(self, event: dict) -> None: 

535 """Display progress for events.""" 

536 global _needs_redraw 

537 # SIGWINCH redraw: terminal was resized; re-render the pinned pane 

538 # before processing the next event so the layout matches the new size. 

539 if _needs_redraw and self.in_pinned_mode and 0 in self.last_state_at_depth: 

540 self._redraw_pinned(self.last_state_at_depth[0]) 

541 _needs_redraw = False 

542 

543 event_type = event.get("event") 

544 depth = event.get("depth", 0) 

545 indent = " " * depth 

546 tw = terminal_width() 

547 max_line = tw - 8 - len(indent) 

548 

549 if event_type == "state_enter": 

550 self.current_iteration[0] = event.get("iteration", 0) 

551 state = event.get("state", "") 

552 elapsed_str = self._elapsed_str() if not self.quiet else "" 

553 # Non-pinned --clear path keeps the bare full-screen clear. 

554 if self.clear_screen and sys.stdout.isatty() and depth == 0 and not self.in_pinned_mode: 

555 print("\033[2J\033[H", end="", flush=True) 

556 # Update last-known state at this depth and clear stale deeper entries. 

557 old_state = self.last_state_at_depth.get(depth) 

558 if old_state is not None and old_state != state: 

559 self.prev_state_at_depth[depth] = old_state 

560 self.last_state_at_depth[depth] = state 

561 for k in [k for k in self.last_state_at_depth if k > depth]: 

562 del self.last_state_at_depth[k] 

563 self.prev_state_at_depth.pop(k, None) 

564 # Load child FSM for the current state at this depth 

565 parent_at_depth = self.fsm if depth == 0 else self.child_fsm_stack.get(depth - 1) 

566 if parent_at_depth is not None and state in parent_at_depth.states: 

567 fsm_state = parent_at_depth.states[state] 

568 if fsm_state.loop is not None: 

569 try: 

570 self.child_fsm_stack[depth] = load_loop( 

571 fsm_state.loop, self.loops_dir, Logger() 

572 ) 

573 except (FileNotFoundError, ValueError): 

574 pass 

575 else: 

576 self.child_fsm_stack[depth] = None 

577 else: 

578 self.child_fsm_stack[depth] = None 

579 # Clear stale deeper child FSM entries 

580 for k in [k for k in self.child_fsm_stack if k > depth]: 

581 del self.child_fsm_stack[k] 

582 

583 if self.in_pinned_mode: 

584 state0 = self.last_state_at_depth.get(0) 

585 if state0 is None: 

586 state0 = state 

587 self._redraw_pinned(state0) 

588 elif self.show_diagrams: 

589 from little_loops.cli.loop.layout import ( 

590 _collect_edges, 

591 _filter_main_path_graph, 

592 _render_fsm_diagram, 

593 ) 

594 

595 assert self.facets is not None 

596 

597 # Find deepest active loop — show only that one. 

598 active_fsm_diag = self.fsm 

599 active_highlight = self.last_state_at_depth.get(0) 

600 active_depth_diag = 0 

601 for d in sorted(self.child_fsm_stack.keys()): 

602 child_at_d = self.child_fsm_stack[d] 

603 if child_at_d is not None and (d + 1) in self.last_state_at_depth: 

604 active_fsm_diag = child_at_d 

605 active_highlight = self.last_state_at_depth.get(d + 1) 

606 active_depth_diag = d + 1 

607 

608 # Fall back to full scope when the highlighted state is hidden in main scope. 

609 active_scope = self.facets.scope 

610 fallback_note: str | None = None 

611 if active_scope == "main" and active_highlight is not None: 

612 _filtered_edges, active_reachable = _filter_main_path_graph( 

613 active_fsm_diag, _collect_edges(active_fsm_diag) 

614 ) 

615 if active_highlight not in active_reachable: 

616 active_scope = "full" 

617 fallback_note = ( 

618 f"(showing full diagram: active state " 

619 f"{active_highlight!r} is off the main path)" 

620 ) 

621 diagram = _render_fsm_diagram( 

622 active_fsm_diag, 

623 highlight_state=active_highlight, 

624 highlight_color=self.highlight_color, 

625 edge_label_colors=self.edge_label_colors, 

626 badges=self.badges, 

627 mode=active_scope, 

628 suppress_labels=not self.facets.edge_labels, 

629 title_only=self.facets.state_detail == "title", 

630 ) 

631 # Header: breadcrumb shows immediate parent when inside a sub-loop. 

632 if active_depth_diag > 0: 

633 imm_parent_name = ( 

634 self.fsm.name 

635 if active_depth_diag == 1 

636 else (self.child_fsm_stack.get(active_depth_diag - 2) or self.fsm).name 

637 ) 

638 imm_parent_state = self.last_state_at_depth.get(active_depth_diag - 1, "") 

639 header_text = ( 

640 f"== loop: {active_fsm_diag.name} ({imm_parent_name}{imm_parent_state}) " 

641 ) 

642 else: 

643 header_text = f"== loop: {self.fsm.name} " 

644 header = header_text + "=" * max(0, tw - len(header_text)) 

645 print(header, flush=True) 

646 if fallback_note is not None: 

647 print(fallback_note, flush=True) 

648 for key, value in _artifact_lines(self.fsm, self.loop_path): 

649 print(f" {key}: {colorize(value, '2')}", flush=True) 

650 if self.model is not None: 

651 print(f" model: {colorize(self.model, '2')}", flush=True) 

652 print(diagram, flush=True) 

653 # In pinned mode the iteration line is part of the pinned pane; 

654 # only print it inline for non-pinned paths. 

655 if not self.quiet and not self.in_pinned_mode: 

656 print( 

657 f"{indent}[{self.current_iteration[0]}/{self.fsm.max_iterations}] {colorize(state, '1')} ({colorize(elapsed_str, '2')})", 

658 end="", 

659 flush=True, 

660 ) 

661 

662 elif event_type == "action_start": 

663 if not self.quiet: 

664 action = event.get("action", "") 

665 is_prompt = event.get("is_prompt", False) 

666 if is_prompt: 

667 lines = action.strip().splitlines() 

668 line_count = len(lines) 

669 prompt_badge = "✦" # ✦ 

670 if self.verbose: 

671 print( 

672 f"{indent} -> {colorize(prompt_badge, '2')} {colorize(f'({line_count} lines)', '2')}", 

673 flush=True, 

674 ) 

675 for line in lines: 

676 print(f"{indent} {line}", flush=True) 

677 else: 

678 first_line = lines[0] if lines else "" 

679 preview = first_line[:60] + "..." if len(first_line) > 60 else first_line 

680 print( 

681 f"{indent} -> {colorize(prompt_badge, '2')} {colorize(preview, '2')}", 

682 flush=True, 

683 ) 

684 else: 

685 if self.verbose: 

686 action_display = action 

687 else: 

688 action_display = ( 

689 action[:max_line] + "..." if len(action) > max_line else action 

690 ) 

691 print(f"{indent} -> {colorize(action_display, '2')}", flush=True) 

692 

693 elif event_type == "action_output": 

694 if not self.quiet: 

695 line = event.get("line", "") 

696 if line.strip(): 

697 print(f"{indent} {line}", flush=True) 

698 

699 elif event_type == "action_complete": 

700 actual_model = event.get("model") 

701 if actual_model: 

702 self.model = actual_model 

703 if not self.quiet: 

704 duration_ms = event.get("duration_ms", 0) 

705 exit_code = event.get("exit_code", 0) 

706 duration_sec = duration_ms / 1000 

707 if duration_sec < 60: 

708 duration_str = f"{duration_sec:.1f}s" 

709 else: 

710 minutes = int(duration_sec // 60) 

711 seconds = duration_sec % 60 

712 duration_str = f"{minutes}m {seconds:.0f}s" 

713 parts = [f"{indent} ({colorize(duration_str, '2')})"] 

714 if exit_code == 124: 

715 parts.append(colorize("timed out", "38;5;208")) 

716 elif exit_code != 0: 

717 parts.append(colorize(f"exit: {exit_code}", "38;5;208")) 

718 print(" ".join(parts), flush=True) 

719 

720 elif event_type == "baseline_complete": 

721 if not self.quiet: 

722 h_ms = event.get("harness_duration_ms", 0) 

723 b_ms = event.get("baseline_duration_ms", 0) 

724 h_tok = event.get("harness_tokens", 0) 

725 b_tok = event.get("baseline_tokens", 0) 

726 print( 

727 f"{indent} baseline: {colorize(f'{b_ms / 1000:.1f}s', '2')}, " 

728 f"{colorize(str(b_tok), '2')} tokens | " 

729 f"harness: {colorize(f'{h_ms / 1000:.1f}s', '2')}, " 

730 f"{colorize(str(h_tok), '2')} tokens", 

731 flush=True, 

732 ) 

733 

734 elif event_type == "evaluate": 

735 if not self.quiet: 

736 verdict = event.get("verdict", "") 

737 confidence = event.get("confidence") 

738 reason = event.get("reason", "") 

739 error = event.get("error", "") 

740 _elc = self.edge_label_colors or {} 

741 if verdict in ("yes", "target", "progress"): 

742 _vc = _elc.get("yes", "32") 

743 symbol = colorize("✓", _vc) 

744 verdict_colored = colorize(verdict, _vc) 

745 elif verdict == "no": 

746 _vc = _elc.get("no", "38;5;208") 

747 symbol = colorize("✗", _vc) 

748 verdict_colored = colorize(verdict, _vc) 

749 elif verdict == "error": 

750 _vc = _elc.get("error", "38;5;208") 

751 symbol = colorize("✗", _vc) 

752 verdict_colored = colorize(verdict, _vc) 

753 else: 

754 symbol = colorize("✗", "38;5;208") 

755 verdict_colored = colorize(verdict, "2") 

756 # Build verdict line 

757 if error and verdict == "error": 

758 verdict_line = f"{symbol} {verdict_colored}: {error}" 

759 elif confidence is not None: 

760 verdict_line = ( 

761 f"{symbol} {verdict_colored} {colorize(f'({confidence:.2f})', '2')}" 

762 ) 

763 else: 

764 verdict_line = f"{symbol} {verdict_colored}" 

765 print(f"{indent} {verdict_line}", flush=True) 

766 # Show raw_preview for error verdicts to aid diagnosis 

767 raw_preview = event.get("raw_preview", "") 

768 if raw_preview and verdict == "error": 

769 if self.verbose: 

770 sub_lines = raw_preview.splitlines() or [""] 

771 first, rest = sub_lines[0], sub_lines[1:] 

772 print(f"{indent} raw: {first}", flush=True) 

773 for sub in rest: 

774 print(f"{indent} {sub}", flush=True) 

775 else: 

776 print(f"{indent} raw: {raw_preview[:200]}", flush=True) 

777 # Show reason on a second line if present (and not already shown as error) 

778 if reason and not (error and verdict == "error"): 

779 if self.verbose: 

780 for sub in reason.splitlines() or [""]: 

781 print(f"{indent} {sub}", flush=True) 

782 else: 

783 reason_display = reason[:300] + "..." if len(reason) > 300 else reason 

784 print(f"{indent} {reason_display}", flush=True) 

785 

786 elif event_type == "route": 

787 if not self.quiet: 

788 to_state = event.get("to", "") 

789 print( 

790 f"{indent} {colorize('->', '2')} {colorize(to_state, '1')}", 

791 flush=True, 

792 ) 

793 

794 elif event_type == "max_iterations_summary": 

795 if not self.quiet: 

796 summary_state = event.get("summary_state", "") 

797 iters = event.get("iterations", 0) 

798 msg = f"iteration cap reached ({iters}); running summary state '{summary_state}'" 

799 print(f"{indent} {colorize(msg, '38;5;208')}", flush=True) 

800 

801 elif event_type == "stall_detected": 

802 if not self.quiet: 

803 state = event.get("state", "") 

804 exit_code = event.get("exit_code", 0) 

805 verdict = event.get("verdict", "") 

806 consecutive = event.get("consecutive", 0) 

807 action = event.get("action", "abort") 

808 triple = f"(exit_code={exit_code}, verdict='{verdict}')" 

809 msg = ( 

810 f"stall_detected: state '{state}' produced {triple} " 

811 f"for {consecutive} consecutive iterations -> {action}" 

812 ) 

813 print(f"{indent} {colorize(msg, '38;5;208')}", flush=True) 

814 

815 

816def get_builtin_loops_dir() -> Path: 

817 """Get the path to built-in loops bundled with the plugin.""" 

818 return Path(__file__).parent.parent.parent / "loops" 

819 

820 

821def _artifact_lines(fsm: FSMLoop, loop_path: Path | None) -> list[tuple[str, str]]: 

822 """Extract path-like context values from *fsm* for display in artifact headers. 

823 

824 Returns a list of ``(key, value)`` pairs where *value* is a non-empty string 

825 that starts with ``.``, ``/``, or ``~``, or contains ``/``, and does not 

826 contain ``${`` (unresolved template expression). When *loop_path* is not 

827 ``None``, the first entry is always ``("loop", str(loop_path))``. 

828 """ 

829 pairs: list[tuple[str, str]] = [] 

830 if loop_path is not None: 

831 pairs.append(("loop", str(loop_path))) 

832 context: dict[str, Any] = getattr(fsm, "context", None) or {} 

833 for key, value in context.items(): 

834 if not isinstance(value, str) or not value: 

835 continue 

836 if "${" in value: 

837 continue 

838 if value.startswith(".") or value.startswith("/") or value.startswith("~") or "/" in value: 

839 pairs.append((key, value)) 

840 return pairs 

841 

842 

843def resolve_loop_path(name_or_path: str, loops_dir: Path) -> Path: 

844 """Resolve loop name to file path.""" 

845 path = Path(name_or_path) 

846 if path.exists(): 

847 return path 

848 

849 # Try <loops_dir>/<name>.fsm.yaml first (compiled FSM) 

850 fsm_path = loops_dir / f"{name_or_path}.fsm.yaml" 

851 if fsm_path.exists(): 

852 return fsm_path 

853 

854 # Fall back to <loops_dir>/<name>.yaml 

855 loops_path = loops_dir / f"{name_or_path}.yaml" 

856 if loops_path.exists(): 

857 return loops_path 

858 

859 # Fall back to built-in loops from plugin directory 

860 builtin_path = get_builtin_loops_dir() / f"{name_or_path}.yaml" 

861 if builtin_path.exists(): 

862 return builtin_path 

863 

864 raise FileNotFoundError(f"Loop not found: {name_or_path}") 

865 

866 

867def load_loop(name_or_path: str, loops_dir: Path, logger: Logger) -> FSMLoop: 

868 """Load and validate a loop. 

869 

870 Raises: 

871 FileNotFoundError: If loop not found. 

872 ValueError: If loop is invalid. 

873 """ 

874 from little_loops.fsm.validation import load_and_validate 

875 

876 path = resolve_loop_path(name_or_path, loops_dir) 

877 fsm, _ = load_and_validate(path) 

878 return fsm 

879 

880 

881def load_loop_with_spec( 

882 name_or_path: str, loops_dir: Path, logger: Logger 

883) -> tuple[FSMLoop, dict[str, Any]]: 

884 """Load a loop and return both the FSMLoop and raw spec dict. 

885 

886 Used by commands that need access to raw YAML fields (e.g., description). 

887 

888 Raises: 

889 FileNotFoundError: If loop not found. 

890 ValueError: If loop is invalid. 

891 """ 

892 import yaml 

893 

894 from little_loops.fsm.validation import load_and_validate 

895 

896 path = resolve_loop_path(name_or_path, loops_dir) 

897 

898 with open(path) as f: 

899 spec = yaml.safe_load(f) 

900 

901 fsm, _ = load_and_validate(path) 

902 return fsm, spec 

903 

904 

905def print_execution_plan(fsm: FSMLoop, edge_label_colors: dict[str, str] | None = None) -> None: 

906 """Print dry-run execution plan.""" 

907 _elc = edge_label_colors or {} 

908 _yes_color = _elc.get("yes", "32") 

909 tw = terminal_width() 

910 print(colorize(f"Execution plan for: {fsm.name}", "1")) 

911 print() 

912 print("States:") 

913 for name, state in fsm.states.items(): 

914 terminal_marker = colorize(" [TERMINAL]", _yes_color) if state.terminal else "" 

915 print(f" {colorize(f'[{name}]', '1')}{terminal_marker}") 

916 if state.action: 

917 if state.action_type == "prompt": 

918 lines = state.action.strip().splitlines() 

919 preview = "\n ".join(lines[:3]) 

920 if len(lines) > 3 or len(state.action) > 200: 

921 preview += " ..." 

922 print(f" action: |\n {preview}") 

923 else: 

924 max_action = tw - 16 

925 action_display = ( 

926 state.action[:max_action] + "..." 

927 if len(state.action) > max_action 

928 else state.action 

929 ) 

930 print(f" action: {action_display}") 

931 if state.evaluate: 

932 print(f" evaluate: {state.evaluate.type}") 

933 if state.on_yes: 

934 print(f" on_yes {colorize('->', '2')} {colorize(state.on_yes, '2')}") 

935 if state.on_no: 

936 print(f" on_no {colorize('->', '2')} {colorize(state.on_no, '2')}") 

937 if state.on_error: 

938 print(f" on_error {colorize('->', '2')} {colorize(state.on_error, '2')}") 

939 if state.next: 

940 print(f" next {colorize('->', '2')} {colorize(state.next, '2')}") 

941 if state.route: 

942 print(" route:") 

943 for verdict, target in state.route.routes.items(): 

944 print(f" {verdict} {colorize('->', '2')} {colorize(target, '2')}") 

945 if state.route.default: 

946 print(f" _ {colorize('->', '2')} {colorize(state.route.default, '2')}") 

947 print() 

948 print(f"Initial state: {fsm.initial}") 

949 print(f"Max iterations: {fsm.max_iterations}") 

950 if fsm.timeout: 

951 print(f"Timeout: {fsm.timeout}s") 

952 if fsm.context: 

953 print("Context:") 

954 for key, value in fsm.context.items(): 

955 print(f" {key}: {value!r}") 

956 

957 

958def _make_instance_id(loop_name: str) -> str: 

959 """Generate a unique instance ID for a loop run.""" 

960 return f"{loop_name}-{datetime.now().strftime('%Y%m%dT%H%M%S')}" 

961 

962 

963def run_background( 

964 loop_name: str, 

965 args: argparse.Namespace, 

966 loops_dir: Path, 

967 subcommand: str = "run", 

968 instance_id: str | None = None, 

969) -> int: 

970 """Launch loop as a detached background process. 

971 

972 Spawns a new process with start_new_session=True that re-executes 

973 the loop with --foreground-internal. The parent writes the PID file 

974 and returns immediately. 

975 

976 Args: 

977 subcommand: The ll-loop subcommand to spawn ("run" or "resume"). 

978 instance_id: Pre-resolved instance ID. When provided, skips 

979 _make_instance_id() allocation. Used by cmd_resume() to pass 

980 an already-discovered resumable instance. 

981 

982 Returns: 

983 Exit code (0 = launched successfully). 

984 """ 

985 running_dir = loops_dir / ".running" 

986 running_dir.mkdir(parents=True, exist_ok=True) 

987 

988 # Pre-flight scope conflict check — detect conflicts before spawning the child 

989 # so the user gets immediate feedback instead of a silent child failure. 

990 logger = Logger() 

991 try: 

992 fsm = load_loop(loop_name, loops_dir, logger) 

993 except (FileNotFoundError, ValueError) as e: 

994 print(f"Error loading loop '{loop_name}': {e}", file=sys.stderr) 

995 return 1 

996 

997 lock_manager = LockManager(loops_dir) 

998 # Build context for scope resolution: YAML defaults + CLI --context overrides. 

999 # CLI --context is only forwarded to the child process (line ~1018); we parse 

1000 # it locally so resolve_scope() can use it for the pre-flight conflict check. 

1001 scope_context = dict(fsm.context) 

1002 for kv in getattr(args, "context", None) or []: 

1003 key, _, value = kv.partition("=") 

1004 scope_context[key.strip()] = value.strip() 

1005 scope = resolve_scope(fsm.scope or ["."], scope_context) 

1006 conflict = lock_manager.find_conflict(scope) 

1007 if conflict and not getattr(args, "queue", False) and not getattr(args, "no_lock", False): 

1008 print(f"Scope conflict with running loop: {conflict.loop_name}", file=sys.stderr) 

1009 print(f" Conflicting scope: {conflict.scope}", file=sys.stderr) 

1010 print(" Use --queue to wait for it to finish", file=sys.stderr) 

1011 return 1 

1012 

1013 if instance_id is None: 

1014 instance_id = _make_instance_id(loop_name) 

1015 pid_file = running_dir / f"{instance_id}.pid" 

1016 log_file = running_dir / f"{instance_id}.log" 

1017 

1018 # Build re-exec command with --foreground-internal instead of --background 

1019 cmd = [ 

1020 sys.executable, 

1021 "-m", 

1022 "little_loops.cli.loop", 

1023 subcommand, 

1024 loop_name, 

1025 ] 

1026 input_val = getattr(args, "input", None) 

1027 if input_val is not None: 

1028 cmd.append(input_val) 

1029 cmd.append("--foreground-internal") 

1030 cmd.extend(["--instance-id", instance_id]) 

1031 

1032 # Forward relevant args 

1033 max_iter = getattr(args, "max_iterations", None) 

1034 if max_iter: 

1035 cmd.extend(["--max-iterations", str(max_iter)]) 

1036 if getattr(args, "no_llm", False): 

1037 cmd.append("--no-llm") 

1038 llm_model = getattr(args, "llm_model", None) 

1039 if llm_model: 

1040 cmd.extend(["--llm-model", llm_model]) 

1041 if getattr(args, "verbose", False): 

1042 cmd.append("--verbose") 

1043 show_diagrams_raw = getattr(args, "show_diagrams", None) 

1044 if show_diagrams_raw is not None: 

1045 if show_diagrams_raw is True: 

1046 cmd.append("--show-diagrams") 

1047 else: 

1048 cmd.extend(["--show-diagrams", show_diagrams_raw]) 

1049 diagram_edge_labels = getattr(args, "diagram_edge_labels", None) 

1050 if diagram_edge_labels is not None: 

1051 cmd.extend(["--diagram-edge-labels", diagram_edge_labels]) 

1052 diagram_state_detail = getattr(args, "diagram_state_detail", None) 

1053 if diagram_state_detail is not None: 

1054 cmd.extend(["--diagram-state-detail", diagram_state_detail]) 

1055 diagram_scope = getattr(args, "diagram_scope", None) 

1056 if diagram_scope is not None: 

1057 cmd.extend(["--diagram-scope", diagram_scope]) 

1058 if getattr(args, "quiet", False): 

1059 cmd.append("--quiet") 

1060 if getattr(args, "queue", False): 

1061 cmd.append("--queue") 

1062 if getattr(args, "no_lock", False): 

1063 cmd.append("--no-lock") 

1064 for kv in getattr(args, "context", None) or []: 

1065 cmd.extend(["--context", kv]) 

1066 program_md = getattr(args, "program_md", None) 

1067 if program_md is not None: 

1068 cmd.extend(["--program-md", str(program_md)]) 

1069 delay = getattr(args, "delay", None) 

1070 if delay is not None: 

1071 cmd.extend(["--delay", str(delay)]) 

1072 handoff_threshold = getattr(args, "handoff_threshold", None) 

1073 if handoff_threshold is not None: 

1074 cmd.extend(["--handoff-threshold", str(handoff_threshold)]) 

1075 context_limit = getattr(args, "context_limit", None) 

1076 if context_limit is not None: 

1077 cmd.extend(["--context-limit", str(context_limit)]) 

1078 if getattr(args, "baseline", False): 

1079 cmd.append("--baseline") 

1080 baseline_skill = getattr(args, "baseline_skill", None) 

1081 if baseline_skill is not None: 

1082 cmd.extend(["--baseline-skill", baseline_skill]) 

1083 items = getattr(args, "items", None) 

1084 if items is not None: 

1085 cmd.extend(["--items", str(items)]) 

1086 

1087 log_file.parent.mkdir(parents=True, exist_ok=True) 

1088 with open(log_file, "w") as log_fh: 

1089 process = subprocess.Popen( 

1090 cmd, 

1091 start_new_session=True, 

1092 stdout=log_fh, 

1093 stderr=log_fh, 

1094 stdin=subprocess.DEVNULL, 

1095 ) 

1096 

1097 pid_file.write_text(str(process.pid)) 

1098 print( 

1099 f"Loop {colorize(loop_name, '1')} started in background (PID: {colorize(str(process.pid), '2')})" 

1100 ) 

1101 print(f" Log: {colorize(str(log_file), '2')}") 

1102 print(f" Status: {colorize(f'll-loop status {loop_name}', '2')}") 

1103 print(f" Stop: {colorize(f'll-loop stop {loop_name}', '2')}") 

1104 return 0 

1105 

1106 

1107def run_foreground( 

1108 executor: Any, 

1109 fsm: FSMLoop, 

1110 args: argparse.Namespace, 

1111 highlight_color: str = "32", 

1112 edge_label_colors: dict[str, str] | None = None, 

1113 badges: dict[str, str] | None = None, 

1114 mode: str = "run", 

1115 instance_id: str | None = None, 

1116 running_dir: Path | None = None, 

1117 loop_path: Path | None = None, 

1118 model: str | None = None, 

1119) -> int: 

1120 """Run loop with progress display. 

1121 

1122 Args: 

1123 highlight_color: ANSI SGR code for the active FSM state highlight in verbose mode. 

1124 edge_label_colors: Optional label→SGR-code mapping for transition edge labels. 

1125 badges: Optional glyph-key→string mapping for state type badges in FSM diagrams. 

1126 mode: ``"run"`` (default) calls ``executor.run()``; ``"resume"`` calls 

1127 ``executor.resume()`` so a resumed loop reuses the same display-wiring 

1128 path as a fresh run (BUG-1645). In ``"resume"`` mode a ``None`` result 

1129 from ``executor.resume()`` is treated as "nothing to resume": a warning 

1130 is logged and exit code 1 is returned before any alt-screen sequences 

1131 are emitted. 

1132 

1133 instance_id: When provided and not a background-spawned child 

1134 (``foreground_internal=False``), stdout/stderr are teed to 

1135 ``{running_dir}/{instance_id}.log`` with ANSI sequences stripped. 

1136 running_dir: Directory for the log file. Defaults to ``.running`` 

1137 alongside the loops directory. 

1138 Returns: 

1139 Exit code (0 = success). 

1140 """ 

1141 if mode not in ("run", "resume"): 

1142 raise ValueError(f"run_foreground: invalid mode {mode!r}; expected 'run' or 'resume'") 

1143 

1144 _orig_stdout = sys.stdout 

1145 _orig_stderr = sys.stderr 

1146 _log_fh: Any = None 

1147 if instance_id is not None and not getattr(args, "foreground_internal", False): 

1148 _log_path = (running_dir or Path(".running")) / f"{instance_id}.log" 

1149 _log_path.parent.mkdir(parents=True, exist_ok=True) 

1150 _log_fh = open(_log_path, "w") 

1151 sys.stdout = _TeeWriter(_orig_stdout, _log_fh) # type: ignore[assignment] 

1152 sys.stderr = _TeeWriter(_orig_stderr, _log_fh) # type: ignore[assignment] 

1153 

1154 try: 

1155 # Create the state feed renderer — encapsulates display state and event handling. 

1156 renderer = StateFeedRenderer( 

1157 fsm, 

1158 args, 

1159 highlight_color=highlight_color, 

1160 edge_label_colors=edge_label_colors, 

1161 badges=badges, 

1162 loops_dir=getattr(executor, "loops_dir", Path(".")), 

1163 loop_path=loop_path, 

1164 model=model, 

1165 ) 

1166 if not renderer.quiet: 

1167 print(f"Running loop: {colorize(fsm.name, '1')}") 

1168 print(f"Max iterations: {colorize(str(fsm.max_iterations), '2')}") 

1169 for key, value in _artifact_lines(fsm, loop_path): 

1170 print(f" {key}: {colorize(value, '2')}") 

1171 if model is not None: 

1172 print(f" model: {colorize(model, '2')}") 

1173 print() 

1174 

1175 # Wire progress display via the EventBus on PersistentExecutor 

1176 if not renderer.quiet or renderer.show_diagrams: 

1177 if hasattr(executor, "event_bus"): 

1178 executor.event_bus.register(renderer.handle_event) 

1179 else: 

1180 executor._on_event = renderer.handle_event 

1181 

1182 # Capture the last failure-relevant message from the event stream so the 

1183 # completion summary can surface *why* a run failed. This is the only reliable 

1184 # source: on on_error / exception routes the executor leaves prev_result and 

1185 # captured empty, and alt-screen teardown or non-verbose mode hide the live 

1186 # output. action_error carries interpolation/exception reasons; a non-zero 

1187 # action_complete carries the failing state's stdout. 

1188 _failure_capture: dict[str, str] = {} 

1189 

1190 def _capture_failure(event: dict[str, Any]) -> None: 

1191 ev = event.get("event") 

1192 if ev == "action_error" and event.get("error"): 

1193 _failure_capture["error"] = str(event["error"]) 

1194 elif ev == "action_complete" and event.get("exit_code") not in (0, None): 

1195 out = event.get("output") or event.get("output_preview") or "" 

1196 if out: 

1197 _failure_capture["output"] = str(out) 

1198 

1199 if not renderer.quiet: 

1200 if hasattr(executor, "event_bus"): 

1201 executor.event_bus.register(_capture_failure) 

1202 else: 

1203 _prev_capture_cb = executor._on_event 

1204 

1205 def _chained_capture(event: dict[str, Any]) -> None: 

1206 if _prev_capture_cb: 

1207 _prev_capture_cb(event) 

1208 _capture_failure(event) 

1209 

1210 executor._on_event = _chained_capture 

1211 

1212 # Wire follow mode — streams history-formatted events independently of quiet 

1213 if getattr(args, "follow", False): 

1214 from little_loops.cli.loop.info import _format_history_event 

1215 

1216 tw = terminal_width() 

1217 _verbose = renderer.verbose 

1218 

1219 def _follow_callback(event: dict[str, Any]) -> None: 

1220 line = _format_history_event(event, verbose=_verbose, width=tw) 

1221 if line is not None: 

1222 print(line, flush=True) 

1223 

1224 if hasattr(executor, "event_bus"): 

1225 executor.event_bus.register(_follow_callback) 

1226 else: 

1227 _prev_on_event = executor._on_event 

1228 

1229 def _chained(event: dict[str, Any]) -> None: 

1230 if _prev_on_event: 

1231 _prev_on_event(event) 

1232 _follow_callback(event) 

1233 

1234 executor._on_event = _chained 

1235 

1236 # Enter alternate screen buffer when showing diagrams with clear to prevent 

1237 # scrollback contamination from diagrams taller than the terminal height. 

1238 global _using_alt_screen 

1239 if renderer.show_diagrams and renderer.clear_screen and sys.stdout.isatty(): 

1240 _using_alt_screen = True 

1241 print("\033[?1049h\033[H", end="", flush=True) 

1242 _install_sigwinch_handler() 

1243 

1244 try: 

1245 if mode == "resume": 

1246 result = executor.resume() 

1247 # "Nothing to resume" path: no run actually executed, so don't fall 

1248 # through to completion-line formatting. Exit cleanly with code 1. 

1249 if result is None: 

1250 Logger().warning(f"Nothing to resume for: {fsm.name}") 

1251 return 1 

1252 else: 

1253 result = executor.run() 

1254 finally: 

1255 # Remember whether we were in the alt-screen so the summary block can 

1256 # decide whether the failing state's output still needs re-printing 

1257 # (alt-screen teardown wipes it from scrollback). 

1258 _was_alt_screen = _using_alt_screen 

1259 if _using_alt_screen: 

1260 # Reset DECSTBM scroll region BEFORE exiting alt-screen, otherwise 

1261 # the main buffer is left with a restricted scroll region (one of 

1262 # the Success Metrics for ENH-1642). 

1263 print("\033[r", end="", flush=True) 

1264 print("\033[?1049l", end="", flush=True) 

1265 _using_alt_screen = False 

1266 _restore_sigwinch_handler() 

1267 

1268 if not renderer.quiet: 

1269 print() 

1270 duration_sec = result.duration_ms / 1000 

1271 if duration_sec < 60: 

1272 duration_str = f"{duration_sec:.1f}s" 

1273 else: 

1274 minutes = int(duration_sec // 60) 

1275 seconds = duration_sec % 60 

1276 duration_str = f"{minutes}m {seconds:.0f}s" 

1277 # A terminal state whose name is not "done" represents failure (the 

1278 # established convention; see sub-FSM routing in executor.py). Colour 

1279 # only genuine success green so a `failed` terminal doesn't read as a pass. 

1280 _is_success = result.terminated_by in ("terminal", "signal", "handoff") and not ( 

1281 result.terminated_by == "terminal" and result.final_state != "done" 

1282 ) 

1283 if _is_success: 

1284 state_colored = colorize(result.final_state, "32") 

1285 else: 

1286 state_colored = colorize(result.final_state, "38;5;208") 

1287 

1288 # Surface the failing state's output as the failure reason. It is otherwise 

1289 # invisible: the alt-screen wipes it on teardown, and in non-verbose mode the 

1290 # per-state stdout is never printed inline at all. Skip only the verbose 

1291 # non-alt-screen case, where the live renderer already echoed it. 

1292 if not _is_success and (_was_alt_screen or not renderer.verbose): 

1293 reason_text = ( 

1294 _failure_capture.get("error") 

1295 or _failure_capture.get("output") 

1296 or result.error 

1297 or "" 

1298 ).strip() 

1299 if reason_text: 

1300 print() 

1301 print(colorize("Failure reason:", "1")) 

1302 for _line in reason_text.splitlines()[-40:]: # cap to bound scrollback 

1303 print(colorize("│ " + _line, "2")) 

1304 

1305 completion_prefix = "Resumed and completed" if mode == "resume" else "Loop completed" 

1306 rejection_count = 0 

1307 for _t in getattr(getattr(executor, "event_bus", None), "_transports", []): 

1308 if hasattr(_t, "get_stats"): 

1309 rejection_count += _t.get_stats().get("client_rejections", 0) 

1310 suffix = f", {rejection_count} client rejections" if rejection_count > 0 else "" 

1311 

1312 # Print per-state token/cost table if usage data was collected 

1313 run_dir = fsm.context.get("run_dir", "") 

1314 if run_dir: 

1315 try: 

1316 _print_usage_summary(Path(run_dir) / "usage.jsonl") 

1317 except Exception: 

1318 pass # Non-fatal: display failure shouldn't block exit 

1319 

1320 print( 

1321 f"{completion_prefix}: {state_colored} ({result.iterations} iterations, {duration_str}{suffix})" 

1322 ) 

1323 

1324 # FEAT-1822: Print A/B summary if baseline was enabled 

1325 if run_dir: 

1326 ab_path = Path(run_dir) / "ab.json" 

1327 if ab_path.exists(): 

1328 try: 

1329 _print_ab_summary(ab_path) 

1330 except Exception: 

1331 pass # Non-fatal: display failure shouldn't block exit 

1332 

1333 return EXIT_CODES.get(result.terminated_by, 1) 

1334 finally: 

1335 sys.stdout = _orig_stdout 

1336 sys.stderr = _orig_stderr 

1337 if _log_fh is not None: 

1338 _log_fh.close() 

1339 

1340 

1341def _print_usage_summary(usage_path: Path) -> None: 

1342 """Print per-state token usage summary from usage.jsonl. 

1343 

1344 Args: 

1345 usage_path: Path to usage.jsonl written by PersistentExecutor 

1346 """ 

1347 from collections import defaultdict 

1348 

1349 if not usage_path.exists(): 

1350 return 

1351 lines = usage_path.read_text(encoding="utf-8").splitlines() 

1352 if not lines: 

1353 return 

1354 

1355 per_state: dict[str, dict[str, Any]] = defaultdict( 

1356 lambda: { 

1357 "invocations": 0, 

1358 "input": 0, 

1359 "output": 0, 

1360 "cache_read": 0, 

1361 "cache_creation": 0, 

1362 "model": "unknown", 

1363 "est_cost": 0.0, 

1364 "has_unknown_model": False, 

1365 } 

1366 ) 

1367 for raw in lines: 

1368 try: 

1369 row = json.loads(raw) 

1370 except json.JSONDecodeError: 

1371 continue 

1372 state = row.get("state", "unknown") 

1373 model = row.get("model", "unknown") 

1374 inp = row.get("input_tokens", 0) 

1375 out = row.get("output_tokens", 0) 

1376 cr = row.get("cache_read_tokens", 0) 

1377 cc = row.get("cache_creation_tokens", 0) 

1378 bucket = per_state[state] 

1379 bucket["invocations"] += 1 

1380 bucket["input"] += inp 

1381 bucket["output"] += out 

1382 bucket["cache_read"] += cr 

1383 bucket["cache_creation"] += cc 

1384 bucket["model"] = model 

1385 cost = estimate_cost_usd(model, inp, out, cr, cc) 

1386 if cost is None: 

1387 bucket["has_unknown_model"] = True 

1388 else: 

1389 bucket["est_cost"] += cost 

1390 

1391 if not per_state: 

1392 return 

1393 

1394 print() 

1395 print(f"{'state':<24} {'invoc':>5} {'input':>8} {'output':>8} {'cache':>8} {'est_cost':>10}") 

1396 print("-" * 68) 

1397 for state, b in sorted(per_state.items()): 

1398 cache = b["cache_read"] + b["cache_creation"] 

1399 cost_str = f"${b['est_cost']:.4f}" if not b["has_unknown_model"] else "n/a" 

1400 print( 

1401 f"{state:<24} {b['invocations']:>5} {b['input']:>8} {b['output']:>8} {cache:>8} {cost_str:>10}" 

1402 ) 

1403 print() 

1404 

1405 

1406def _print_ab_summary(ab_path: Path) -> None: 

1407 """Print A/B comparison summary from ab.json. 

1408 

1409 Args: 

1410 ab_path: Path to ab.json file written by the executor 

1411 """ 

1412 from little_loops.ab_writer import read_ab_json 

1413 

1414 results = read_ab_json(str(ab_path.parent)) 

1415 if results is None or not results.per_item: 

1416 return 

1417 

1418 n = len(results.per_item) 

1419 harness_pct = results.harness_pass_rate * 100 

1420 baseline_pct = results.baseline_pass_rate * 100 

1421 delta_pct = results.delta * 100 

1422 

1423 tokens_ratio = ( 

1424 results.median_tokens_harness / results.median_tokens_baseline 

1425 if results.median_tokens_baseline > 0 

1426 else 0 

1427 ) 

1428 dur_ratio = ( 

1429 results.median_duration_harness / results.median_duration_baseline 

1430 if results.median_duration_baseline > 0 

1431 else 0 

1432 ) 

1433 

1434 def _fmt_dur(ms: float) -> str: 

1435 if ms < 1000: 

1436 return f"{ms:.0f}ms" 

1437 elif ms < 60000: 

1438 return f"{ms / 1000:.1f}s" 

1439 else: 

1440 return f"{ms / 60000:.1f}m" 

1441 

1442 tokens_dir = "+" if tokens_ratio > 1 else "-" 

1443 dur_dir = "+" if dur_ratio > 1 else "-" 

1444 

1445 print() 

1446 print(f"A/B Summary (n={n})") 

1447 print(f" Harness pass-rate: {harness_pct:.0f}%") 

1448 print(f" Baseline pass-rate: {baseline_pct:.0f}%") 

1449 print(f" Delta: {delta_pct:+.0f}%") 

1450 print() 

1451 print( 

1452 f" Median tokens: harness={results.median_tokens_harness} " 

1453 f"baseline={results.median_tokens_baseline} " 

1454 f"({tokens_dir}{abs(tokens_ratio - 1) * 100:.0f}%)" 

1455 ) 

1456 print( 

1457 f" Median duration: harness={_fmt_dur(results.median_duration_harness)} " 

1458 f"baseline={_fmt_dur(results.median_duration_baseline)} " 

1459 f"({dur_dir}{abs(dur_ratio - 1) * 100:.0f}%)" 

1460 ) 

1461 

1462 # Verdict line 

1463 quality_verdict = ( 

1464 "harness wins on quality" 

1465 if results.delta > 0 

1466 else "baseline wins on quality" 

1467 if results.delta < 0 

1468 else "no quality difference" 

1469 ) 

1470 cost_verdict = ( 

1471 f"costs ~{abs(tokens_ratio - 1) * 100:.0f}% more tokens" 

1472 if tokens_ratio > 1 

1473 else ( 

1474 f"costs ~{abs(tokens_ratio - 1) * 100:.0f}% fewer tokens" 

1475 if tokens_ratio < 1 

1476 else "same token cost" 

1477 ) 

1478 ) 

1479 print(f" Verdict: {quality_verdict}, {cost_verdict}") 

1480 print() 

1481 print(f"Per-item: {ab_path}")