Coverage for little_loops / fsm / persistence.py: 38%

272 statements  

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

1"""State persistence and event streaming for FSM loops. 

2 

3This module provides persistence capabilities for FSM loop execution: 

4- LoopState: Dataclass representing loop execution state 

5- StatePersistence: File I/O for state and events 

6- PersistentExecutor: Wrapper that persists state during execution 

7- Utility functions for listing running loops and reading history 

8 

9File structure: 

10 .loops/ 

11 ├── fix-types.yaml # Loop definition 

12 ├── .running/ # Runtime state (auto-managed) 

13 │ ├── fix-types.state.json 

14 │ └── fix-types.events.jsonl 

15 └── .history/ # Archived run logs (auto-populated) 

16 └── 2024-01-15T103000-fix-types/ 

17 ├── state.json 

18 └── events.jsonl 

19""" 

20 

21from __future__ import annotations 

22 

23import json 

24import logging 

25import os 

26import re 

27import shutil 

28import tempfile 

29import time 

30from dataclasses import dataclass, field 

31from datetime import UTC, datetime 

32from pathlib import Path 

33from typing import Any 

34 

35from little_loops.events import EventBus 

36from little_loops.fsm.concurrency import _process_alive 

37from little_loops.fsm.executor import EventCallback, ExecutionResult, FSMExecutor 

38from little_loops.fsm.schema import FSMLoop 

39 

40RUNNING_DIR = ".running" 

41HISTORY_DIR = ".history" 

42 

43logger = logging.getLogger(__name__) 

44 

45_RUN_FOLDER = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{6})-(.+)$") 

46 

47 

48def _parse_run_folder(name: str) -> tuple[str, str] | None: 

49 """Return (run_id, loop_name) from a flat history folder name, or None.""" 

50 m = _RUN_FOLDER.match(name) 

51 return (m.group(1), m.group(2)) if m else None 

52 

53 

54def _iso_now() -> str: 

55 """Return current time as ISO 8601 string.""" 

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

57 

58 

59def _now_ms() -> int: 

60 """Return current time in milliseconds.""" 

61 return int(time.time() * 1000) 

62 

63 

64@dataclass 

65class LoopState: 

66 """Persistent state for an FSM loop execution. 

67 

68 This captures all runtime state needed to resume a loop: 

69 - Current state and iteration 

70 - Captured variables and previous result 

71 - Last evaluation result 

72 - Timestamps and status 

73 

74 Attributes: 

75 loop_name: Name of the loop 

76 current_state: Current FSM state name 

77 iteration: Current iteration count (1-based) 

78 captured: Captured action outputs by variable name 

79 prev_result: Previous state's result (output, exit_code, state) 

80 last_result: Last evaluation result (verdict, details) 

81 started_at: ISO timestamp when loop started 

82 updated_at: ISO timestamp when state was last saved 

83 status: Execution status (running, completed, failed, interrupted, awaiting_continuation, timed_out) 

84 continuation_prompt: Continuation context from handoff signal (if status is awaiting_continuation) 

85 accumulated_ms: Total milliseconds elapsed across all segments up to this save (used to restore 

86 elapsed time correctly after resume, so duration_ms and ${loop.elapsed_ms} reflect the 

87 full loop lifetime rather than only the most recent segment) 

88 """ 

89 

90 loop_name: str 

91 current_state: str 

92 iteration: int 

93 captured: dict[str, dict[str, Any]] 

94 prev_result: dict[str, Any] | None 

95 last_result: dict[str, Any] | None 

96 started_at: str 

97 updated_at: str 

98 status: ( 

99 str # "running", "completed", "failed", "interrupted", "awaiting_continuation", "timed_out" 

100 ) 

101 continuation_prompt: str | None = None 

102 accumulated_ms: int = 0 # total elapsed ms across all segments (for resume offset) 

103 retry_counts: dict[str, int] = field(default_factory=dict) # per-state retry tracking 

104 active_sub_loop: str | None = None # name of currently executing sub-loop (observability) 

105 

106 def to_dict(self) -> dict[str, Any]: 

107 """Convert to dictionary for JSON serialization.""" 

