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

431 statements  

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

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

2 

3from __future__ import annotations 

4 

5import argparse 

6import atexit 

7import hashlib 

8import json 

9import os 

10import signal 

11import time 

12from pathlib import Path 

13 

14from little_loops.cli.loop._helpers import ( 

15 load_loop, 

16 register_loop_signal_handlers, 

17 resolve_loop_path, 

18 run_background, 

19) 

20from little_loops.fsm.concurrency import _process_alive 

21from little_loops.fsm.persistence import ( 

22 LoopState, 

23 StatePersistence, 

24 _find_instances, 

25 _read_pid_file, 

26 _reconcile_stale_running, 

27) 

28from little_loops.logger import Logger 

29 

30 

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

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

33 

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

35 """ 

36 from little_loops.cli.output import format_relative_time 

37 

38 return format_relative_time(seconds) 

39 

40 

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

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

43 

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

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

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

47 """ 

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

49 if log_file.exists(): 

50 return str(log_file) 

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

52 if pid_file.exists(): 

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

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

55 

56 

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

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

59 

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

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

62 """ 

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

64 if not events_file.exists(): 

65 return None, None 

66 try: 

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

68 count = len(lines) 

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

70 if lines: 

71 try: 

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

73 last_ts = last_entry.get("ts") 

74 if last_ts is not None: 

75 from datetime import UTC, datetime 

76 

77 dt = datetime.fromisoformat(last_ts) 

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

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

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

81 pass 

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

83 except OSError: 

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

85 

86 

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

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

89 os.kill(pid, signal.SIGTERM) 

90 for _ in range(10): 

91 time.sleep(1) 

92 if not _process_alive(pid): 

93 return 

94 try: 

95 os.kill(pid, signal.SIGKILL) 

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

97 except OSError: 

98 pass 

99 

100 

101def _status_single( 

102 instance_id: str | None, 

103 state: LoopState, 

104 loop_name: str, 

105 running_dir: Path, 

106 args: argparse.Namespace | None, 

107) -> int: 

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

109 from little_loops.cli.output import print_json 

110 

111 stem = instance_id or loop_name 

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

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

114 

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

116 pid = _read_pid_file(pid_file) 

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

118 

119 if pid is None: 

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

121 if lock_file_path.exists(): 

122 try: 

123 with open(lock_file_path) as _lf: 

124 lock_data = json.load(_lf) 

125 pid = lock_data.get("pid") 

126 if pid is not None: 

127 pid_source = "lock_file" 

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

129 pass 

130 

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

132 log_file_str: str | None = None 

133 log_updated_ago: str | None = None 

134 last_event: str | None = None 

135 if log_file.exists(): 

136 log_file_str = str(log_file) 

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

138 log_updated_ago = _format_relative_time(age_seconds) 

139 try: 

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

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

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

143 except OSError: 

144 last_event = None 

145 

146 events_file_str, events_detail_line = _get_events_info(running_dir, stem) 

147 

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

149 d = state.to_dict() 

150 d["pid"] = pid 

151 d["pid_source"] = pid_source 

152 d["log_file"] = log_file_str 

153 d["log_updated_ago"] = log_updated_ago 

154 d["last_event"] = last_event 

155 d["events_file"] = events_file_str 

156 print_json(d) 

157 return 0 

158 

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

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

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

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

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

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

165 

166 if pid is not None: 

167 if _process_alive(pid): 

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

169 else: 

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

171 

172 log_label = _format_log_label(running_dir, stem) 

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

174 if log_file_str is not None: 

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

176 if last_event: 

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

178 

179 if events_detail_line is not None: 

180 print(events_detail_line) 

181 

182 if state.continuation_prompt: 

183 prompt_preview = state.continuation_prompt[:200] 

184 if len(state.continuation_prompt) > 200: 

185 prompt_preview += "..." 

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

187 return 0 

188 

189 

190def cmd_status( 

191 loop_name: str, 

192 loops_dir: Path, 

193 logger: Logger, 

194 args: argparse.Namespace | None = None, 

195) -> int: 

196 """Show loop status.""" 

197 from little_loops.cli.output import print_json 

198 

199 running_dir = loops_dir / ".running" 

200 instances = _find_instances(loop_name, running_dir) 

201 

202 if not instances: 

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

204 return 1 

205 

206 if len(instances) == 1: 

207 instance_id, state = instances[0] 

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

209 

210 # Multiple instances: aggregate display 

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

212 result_list = [] 

213 for instance_id, state in instances: 

214 stem = instance_id or loop_name 

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

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

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

218 pid = _read_pid_file(pid_file) 

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

220 if pid is None: 

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

222 if lock_file_path.exists(): 

223 try: 

224 with open(lock_file_path) as _lf: 

225 lock_data = json.load(_lf) 

226 pid = lock_data.get("pid") 

227 if pid is not None: 

228 pid_source = "lock_file" 

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

230 pass 

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

232 d = state.to_dict() 

233 d["instance_id"] = instance_id 

234 d["pid"] = pid 

235 d["pid_source"] = pid_source 

236 if log_file.exists(): 

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

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

239 else: 

240 d["log_file"] = None 

241 d["log_updated_ago"] = None 

242 events_file_str, _ = _get_events_info(running_dir, stem) 

243 d["events_file"] = events_file_str 

244 result_list.append(d) 

245 print_json(result_list) 

246 return 0 

247 

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

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

250 stem = instance_id or loop_name 

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

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

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

254 pid = _read_pid_file(pid_file) 

255 if pid is None: 

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

257 if lock_file_path.exists(): 

258 try: 

259 with open(lock_file_path) as _lf: 

260 lock_data = json.load(_lf) 

261 pid = lock_data.get("pid") 

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

263 pass 

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

265 

266 print() 

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

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

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

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

271 if pid is not None: 

272 if _process_alive(pid): 

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

274 else: 

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

276 log_label = _format_log_label(running_dir, stem) 

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

278 if log_file.exists(): 

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

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

281 _, events_detail_line = _get_events_info(running_dir, stem) 

282 if events_detail_line is not None: 

283 print(f" {events_detail_line}") 

284 return 0 

285 

286 

287def cmd_stop( 

288 loop_name: str, 

289 loops_dir: Path, 

290 logger: Logger, 

291) -> int: 

292 """Stop a running loop.""" 

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

294 

295 running_dir = loops_dir / ".running" 

296 instances = _find_instances(loop_name, running_dir) 

297 

298 if not instances: 

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

300 return 1 

301 

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

303 

304 if not running_instances: 

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

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

307 for instance_id, state in instances: 

308 stem = instance_id or loop_name 

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

310 if lock_file.exists(): 

311 lock_pid: int | None = None 

312 try: 

313 with open(lock_file) as _lf: 

314 lock_data = json.load(_lf) 

315 lock_pid = lock_data.get("pid") 

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

317 pass 

318 if lock_pid and _process_alive(lock_pid): 

319 logger.warning( 

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

321 "Killing orphaned lock holder..." 

322 ) 

323 _kill_with_timeout(lock_pid, stem, logger) 

324 lock_file.unlink(missing_ok=True) 

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

326 return 0 

327 elif lock_pid is not None: 

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

329 lock_file.unlink(missing_ok=True) 

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

331 return 0 

332 _, state = instances[0] 

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

334 return 1 

335 

336 for instance_id, state in running_instances: 

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

338 stem = instance_id or loop_name 

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

340 pid = _read_pid_file(pid_file) 

341 

342 if pid is not None: 

343 if _process_alive(pid): 

344 _kill_with_timeout(pid, stem, logger) 

345 state.status = "interrupted" 

346 persistence.save_state(state) 

347 pid_file.unlink(missing_ok=True) 

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

349 else: 

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

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

352 pid_file.unlink(missing_ok=True) 

353 else: 

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

355 state.status = "interrupted" 

356 persistence.save_state(state) 

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

358 

359 return 0 

360 

361 

362def cmd_resume( 

363 loop_name: str, 

364 args: argparse.Namespace, 

365 loops_dir: Path, 

366 logger: Logger, 

367) -> int: 

368 """Resume an interrupted loop.""" 

369 import os 

370 

371 from little_loops.fsm.persistence import RESUMABLE_STATUSES, PersistentExecutor 

372 

373 running_dir = loops_dir / ".running" 

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

375 

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

377 instances = _find_instances(loop_name, running_dir) 

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

379 

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

381 if explicit_instance_id: 

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

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

384 ] 

385 if not filtered: 

386 print( 

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

388 ) 

389 if resumable: 

390 print("Resumable instances:") 

391 for iid, _ in resumable: 

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

393 return 1 

394 resumable = filtered 

395 elif len(resumable) > 1: 

396 # Auto-select the most recent instance by sorted filename (matches cmd_monitor()). 

397 selected_id = resumable[-1][0] 

398 print(f"Auto-selected latest instance: {selected_id}") 

399 resumable = [resumable[-1]] 

400 

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

402 if resumable: 

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

404 state_for_display = resumable[0][1] 

405 else: 

406 instance_id = None 

407 state_for_display = None 

408 

409 # Background mode: spawn detached process and return 

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

411 if instance_id is None: 

412 print(f"No resumable instances of '{loop_name}'.") 

413 return 1 

414 return run_background( 

415 loop_name, args, loops_dir, subcommand="resume", instance_id=instance_id 

416 ) 

417 

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

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

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

421 if instance_id is None: 

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

423 

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

425 foreground_pid_file: Path | None = pid_file 

426 

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

428 pid_file.parent.mkdir(parents=True, exist_ok=True) 

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

430 

431 def _cleanup_pid() -> None: 

432 pid_file.unlink(missing_ok=True) 

433 

434 atexit.register(_cleanup_pid) 

435 

436 try: 

437 fsm = load_loop(loop_name, loops_dir, logger) 

438 except FileNotFoundError as e: 

439 logger.error(str(e)) 

440 return 1 

441 except ValueError as e: 

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

443 return 1 

444 

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

446 logger.error( 

447 "--baseline is not supported with resume. Start a fresh run with 'll-loop run'." 

448 ) 

449 return 1 

450 

451 try: 

452 loop_path = resolve_loop_path(loop_name, loops_dir) 

453 except FileNotFoundError: 

454 loop_path = None 

455 

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

457 if "=" not in kv: 

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

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

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

461 

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

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

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

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

466 

467 # Re-inject input hash for checkpoint fingerprinting during resumed runs. 

468 if "input_hash" not in fsm.context and isinstance(fsm.context.get("input"), str): 

469 fsm.context["input_hash"] = hashlib.sha256(fsm.context["input"].encode()).hexdigest()[:12] 

470 

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

472 fsm.backoff = args.delay 

473 

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

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

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

477 

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

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

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

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

482 

483 # Show context if resuming from a handoff 

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

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

486 if state_for_display.continuation_prompt: 

487 prompt_preview = state_for_display.continuation_prompt[:500] 

488 if len(state_for_display.continuation_prompt) > 500: 

489 prompt_preview += "..." 

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

491 print() 

492 

493 from little_loops.config import BRConfig 

494 from little_loops.design_tokens import load_design_tokens, render_as_prompt_context 

495 from little_loops.extension import wire_extensions 

496 from little_loops.fsm.rate_limit_circuit import RateLimitCircuit 

497 from little_loops.transport import wire_transports 

498 

499 config = BRConfig(Path.cwd()) 

500 

501 if not fsm.context.get("design_tokens_context"): 

502 _tokens = load_design_tokens(config) 

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

504 

505 circuit = ( 

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

507 if config.commands.rate_limits.circuit_breaker_enabled 

508 else None 

509 ) 

510 executor = PersistentExecutor( 

511 fsm, loops_dir=loops_dir, circuit=circuit, instance_id=instance_id 

512 ) 

513 

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

515 register_loop_signal_handlers(executor, pid_file=foreground_pid_file) 

516 

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

518 wire_transports(executor.event_bus, config.events) 

519 

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

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

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

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

524 from little_loops.cli.loop._helpers import run_foreground 

525 

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

527 _highlight_color = config.cli.colors.fsm_active_state 

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

529 

530 try: 

531 return run_foreground( 

532 executor, 

533 fsm, 

534 args, 

535 highlight_color=_highlight_color, 

536 edge_label_colors=_edge_label_colors, 

537 badges=_badges, 

538 mode="resume", 

539 instance_id=instance_id, 

540 running_dir=running_dir, 

541 loop_path=loop_path, 

542 model=fsm.llm.model, 

543 ) 

544 finally: 

545 executor.close_transports() 

546 

547 

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

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

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

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

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

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

554 

555 

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

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

558 

559 Read-only attach: tails ``<stem>.events.jsonl`` and forwards events to a 

560 ``StateFeedRenderer``. Ctrl-C detaches without sending any signal to the 

561 loop process (FEAT-1764). 

562 """ 

