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

317 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-04-11 23:20 -0500

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

2 

3from __future__ import annotations 

4 

5import argparse 

6import signal 

7import subprocess 

8import sys 

9import time 

10from pathlib import Path 

11from types import FrameType 

12from typing import TYPE_CHECKING, Any 

13 

14from little_loops.cli.output import colorize, terminal_width 

15from little_loops.logger import Logger 

16 

17if TYPE_CHECKING: 

18 from little_loops.fsm.schema import FSMLoop 

19 

20# Exit code mapping for terminated_by values 

21EXIT_CODES: dict[str, int] = { 

22 "terminal": 0, 

23 "signal": 0, 

24 "handoff": 0, 

25 "max_iterations": 1, 

26 "timeout": 1, 

27} 

28 

29# Module-level shutdown state for signal handling 

30_loop_shutdown_requested: bool = False 

31_loop_executor: Any = None 

32_loop_pid_file: Path | None = None 

33_using_alt_screen: bool = False 

34 

35 

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

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

38 

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

40 Second signal: Force immediate exit. 

41 """ 

42 global _loop_shutdown_requested, _using_alt_screen 

43 if _loop_shutdown_requested: 

44 # Second signal - force exit 

45 if _loop_pid_file is not None: 

46 _loop_pid_file.unlink(missing_ok=True) 

47 if _using_alt_screen: 

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

49 print("\nForce shutdown requested", file=sys.stderr) 

50 sys.exit(1) 

51 _loop_shutdown_requested = True 

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

53 if _loop_executor is not None: 

54 _loop_executor.request_shutdown() 

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

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

57 if inner is not None: 

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

59 if runner is not None: 

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

61 if proc is not None: 

62 proc.kill() 

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

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

65 if fsm_proc is not None: 

66 fsm_proc.kill() 

67 

68 

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

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

71 

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

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

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

75 

76 Args: 

77 executor: The PersistentExecutor instance to request shutdown on. 

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

79 """ 

80 global _loop_shutdown_requested, _loop_executor, _loop_pid_file 

81 _loop_shutdown_requested = False 

82 _loop_executor = executor 

83 _loop_pid_file = pid_file 

84 signal.signal(signal.SIGINT, _loop_signal_handler) 

85 signal.signal(signal.SIGTERM, _loop_signal_handler) 

86 

87 

88def get_builtin_loops_dir() -> Path: 

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

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

91 

92 

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

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

95 path = Path(name_or_path) 

96 if path.exists(): 

97 return path 

98 

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

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

101 if fsm_path.exists(): 

102 return fsm_path 

103 

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

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

106 if loops_path.exists(): 

107 return loops_path 

108 

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

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

111 if builtin_path.exists(): 

112 return builtin_path 

113 

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

115 

116 

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

118 """Load and validate a loop. 

119 

120 Raises: 

121 FileNotFoundError: If loop not found. 

122 ValueError: If loop is invalid. 

123 """ 

124 from little_loops.fsm.validation import load_and_validate 

125 

126 path = resolve_loop_path(name_or_path, loops_dir) 

127 fsm, _ = load_and_validate(path) 

128 return fsm 

129 

130 

