piocloop 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
piocloop/loop.py ADDED
@@ -0,0 +1,487 @@
1
+ """The orchestration loop, decoupled from Textual.
2
+
3
+ Deliberate deviation from PLAN.md Phase 4, which put this in `tui.py`: the
4
+ watchdog, stall detection and error-streak logic are the parts most likely to
5
+ harbour a hang, and they are only testable in isolation if they do not need a
6
+ terminal. `tui.py` is now a thin view over this engine.
7
+
8
+ Termination guarantees (DESIGN §4.2 / §4.5):
9
+
10
+ * `_await_settle` cannot block forever — it soft-polls, then aborts at the
11
+ iteration deadline, then respawns a wedged process.
12
+ * `run()` never exits on a recoverable error; it backs off and continues, and
13
+ parks in a resumable state on a streak, a stall, or max-iterations.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import contextlib
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Awaitable, Callable, Optional, Protocol, Sequence
24
+
25
+ from .pi_client import PiClient, PiError, PiExited, PiTimeout
26
+ from .pi_events import (
27
+ AssistantText,
28
+ EventMapper,
29
+ Notice,
30
+ PlanTouched,
31
+ ProcessExited,
32
+ Settled,
33
+ Started,
34
+ Thinking,
35
+ ToolEnded,
36
+ ToolStarted,
37
+ Usage,
38
+ )
39
+ from .plan_parser import (
40
+ PlanProgress,
41
+ is_plan_complete,
42
+ read_current_task,
43
+ read_plan_complete_summary,
44
+ read_plan_progress,
45
+ )
46
+
47
+ # --- states ---------------------------------------------------------------
48
+
49
+ STATE_STARTING = "starting"
50
+ STATE_READY = "ready"
51
+ STATE_RUNNING = "running"
52
+ STATE_PAUSING = "pausing"
53
+ STATE_PAUSED = "paused"
54
+ STATE_STALLED = "stalled"
55
+ STATE_COMPLETE = "complete"
56
+ STATE_ERROR = "error"
57
+
58
+
59
+ @dataclass
60
+ class LoopConfig:
61
+ prompt_file: Path
62
+ plan_file: Path
63
+ argv: Sequence[str]
64
+ cwd: Optional[str] = None
65
+ dialog_policy: str = "cancel"
66
+ max_iterations: int = 100
67
+ iteration_timeout: float = 1800.0
68
+ max_stalls: int = 3
69
+ verbose: bool = False
70
+ # watchdog tuning — overridable so tests need not wait 30s
71
+ soft_poll_interval: float = 30.0
72
+ idle_polls_to_settle: int = 2
73
+ abort_grace: float = 30.0
74
+ max_respawns: int = 3
75
+ max_error_streak: int = 3
76
+ error_backoff_base: float = 2.0
77
+ command_timeout: float = 15.0 # deadline for get_state / abort round-trips
78
+
79
+
80
+ class LoopUI(Protocol):
81
+ """Everything the engine needs from a view."""
82
+
83
+ def log_line(self, kind: str, text: str, detail: str = "") -> None: ...
84
+ def set_state(self, state: str) -> None: ...
85
+ def on_progress(self) -> None: ...
86
+ def on_complete(self, summary: str) -> None: ...
87
+
88
+
89
+ class NullUI:
90
+ def log_line(self, kind: str, text: str, detail: str = "") -> None:
91
+ pass
92
+
93
+ def set_state(self, state: str) -> None:
94
+ pass
95
+
96
+ def on_progress(self) -> None:
97
+ pass
98
+
99
+ def on_complete(self, summary: str) -> None:
100
+ pass
101
+
102
+
103
+ class LoopEngine:
104
+ def __init__(
105
+ self,
106
+ config: LoopConfig,
107
+ ui: Optional[LoopUI] = None,
108
+ *,
109
+ client_factory: Optional[Callable[[], PiClient]] = None,
110
+ ) -> None:
111
+ self.config = config
112
+ self.ui: LoopUI = ui or NullUI()
113
+ self._client_factory = client_factory or self._default_client_factory
114
+
115
+ self.state = STATE_STARTING
116
+ self.iteration = 0
117
+ self.progress: Optional[PlanProgress] = None
118
+ self.current_task: Optional[str] = None
119
+ self.total_tokens = 0
120
+ self.total_cost = 0.0
121
+ self.error_streak = 0
122
+ self.respawns = 0
123
+ self.stall_count = 0
124
+ self.iter_times: list[float] = []
125
+ self.iter_start_time: Optional[float] = None
126
+
127
+ self._last_completed = -1
128
+ self._paused = False
129
+ self._stop_requested = False
130
+ self._seen_agent_start = False
131
+
132
+ self.client: Optional[PiClient] = None
133
+ self._mapper = EventMapper(config.plan_file)
134
+ self._event_task: Optional[asyncio.Task] = None
135
+
136
+ self._settled = asyncio.Event()
137
+ self._start_event = asyncio.Event()
138
+ self._resume_event = asyncio.Event()
139
+
140
+ # ------------------------------------------------------------------
141
+ # Client lifecycle
142
+ # ------------------------------------------------------------------
143
+
144
+ def _default_client_factory(self) -> PiClient:
145
+ return PiClient(
146
+ self.config.argv,
147
+ cwd=self.config.cwd,
148
+ dialog_policy=self.config.dialog_policy,
149
+ )
150
+
151
+ async def boot(self) -> bool:
152
+ """Start `pi`. Returns False (and sets STATE_ERROR) if it cannot start."""
153
+ try:
154
+ await self._spawn_client()
155
+ except PiError as exc:
156
+ self._set_state(STATE_ERROR)
157
+ self.ui.log_line("error", f"Could not start pi: {exc}")
158
+ return False
159
+ self.reload_plan()
160
+ self._set_state(STATE_READY)
161
+ self.ui.log_line("start", "pi ready")
162
+ return True
163
+
164
+ async def _spawn_client(self) -> None:
165
+ client = self._client_factory()
166
+ await client.start()
167
+ self.client = client
168
+ self._event_task = asyncio.create_task(self._pump_events(client), name="pi-events")
169
+
170
+ async def shutdown(self) -> None:
171
+ task, self._event_task = self._event_task, None
172
+ if task is not None:
173
+ task.cancel()
174
+ with contextlib.suppress(asyncio.CancelledError, Exception):
175
+ await task
176
+ client, self.client = self.client, None
177
+ if client is not None:
178
+ with contextlib.suppress(Exception):
179
+ await client.close()
180
+
181
+ async def _respawn_client(self) -> None:
182
+ self.respawns += 1
183
+ self.ui.log_line("warn", f"Restarting pi (respawn {self.respawns}/{self.config.max_respawns})")
184
+ await self.shutdown()
185
+ self._mapper = EventMapper(self.config.plan_file)
186
+ await self._spawn_client()
187
+
188
+ # ------------------------------------------------------------------
189
+ # Events
190
+ # ------------------------------------------------------------------
191
+
192
+ async def _pump_events(self, client: PiClient) -> None:
193
+ while True:
194
+ raw = await client.events.get()
195
+ if self.config.verbose:
196
+ self.ui.log_line("info", f"raw {raw.get('type')}", str(raw)[:120])
197
+ for event in self._mapper.map(raw):
198
+ self.handle_event(event)
199
+
200
+ def handle_event(self, event) -> None:
201
+ if isinstance(event, Started):
202
+ self._seen_agent_start = True
203
+ self.ui.log_line("start", "Agent started")
204
+ elif isinstance(event, Settled):
205
+ self.ui.log_line("idle", "Agent settled")
206
+ self._settled.set()
207
+ elif isinstance(event, ToolStarted):
208
+ self.ui.log_line("tool", event.tool, event.detail)
209
+ elif isinstance(event, ToolEnded):
210
+ if event.is_error:
211
+ self.ui.log_line("warn", f"{event.tool} failed", event.path or "")
212
+ elif isinstance(event, PlanTouched):
213
+ self.ui.log_line("edit", "plan file updated")
214
+ self.reload_plan()
215
+ elif isinstance(event, AssistantText):
216
+ self.ui.log_line("ai", event.text[:200].replace("\n", " "))
217
+ elif isinstance(event, Thinking):
218
+ self.ui.log_line("think", event.text[:120].replace("\n", " "))
219
+ elif isinstance(event, Usage):
220
+ self.total_tokens += event.input_tokens + event.output_tokens
221
+ self.total_cost += event.cost
222
+ self.ui.on_progress()
223
+ elif isinstance(event, Notice):
224
+ self.ui.log_line(event.kind, event.text)
225
+ elif isinstance(event, ProcessExited):
226
+ self.ui.log_line("error", f"pi exited (code {event.returncode})")
227
+ for line in event.stderr_tail[-5:]:
228
+ self.ui.log_line("stderr", line)
229
+ # Unblock the loop; the watchdog decides what happens next.
230
+ self._settled.set()
231
+
232
+ # ------------------------------------------------------------------
233
+ # Plan
234
+ # ------------------------------------------------------------------
235
+
236
+ def reload_plan(self) -> None:
237
+ try:
238
+ self.progress = read_plan_progress(self.config.plan_file)
239
+ self.current_task = read_current_task(self.config.plan_file)
240
+ except OSError:
241
+ pass
242
+ self.ui.on_progress()
243
+
244
+ # ------------------------------------------------------------------
245
+ # Main loop
246
+ # ------------------------------------------------------------------
247
+
248
+ async def run(self, auto_start: bool = False) -> None:
249
+ if auto_start:
250
+ self._start_event.set()
251
+ await self._start_event.wait()
252
+ if self._stop_requested:
253
+ return
254
+
255
+ self._set_state(STATE_RUNNING)
256
+ self.ui.log_line("start", "Loop started")
257
+
258
+ try:
259
+ while not self._stop_requested:
260
+ self.reload_plan()
261
+
262
+ if is_plan_complete(self.config.plan_file):
263
+ self._finish_complete()
264
+ return
265
+
266
+ if self.iteration >= self.config.max_iterations:
267
+ self.ui.log_line(
268
+ "warn",
269
+ f"Reached max-iterations ({self.config.max_iterations}) — stopping",
270
+ )
271
+ self._set_state(STATE_ERROR)
272
+ if not await self._wait_for_resume():
273
+ return
274
+ continue
275
+
276
+ try:
277
+ await self._run_iteration()
278
+ except (PiError, PiExited, PiTimeout, OSError) as exc:
279
+ if self._stop_requested:
280
+ return
281
+ self.error_streak += 1
282
+ self.ui.log_line("error", f"Iteration failed: {exc}")
283
+ if self.error_streak >= self.config.max_error_streak:
284
+ self.ui.log_line(
285
+ "error",
286
+ f"{self.error_streak} consecutive failures — stopping",
287
+ )
288
+ self._set_state(STATE_ERROR)
289
+ if not await self._wait_for_resume():
290
+ return
291
+ continue
292
+ backoff = self.config.error_backoff_base ** self.error_streak
293
+ self.ui.log_line("info", f"Retrying in {backoff:.0f}s")
294
+ await asyncio.sleep(backoff)
295
+ continue
296
+ else:
297
+ self.error_streak = 0
298
+ self.respawns = 0
299
+
300
+ if self._stop_requested:
301
+ break
302
+
303
+ self.reload_plan()
304
+ if self._check_stall():
305
+ if not await self._wait_for_resume():
306
+ return
307
+ continue
308
+
309
+ if self._paused:
310
+ self._set_state(STATE_PAUSED)
311
+ self.ui.log_line("info", "Paused — press Space to resume")
312
+ if not await self._wait_for_resume():
313
+ return
314
+ finally:
315
+ self.ui.log_line("info", "Loop stopped")
316
+
317
+ async def _run_iteration(self) -> None:
318
+ assert self.client is not None
319
+ self._settled.clear()
320
+ self._seen_agent_start = False
321
+
322
+ await self.client.new_session()
323
+ self.iteration += 1
324
+ self.iter_start_time = time.monotonic()
325
+ self.ui.log_line("start", f"Iteration {self.iteration}")
326
+ self.ui.on_progress()
327
+
328
+ prompt_text = self.config.prompt_file.read_text(encoding="utf-8").replace(
329
+ "{{PLAN_FILE}}", str(self.config.plan_file)
330
+ )
331
+ await self.client.prompt(prompt_text)
332
+ await self._await_settle()
333
+
334
+ if self.iter_start_time is not None:
335
+ self.iter_times.append(time.monotonic() - self.iter_start_time)
336
+ self.iter_start_time = None
337
+
338
+ async def _await_settle(self) -> None:
339
+ """Bounded wait for `agent_settled`. Cannot hang (DESIGN §4.2)."""
340
+ assert self.client is not None
341
+ cfg = self.config
342
+ deadline = time.monotonic() + cfg.iteration_timeout
343
+ idle_polls = 0
344
+
345
+ while not self._stop_requested:
346
+ remaining = deadline - time.monotonic()
347
+ if remaining <= 0:
348
+ break
349
+
350
+ try:
351
+ await asyncio.wait_for(
352
+ self._settled.wait(),
353
+ timeout=min(cfg.soft_poll_interval, remaining),
354
+ )
355
+ return
356
+ except asyncio.TimeoutError:
357
+ pass
358
+
359
+ # stage 1 — soft poll
360
+ if self.client.exited.is_set():
361
+ raise PiExited("pi exited during iteration")
362
+ try:
363
+ state = await self.client.get_state(timeout=cfg.command_timeout)
364
+ except (PiTimeout, PiExited) as exc:
365
+ self.ui.log_line("warn", f"State poll failed: {exc}")
366
+ idle_polls = 0
367
+ continue
368
+
369
+ if state.get("isStreaming"):
370
+ idle_polls = 0
371
+ continue
372
+
373
+ idle_polls += 1
374
+ # Consecutive quiet polls only: a slow provider can legitimately be
375
+ # not-yet-streaming right after the prompt is accepted.
376
+ if idle_polls >= cfg.idle_polls_to_settle:
377
+ self.ui.log_line("warn", "Agent idle but no settle event — continuing")
378
+ return
379
+
380
+ if self._stop_requested:
381
+ return
382
+
383
+ # stage 2 — deadline exceeded, abort
384
+ self.ui.log_line("warn", f"Iteration exceeded {cfg.iteration_timeout:.0f}s — aborting")
385
+ with contextlib.suppress(PiError, PiExited, PiTimeout):
386
+ await self.client.abort(timeout=cfg.command_timeout)
387
+ try:
388
+ await asyncio.wait_for(self._settled.wait(), timeout=cfg.abort_grace)
389
+ return
390
+ except asyncio.TimeoutError:
391
+ pass
392
+
393
+ # stage 3 — wedged, respawn
394
+ if self.respawns >= cfg.max_respawns:
395
+ raise PiError(f"pi wedged and respawned {self.respawns} times — giving up")
396
+ await self._respawn_client()
397
+ raise PiTimeout("iteration aborted; pi restarted")
398
+
399
+ # ------------------------------------------------------------------
400
+ # Stall / pause / completion
401
+ # ------------------------------------------------------------------
402
+
403
+ def _check_stall(self) -> bool:
404
+ if not self.config.max_stalls or self.progress is None:
405
+ return False
406
+ completed = self.progress.completed
407
+ if completed > self._last_completed:
408
+ self._last_completed = completed
409
+ self.stall_count = 0
410
+ return False
411
+
412
+ self.stall_count += 1
413
+ if self.stall_count < self.config.max_stalls:
414
+ self.ui.log_line(
415
+ "warn",
416
+ f"No progress this iteration ({self.stall_count}/{self.config.max_stalls})",
417
+ )
418
+ return False
419
+
420
+ self._set_state(STATE_STALLED)
421
+ self.ui.log_line(
422
+ "warn",
423
+ f"No progress for {self.stall_count} iterations — stopping. "
424
+ f"The agent may be stuck on: {self.current_task or 'unknown task'}",
425
+ )
426
+ return True
427
+
428
+ async def _wait_for_resume(self) -> bool:
429
+ """Park until resumed. Returns False if the app is stopping."""
430
+ self._resume_event.clear()
431
+ await self._resume_event.wait()
432
+ if self._stop_requested:
433
+ return False
434
+ self._paused = False
435
+ self.stall_count = 0
436
+ self.error_streak = 0
437
+ self._set_state(STATE_RUNNING)
438
+ self.ui.log_line("info", "Resumed")
439
+ return True
440
+
441
+ def _finish_complete(self) -> None:
442
+ self._set_state(STATE_COMPLETE)
443
+ self.current_task = None
444
+ self.reload_plan()
445
+ summary = read_plan_complete_summary(self.config.plan_file) or "Done."
446
+ self.ui.log_line("complete", "Plan complete!")
447
+ self.ui.on_complete(summary)
448
+
449
+ def _set_state(self, state: str) -> None:
450
+ self.state = state
451
+ self.ui.set_state(state)
452
+
453
+ # ------------------------------------------------------------------
454
+ # External actions
455
+ # ------------------------------------------------------------------
456
+
457
+ def request_start(self) -> None:
458
+ self._start_event.set()
459
+
460
+ def request_pause(self) -> None:
461
+ self._paused = True
462
+ self._set_state(STATE_PAUSING)
463
+
464
+ def request_resume(self) -> None:
465
+ self._paused = False
466
+ self._resume_event.set()
467
+
468
+ def request_retry(self) -> None:
469
+ if self.state == STATE_ERROR and self.iteration >= self.config.max_iterations:
470
+ self.config.max_iterations += 10
471
+ self.ui.log_line("info", f"max-iterations raised to {self.config.max_iterations}")
472
+ self._resume_event.set()
473
+
474
+ def request_stop(self) -> None:
475
+ self._stop_requested = True
476
+ self._settled.set()
477
+ self._resume_event.set()
478
+ self._start_event.set()
479
+
480
+ async def abort_current(self) -> None:
481
+ if self.client is not None:
482
+ with contextlib.suppress(PiError, PiExited, PiTimeout):
483
+ await self.client.abort(timeout=self.config.command_timeout)
484
+
485
+ @property
486
+ def stopping(self) -> bool:
487
+ return self._stop_requested