563 loop_name = args.loop 

564 running_dir = loops_dir / ".running" 

565 instances = _find_instances(loop_name, running_dir) 

566 

567 if not instances: 

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

569 return 1 

570 

571 instance_id, state = instances[-1] 

572 stem = instance_id or loop_name 

573 

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

575 if pid is None: 

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

577 

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

579 _print_last_state(state) 

580 return 0 

581 

582 # Fabricate missing Namespace attrs that StateFeedRenderer reads. 

583 for attr, default in ( 

584 ("quiet", False), 

585 ("verbose", False), 

586 ("show_diagrams", None), 

587 ("clear", True), 

588 ("diagram_edge_labels", None), 

589 ("diagram_state_detail", None), 

590 ("diagram_scope", None), 

591 ("follow", False), 

592 ): 

593 if not hasattr(args, attr): 

594 setattr(args, attr, default) 

595 

596 try: 

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

598 except (FileNotFoundError, ValueError) as e: 

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

600 return 1 

601 

602 try: 

603 loop_path = resolve_loop_path(loop_name, loops_dir) 

604 except FileNotFoundError: 

605 loop_path = None 

606 

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

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

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

610 from little_loops.cli.loop._helpers import ( 

611 StateFeedRenderer, 

612 _install_sigwinch_handler, 

613 _restore_sigwinch_handler, 

614 ) 

615 

616 renderer = StateFeedRenderer( 

617 fsm, args, loops_dir=loops_dir, loop_path=loop_path, model=fsm.llm.model 

618 ) 

619 

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

621 

622 if not events_file.exists(): 

623 _print_last_state(state) 

624 return 0 

625 

626 sigwinch_installed = False 

627 if renderer.in_pinned_mode: 

628 _install_sigwinch_handler() 

629 sigwinch_installed = True 

630 

631 try: 

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

633 ev_f.seek(0, 2) 

634 while True: 

635 progressed = False 

636 try: 

637 line = ev_f.readline() 

638 except FileNotFoundError: 

639 break 

640 if line: 

641 progressed = True 

642 stripped = line.strip() 

643 if stripped: 

644 try: 

645 event = json.loads(stripped) 

646 except json.JSONDecodeError: 

647 event = None 

648 if event is not None: 

649 renderer.handle_event(event) 

650 if not progressed: 

651 if not _process_alive(pid): 

652 break 

653 if not events_file.exists(): 

654 break 

655 time.sleep(0.1) 

656 except KeyboardInterrupt: 

657 return 0 

658 finally: 

659 if sigwinch_installed: 

660 _restore_sigwinch_handler() 

661 

662 return 0