108 result = { 

109 "loop_name": self.loop_name, 

110 "current_state": self.current_state, 

111 "iteration": self.iteration, 

112 "captured": self.captured, 

113 "prev_result": self.prev_result, 

114 "last_result": self.last_result, 

115 "started_at": self.started_at, 

116 "updated_at": self.updated_at, 

117 "status": self.status, 

118 "accumulated_ms": self.accumulated_ms, 

119 } 

120 if self.continuation_prompt is not None: 

121 result["continuation_prompt"] = self.continuation_prompt 

122 if self.retry_counts: 

123 result["retry_counts"] = self.retry_counts 

124 if self.active_sub_loop is not None: 

125 result["active_sub_loop"] = self.active_sub_loop 

126 return result 

127 

128 @classmethod 

129 def from_dict(cls, data: dict[str, Any]) -> LoopState: 

130 """Create LoopState from dictionary. 

131 

132 Args: 

133 data: Dictionary with loop state fields 

134 

135 Returns: 

136 LoopState instance 

137 """ 

138 return cls( 

139 loop_name=data["loop_name"], 

140 current_state=data["current_state"], 

141 iteration=data["iteration"], 

142 captured=data.get("captured", {}), 

143 prev_result=data.get("prev_result"), 

144 last_result=data.get("last_result"), 

145 started_at=data["started_at"], 

146 updated_at=data.get("updated_at", ""), 

147 status=data["status"], 

148 continuation_prompt=data.get("continuation_prompt"), 

149 accumulated_ms=data.get("accumulated_ms", 0), 

150 retry_counts=data.get("retry_counts", {}), 

151 active_sub_loop=data.get("active_sub_loop"), 

152 ) 

153 

154 

155class StatePersistence: 

156 """Manage loop state persistence and event streaming. 

157 

158 Handles file I/O for: 

159 - State file: JSON file with current execution state 

160 - Events file: JSONL file with execution events (append-only) 

161 

162 Files are stored in .loops/.running/<loop_name>.* 

163 """ 

164 

165 def __init__(self, loop_name: str, loops_dir: Path | None = None) -> None: 

166 """Initialize persistence for a loop. 

167 

168 Args: 

169 loop_name: Name of the loop 

170 loops_dir: Base directory for loops (default: .loops) 

171 """ 

172 self.loop_name = loop_name 

173 self.loops_dir = loops_dir or Path(".loops") 

174 self.running_dir = self.loops_dir / RUNNING_DIR 

175 self.state_file = self.running_dir / f"{loop_name}.state.json" 

176 self.events_file = self.running_dir / f"{loop_name}.events.jsonl" 

177 

178 def initialize(self) -> None: 

179 """Create running directory if needed.""" 

180 self.running_dir.mkdir(parents=True, exist_ok=True) 

181 

182 def save_state(self, state: LoopState) -> None: 

183 """Save current state to file using an atomic write. 

184 

185 Updates the updated_at timestamp before saving. Writes to a temporary 

186 file first, then renames it over the target to avoid leaving a corrupt 

187 or empty state file if the process is killed mid-write. 

188 

189 Args: 

190 state: LoopState to save 

191 """ 

192 state.updated_at = _iso_now() 

193 data = json.dumps(state.to_dict(), indent=2) 

194 tmp_fd, tmp_path = tempfile.mkstemp(dir=self.state_file.parent, suffix=".tmp") 

195 try: 

196 with os.fdopen(tmp_fd, "w") as f: 

197 f.write(data) 

198 os.replace(tmp_path, self.state_file) 

199 except Exception: 

200 os.unlink(tmp_path) 

201 raise 

202 

203 def load_state(self) -> LoopState | None: 

204 """Load state from file, or None if not exists. 

205 

206 Returns: 

207 LoopState if file exists and is valid, None otherwise 

208 """ 

209 if not self.state_file.exists(): 

210 return None 

211 try: 

212 data = json.loads(self.state_file.read_text()) 

213 except json.JSONDecodeError: 

214 return None 

215 try: 

216 return LoopState.from_dict(data) 

217 except KeyError as e: 

