Coverage for little_loops / fsm / persistence.py: 19%
473 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:54 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:54 -0500
1"""State persistence and event streaming for FSM loops.
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
9File structure:
10 .loops/
11 ├── fix-types.yaml # Loop definition
12 ├── .running/ # Runtime state (auto-managed)
13 │ ├── fix-types-20260503T122306.state.json
14 │ └── fix-types-20260503T122306.events.jsonl
15 └── .history/ # Archived run logs (auto-populated)
16 └── 2024-01-15T103000-fix-types/
17 ├── state.json
18 ├── events.jsonl
19 └── summary.json # present when loop wrote one to run_dir
20"""
22from __future__ import annotations
24import json
25import logging
26import os
27import re
28import shutil
29import subprocess
30import tempfile
31import time
32from dataclasses import dataclass, field
33from datetime import UTC, datetime
34from pathlib import Path
35from typing import Any
37from little_loops.events import EventBus
38from little_loops.fsm.concurrency import _process_alive
39from little_loops.fsm.executor import EventCallback, ExecutionResult, FSMExecutor
40from little_loops.fsm.schema import FSMLoop
41from little_loops.fsm.validation import _is_meta_loop
43RUNNING_DIR = ".running"
44HISTORY_DIR = ".history"
46RESUMABLE_STATUSES: frozenset[str] = frozenset({"running", "awaiting_continuation", "interrupted"})
48logger = logging.getLogger(__name__)
50_RUN_FOLDER = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{6})-(.+)$")
51_INSTANCE_SUFFIX = re.compile(r"-\d{8}T\d{6}$")
54def _parse_run_folder(name: str) -> tuple[str, str] | None:
55 """Return (run_id, loop_name) from a flat history folder name, or None."""
56 m = _RUN_FOLDER.match(name)
57 return (m.group(1), m.group(2)) if m else None
60def _iso_now() -> str:
61 """Return current time as ISO 8601 string."""
62 return datetime.now(UTC).isoformat()
65def _now_ms() -> int:
66 """Return current time in milliseconds."""
67 return int(time.time() * 1000)
70def _verdict_is_yes(verdict: str) -> bool:
71 """Return True if verdict maps to a positive (yes) outcome."""
72 return verdict.startswith("yes") or verdict in ("progress", "success")
75def _parse_diff_stat(text: str) -> dict[str, int] | None:
76 """Parse 'git diff --stat' summary line into structured dict."""
77 last_line = text.strip().rsplit("\n", 1)[-1] if text.strip() else ""
78 m = re.search(
79 r"(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?",
80 last_line,
81 )
82 if not m:
83 return None
84 return {
85 "files_changed": int(m.group(1)),
86 "insertions": int(m.group(2) or 0),
87 "deletions": int(m.group(3) or 0),
88 }
91def _get_diff_stats() -> dict[str, int] | None:
92 """Run 'git diff --stat HEAD' and return structured stats, or None on failure."""
93 try:
94 proc = subprocess.run(
95 ["git", "diff", "--stat", "HEAD"],
96 capture_output=True,
97 text=True,
98 timeout=10,
99 )
100 if proc.returncode == 0:
101 return _parse_diff_stat(proc.stdout)
102 except Exception:
103 pass
104 return None
107def _read_pid_file(pid_file: Path) -> int | None:
108 """Read and validate a PID file, returning the PID or None."""
109 if not pid_file.exists():
110 return None
111 try:
112 return int(pid_file.read_text().strip())
113 except (ValueError, OSError):
114 return None
117def _resolve_live_pid(running_dir: Path, stem: str, state: LoopState) -> int | None:
118 """Return the canonical PID for an instance via .pid → .lock → state.pid chain.
120 Returns None when no PID can be resolved from any source.
121 """
122 pid = _read_pid_file(running_dir / f"{stem}.pid")
123 if pid is not None:
124 return pid
125 lock_file = running_dir / f"{stem}.lock"
126 if lock_file.exists():
127 try:
128 with open(lock_file) as _lf:
129 lock_data = json.load(_lf)
130 pid = lock_data.get("pid")
131 if pid is not None:
132 return pid
133 except (json.JSONDecodeError, KeyError, OSError):
134 pass
135 return state.pid
138def _reconcile_stale_running(
139 state: LoopState,
140 persistence: StatePersistence,
141 running_dir: Path,
142 stem: str,
143) -> LoopState:
144 """Flip a running-state entry to interrupted when its PID is provably dead.
146 Called on the read path in cmd_status and list_running_loops so orphaned
147 foreground-crash entries self-heal without requiring manual cleanup-loops
148 intervention.
149 """
150 if state.status != "running":
151 return state
152 pid = _resolve_live_pid(running_dir, stem, state)
153 if pid is None:
154 return state # no PID resolvable — cannot determine liveness, leave alone
155 if _process_alive(pid):
156 return state
157 state.status = "interrupted"
158 state.reconciled_at = datetime.now(UTC).isoformat()
159 persistence.save_state(state)
160 return state
163@dataclass
164class LoopState:
165 """Persistent state for an FSM loop execution.
167 This captures all runtime state needed to resume a loop:
168 - Current state and iteration
169 - Captured variables and previous result
170 - Last evaluation result
171 - Timestamps and status
173 Attributes:
174 loop_name: Name of the loop
175 current_state: Current FSM state name
176 iteration: Current iteration count (1-based)
177 captured: Captured action outputs by variable name
178 prev_result: Previous state's result (output, exit_code, state)
179 last_result: Last evaluation result (verdict, details)
180 started_at: ISO timestamp when loop started
181 updated_at: ISO timestamp when state was last saved
182 status: Execution status (running, completed, failed, interrupted, awaiting_continuation, timed_out)
183 continuation_prompt: Continuation context from handoff signal (if status is awaiting_continuation)
184 accumulated_ms: Total milliseconds elapsed across all segments up to this save (used to restore
185 elapsed time correctly after resume, so duration_ms and ${loop.elapsed_ms} reflect the
186 full loop lifetime rather than only the most recent segment)
187 """
189 loop_name: str
190 current_state: str
191 iteration: int
192 captured: dict[str, dict[str, Any]]
193 prev_result: dict[str, Any] | None
194 last_result: dict[str, Any] | None
195 started_at: str
196 updated_at: str
197 status: (
198 str # "running", "completed", "failed", "interrupted", "awaiting_continuation", "timed_out"
199 )
200 continuation_prompt: str | None = None
201 accumulated_ms: int = 0 # total elapsed ms across all segments (for resume offset)
202 retry_counts: dict[str, int] = field(default_factory=dict) # per-state retry tracking
203 # Per-state rate-limit retry tracking (ENH-1133: dict-of-record).
204 # Each record: {"short_retries": int, "long_retries": int,
205 # "total_wait_seconds": float, "first_seen_at": float | None}.
206 # Legacy int values (dict[str, int]) are coerced in from_dict.
207 rate_limit_retries: dict[str, dict[str, Any]] = field(default_factory=dict)
208 # Count of consecutive rate_limit_exhausted emissions across states. Reset
209 # on any non-rate-limited state outcome. Persisted for resume durability.
210 consecutive_rate_limit_exhaustions: int = 0
211 # Per-edge revisit tracking for cycle detection.
212 edge_revisit_counts: dict[str, int] = field(default_factory=dict)
213 # BUG-2204: full-pass counter (maintain-mode restarts); 0 for loops without maintain.
214 iteration_count: int = 0
215 active_sub_loop: str | None = None # name of currently executing sub-loop (observability)
216 pid: int | None = None # OS PID of the process that started this run (for reconciliation sweep)
217 reconciled_at: str | None = None # ISO timestamp when orphaned-running state was auto-flipped
218 messages: list[str] = field(default_factory=list)
220 def to_dict(self) -> dict[str, Any]:
221 """Convert to dictionary for JSON serialization."""
222 result = {
223 "loop_name": self.loop_name,
224 "current_state": self.current_state,
225 "iteration": self.iteration,
226 "captured": self.captured,
227 "prev_result": self.prev_result,
228 "last_result": self.last_result,
229 "started_at": self.started_at,
230 "updated_at": self.updated_at,
231 "status": self.status,
232 "accumulated_ms": self.accumulated_ms,
233 }
234 if self.continuation_prompt is not None:
235 result["continuation_prompt"] = self.continuation_prompt
236 if self.retry_counts:
237 result["retry_counts"] = self.retry_counts
238 if self.rate_limit_retries:
239 result["rate_limit_retries"] = self.rate_limit_retries
240 if self.consecutive_rate_limit_exhaustions:
241 result["consecutive_rate_limit_exhaustions"] = self.consecutive_rate_limit_exhaustions
242 if self.edge_revisit_counts:
243 result["edge_revisit_counts"] = self.edge_revisit_counts
244 if self.iteration_count:
245 result["iteration_count"] = self.iteration_count
246 if self.active_sub_loop is not None:
247 result["active_sub_loop"] = self.active_sub_loop
248 if self.pid is not None:
249 result["pid"] = self.pid
250 if self.reconciled_at is not None:
251 result["reconciled_at"] = self.reconciled_at
252 if self.messages:
253 result["messages"] = self.messages
254 return result
256 @classmethod
257 def from_dict(cls, data: dict[str, Any]) -> LoopState:
258 """Create LoopState from dictionary.
260 Migrates legacy ``rate_limit_retries`` values from ``dict[str, int]``
261 (BUG-1107 pre-ENH-1133 shape) to the dict-of-record shape. Integer
262 values are coerced to ``{"short_retries": <int>, "long_retries": 0,
263 "total_wait_seconds": 0.0, "first_seen_at": None}``.
265 Args:
266 data: Dictionary with loop state fields
268 Returns:
269 LoopState instance
270 """
271 raw_rl = data.get("rate_limit_retries", {}) or {}
272 migrated_rl: dict[str, dict[str, Any]] = {}
273 for state_name, value in raw_rl.items():
274 if isinstance(value, int):
275 migrated_rl[state_name] = {
276 "short_retries": value,
277 "long_retries": 0,
278 "total_wait_seconds": 0.0,
279 "first_seen_at": None,
280 }
281 elif isinstance(value, dict):
282 migrated_rl[state_name] = value
283 return cls(
284 loop_name=data["loop_name"],
285 current_state=data["current_state"],
286 iteration=data["iteration"],
287 captured=data.get("captured", {}),
288 prev_result=data.get("prev_result"),
289 last_result=data.get("last_result"),
290 started_at=data["started_at"],
291 updated_at=data.get("updated_at", ""),
292 status=data["status"],
293 continuation_prompt=data.get("continuation_prompt"),
294 accumulated_ms=data.get("accumulated_ms", 0),
295 retry_counts=data.get("retry_counts", {}),
296 rate_limit_retries=migrated_rl,
297 consecutive_rate_limit_exhaustions=data.get("consecutive_rate_limit_exhaustions", 0),
298 edge_revisit_counts=data.get("edge_revisit_counts", {}),
299 iteration_count=data.get("iteration_count", 0),
300 active_sub_loop=data.get("active_sub_loop"),
301 pid=data.get("pid"),
302 reconciled_at=data.get("reconciled_at"),
303 messages=data.get("messages", []),
304 )
307class StatePersistence:
308 """Manage loop state persistence and event streaming.
310 Handles file I/O for:
311 - State file: JSON file with current execution state
312 - Events file: JSONL file with execution events (append-only)
314 Files are stored in .loops/.running/<instance_id>.*
315 """
317 def __init__(
318 self, loop_name: str, loops_dir: Path | None = None, instance_id: str | None = None
319 ) -> None:
320 """Initialize persistence for a loop.
322 Args:
323 loop_name: Name of the loop
324 loops_dir: Base directory for loops (default: .loops)
325 instance_id: Optional unique instance identifier; falls back to loop_name when None
326 """
327 self.loop_name = loop_name
328 self.loops_dir = loops_dir or Path(".loops")
329 self.running_dir = self.loops_dir / RUNNING_DIR
330 stem = instance_id or loop_name
331 self.state_file = self.running_dir / f"{stem}.state.json"
332 self.events_file = self.running_dir / f"{stem}.events.jsonl"
333 self.meta_eval_file = self.running_dir / f"{stem}.meta-eval.jsonl"
335 def initialize(self) -> None:
336 """Create running directory if needed."""
337 self.running_dir.mkdir(parents=True, exist_ok=True)
339 def save_state(self, state: LoopState) -> None:
340 """Save current state to file using an atomic write.
342 Updates the updated_at timestamp before saving. Writes to a temporary
343 file first, then renames it over the target to avoid leaving a corrupt
344 or empty state file if the process is killed mid-write.
346 Args:
347 state: LoopState to save
348 """
349 state.updated_at = _iso_now()
350 data = json.dumps(state.to_dict(), indent=2)
351 tmp_fd, tmp_path = tempfile.mkstemp(dir=self.state_file.parent, suffix=".tmp")
352 try:
353 with os.fdopen(tmp_fd, "w") as f:
354 f.write(data)
355 os.replace(tmp_path, self.state_file)
356 except Exception:
357 os.unlink(tmp_path)
358 raise
360 def load_state(self) -> LoopState | None:
361 """Load state from file, or None if not exists.
363 Returns:
364 LoopState if file exists and is valid, None otherwise
365 """
366 if not self.state_file.exists():
367 return None
368 try:
369 data = json.loads(self.state_file.read_text())
370 except json.JSONDecodeError:
371 return None
372 try:
373 return LoopState.from_dict(data)
374 except KeyError as e:
375 logger.warning("Corrupted state file %s: missing key %s", self.state_file, e)
376 return None
378 def clear_state(self) -> None:
379 """Remove state file."""
380 if self.state_file.exists():
381 self.state_file.unlink()
383 def append_event(self, event: dict[str, Any]) -> None:
384 """Append event to JSONL file.
386 Args:
387 event: Event dictionary to append
388 """
389 with open(self.events_file, "a", encoding="utf-8") as f:
390 f.write(json.dumps(event) + "\n")
392 def read_events(self) -> list[dict[str, Any]]:
393 """Read all events from file.
395 Returns:
396 List of event dictionaries, empty if file doesn't exist
397 """
398 if not self.events_file.exists():
399 return []
400 events: list[dict[str, Any]] = []
401 with open(self.events_file, encoding="utf-8") as f:
402 for line in f:
403 line = line.strip()
404 if line:
405 try:
406 events.append(json.loads(line))
407 except json.JSONDecodeError:
408 continue # Skip malformed lines
409 return events
411 def clear_events(self) -> None:
412 """Remove events file."""
413 if self.events_file.exists():
414 self.events_file.unlink()
416 def clear_meta_eval(self) -> None:
417 """Remove meta-eval file."""
418 if self.meta_eval_file.exists():
419 self.meta_eval_file.unlink()
421 def archive_run(self, run_dir: Path | None = None) -> Path | None:
422 """Archive current run files to .history/ before clearing.
424 Reads the current state to derive the run timestamp, then copies
425 state.json, events.jsonl, and (when present) meta-eval.jsonl and
426 summary.json into:
427 <loops_dir>/.history/<run_id>-<loop_name>/
429 where run_id is a compact ISO timestamp derived from started_at
430 (e.g. "2024-01-15T103000" from "2024-01-15T10:30:00.123456+00:00").
432 Args:
433 run_dir: Optional path to the loop's run directory. When provided,
434 summary.json is copied from run_dir to the archive directory if
435 it exists. Pass None (default) when the run directory is not
436 available (e.g. stale-run cleanup paths).
438 Returns:
439 Path to the archive directory if files were archived, None if
440 there were no files to archive (fresh run).
441 """
442 has_state = self.state_file.exists()
443 has_events = self.events_file.exists()
444 if not has_state and not has_events:
445 return None
447 # Derive run ID from started_at in state file, or fall back to now
448 state = self.load_state()
449 if state is not None and state.started_at:
450 # Compact ISO: strip colons, dots, plus signs; take first 19 chars
451 # e.g. "2024-01-15T10:30:00.123+00:00" → "2024-01-15T103000"
452 run_id = state.started_at.replace(":", "").replace(".", "").replace("+", "")[:17]
453 else:
454 run_id = datetime.now(UTC).strftime("%Y-%m-%dT%H%M%S")
456 run_folder = f"{run_id}-{self.loop_name}"
457 archive_dir = self.loops_dir / HISTORY_DIR / run_folder
458 archive_dir.mkdir(parents=True, exist_ok=True)
460 if has_state:
461 shutil.copy2(self.state_file, archive_dir / "state.json")
462 if has_events:
463 shutil.copy2(self.events_file, archive_dir / "events.jsonl")
464 if self.meta_eval_file.exists():
465 shutil.copy2(self.meta_eval_file, archive_dir / "meta-eval.jsonl")
466 if run_dir is not None:
467 summary_src = run_dir / "summary.json"
468 if summary_src.exists():
469 shutil.copy2(summary_src, archive_dir / "summary.json")
471 return archive_dir
473 def clear_all(self) -> None:
474 """Archive current run files then clear state and events (for new run)."""
475 self.archive_run()
476 self.clear_state()
477 self.clear_events()
478 self.clear_meta_eval()
481def _reconcile_stale_runs(loops_dir: Path) -> int:
482 """Archive state files in .running/ that belong to dead or terminal processes.
484 Called at loop startup to clean up files left by crashed or interrupted runs.
485 Returns the count of archived files.
487 Strategy (mirrors LockManager.find_conflict() stale-lock cleanup):
488 - Terminal-status files (completed/failed/timed_out) are archived
489 unconditionally — they are definitionally stale by invariant.
490 - status="interrupted" files are left alone so the user can resume them.
491 - status="running" files are checked via their sibling .pid file; archived
492 only if the PID is confirmed dead. No .pid file → leave alone (can't confirm).
493 """
494 running_dir = loops_dir / RUNNING_DIR
495 if not running_dir.exists():
496 return 0
498 terminal_statuses = {"completed", "failed", "timed_out"}
499 archived = 0
501 for state_file in running_dir.glob("*.state.json"):
502 try:
503 data = json.loads(state_file.read_text())
504 state = LoopState.from_dict(data)
505 except (json.JSONDecodeError, KeyError, OSError):
506 continue
508 is_stale = state.status in terminal_statuses
510 if not is_stale and state.status == "running":
511 stem = state_file.name.removesuffix(".state.json")
512 pid_file = running_dir / f"{stem}.pid"
513 if pid_file.exists():
514 try:
515 pid = int(pid_file.read_text().strip())
516 is_stale = not _process_alive(pid)
517 except (OSError, ValueError):
518 pass
520 if not is_stale:
521 continue
523 stem = state_file.name.removesuffix(".state.json")
524 instance_id = stem if stem != state.loop_name else None
525 persistence = StatePersistence(
526 loop_name=state.loop_name,
527 loops_dir=loops_dir,
528 instance_id=instance_id,
529 )
530 try:
531 persistence.clear_all()
532 (running_dir / f"{stem}.pid").unlink(missing_ok=True)
533 archived += 1
534 logger.debug("Archived stale run: %s (status=%s)", stem, state.status)
535 except OSError as e:
536 logger.warning("Failed to archive stale run %s: %s", stem, e)
538 if archived:
539 logger.info("Reconciliation sweep archived %d stale run(s) from .running/", archived)
541 return archived
544class PersistentExecutor:
545 """FSM Executor with state persistence and event streaming.
547 Wraps FSMExecutor to:
548 - Save state after each state transition
549 - Append events to JSONL file as they occur
550 - Support resuming from saved state
551 - Support graceful shutdown via signal handling
552 """
554 def __init__(
555 self,
556 fsm: FSMLoop,
557 persistence: StatePersistence | None = None,
558 loops_dir: Path | None = None,
559 instance_id: str | None = None,
560 pid: int | None = None,
561 **executor_kwargs: Any,
562 ) -> None:
563 """Initialize persistent executor.
565 Args:
566 fsm: FSM loop definition
567 persistence: Optional pre-configured persistence (for testing)
568 loops_dir: Base directory for loops (default: .loops)
569 instance_id: Optional unique instance identifier for file path scoping
570 pid: OS PID of the running process; stored in saved state for reconciliation
571 **executor_kwargs: Additional kwargs for FSMExecutor
572 """
573 from little_loops.fsm.handoff_handler import HandoffBehavior, HandoffHandler
574 from little_loops.fsm.signal_detector import SignalDetector
576 self.fsm = fsm
577 self.loops_dir = loops_dir
578 self._run_pid = pid
579 self.persistence = persistence or StatePersistence(
580 fsm.name, loops_dir or Path(".loops"), instance_id=instance_id
581 )
582 self.persistence.initialize()
584 # Create signal detector and handler based on FSM config
585 signal_detector = SignalDetector()
586 handoff_handler = HandoffHandler(HandoffBehavior(fsm.on_handoff))
588 # Create base executor with event callback that persists
589 self._executor = FSMExecutor(
590 fsm,
591 event_callback=self._handle_event,
592 signal_detector=signal_detector,
593 handoff_handler=handoff_handler,
594 loops_dir=self.loops_dir,
595 **executor_kwargs,
596 )
597 self._last_result: dict[str, Any] | None = None
598 self._last_non_llm_result: dict[str, Any] | None = None
599 self._continuation_prompt: str | None = None
600 self.event_bus = EventBus()
602 @property
603 def _on_event(self) -> EventCallback | None:
604 """Backward-compatible access to the first observer on the event bus."""
605 return self.event_bus._observers[0][0] if self.event_bus._observers else None
607 @_on_event.setter
608 def _on_event(self, callback: EventCallback | None) -> None:
609 """Backward-compatible setter: replaces all observers with this one."""
610 self.event_bus._observers.clear()
611 if callback is not None:
612 self.event_bus.register(callback)
614 def close_transports(self) -> None:
615 """Close all transports registered on the underlying EventBus."""
616 self.event_bus.close_transports()
618 def request_shutdown(self) -> None:
619 """Request graceful shutdown of the executor.
621 Delegates to the underlying FSMExecutor's request_shutdown method.
622 The loop will exit cleanly after the current state completes,
623 saving state as "interrupted" so it can be resumed later.
624 """
625 self._executor.request_shutdown()
627 def _handle_event(self, event: dict[str, Any]) -> None:
628 """Handle event: persist to file and save state.
630 Args:
631 event: Event dictionary from executor
632 """
633 self.persistence.append_event(event)
635 event_type = event.get("event")
637 # Write per-state token usage to usage.jsonl when an LLM action completes.
638 # Shell and mcp_tool invocations produce no token data and are skipped.
639 if event_type == "action_complete" and "input_tokens" in event:
640 run_dir = self.fsm.context.get("run_dir", "")
641 if run_dir:
642 usage_path = Path(run_dir) / "usage.jsonl"
643 entry = {
644 "iteration": self._executor.iteration,
645 "state": self._executor.current_state,
646 "action_type": "prompt" if event.get("is_prompt") else "shell_or_mcp",
647 "input_tokens": event["input_tokens"],
648 "output_tokens": event["output_tokens"],
649 "cache_read_tokens": event.get("cache_read_tokens", 0),
650 "cache_creation_tokens": event.get("cache_creation_tokens", 0),
651 "model": event.get("model", "unknown"),
652 "timestamp": event.get("ts", ""),
653 }
654 with open(usage_path, "a", encoding="utf-8") as f:
655 f.write(json.dumps(entry) + "\n")
657 # Append shared message to messages.jsonl when a state appends to the log.
658 if event_type == "messages_append":
659 run_dir = self.fsm.context.get("run_dir", "")
660 if run_dir:
661 messages_path = Path(run_dir) / "messages.jsonl"
662 entry = {
663 "iteration": self._executor.iteration,
664 "state": event.get("state", ""),
665 "message": event.get("message", ""),
666 "timestamp": event.get("ts", ""),
667 }
668 with open(messages_path, "a", encoding="utf-8") as f:
669 f.write(json.dumps(entry) + "\n")
671 # Save state after state transitions
672 if event_type in ("state_enter", "loop_complete", "baseline_complete"):
673 self._save_state()
675 # Track evaluation results for state persistence
676 if event_type == "evaluate":
677 self._last_result = {
678 "verdict": event.get("verdict"),
679 "details": {
680 k: v for k, v in event.items() if k not in ("event", "ts", "type", "verdict")
681 },
682 }
683 eval_type = event.get("type", "")
684 if eval_type != "llm_structured":
685 self._last_non_llm_result = {
686 "state": self._executor.current_state,
687 "evaluator": eval_type,
688 "verdict": event.get("verdict", ""),
689 "details": {
690 k: v
691 for k, v in event.items()
692 if k not in ("event", "ts", "type", "verdict")
693 },
694 }
695 elif _is_meta_loop(self.fsm):
696 self._write_meta_eval_entry(event)
698 # Track handoff events for continuation prompt
699 if event_type == "handoff_detected":
700 self._continuation_prompt = event.get("continuation")
702 # Delegate to registered observers (e.g. progress display, extensions)
703 self.event_bus.emit(event)
705 def _write_meta_eval_entry(self, event: dict[str, Any]) -> None:
706 """Append one JSONL entry to meta-eval.jsonl for an llm_structured evaluate in a meta-loop."""
707 non_llm = self._last_non_llm_result or {}
708 non_llm_details = non_llm.get("details", {})
710 llm_verdict = event.get("verdict", "")
711 ext_verdict = non_llm.get("verdict", "")
712 agreed: bool | None = (
713 _verdict_is_yes(llm_verdict) == _verdict_is_yes(ext_verdict) if ext_verdict else None
714 )
716 ext_value = non_llm_details.get("current", non_llm_details.get("value"))
717 ext_target = non_llm_details.get("target")
719 entry: dict[str, Any] = {
720 "iteration": self._executor.iteration,
721 "ts": _iso_now(),
722 "loop": self.fsm.name,
723 "state": self._executor.current_state,
724 "llm_verdict": llm_verdict,
725 "llm_rationale": (event.get("reason") or "")[:200],
726 "external_verdict": ext_verdict or None,
727 "external_state": non_llm.get("state"),
728 "external_evaluator": non_llm.get("evaluator") or None,
729 "external_value": str(ext_value) if ext_value is not None else None,
730 "external_target": str(ext_target) if ext_target is not None else None,
731 "diff_stats": _get_diff_stats(),
732 "agreed": agreed,
733 }
734 with open(self.persistence.meta_eval_file, "a", encoding="utf-8") as f:
735 f.write(json.dumps(entry) + "\n")
737 def _save_state(self) -> None:
738 """Save current executor state to file."""
739 status = "running"
740 if self._executor.current_state:
741 state_config = self.fsm.states.get(self._executor.current_state)
742 if state_config and state_config.terminal:
743 status = "completed"
745 state = LoopState(
746 loop_name=self.fsm.name,
747 current_state=self._executor.current_state,
748 iteration=self._executor.iteration,
749 captured=self._executor.captured,
750 prev_result=self._executor.prev_result,
751 last_result=self._last_result,
752 started_at=self._executor.started_at,
753 updated_at="", # Will be set by save_state
754 status=status,
755 accumulated_ms=_now_ms()
756 - self._executor.start_time_ms
757 + self._executor.elapsed_offset_ms,
758 retry_counts=dict(self._executor._retry_counts),
759 rate_limit_retries={k: dict(v) for k, v in self._executor._rate_limit_retries.items()},
760 consecutive_rate_limit_exhaustions=(self._executor._consecutive_rate_limit_exhaustions),
761 edge_revisit_counts=dict(self._executor._edge_revisit_counts),
762 iteration_count=self._executor._iteration_count,
763 pid=self._run_pid,
764 messages=list(self._executor.messages),
765 )
766 self.persistence.save_state(state)
768 def run(self, clear_previous: bool = True) -> ExecutionResult:
769 """Run the FSM with persistence.
771 Args:
772 clear_previous: If True, clear previous state/events before running
774 Returns:
775 ExecutionResult from the execution
776 """
777 if clear_previous:
778 self.persistence.clear_all()
780 result = self._executor.run()
782 # Update final state
783 final_status = "completed" if result.terminated_by == "terminal" else "failed"
784 if result.terminated_by in ("max_steps", "max_iterations_reached", "signal"):
785 final_status = "interrupted"
786 if result.terminated_by == "handoff":
787 final_status = "awaiting_continuation"
788 if result.terminated_by == "timeout":
789 final_status = "timed_out"
790 if result.terminated_by == "cycle_detected":
791 final_status = "failed"
793 final_state = LoopState(
794 loop_name=self.fsm.name,
795 current_state=result.final_state,
796 iteration=result.iterations,
797 captured=result.captured,
798 prev_result=self._executor.prev_result,
799 last_result=self._last_result,
800 started_at=self._executor.started_at,
801 updated_at="",
802 status=final_status,
803 continuation_prompt=self._continuation_prompt,
804 accumulated_ms=result.duration_ms,
805 )
806 self.persistence.save_state(final_state)
807 run_dir_str = self.fsm.context.get("run_dir", "")
808 self.persistence.archive_run(run_dir=Path(run_dir_str) if run_dir_str else None)
810 return result
812 def resume(self) -> ExecutionResult | None:
813 """Resume from saved state, or None if no resumable state.
815 Resumable states are: "running", "awaiting_continuation", and "interrupted".
817 Returns:
818 ExecutionResult if resumed and completed, None if no resumable state
819 """
820 state = self.persistence.load_state()
821 if state is None:
822 return None
824 if state.status not in RESUMABLE_STATUSES:
825 return None # Already completed/failed
827 # Restore executor state
828 self._executor.current_state = state.current_state
829 self._executor.iteration = state.iteration
830 self._executor.captured = state.captured
831 self._executor.prev_result = state.prev_result
832 self._executor.started_at = state.started_at
833 self._last_result = state.last_result
834 self._executor._retry_counts = dict(state.retry_counts)
835 self._executor._rate_limit_retries = {
836 k: dict(v) for k, v in state.rate_limit_retries.items()
837 }
838 self._executor._consecutive_rate_limit_exhaustions = (
839 state.consecutive_rate_limit_exhaustions
840 )
841 self._executor._edge_revisit_counts = dict(state.edge_revisit_counts)
842 self._executor._iteration_count = state.iteration_count
843 self._executor.messages = list(state.messages)
845 # Restore accumulated elapsed time so duration_ms and ${loop.elapsed_ms} reflect
846 # the full loop lifetime (all segments), not just the resumed segment.
847 # FSMExecutor.run() will reset start_time_ms to _now_ms(), so we use elapsed_offset_ms
848 # to carry forward the time already spent before this resume.
849 self._executor.elapsed_offset_ms = state.accumulated_ms
851 # Clear any pending signals from previous run
852 self._executor._pending_handoff = None
853 self._executor._pending_error = None
855 # Emit resume event with continuation context if available
856 resume_event: dict[str, Any] = {
857 "event": "loop_resume",
858 "ts": _iso_now(),
859 "loop": self.fsm.name,
860 "from_state": state.current_state,
861 "iteration": state.iteration,
862 }
863 if state.status == "awaiting_continuation" and state.continuation_prompt:
864 resume_event["from_handoff"] = True
865 resume_event["continuation_prompt"] = state.continuation_prompt
866 self.persistence.append_event(resume_event)
867 self.event_bus.emit(resume_event)
869 # Continue execution (don't clear previous events)
870 return self.run(clear_previous=False)
873def _find_instances(loop_name: str, running_dir: Path) -> list[tuple[str | None, LoopState]]:
874 """Discover all state-file instances for *loop_name* in *running_dir*.
876 Globs ``{loop_name}-*.state.json`` for instance-scoped files and
877 ``{loop_name}.state.json`` for legacy bare-name files.
879 Returns:
880 List of ``(instance_id, LoopState)`` tuples sorted by file name.
881 *instance_id* is the file stem (e.g. ``"autodev-20260503T122306"``)
882 for instance-scoped files, or ``None`` for legacy bare-name files.
883 """
884 if not running_dir.exists():
885 return []
887 instances: list[tuple[str | None, LoopState]] = []
889 # Instance-scoped files: {loop_name}-YYYYMMDDTHHMMSS.state.json
890 # Use Path(stem).stem to strip both suffixes (.state.json → base stem).
891 for state_file in sorted(running_dir.glob(f"{loop_name}-*.state.json")):
892 base_stem = Path(state_file.stem).stem # e.g. "autodev-20260503T122306"
893 if not _INSTANCE_SUFFIX.search(base_stem):
894 continue # skip files like "loop-name-extra" that don't match timestamp pattern
895 try:
896 data = json.loads(state_file.read_text())
897 instances.append((base_stem, LoopState.from_dict(data)))
898 except (json.JSONDecodeError, KeyError):
899 continue
901 # Legacy bare-name file: {loop_name}.state.json
902 legacy_file = running_dir / f"{loop_name}.state.json"
903 if legacy_file.exists():
904 try:
905 data = json.loads(legacy_file.read_text())
906 instances.append((None, LoopState.from_dict(data)))
907 except (json.JSONDecodeError, KeyError):
908 pass
910 return instances
913def list_running_loops(loops_dir: Path | None = None) -> list[LoopState]:
914 """List all loops with saved state.
916 Args:
917 loops_dir: Base directory for loops (default: .loops)
919 Returns:
920 List of LoopState objects for all loops with state files
921 """
922 base_dir = loops_dir or Path(".loops")
923 running_dir = base_dir / RUNNING_DIR
925 if not running_dir.exists():
926 return []
928 states: list[LoopState] = []
929 for state_file in running_dir.glob("*.state.json"):
930 try:
931 data = json.loads(state_file.read_text())
932 state = LoopState.from_dict(data)
933 except (json.JSONDecodeError, KeyError):
934 continue # Skip malformed files
935 stem = state_file.stem.removesuffix(".state")
936 persistence = StatePersistence(
937 state.loop_name, base_dir, instance_id=stem if stem != state.loop_name else None
938 )
939 state = _reconcile_stale_running(state, persistence, running_dir, stem)
940 states.append(state)
942 # Include loops that have a PID file but no state file yet (still starting up).
943 # Strip instance-ID timestamp suffix (e.g. "autodev-20240115T103000" → "autodev")
944 # before the known_names check to avoid spurious "starting" entries for loops
945 # that already have a state file under their logical name.
946 known_names = {s.loop_name for s in states}
947 for pid_file in running_dir.glob("*.pid"):
948 logical_name = _INSTANCE_SUFFIX.sub("", pid_file.stem)
949 if logical_name in known_names:
950 continue # state file already covers this loop
951 try:
952 pid = int(pid_file.read_text().strip())
953 except (ValueError, OSError):
954 continue
955 if _process_alive(pid):
956 states.append(
957 LoopState(
958 loop_name=logical_name,
959 current_state="(initializing)",
960 iteration=0,
961 captured={},
962 prev_result=None,
963 last_result=None,
964 started_at="",
965 updated_at="",
966 status="starting",
967 )
968 )
970 return states
973def list_run_history(loop_name: str, loops_dir: Path | None = None) -> list[LoopState]:
974 """List archived runs for a loop, newest first.
976 Reads state files from .loops/.history/<run_id>-<loop_name>/state.json and
977 returns them sorted by started_at descending (most recent run first).
979 Also checks the legacy nested layout .loops/.history/<loop_name>/*/state.json
980 for backward compatibility with existing history folders.
982 Args:
983 loop_name: Name of the loop
984 loops_dir: Base directory for loops (default: .loops)
986 Returns:
987 List of LoopState objects for all archived runs, newest first.
988 Returns an empty list if no history exists.
989 """
990 base_dir = loops_dir or Path(".loops")
991 history_dir = base_dir / HISTORY_DIR
993 if not history_dir.exists():
994 return []
996 states: list[LoopState] = []
998 # Flat layout: <run_id>-<loop_name>/state.json
999 for state_file in history_dir.glob(f"*-{loop_name}/state.json"):
1000 try:
1001 data = json.loads(state_file.read_text())
1002 states.append(LoopState.from_dict(data))
1003 except (json.JSONDecodeError, KeyError):
1004 continue
1006 # Backward compat: legacy nested layout <loop_name>/<run_id>/state.json
1007 old_loop_dir = history_dir / loop_name
1008 if old_loop_dir.exists():
1009 logger.warning(
1010 "Found legacy nested history at %s; migrate to flat layout by moving "
1011 "each run to .history/<run_id>-%s/",
1012 old_loop_dir,
1013 loop_name,
1014 )
1015 for state_file in old_loop_dir.glob("*/state.json"):
1016 try:
1017 data = json.loads(state_file.read_text())
1018 states.append(LoopState.from_dict(data))
1019 except (json.JSONDecodeError, KeyError):
1020 continue
1022 states.sort(key=lambda s: s.started_at, reverse=True)
1023 return states
1026def get_archived_events(
1027 loop_name: str, run_id: str, loops_dir: Path | None = None
1028) -> list[dict[str, Any]]:
1029 """Read events for a specific archived run.
1031 Args:
1032 loop_name: Name of the loop
1033 run_id: The run directory name (compact timestamp)
1034 loops_dir: Base directory for loops (default: .loops)
1036 Returns:
1037 List of event dictionaries, empty if not found.
1038 """
1039 base_dir = loops_dir or Path(".loops")
1040 run_folder = f"{run_id}-{loop_name}"
1041 events_file = base_dir / HISTORY_DIR / run_folder / "events.jsonl"
1043 if not events_file.exists():
1044 return []
1046 events: list[dict[str, Any]] = []
1047 with open(events_file, encoding="utf-8") as f:
1048 for line in f:
1049 line = line.strip()
1050 if line:
1051 try:
1052 events.append(json.loads(line))
1053 except json.JSONDecodeError:
1054 continue
1055 return events
1058def get_loop_history(loop_name: str, loops_dir: Path | None = None) -> list[dict[str, Any]]:
1059 """Get event history for a loop.
1061 Args:
1062 loop_name: Name of the loop
1063 loops_dir: Base directory for loops (default: .loops)
1065 Returns:
1066 List of event dictionaries
1067 """
1068 persistence = StatePersistence(loop_name, loops_dir)
1069 return persistence.read_events()