|
1 | | -"""Extracted run loop with structured event emission. |
| 1 | +"""Run loop with structured event emission. |
2 | 2 |
|
3 | | -The core ``run_loop`` function is the autonomous agent loop previously |
4 | | -inlined in ``cli.py:run()``. It accepts a ``RunConfig``, ``RunState``, |
5 | | -and ``EventEmitter``, making it reusable from both CLI and UI contexts. |
| 3 | +The core ``run_loop`` function is the autonomous agent loop. It accepts |
| 4 | +a ``RunConfig``, ``RunState``, and ``EventEmitter``, making it reusable |
| 5 | +from both CLI and UI contexts. |
| 6 | +
|
| 7 | +Run data types (``RunStatus``, ``RunConfig``, ``RunState``) live in |
| 8 | +``_run_types.py`` so modules that only need types don't import the engine. |
6 | 9 | """ |
7 | 10 |
|
8 | 11 | from __future__ import annotations |
|
11 | 14 | import sys |
12 | 15 | import time |
13 | 16 | import traceback |
14 | | -import threading |
15 | | -from dataclasses import dataclass, field |
16 | 17 | from datetime import datetime |
17 | | -from enum import Enum |
18 | 18 | from pathlib import Path |
19 | 19 | from typing import Any, NamedTuple |
20 | 20 | from ralphify._events import Event, EventEmitter, EventType, NullEmitter |
21 | 21 | from ralphify._output import collect_output, format_duration |
| 22 | +from ralphify._run_types import RunConfig, RunState, RunStatus |
22 | 23 | from ralphify.checks import ( |
23 | 24 | Check, |
24 | 25 | discover_checks, |
|
36 | 37 | from ralphify.instructions import Instruction, discover_instructions, resolve_instructions |
37 | 38 |
|
38 | 39 |
|
39 | | -class RunStatus(Enum): |
40 | | - """Lifecycle status of a run.""" |
41 | | - |
42 | | - PENDING = "pending" |
43 | | - RUNNING = "running" |
44 | | - PAUSED = "paused" |
45 | | - STOPPING = "stopping" |
46 | | - STOPPED = "stopped" |
47 | | - COMPLETED = "completed" |
48 | | - FAILED = "failed" |
49 | | - |
50 | | - |
51 | | -@dataclass |
52 | | -class RunConfig: |
53 | | - """All settings for a single run. Mutable — fields can change mid-run.""" |
54 | | - |
55 | | - command: str |
56 | | - args: list[str] |
57 | | - prompt_file: str |
58 | | - prompt_text: str | None = None |
59 | | - prompt_name: str | None = None |
60 | | - max_iterations: int | None = None |
61 | | - delay: float = 0 |
62 | | - timeout: float | None = None |
63 | | - stop_on_error: bool = False |
64 | | - log_dir: str | None = None |
65 | | - project_root: Path = field(default_factory=lambda: Path(".")) |
66 | | - |
67 | | - |
68 | | -@dataclass |
69 | | -class RunState: |
70 | | - """Observable state for a run. |
71 | | -
|
72 | | - Control methods (:meth:`request_stop`, :meth:`request_pause`, |
73 | | - :meth:`request_resume`) use :class:`threading.Event` so the run loop |
74 | | - can react at iteration boundaries without busy-waiting. |
75 | | -
|
76 | | - **Threading model**: counters (``iteration``, ``completed``, etc.) are |
77 | | - written only by the engine thread and read by API threads. Under |
78 | | - CPython's GIL this is safe — readers may see a briefly stale value, |
79 | | - which is acceptable for dashboard display. |
80 | | - """ |
81 | | - |
82 | | - run_id: str |
83 | | - status: RunStatus = RunStatus.PENDING |
84 | | - iteration: int = 0 |
85 | | - completed: int = 0 |
86 | | - failed: int = 0 |
87 | | - timed_out: int = 0 |
88 | | - |
89 | | - _stop_requested: bool = False |
90 | | - _pause_event: threading.Event = field(default_factory=threading.Event) |
91 | | - _reload_requested: bool = False |
92 | | - |
93 | | - def __post_init__(self) -> None: |
94 | | - # Start un-paused |
95 | | - self._pause_event.set() |
96 | | - |
97 | | - def request_stop(self) -> None: |
98 | | - """Signal the loop to stop after the current iteration.""" |
99 | | - self._stop_requested = True |
100 | | - # Unpause so the loop can exit |
101 | | - self._pause_event.set() |
102 | | - |
103 | | - def request_pause(self) -> None: |
104 | | - """Pause the loop between iterations until resumed.""" |
105 | | - self.status = RunStatus.PAUSED |
106 | | - self._pause_event.clear() |
107 | | - |
108 | | - def request_resume(self) -> None: |
109 | | - """Resume a paused loop.""" |
110 | | - self.status = RunStatus.RUNNING |
111 | | - self._pause_event.set() |
112 | | - |
113 | | - def request_reload(self) -> None: |
114 | | - """Request re-discovery of primitives before the next iteration.""" |
115 | | - self._reload_requested = True |
116 | | - |
117 | | - @property |
118 | | - def stop_requested(self) -> bool: |
119 | | - """Whether a stop has been requested.""" |
120 | | - return self._stop_requested |
121 | | - |
122 | | - @property |
123 | | - def paused(self) -> bool: |
124 | | - """Whether the run is currently paused.""" |
125 | | - return not self._pause_event.is_set() |
126 | | - |
127 | | - def wait_for_unpause(self, timeout: float | None = None) -> bool: |
128 | | - """Block until unpaused or timeout. Returns True if unpaused.""" |
129 | | - return self._pause_event.wait(timeout=timeout) |
130 | | - |
131 | | - def consume_reload_request(self) -> bool: |
132 | | - """If a reload was requested, clear the flag and return True.""" |
133 | | - if self._reload_requested: |
134 | | - self._reload_requested = False |
135 | | - return True |
136 | | - return False |
137 | | - |
138 | | - |
139 | 40 | def _write_log( |
140 | 41 | log_path_dir: Path, |
141 | 42 | iteration: int, |
|
0 commit comments