218 logger.warning("Corrupted state file %s: missing key %s", self.state_file, e) 

219 return None 

220 

221 def clear_state(self) -> None: 

222 """Remove state file.""" 

223 if self.state_file.exists(): 

224 self.state_file.unlink() 

225 

226 def append_event(self, event: dict[str, Any]) -> None: 

227 """Append event to JSONL file. 

228 

229 Args: 

230 event: Event dictionary to append 

231 """ 

232 with open(self.events_file, "a", encoding="utf-8") as f: 

233 f.write(json.dumps(event) + "\n") 

234 

235 def read_events(self) -> list[dict[str, Any]]: 

236 """Read all events from file. 

237 

238 Returns: 

239 List of event dictionaries, empty if file doesn't exist 

240 """ 

241 if not self.events_file.exists(): 

242 return [] 

243 events: list[dict[str, Any]] = [] 

244 with open(self.events_file, encoding="utf-8") as f: 

245 for line in f: 

246 line = line.strip() 

247 if line: 

248 try: 

249 events.append(json.loads(line)) 

250 except json.JSONDecodeError: 

251 continue # Skip malformed lines 

252 return events 

253 

254 def clear_events(self) -> None: 

255 """Remove events file.""" 

256 if self.events_file.exists(): 

257 self.events_file.unlink() 

258 

259 def archive_run(self) -> Path | None: 

260 """Archive current run files to .history/ before clearing. 

261 

262 Reads the current state to derive the run timestamp, then copies 

263 both state.json and events.jsonl into: 

264 <loops_dir>/.history/<run_id>-<loop_name>/ 

265 

266 where run_id is a compact ISO timestamp derived from started_at 

267 (e.g. "2024-01-15T103000" from "2024-01-15T10:30:00.123456+00:00"). 

268 

269 Returns: 

270 Path to the archive directory if files were archived, None if 

271 there were no files to archive (fresh run). 

272 """ 

273 has_state = self.state_file.exists() 

274 has_events = self.events_file.exists() 

275 if not has_state and not has_events: 

276 return None 

277 

278 # Derive run ID from started_at in state file, or fall back to now 

279 state = self.load_state() 

280 if state is not None and state.started_at: 

281 # Compact ISO: strip colons, dots, plus signs; take first 19 chars 

282 # e.g. "2024-01-15T10:30:00.123+00:00" → "2024-01-15T103000" 

283 run_id = state.started_at.replace(":", "").replace(".", "").replace("+", "")[:17] 

284 else: 

285 run_id = datetime.now(UTC).strftime("%Y-%m-%dT%H%M%S") 

286 

287 run_folder = f"{run_id}-{self.loop_name}" 

288 archive_dir = self.loops_dir / HISTORY_DIR / run_folder 

289 archive_dir.mkdir(parents=True, exist_ok=True) 

290 

291 if has_state: 

292 shutil.copy2(self.state_file, archive_dir / "state.json") 

293 if has_events: 

294 shutil.copy2(self.events_file, archive_dir / "events.jsonl") 

295 

296 return archive_dir 

297 

298 def clear_all(self) -> None: 

299 """Archive current run files then clear state and events (for new run).""" 

300 self.archive_run() 

301 self.clear_state() 

302 self.clear_events() 

303 

304 

305class PersistentExecutor: 

306 """FSM Executor with state persistence and event streaming. 

307 

308 Wraps FSMExecutor to: 

309 - Save state after each state transition 

310 - Append events to JSONL file as they occur 

311 - Support resuming from saved state 

312 - Support graceful shutdown via signal handling 

313 """ 

314 

315 def __init__( 

316 self, 

317 fsm: FSMLoop, 

318 persistence: StatePersistence | None = None, 

319 loops_dir: Path | None = None, 

320 **executor_kwargs: Any, 

321 ) -> None: 

322 """Initialize persistent executor. 

323 

324 Args: 

325 fsm: FSM loop definition 

326 persistence: Optional pre-configured persistence (for testing) 

327 loops_dir: Base directory for loops (default: .loops) 

328 **executor_kwargs: Additional kwargs for FSMExecutor 

329 """ 

