Coverage for little_loops / cli / loop / lifecycle.py: 0%

431 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-28 13:07 -0500

1"""ll-loop lifecycle subcommands: status, stop, resume.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import atexit 

7import json 

8import os 

9import signal 

10import time 

11from pathlib import Path 

12 

13from little_loops.cli.loop._helpers import ( 

14 load_loop, 

15 register_loop_signal_handlers, 

16 run_background, 

17) 

18from little_loops.fsm.concurrency import _process_alive 

19from little_loops.fsm.persistence import ( 

20 LoopState, 

21 StatePersistence, 

22 _find_instances, 

23 _read_pid_file, 

24 _reconcile_stale_running, 

25) 

26from little_loops.logger import Logger 

27 

28 

29def _format_relative_time(seconds: float) -> str: 

30 """Format seconds as a human-readable relative time string (e.g., '3m ago'). 

31 

32 Delegates to the shared ``format_relative_time`` in ``cli.output``. 

33 """ 

34 from little_loops.cli.output import format_relative_time 

35 

36 return format_relative_time(seconds) 

37 

38 

39def _format_log_label(running_dir: Path, stem: str) -> str: 

40 """Return one of three Log: labels based on run-mode signals. 

41 

42 - log exists → path string (background run, normal) 

43 - no log, no pid → foreground run (output went to terminal) 

44 - no log, pid exists → background run whose log was deleted (something wrong) 

45 """ 

46 log_file = running_dir / f"{stem}.log" 

47 if log_file.exists(): 

48 return str(log_file) 

49 pid_file = running_dir / f"{stem}.pid" 

50 if pid_file.exists(): 

51 return f"(expected {log_file}, missing)" 

52 return "(foreground run — output went to terminal)" 

53 

54 

55def _get_events_info(running_dir: Path, stem: str) -> tuple[str | None, str | None]: 

56 """Return (events_file_str, formatted_detail_line) for the events.jsonl file. 

57 

58 Returns (None, None) if the file doesn't exist. 

59 Detail line format: 'Events: <path> (N events, last Xm ago)' 

