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/__init__.py +1 -0
- piocloop/__main__.py +3 -0
- piocloop/cli.py +230 -0
- piocloop/loop.py +487 -0
- piocloop/pi_client.py +441 -0
- piocloop/pi_events.py +326 -0
- piocloop/plan_parser.py +140 -0
- piocloop/tui.py +289 -0
- piocloop-0.1.0.dist-info/METADATA +191 -0
- piocloop-0.1.0.dist-info/RECORD +13 -0
- piocloop-0.1.0.dist-info/WHEEL +4 -0
- piocloop-0.1.0.dist-info/entry_points.txt +2 -0
- piocloop-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|