330 from little_loops.fsm.handoff_handler import HandoffBehavior, HandoffHandler 

331 from little_loops.fsm.signal_detector import SignalDetector 

332 

333 self.fsm = fsm 

334 self.loops_dir = loops_dir 

335 self.persistence = persistence or StatePersistence(fsm.name, loops_dir or Path(".loops")) 

336 self.persistence.initialize() 

337 

338 # Create signal detector and handler based on FSM config 

339 signal_detector = SignalDetector() 

340 handoff_handler = HandoffHandler(HandoffBehavior(fsm.on_handoff)) 

341 

342 # Create base executor with event callback that persists 

343 self._executor = FSMExecutor( 

344 fsm, 

345 event_callback=self._handle_event, 

346 signal_detector=signal_detector, 

347 handoff_handler=handoff_handler, 

348 loops_dir=self.loops_dir, 

349 **executor_kwargs, 

350 ) 

351 self._last_result: dict[str, Any] | None = None 

352 self._continuation_prompt: str | None = None 

353 self.event_bus = EventBus() 

354 

355 @property 

356 def _on_event(self) -> EventCallback | None: 

357 """Backward-compatible access to the first observer on the event bus.""" 

358 return self.event_bus._observers[0][0] if self.event_bus._observers else None 

359 

360 @_on_event.setter 

361 def _on_event(self, callback: EventCallback | None) -> None: 

362 """Backward-compatible setter: replaces all observers with this one.""" 

363 self.event_bus._observers.clear() 

364 if callback is not None: 

365 self.event_bus.register(callback) 

366 

367 def request_shutdown(self) -> None: 

368 """Request graceful shutdown of the executor. 

369 

370 Delegates to the underlying FSMExecutor's request_shutdown method. 

371 The loop will exit cleanly after the current state completes, 

372 saving state as "interrupted" so it can be resumed later. 

373 """ 

374 self._executor.request_shutdown() 

375 

376 def _handle_event(self, event: dict[str, Any]) -> None: 

377 """Handle event: persist to file and save state. 

378 

379 Args: 

380 event: Event dictionary from executor 

381 """ 

382 self.persistence.append_event(event) 

383 

384 # Save state after state transitions 

385 event_type = event.get("event") 

386 if event_type in ("state_enter", "loop_complete"): 

387 self._save_state() 

388 

389 # Track evaluation results for state persistence 

390 if event_type == "evaluate": 

391 self._last_result = { 

392 "verdict": event.get("verdict"), 

393 "details": { 

394 k: v for k, v in event.items() if k not in ("event", "ts", "type", "verdict") 

395 }, 

396 } 

397 

398 # Track handoff events for continuation prompt 

399 if event_type == "handoff_detected": 

400 self._continuation_prompt = event.get("continuation") 

401 

402 # Delegate to registered observers (e.g. progress display, extensions) 

403 self.event_bus.emit(event) 

404 

405 def _save_state(self) -> None: 

406 """Save current executor state to file.""" 

407 status = "running" 

408 if self._executor.current_state: 

409 state_config = self.fsm.states.get(self._executor.current_state) 

410 if state_config and state_config.terminal: 

411 status = "completed" 

412 

413 state = LoopState( 

414 loop_name=self.fsm.name, 

415 current_state=self._executor.current_state, 

416 iteration=self._executor.iteration, 

417 captured=self._executor.captured, 

418 prev_result=self._executor.prev_result, 

419 last_result=self._last_result, 

420 started_at=self._executor.started_at, 

421 updated_at="", # Will be set by save_state 

422 status=status, 

423 accumulated_ms=_now_ms() 

424 - self._executor.start_time_ms 

425 + self._executor.elapsed_offset_ms, 

426 retry_counts=dict(self._executor._retry_counts), 

427 ) 

428 self.persistence.save_state(state) 

429 

430 def run(self, clear_previous: bool = True) -> ExecutionResult: 

431 """Run the FSM with persistence. 

432 

433 Args: 

434 clear_previous: If True, clear previous state/events before running 

435 

436 Returns: 

437 ExecutionResult from the execution 

438 """ 