131def load_loop_with_spec( 

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

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

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

135 

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

137 

138 Raises: 

139 FileNotFoundError: If loop not found. 

140 ValueError: If loop is invalid. 

141 """ 

142 import yaml 

143 

144 from little_loops.fsm.validation import load_and_validate 

145 

146 path = resolve_loop_path(name_or_path, loops_dir) 

147 

148 with open(path) as f: 

149 spec = yaml.safe_load(f) 

150 

151 fsm, _ = load_and_validate(path) 

152 return fsm, spec 

153 

154 

155def print_execution_plan(fsm: FSMLoop) -> None: 

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

157 tw = terminal_width() 

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

159 print() 

160 print("States:") 

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

162 terminal_marker = colorize(" [TERMINAL]", "32") if state.terminal else "" 

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

164 if state.action: 

165 if state.action_type == "prompt": 

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

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

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

169 preview += " ..." 

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

171 else: 

172 max_action = tw - 16 

173 action_display = ( 

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

175 if len(state.action) > max_action 

176 else state.action 

177 ) 

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

179 if state.evaluate: 

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

181 if state.on_yes: 

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

183 if state.on_no: 

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

185 if state.on_error: 

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

187 if state.next: 

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

189 if state.route: 

190 print(" route:") 

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

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

193 if state.route.default: 

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

195 print() 

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

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

198 if fsm.timeout: 

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

200 if fsm.context: 

201 print("Context:") 

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

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

204 

205 

206def run_background( 

207 loop_name: str, args: argparse.Namespace, loops_dir: Path, subcommand: str = "run" 

208) -> int: 

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

210 

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

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

213 and returns immediately. 

214 

215 Args: 

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

217 

218 Returns: 

219 Exit code (0 = launched successfully). 

220 """ 

221 running_dir = loops_dir / ".running" 

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

223 

224 pid_file = running_dir / f"{loop_name}.pid" 

225 log_file = running_dir / f"{loop_name}.log" 

226 

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

228 cmd = [ 

229 sys.executable, 

230 "-m", 

231 "little_loops.cli.loop", 

232 subcommand, 

233 loop_name, 

234 "--foreground-internal", 

235 ] 

236 

237 # Forward relevant args 

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

239 if max_iter: 

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

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

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

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

244 if llm_model: 

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

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

247 cmd.append("--verbose") 

248 if getattr(args, "show_diagrams", False): 

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

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

251 cmd.append("--quiet") 

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

253 cmd.append("--queue") 

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

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

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

257 if delay is not None: 

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

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

260 if handoff_threshold is not None: 

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

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

263 if context_limit is not None: 

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

265 

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

267 process = subprocess.Popen( 

268 cmd, 

269 start_new_session=True, 

270 stdout=log_fh, 

271 stderr=log_fh, 

272 stdin=subprocess.DEVNULL, 

273 ) 

274 

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

276 print( 

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

278 ) 

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

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

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

282 return 0 

283 

284 

285def run_foreground( 

286 executor: Any, 

287 fsm: FSMLoop, 

288 args: argparse.Namespace, 

289 highlight_color: str = "32", 

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

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

292) -> int: 

293 """Run loop with progress display. 

294 

295 Args: 

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

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

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

299 

300 Returns: 

301 Exit code (0 = success). 

302 """ 

303 quiet = getattr(args, "quiet", False) 

304 verbose = getattr(args, "verbose", False) 

305 show_diagrams = getattr(args, "show_diagrams", False) 

306 clear_screen = getattr(args, "clear", False) 

307 if not quiet: 

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

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

310 print() 

311 

312 current_iteration = [0] # Use list to allow mutation in closure 

313 last_state_at_depth: dict[int, str] = {} # Track last known state per nesting depth 

314 child_fsm_stack: dict[int, FSMLoop | None] = {} # Active child FSM per depth 

315 loop_start_time = time.monotonic() 

316 

317 def display_progress(event: dict) -> None: 

318 """Display progress for events.""" 

319 event_type = event.get("event") 

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

321 indent = " " * depth 

322 tw = terminal_width() 

323 max_line = tw - 8 - len(indent) 

324 

325 if event_type == "state_enter": 

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

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

328 if not quiet: 

329 elapsed_int = int(time.monotonic() - loop_start_time) 

330 if elapsed_int < 60: 

331 elapsed_str = f"{elapsed_int}s" 

332 else: 

333 elapsed_str = f"{elapsed_int // 60}m {elapsed_int % 60}s" 

334 if clear_screen and sys.stdout.isatty() and depth == 0: 

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

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

337 last_state_at_depth[depth] = state 

338 for k in [k for k in last_state_at_depth if k > depth]: 

339 del last_state_at_depth[k] 

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

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

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

343 fsm_state = parent_at_depth.states[state] 

344 if fsm_state.loop is not None: 

345 try: 

346 child_fsm_stack[depth] = load_loop( 

347 fsm_state.loop, executor.loops_dir, Logger() 

348 ) 

349 except (FileNotFoundError, ValueError): 

350 pass # leave child_fsm_stack[depth] unchanged on failure 

351 else: 

352 child_fsm_stack[depth] = None 

353 else: 

354 child_fsm_stack[depth] = None 

355 # Clear stale deeper child FSM entries 

356 for k in [k for k in child_fsm_stack if k > depth]: 

357 del child_fsm_stack[k] 

358 if show_diagrams: 

359 from little_loops.cli.loop.layout import _render_fsm_diagram 

360 

361 diagram = _render_fsm_diagram( 

362 fsm, 

363 highlight_state=last_state_at_depth.get(0), 

364 highlight_color=highlight_color, 

365 edge_label_colors=edge_label_colors, 

366 badges=badges, 

367 ) 

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

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

370 print(header, flush=True) 

371 print(diagram, flush=True) 

372 for d, child_fsm_at_d in sorted(child_fsm_stack.items()): 

373 if child_fsm_at_d is not None and (d + 1) in last_state_at_depth: 

374 child_name = child_fsm_at_d.name 

375 separator_text = f"\u2500\u2500 sub-loop: {child_name} " 

376 separator = separator_text + "\u2500" * max(0, tw - len(separator_text)) 

377 print(separator, flush=True) 

378 child_diagram = _render_fsm_diagram( 

379 child_fsm_at_d, 

380 highlight_state=last_state_at_depth.get(d + 1), 

381 highlight_color=highlight_color, 

382 edge_label_colors=edge_label_colors, 

383 badges=badges, 

384 ) 

385 print(child_diagram, flush=True) 

386 if not quiet: 

387 print( 

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

389 end="", 

390 flush=True, 

391 ) 

392 

393 elif event_type == "action_start": 

394 if not quiet: 

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

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

397 if is_prompt: 

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

399 line_count = len(lines) 

400 prompt_badge = "\u2726" # ✦ 

401 print( 

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

403 flush=True, 

404 ) 

405 show_count = line_count if verbose else min(5, line_count) 

406 for line in lines[:show_count]: 

407 display = line[:max_line] + "..." if len(line) > max_line else line 

408 print(f"{indent} {display}", flush=True) 

409 if line_count > show_count: 

410 print( 

411 f"{indent} ... ({line_count - show_count} more lines)", flush=True 

412 ) 

413 else: 

414 action_display = action[:max_line] + "..." if len(action) > max_line else action 

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

416 

417 elif event_type == "action_output": 

418 if not quiet and verbose: 

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

420 if line.strip(): 

421 display = line[:max_line] + "..." if len(line) > max_line else line 

422 print(f"{indent} {display}", flush=True) 

423 

424 elif event_type == "action_complete": 

425 if not quiet: 

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

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

428 output_preview = event.get("output_preview") 

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

430 duration_sec = duration_ms / 1000 

431 if duration_sec < 60: 

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

433 else: 

434 minutes = int(duration_sec // 60) 

435 seconds = duration_sec % 60 

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

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

438 if exit_code == 124: 

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

440 elif exit_code != 0: 

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

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

443 # Skip output preview for prompt states (already streamed) and in verbose mode 

444 # (lines already shown via action_output events). In non-verbose mode, show 

445 # a tail summary for shell states. 

446 if output_preview and not is_prompt and not verbose: 

447 lines = [ln for ln in output_preview.splitlines() if ln.strip()] 

448 show_lines = lines[-8:] if lines else [] 

449 for line in show_lines: 

450 display = line[:max_line] + "..." if len(line) > max_line else line 

451 print(f"{indent} {display}", flush=True) 

452 

453 elif event_type == "evaluate": 

454 if not quiet: 

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

456 confidence = event.get("confidence") 

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

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

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

460 symbol = colorize("\u2713", "32") # green checkmark 

461 verdict_colored = colorize(verdict, "32") 

462 else: 

463 symbol = colorize("\u2717", "38;5;208") # orange x mark 

464 verdict_colored = ( 

465 colorize(verdict, "38;5;208") 

466 if verdict in ("no", "error") 

467 else colorize(verdict, "2") 

468 ) 

469 # Build verdict line 

470 if error and verdict == "error": 

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

472 elif confidence is not None: 

473 verdict_line = ( 

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

475 ) 

476 else: 

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

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

479 # Show raw_preview for error verdicts to aid diagnosis 

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

481 if raw_preview and verdict == "error": 

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

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

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

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

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

487 

488 elif event_type == "route": 

489 if not quiet: 

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

491 print(f"{indent} {colorize('->', '2')} {colorize(to_state, '1')}", flush=True) 

492 

493 # Wire progress display via the EventBus on PersistentExecutor 

494 if not quiet or show_diagrams: 

495 if hasattr(executor, "event_bus"): 

496 executor.event_bus.register(display_progress) 

497 else: 

498 executor._on_event = display_progress 

499 

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

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

502 global _using_alt_screen 

503 if show_diagrams and clear_screen and sys.stdout.isatty(): 

504 _using_alt_screen = True 

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

506 

507 try: 

508 result = executor.run() 

509 finally: 

510 if _using_alt_screen: 

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

512 _using_alt_screen = False 

513 

514 if not quiet: 

515 print() 

516 duration_sec = result.duration_ms / 1000 

517 if duration_sec < 60: 

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

519 else: 

520 minutes = int(duration_sec // 60) 

521 seconds = duration_sec % 60 

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

523 if result.terminated_by == "terminal": 

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

525 else: 

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

527 print(f"Loop completed: {state_colored} ({result.iterations} iterations, {duration_str})") 

528 

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