60 """ 

61 events_file = running_dir / f"{stem}.events.jsonl" 

62 if not events_file.exists(): 

63 return None, None 

64 try: 

65 lines = [ln for ln in events_file.read_text().splitlines() if ln.strip()] 

66 count = len(lines) 

67 detail = f"({count} events)" 

68 if lines: 

69 try: 

70 last_entry = json.loads(lines[-1]) 

71 last_ts = last_entry.get("ts") 

72 if last_ts is not None: 

73 from datetime import UTC, datetime 

74 

75 dt = datetime.fromisoformat(last_ts) 

76 age = (datetime.now(tz=UTC) - dt).total_seconds() 

77 detail = f"({count} events, last {_format_relative_time(age)})" 

78 except (json.JSONDecodeError, AttributeError, ValueError, OverflowError): 

79 pass 

80 return str(events_file), f"Events: {events_file} {detail}" 

81 except OSError: 

82 return str(events_file), f"Events: {events_file}" 

83 

84 

85def _kill_with_timeout(pid: int, label: str, logger: Logger) -> None: 

86 """Send SIGTERM to pid; escalate to SIGKILL after 10 s if still alive.""" 

87 os.kill(pid, signal.SIGTERM) 

88 for _ in range(10): 

89 time.sleep(1) 

90 if not _process_alive(pid): 

91 return 

92 try: 

93 os.kill(pid, signal.SIGKILL) 

94 logger.warning(f"Sent SIGKILL to {label} (PID: {pid})") 

95 except OSError: 

96 pass 

97 

98 

99def _status_single( 

100 instance_id: str | None, 

101 state: LoopState, 

102 loop_name: str, 

103 running_dir: Path, 

104 args: argparse.Namespace | None, 

105) -> int: 

106 """Render status for one instance (human-readable or JSON).""" 

107 from little_loops.cli.output import print_json 

108 

109 stem = instance_id or loop_name 

110 persistence = StatePersistence(loop_name, running_dir.parent, instance_id=instance_id) 

111 state = _reconcile_stale_running(state, persistence, running_dir, stem) 

112 

113 pid_file = running_dir / f"{stem}.pid" 

114 pid = _read_pid_file(pid_file) 

115 pid_source: str | None = "pid_file" if pid is not None else None 

116 

117 if pid is None: 

118 lock_file_path = running_dir / f"{stem}.lock" 

119 if lock_file_path.exists(): 

120 try: 

121 with open(lock_file_path) as _lf: 

122 lock_data = json.load(_lf) 

123 pid = lock_data.get("pid") 

124 if pid is not None: 

125 pid_source = "lock_file" 

126 except (json.JSONDecodeError, KeyError, OSError): 

127 pass 

128 

129 log_file = running_dir / f"{stem}.log" 

130 log_file_str: str | None = None 

131 log_updated_ago: str | None = None 

132 last_event: str | None = None 

133 if log_file.exists(): 

134 log_file_str = str(log_file) 

135 age_seconds = time.time() - log_file.stat().st_mtime 

136 log_updated_ago = _format_relative_time(age_seconds) 

137 try: 

138 lines = log_file.read_text().splitlines() 

139 non_empty = [ln for ln in lines if ln.strip()] 

140 last_event = non_empty[-1] if non_empty else None 

141 except OSError: 

142 last_event = None 

143 

144 events_file_str, events_detail_line = _get_events_info(running_dir, stem) 

145 

146 if getattr(args, "json", False): 

147 d = state.to_dict() 

148 d["pid"] = pid 

149 d["pid_source"] = pid_source 

150 d["log_file"] = log_file_str 

151 d["log_updated_ago"] = log_updated_ago 

152 d["last_event"] = last_event 

153 d["events_file"] = events_file_str 

154 print_json(d) 

155 return 0 

156 

157 print(f"Loop: {state.loop_name}") 

158 print(f"Status: {state.status}") 

159 print(f"Current state: {state.current_state}") 

160 print(f"Iteration: {state.iteration}") 

161 print(f"Started: {state.started_at}") 

162 print(f"Updated: {state.updated_at}") 

163 

164 if pid is not None: 

165 if _process_alive(pid): 

166 print(f"PID: {pid} (running)") 

167 else: 

168 print(f"PID: {pid} (not running - stale PID file)") 

169 

170 log_label = _format_log_label(running_dir, stem) 

171 print(f"Log: {log_label}") 

172 if log_file_str is not None: 

173 print(f"Log updated: {log_updated_ago}") 

174 if last_event: 

175 print(f"Last event: {last_event}") 

176 

177 if events_detail_line is not None: 

178 print(events_detail_line) 

179 

180 if state.continuation_prompt: 

181 prompt_preview = state.continuation_prompt[:200] 

182 if len(state.continuation_prompt) > 200: 

183 prompt_preview += "..." 

184 print(f"Continuation context: {prompt_preview}") 

185 return 0 

186 

187 

188def cmd_status( 

189 loop_name: str, 

190 loops_dir: Path, 

191 logger: Logger, 

192 args: argparse.Namespace | None = None, 

193) -> int: 

194 """Show loop status.""" 

195 from little_loops.cli.output import print_json 

196 

197 running_dir = loops_dir / ".running" 

198 instances = _find_instances(loop_name, running_dir) 

199 

200 if not instances: 

201 logger.error(f"No state found for: {loop_name}") 

202 return 1 

203 

204 if len(instances) == 1: 

205 instance_id, state = instances[0] 

206 return _status_single(instance_id, state, loop_name, running_dir, args) 

207 

208 # Multiple instances: aggregate display 

209 if getattr(args, "json", False): 

210 result_list = [] 

211 for instance_id, state in instances: 

212 stem = instance_id or loop_name 

213 persistence = StatePersistence(loop_name, loops_dir, instance_id=instance_id) 

214 state = _reconcile_stale_running(state, persistence, running_dir, stem) 

215 pid_file = running_dir / f"{stem}.pid" 

216 pid = _read_pid_file(pid_file) 

217 pid_source: str | None = "pid_file" if pid is not None else None 

218 if pid is None: 

219 lock_file_path = running_dir / f"{stem}.lock" 

220 if lock_file_path.exists(): 

221 try: 

222 with open(lock_file_path) as _lf: 

223 lock_data = json.load(_lf) 

224 pid = lock_data.get("pid") 

225 if pid is not None: 

226 pid_source = "lock_file" 

227 except (json.JSONDecodeError, KeyError, OSError): 

228 pass 

229 log_file = running_dir / f"{stem}.log" 

230 d = state.to_dict() 

231 d["instance_id"] = instance_id 

232 d["pid"] = pid 

233 d["pid_source"] = pid_source 

234 if log_file.exists(): 

235 d["log_file"] = str(log_file) 

236 d["log_updated_ago"] = _format_relative_time(time.time() - log_file.stat().st_mtime) 

237 else: 

238 d["log_file"] = None 

239 d["log_updated_ago"] = None 

240 events_file_str, _ = _get_events_info(running_dir, stem) 

241 d["events_file"] = events_file_str 

242 result_list.append(d) 

243 print_json(result_list) 

244 return 0 

245 

246 print(f"{len(instances)} instances of '{loop_name}':") 

247 for i, (instance_id, state) in enumerate(instances, 1): 

248 stem = instance_id or loop_name 

249 persistence = StatePersistence(loop_name, loops_dir, instance_id=instance_id) 

250 state = _reconcile_stale_running(state, persistence, running_dir, stem) 

251 pid_file = running_dir / f"{stem}.pid" 

252 pid = _read_pid_file(pid_file) 

253 if pid is None: 

254 lock_file_path = running_dir / f"{stem}.lock" 

255 if lock_file_path.exists(): 

256 try: 

257 with open(lock_file_path) as _lf: 

258 lock_data = json.load(_lf) 

259 pid = lock_data.get("pid") 

260 except (json.JSONDecodeError, KeyError, OSError): 

261 pass 

262 log_file = running_dir / f"{stem}.log" 

263 

264 print() 

265 print(f"[{i}] {stem}") 

266 print(f" Status: {state.status}") 

267 print(f" Current state: {state.current_state}") 

268 print(f" Iteration: {state.iteration}") 

269 if pid is not None: 

270 if _process_alive(pid): 

271 print(f" PID: {pid} (running)") 

272 else: 

273 print(f" PID: {pid} (not running - stale PID file)") 

274 log_label = _format_log_label(running_dir, stem) 

275 print(f" Log: {log_label}") 

276 if log_file.exists(): 

277 age_seconds = time.time() - log_file.stat().st_mtime 

278 print(f" Log updated: {_format_relative_time(age_seconds)}") 

279 _, events_detail_line = _get_events_info(running_dir, stem) 

280 if events_detail_line is not None: 

281 print(f" {events_detail_line}") 

282 return 0 

283 

284 

285def cmd_stop( 

286 loop_name: str, 

287 loops_dir: Path, 

288 logger: Logger, 

289) -> int: 

290 """Stop a running loop.""" 

291 from little_loops.fsm.persistence import StatePersistence # noqa: PLC0415 

292 

293 running_dir = loops_dir / ".running" 

294 instances = _find_instances(loop_name, running_dir) 

295 

296 if not instances: 

297 logger.error(f"No state found for: {loop_name}") 

298 return 1 

299 

300 running_instances = [(iid, s) for iid, s in instances if s.status == "running"] 

301 

302 if not running_instances: 

303 # Secondary check: an orphaned lock-file with a live PID blocks scope 

304 # acquisition even when state is not "running". Kill the holder and release. 

305 for instance_id, state in instances: 

306 stem = instance_id or loop_name 

307 lock_file = running_dir / f"{stem}.lock" 

308 if lock_file.exists(): 

309 lock_pid: int | None = None 

310 try: 

311 with open(lock_file) as _lf: 

312 lock_data = json.load(_lf) 

313 lock_pid = lock_data.get("pid") 

314 except (json.JSONDecodeError, KeyError, OSError): 

315 pass 

316 if lock_pid and _process_alive(lock_pid): 

317 logger.warning( 

318 f"Loop state is '{state.status}' but lock file holds live PID {lock_pid}. " 

319 "Killing orphaned lock holder..." 

320 ) 

321 _kill_with_timeout(lock_pid, stem, logger) 

322 lock_file.unlink(missing_ok=True) 

323 logger.success(f"Released orphaned scope lock for {stem}") 

324 return 0 

325 elif lock_pid is not None: 

326 # Dead PID: stale lock file, just remove it 

327 lock_file.unlink(missing_ok=True) 

328 logger.info(f"Removed stale lock file for {stem}") 

329 return 0 

330 _, state = instances[0] 

331 logger.error(f"Loop not running: {loop_name} (status: {state.status})") 

332 return 1 

333 

334 for instance_id, state in running_instances: 

335 persistence = StatePersistence(loop_name, loops_dir, instance_id=instance_id) 

336 stem = instance_id or loop_name 

337 pid_file = running_dir / f"{stem}.pid" 

338 pid = _read_pid_file(pid_file) 

339 

340 if pid is not None: 

341 if _process_alive(pid): 

342 _kill_with_timeout(pid, stem, logger) 

343 state.status = "interrupted" 

344 persistence.save_state(state) 

345 pid_file.unlink(missing_ok=True) 

346 logger.success(f"Stopped {stem} (PID: {pid})") 

347 else: 

348 # Process already exited: preserve its final status, only clean up PID file 

349 logger.info(f"Process {pid} not running, cleaning up PID file") 

350 pid_file.unlink(missing_ok=True) 

351 else: 

352 # No PID file: no background process tracked, update state only 

353 state.status = "interrupted" 

354 persistence.save_state(state) 

355 logger.success(f"Marked {stem} as interrupted") 

356 

357 return 0 

358 

359 

360def cmd_resume( 

361 loop_name: str, 

362 args: argparse.Namespace, 

363 loops_dir: Path, 

364 logger: Logger, 

365) -> int: 

366 """Resume an interrupted loop.""" 

367 from little_loops.fsm.persistence import PersistentExecutor 

368 

369 # Background mode: spawn detached process and return 

370 if getattr(args, "background", False): 

371 return run_background(loop_name, args, loops_dir, subcommand="resume") 

372 

373 # Register PID file for all foreground runs so cmd_stop can send SIGTERM (BUG-639). 

374 # Background-spawned processes (foreground_internal=True) have their PID written by the 

375 # parent in run_background(); plain foreground runs must write their own PID here. 

376 import os 

377 

378 running_dir = loops_dir / ".running" 

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

380 

381 # Discover all instances and resolve to a single resumable one. 

382 instances = _find_instances(loop_name, running_dir) 

383 from little_loops.fsm.persistence import RESUMABLE_STATUSES 

384 

385 resumable = [(iid, s) for iid, s in instances if s.status in RESUMABLE_STATUSES] 

386 

387 explicit_instance_id = getattr(args, "instance_id", None) 

388 if explicit_instance_id: 

389 filtered: list[tuple[str | None, LoopState]] = [ 

390 (iid, s) for iid, s in resumable if iid == explicit_instance_id 

391 ] 

392 if not filtered: 

393 print( 

394 f"Instance '{explicit_instance_id}' not found among resumable instances of '{loop_name}'." 

395 ) 

396 if resumable: 

397 print("Resumable instances:") 

398 for iid, _ in resumable: 

399 print(f" {iid or loop_name}") 

400 return 1 

401 resumable = filtered 

402 

403 if len(resumable) > 1: 

404 print(f"Multiple instances of '{loop_name}' are resumable:") 

405 for iid, _ in resumable: 

406 print(f" {iid or loop_name}") 

407 print("Use --instance-id to select one.") 

408 return 1 

409 

410 # Use discovered instance_id (or fall back to args / None for no-state case) 

411 if resumable: 

412 instance_id: str | None = resumable[0][0] 

413 state_for_display = resumable[0][1] 

414 else: 

415 instance_id = getattr(args, "instance_id", None) 

416 state_for_display = None 

417 

418 pid_file = running_dir / f"{instance_id or loop_name}.pid" 

419 foreground_pid_file: Path | None = pid_file 

420 

421 if not getattr(args, "foreground_internal", False): 

422 pid_file.write_text(str(os.getpid())) 

423 

424 def _cleanup_pid() -> None: 

425 pid_file.unlink(missing_ok=True) 

426 

427 atexit.register(_cleanup_pid) 

428 

429 try: 

430 fsm = load_loop(loop_name, loops_dir, logger) 

431 except FileNotFoundError as e: 

432 logger.error(str(e)) 

433 return 1 

434 except ValueError as e: 

435 logger.error(f"Validation error: {e}") 

436 return 1 

437 

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

439 if "=" not in kv: 

440 raise SystemExit(f"Invalid --context format: {kv!r} (expected KEY=VALUE)") 

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

442 fsm.context[key.strip()] = value.strip() 

443 

444 # Re-inject run_dir using the same instance_id as the original run so resumed 

445 # loops write artifacts to the same directory they started with. 

446 if "run_dir" not in fsm.context and instance_id is not None: 

447 fsm.context["run_dir"] = str(loops_dir / "runs" / instance_id) + "/" 

448 

449 if getattr(args, "delay", None) is not None: 

450 fsm.backoff = args.delay 

451 

452 # Apply YAML loop config env-var overrides (CLI flags below overwrite these) 

453 if fsm.config is not None and isinstance(fsm.config.handoff_threshold, int): 

454 os.environ["LL_HANDOFF_THRESHOLD"] = str(fsm.config.handoff_threshold) 

455 

456 if getattr(args, "handoff_threshold", None) is not None: 

457 if not (1 <= args.handoff_threshold <= 100): 

458 raise SystemExit("--handoff-threshold must be between 1 and 100") 

459 os.environ["LL_HANDOFF_THRESHOLD"] = str(args.handoff_threshold) 

460 

461 # Show context if resuming from a handoff 

462 if state_for_display and state_for_display.status == "awaiting_continuation": 

463 print(f"Resuming from context handoff (iteration {state_for_display.iteration})...") 

464 if state_for_display.continuation_prompt: 

465 prompt_preview = state_for_display.continuation_prompt[:500] 

466 if len(state_for_display.continuation_prompt) > 500: 

467 prompt_preview += "..." 

468 print(f"Context: {prompt_preview}") 

469 print() 

470 

471 from little_loops.config import BRConfig 

472 from little_loops.design_tokens import load_design_tokens, render_as_prompt_context 

473 from little_loops.extension import wire_extensions 

474 from little_loops.fsm.rate_limit_circuit import RateLimitCircuit 

475 from little_loops.transport import wire_transports 

476 

477 config = BRConfig(Path.cwd()) 

478 

479 if "design_tokens_context" not in fsm.context: 

480 _tokens = load_design_tokens(config) 

481 fsm.context["design_tokens_context"] = render_as_prompt_context(_tokens) if _tokens else "" 

482 

483 circuit = ( 

484 RateLimitCircuit(Path(config.commands.rate_limits.circuit_breaker_path)) 

485 if config.commands.rate_limits.circuit_breaker_enabled 

486 else None 

487 ) 

488 executor = PersistentExecutor( 

489 fsm, loops_dir=loops_dir, circuit=circuit, instance_id=instance_id 

490 ) 

491 

492 # Register signal handlers for graceful shutdown (same as cmd_run) 

493 register_loop_signal_handlers(executor, pid_file=foreground_pid_file) 

494 

495 wire_extensions(executor.event_bus, config.extensions, executor=executor) 

496 wire_transports(executor.event_bus, config.events) 

497 

498 # Route through run_foreground (BUG-1645) so the resume path shares the 

499 # display-callback wiring that cmd_run gets via run_foreground. Without this 

500 # the FSM event bus has no subscriber on a resumed run, leaving the terminal 

501 # silent for the entire duration (no per-iteration lines, no FSM diagram). 

502 from little_loops.cli.loop._helpers import run_foreground 

503 

504 _edge_label_colors = config.cli.colors.fsm_edge_labels.to_dict() 

505 _highlight_color = config.cli.colors.fsm_active_state 

506 _badges = config.loops.glyphs.to_dict() 

507 

508 try: 

509 return run_foreground( 

510 executor, 

511 fsm, 

512 args, 

513 highlight_color=_highlight_color, 

514 edge_label_colors=_edge_label_colors, 

515 badges=_badges, 

516 mode="resume", 

517 instance_id=instance_id, 

518 running_dir=running_dir, 

519 ) 

520 finally: 

521 executor.close_transports() 

522 

523 

524def _print_last_state(state: LoopState) -> None: 

525 """Print last-known state (used when not actively tailing).""" 

526 print(f"Loop: {state.loop_name}") 

527 print(f"Status: {state.status}") 

528 print(f"Current state: {state.current_state}") 

529 print(f"Iteration: {state.iteration}") 

530 

531 

532def cmd_monitor(args: argparse.Namespace, loops_dir: Path) -> int: 

533 """Attach to a running loop and render its FSM state in realtime. 