439 if clear_previous: 

440 self.persistence.clear_all() 

441 

442 result = self._executor.run() 

443 

444 # Update final state 

445 final_status = "completed" if result.terminated_by == "terminal" else "failed" 

446 if result.terminated_by in ("max_iterations", "signal"): 

447 final_status = "interrupted" 

448 if result.terminated_by == "handoff": 

449 final_status = "awaiting_continuation" 

450 if result.terminated_by == "timeout": 

451 final_status = "timed_out" 

452 

453 final_state = LoopState( 

454 loop_name=self.fsm.name, 

455 current_state=result.final_state, 

456 iteration=result.iterations, 

457 captured=result.captured, 

458 prev_result=self._executor.prev_result, 

459 last_result=self._last_result, 

460 started_at=self._executor.started_at, 

461 updated_at="", 

462 status=final_status, 

463 continuation_prompt=self._continuation_prompt, 

464 accumulated_ms=result.duration_ms, 

465 ) 

466 self.persistence.save_state(final_state) 

467 

468 return result 

469 

470 def resume(self) -> ExecutionResult | None: 

471 """Resume from saved state, or None if no resumable state. 

472 

473 Resumable states are: "running" and "awaiting_continuation". 

474 

475 Returns: 

476 ExecutionResult if resumed and completed, None if no resumable state 

477 """ 

478 state = self.persistence.load_state() 

479 if state is None: 

480 return None 

481 

482 if state.status not in ("running", "awaiting_continuation"): 

483 return None # Already completed/failed 

484 

485 # Restore executor state 

486 self._executor.current_state = state.current_state 

487 self._executor.iteration = state.iteration 

488 self._executor.captured = state.captured 

489 self._executor.prev_result = state.prev_result 

490 self._executor.started_at = state.started_at 

491 self._last_result = state.last_result 

492 self._executor._retry_counts = dict(state.retry_counts) 

493 

494 # Restore accumulated elapsed time so duration_ms and ${loop.elapsed_ms} reflect 

495 # the full loop lifetime (all segments), not just the resumed segment. 

496 # FSMExecutor.run() will reset start_time_ms to _now_ms(), so we use elapsed_offset_ms 

497 # to carry forward the time already spent before this resume. 

498 self._executor.elapsed_offset_ms = state.accumulated_ms 

499 

500 # Clear any pending signals from previous run 

501 self._executor._pending_handoff = None 

502 self._executor._pending_error = None 

503 

504 # Emit resume event with continuation context if available 

505 resume_event: dict[str, Any] = { 

506 "event": "loop_resume", 

507 "ts": _iso_now(), 

508 "loop": self.fsm.name, 

509 "from_state": state.current_state, 

510 "iteration": state.iteration, 

511 } 

512 if state.status == "awaiting_continuation" and state.continuation_prompt: 

513 resume_event["from_handoff"] = True 

514 resume_event["continuation_prompt"] = state.continuation_prompt 

515 self.persistence.append_event(resume_event) 

516 

517 # Continue execution (don't clear previous events) 

518 return self.run(clear_previous=False) 

519 

520 

521def list_running_loops(loops_dir: Path | None = None) -> list[LoopState]: 

522 """List all loops with saved state. 

523 

524 Args: 

525 loops_dir: Base directory for loops (default: .loops) 

526 

527 Returns: 

528 List of LoopState objects for all loops with state files 

529 """ 

530 base_dir = loops_dir or Path(".loops") 

531 running_dir = base_dir / RUNNING_DIR 

532 

533 if not running_dir.exists(): 

534 return [] 

535 

536 states: list[LoopState] = [] 

537 for state_file in running_dir.glob("*.state.json"): 

538 try: 

539 data = json.loads(state_file.read_text()) 

540 states.append(LoopState.from_dict(data)) 

541 except (json.JSONDecodeError, KeyError): 

542 continue # Skip malformed files 

543 

544 # Include loops that have a PID file but no state file yet (still starting up) 

545 known_names = {s.loop_name for s in states} 

546 for pid_file in running_dir.glob("*.pid"): 

547 if pid_file.stem in known_names: 