534 

535 Read-only attach: tails ``<stem>.events.jsonl`` and the log file from disk, 

536 forwarding events to a ``StateFeedRenderer``. Ctrl-C detaches without 

537 sending any signal to the loop process (FEAT-1764). 

538 """ 

539 loop_name = args.loop 

540 running_dir = loops_dir / ".running" 

541 instances = _find_instances(loop_name, running_dir) 

542 

543 if not instances: 

544 print(f"No instances of '{loop_name}' found") 

545 return 1 

546 

547 instance_id, state = instances[-1] 

548 stem = instance_id or loop_name 

549 

550 pid = _read_pid_file(running_dir / f"{stem}.pid") 

551 if pid is None: 

552 pid = getattr(state, "pid", None) 

553 

554 if pid is None or not _process_alive(pid): 

555 _print_last_state(state) 

556 return 0 

557 

558 # Fabricate missing Namespace attrs that StateFeedRenderer reads. 

559 for attr, default in ( 

560 ("quiet", False), 

561 ("verbose", False), 

562 ("show_diagrams", None), 

563 ("clear", False), 

564 ("diagram_edge_labels", None), 

565 ("diagram_state_detail", None), 

566 ("diagram_scope", None), 

567 ("follow", False), 

568 ): 

569 if not hasattr(args, attr): 

570 setattr(args, attr, default) 

571 

572 try: 

573 fsm = load_loop(loop_name, loops_dir, Logger()) 

574 except (FileNotFoundError, ValueError) as e: 

575 print(f"Cannot load loop '{loop_name}': {e}") 

576 return 1 

577 

578 # Late import: tests patch StateFeedRenderer at its module-of-origin 

579 # (little_loops.cli.loop._helpers.StateFeedRenderer); using a function-local 

580 # import ensures the patch takes effect at call time. 

581 from little_loops.cli.loop._helpers import ( 

582 StateFeedRenderer, 

583 _install_sigwinch_handler, 

584 _restore_sigwinch_handler, 

585 ) 

586 

587 renderer = StateFeedRenderer(fsm, args, loops_dir=loops_dir) 

588 

589 log_override = getattr(args, "log_file", None) 

590 log_path = Path(log_override) if log_override else running_dir / f"{stem}.log" 

591 events_file = running_dir / f"{stem}.events.jsonl" 

592 

593 if not events_file.exists(): 

594 _print_last_state(state) 

595 return 0 

596 

597 sigwinch_installed = False 

598 if renderer.in_pinned_mode: 

599 _install_sigwinch_handler() 

600 sigwinch_installed = True 

601 

602 try: 

603 with open(events_file, encoding="utf-8") as ev_f: 

604 ev_f.seek(0, 2) 

605 log_f = None 

606 if log_path.exists(): 

607 log_f = open(log_path, encoding="utf-8") 

608 log_f.seek(0, 2) 

609 try: 

610 while True: 

611 progressed = False 

612 try: 

613 line = ev_f.readline() 

614 except FileNotFoundError: 

615 break 

616 if line: 

617 progressed = True 

618 stripped = line.strip() 

619 if stripped: 

620 try: 

621 event = json.loads(stripped) 

622 except json.JSONDecodeError: 

623 event = None 

624 if event is not None: 

625 renderer.handle_event(event) 

626 if log_f is not None: 

627 try: 

628 log_line = log_f.readline() 

629 except FileNotFoundError: 

630 log_line = "" 

631 if log_line: 

632 progressed = True 

633 print(log_line.rstrip()) 

634 if not progressed: 

635 if not _process_alive(pid): 

636 break 

637 if not events_file.exists(): 

638 break 

639 time.sleep(0.1) 

640 finally: 

641 if log_f is not None: 

642 log_f.close() 

643 except KeyboardInterrupt: 

644 return 0 

645 finally: 

646 if sigwinch_installed: 

647 _restore_sigwinch_handler() 

648 

649 return 0