548 continue # state file already covers this loop 

549 try: 

550 pid = int(pid_file.read_text().strip()) 

551 except (ValueError, OSError): 

552 continue 

553 if _process_alive(pid): 

554 states.append( 

555 LoopState( 

556 loop_name=pid_file.stem, 

557 current_state="(initializing)", 

558 iteration=0, 

559 captured={}, 

560 prev_result=None, 

561 last_result=None, 

562 started_at="", 

563 updated_at="", 

564 status="starting", 

565 ) 

566 ) 

567 

568 return states 

569 

570 

571def list_run_history(loop_name: str, loops_dir: Path | None = None) -> list[LoopState]: 

572 """List archived runs for a loop, newest first. 

573 

574 Reads state files from .loops/.history/<run_id>-<loop_name>/state.json and 

575 returns them sorted by started_at descending (most recent run first). 

576 

577 Also checks the legacy nested layout .loops/.history/<loop_name>/*/state.json 

578 for backward compatibility with existing history folders. 

579 

580 Args: 

581 loop_name: Name of the loop 

582 loops_dir: Base directory for loops (default: .loops) 

583 

584 Returns: 

585 List of LoopState objects for all archived runs, newest first. 

586 Returns an empty list if no history exists. 

587 """ 

588 base_dir = loops_dir or Path(".loops") 

589 history_dir = base_dir / HISTORY_DIR 

590 

591 if not history_dir.exists(): 

592 return [] 

593 

594 states: list[LoopState] = [] 

595 

596 # Flat layout: <run_id>-<loop_name>/state.json 

597 for state_file in history_dir.glob(f"*-{loop_name}/state.json"): 

598 try: 

599 data = json.loads(state_file.read_text()) 

600 states.append(LoopState.from_dict(data)) 

601 except (json.JSONDecodeError, KeyError): 

602 continue 

603 

604 # Backward compat: legacy nested layout <loop_name>/<run_id>/state.json 

605 old_loop_dir = history_dir / loop_name 

606 if old_loop_dir.exists(): 

607 logger.warning( 

608 "Found legacy nested history at %s; migrate to flat layout by moving " 

609 "each run to .history/<run_id>-%s/", 

610 old_loop_dir, 

611 loop_name, 

612 ) 

613 for state_file in old_loop_dir.glob("*/state.json"): 

614 try: 

615 data = json.loads(state_file.read_text()) 

616 states.append(LoopState.from_dict(data)) 

617 except (json.JSONDecodeError, KeyError): 

618 continue 

619 

620 states.sort(key=lambda s: s.started_at, reverse=True) 

621 return states 

622 

623 

624def get_archived_events( 

625 loop_name: str, run_id: str, loops_dir: Path | None = None 

626) -> list[dict[str, Any]]: 

627 """Read events for a specific archived run. 

628 

629 Args: 

630 loop_name: Name of the loop 

631 run_id: The run directory name (compact timestamp) 

632 loops_dir: Base directory for loops (default: .loops) 

633 

634 Returns: 

635 List of event dictionaries, empty if not found. 

636 """ 

637 base_dir = loops_dir or Path(".loops") 

638 run_folder = f"{run_id}-{loop_name}" 

639 events_file = base_dir / HISTORY_DIR / run_folder / "events.jsonl" 

640 

641 if not events_file.exists(): 

642 return [] 

643 

644 events: list[dict[str, Any]] = [] 

645 with open(events_file, encoding="utf-8") as f: 

646 for line in f: 

647 line = line.strip() 

648 if line: 

649 try: 

650 events.append(json.loads(line)) 

651 except json.JSONDecodeError: 

652 continue 

653 return events 

654 

655 

656def get_loop_history(loop_name: str, loops_dir: Path | None = None) -> list[dict[str, Any]]: 

657 """Get event history for a loop. 

658 

659 Args: 

660 loop_name: Name of the loop 

661 loops_dir: Base directory for loops (default: .loops) 

662 

663 Returns: 

664 List of event dictionaries 

665 """ 

666 persistence = StatePersistence(loop_name, loops_dir) 

667 return persistence.read_events()