piocloop 0.1.0__tar.gz → 0.1.2__tar.gz
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-0.1.0 → piocloop-0.1.2}/.gitignore +2 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/PKG-INFO +1 -1
- {piocloop-0.1.0 → piocloop-0.1.2}/pyproject.toml +1 -1
- {piocloop-0.1.0 → piocloop-0.1.2}/src/piocloop/cli.py +4 -1
- {piocloop-0.1.0 → piocloop-0.1.2}/src/piocloop/loop.py +32 -4
- {piocloop-0.1.0 → piocloop-0.1.2}/src/piocloop/pi_client.py +17 -1
- {piocloop-0.1.0 → piocloop-0.1.2}/tests/fake_pi.py +5 -1
- {piocloop-0.1.0 → piocloop-0.1.2}/tests/test_loop.py +53 -3
- {piocloop-0.1.0 → piocloop-0.1.2}/tests/test_pi_client.py +6 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/.github/workflows/publish.yml +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/ANALYSIS.md +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/DESIGN.md +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/LICENSE +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/PLAN.md +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/README.md +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/skills/README.md +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/skills/piocloop-setup/SKILL.md +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/skills/piocloop-setup/assets/PLAN.template.md +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/skills/piocloop-setup/assets/loop-prompt.template.md +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/skills/piocloop-setup/references/PLAN-FORMAT.md +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/skills/piocloop-setup/references/PROMPT-AUTHORING.md +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/src/piocloop/__init__.py +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/src/piocloop/__main__.py +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/src/piocloop/pi_events.py +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/src/piocloop/plan_parser.py +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/src/piocloop/tui.py +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/tests/test_pi_events.py +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/tests/test_plan_parser.py +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/tests/test_skill_assets.py +0 -0
- {piocloop-0.1.0 → piocloop-0.1.2}/tests/test_tui.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: piocloop
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: TUI loop harness that orchestrates the PI coding agent to execute tasks from a PLAN.md file iteratively
|
|
5
5
|
Project-URL: Homepage, https://github.com/rbarzic/piocloop
|
|
6
6
|
Project-URL: Repository, https://github.com/rbarzic/piocloop
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "piocloop"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "TUI loop harness that orchestrates the PI coding agent to execute tasks from a PLAN.md file iteratively"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -101,7 +101,10 @@ def run(
|
|
|
101
101
|
help="How to answer blocking extension dialogs: cancel|allow|deny",
|
|
102
102
|
),
|
|
103
103
|
session_dir: Optional[Path] = typer.Option(None, "--session-dir", help="Passed through to pi"),
|
|
104
|
-
no_session: bool = typer.Option(
|
|
104
|
+
no_session: bool = typer.Option(
|
|
105
|
+
True, "--no-session/--persist-sessions",
|
|
106
|
+
help="Use an ephemeral pi session (default); --persist-sessions enables session files",
|
|
107
|
+
),
|
|
105
108
|
tools: Optional[str] = typer.Option(None, "--tools", help="Comma-separated tool allowlist"),
|
|
106
109
|
exclude_tools: Optional[str] = typer.Option(
|
|
107
110
|
None, "--exclude-tools", help="Comma-separated tool denylist"
|
|
@@ -183,7 +183,18 @@ class LoopEngine:
|
|
|
183
183
|
self.ui.log_line("warn", f"Restarting pi (respawn {self.respawns}/{self.config.max_respawns})")
|
|
184
184
|
await self.shutdown()
|
|
185
185
|
self._mapper = EventMapper(self.config.plan_file)
|
|
186
|
-
|
|
186
|
+
try:
|
|
187
|
+
await self._spawn_client()
|
|
188
|
+
except (PiError, OSError) as exc:
|
|
189
|
+
# shutdown() deliberately clears self.client before starting the
|
|
190
|
+
# replacement. If readiness fails, keep that invariant explicit
|
|
191
|
+
# and turn the failure into an iteration error rather than letting
|
|
192
|
+
# the next retry reach _run_iteration with no client.
|
|
193
|
+
self.client = None
|
|
194
|
+
self.ui.log_line("error", f"Could not restart pi: {exc}")
|
|
195
|
+
if isinstance(exc, PiError):
|
|
196
|
+
raise
|
|
197
|
+
raise PiError(f"Could not restart pi: {exc}") from exc
|
|
187
198
|
|
|
188
199
|
# ------------------------------------------------------------------
|
|
189
200
|
# Events
|
|
@@ -274,6 +285,8 @@ class LoopEngine:
|
|
|
274
285
|
continue
|
|
275
286
|
|
|
276
287
|
try:
|
|
288
|
+
if self.client is None:
|
|
289
|
+
raise PiExited("pi client is unavailable")
|
|
277
290
|
await self._run_iteration()
|
|
278
291
|
except (PiError, PiExited, PiTimeout, OSError) as exc:
|
|
279
292
|
if self._stop_requested:
|
|
@@ -315,11 +328,25 @@ class LoopEngine:
|
|
|
315
328
|
self.ui.log_line("info", "Loop stopped")
|
|
316
329
|
|
|
317
330
|
async def _run_iteration(self) -> None:
|
|
318
|
-
|
|
331
|
+
if self.client is None:
|
|
332
|
+
raise PiExited("pi client is unavailable")
|
|
319
333
|
self._settled.clear()
|
|
320
334
|
self._seen_agent_start = False
|
|
321
335
|
|
|
322
|
-
|
|
336
|
+
try:
|
|
337
|
+
await self.client.new_session()
|
|
338
|
+
except (PiTimeout, PiExited) as exc:
|
|
339
|
+
# A session-switch timeout is not a recoverable iteration error:
|
|
340
|
+
# pi can have accepted the switch while losing its response, so
|
|
341
|
+
# retrying on the same process is unsafe and used to produce the
|
|
342
|
+
# characteristic three consecutive ``new_session`` failures.
|
|
343
|
+
# Replace the process, with start() performing a readiness
|
|
344
|
+
# handshake before the next iteration reaches this point.
|
|
345
|
+
self.ui.log_line("warn", f"new_session failed; restarting pi: {exc}")
|
|
346
|
+
if self.respawns >= self.config.max_respawns:
|
|
347
|
+
raise
|
|
348
|
+
await self._respawn_client()
|
|
349
|
+
raise
|
|
323
350
|
self.iteration += 1
|
|
324
351
|
self.iter_start_time = time.monotonic()
|
|
325
352
|
self.ui.log_line("start", f"Iteration {self.iteration}")
|
|
@@ -337,7 +364,8 @@ class LoopEngine:
|
|
|
337
364
|
|
|
338
365
|
async def _await_settle(self) -> None:
|
|
339
366
|
"""Bounded wait for `agent_settled`. Cannot hang (DESIGN §4.2)."""
|
|
340
|
-
|
|
367
|
+
if self.client is None:
|
|
368
|
+
raise PiExited("pi client is unavailable")
|
|
341
369
|
cfg = self.config
|
|
342
370
|
deadline = time.monotonic() + cfg.iteration_timeout
|
|
343
371
|
idle_polls = 0
|
|
@@ -137,7 +137,17 @@ class PiClient:
|
|
|
137
137
|
# Lifecycle
|
|
138
138
|
# ------------------------------------------------------------------
|
|
139
139
|
|
|
140
|
-
async def start(self) -> None:
|
|
140
|
+
async def start(self, *, readiness_timeout: float = 60.0) -> None:
|
|
141
|
+
"""Start pi and wait until its RPC session is usable.
|
|
142
|
+
|
|
143
|
+
``create_subprocess_exec`` only means that the OS process exists. Pi
|
|
144
|
+
still has to load its model catalogue, session and extensions before
|
|
145
|
+
it starts consuming RPC commands. Sending ``new_session`` during
|
|
146
|
+
that window is particularly fragile with persistent sessions: the
|
|
147
|
+
command can sit unhandled until the caller's deadline expires. A
|
|
148
|
+
bounded get_state handshake makes readiness explicit and also gives
|
|
149
|
+
respawned clients the same startup guarantee as the first client.
|
|
150
|
+
"""
|
|
141
151
|
if self._proc is not None:
|
|
142
152
|
raise PiError("PiClient already started")
|
|
143
153
|
try:
|
|
@@ -158,6 +168,12 @@ class PiClient:
|
|
|
158
168
|
asyncio.create_task(self._read_stdout(), name="pi-stdout"),
|
|
159
169
|
asyncio.create_task(self._read_stderr(), name="pi-stderr"),
|
|
160
170
|
]
|
|
171
|
+
try:
|
|
172
|
+
# Do not let callers race startup with the first session switch.
|
|
173
|
+
await self.request({"type": "get_state"}, timeout=readiness_timeout)
|
|
174
|
+
except Exception:
|
|
175
|
+
await self.close()
|
|
176
|
+
raise
|
|
161
177
|
|
|
162
178
|
async def close(self, timeout: float = 5.0) -> None:
|
|
163
179
|
"""Shut the process down. Idempotent, and safe to call after a crash."""
|
|
@@ -17,6 +17,7 @@ import time
|
|
|
17
17
|
# fallback cannot mistake a stuck run for a finished one.
|
|
18
18
|
_PRETEND_STREAMING = {"busy", "wedged"}
|
|
19
19
|
_streaming = False
|
|
20
|
+
_ready = False
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
def work_the_plan(message: str) -> None:
|
|
@@ -86,7 +87,7 @@ def settle() -> None:
|
|
|
86
87
|
|
|
87
88
|
|
|
88
89
|
def main() -> int:
|
|
89
|
-
global _streaming
|
|
90
|
+
global _streaming, _ready
|
|
90
91
|
scenario = sys.argv[1] if len(sys.argv) > 1 else "normal"
|
|
91
92
|
|
|
92
93
|
while True:
|
|
@@ -116,11 +117,14 @@ def main() -> int:
|
|
|
116
117
|
continue
|
|
117
118
|
|
|
118
119
|
if ctype == "get_state":
|
|
120
|
+
_ready = True
|
|
119
121
|
ok("get_state", rid,
|
|
120
122
|
{"isStreaming": _streaming, "sessionId": "fake", "messageCount": 0})
|
|
121
123
|
continue
|
|
122
124
|
|
|
123
125
|
if ctype == "new_session":
|
|
126
|
+
if scenario == "readiness" and not _ready:
|
|
127
|
+
continue
|
|
124
128
|
ok("new_session", rid, {"cancelled": False})
|
|
125
129
|
continue
|
|
126
130
|
|
|
@@ -24,6 +24,7 @@ from piocloop.loop import (
|
|
|
24
24
|
LoopConfig,
|
|
25
25
|
LoopEngine,
|
|
26
26
|
)
|
|
27
|
+
from piocloop.pi_client import PiTimeout
|
|
27
28
|
|
|
28
29
|
FAKE_PI = str(Path(__file__).parent / "fake_pi.py")
|
|
29
30
|
|
|
@@ -92,6 +93,26 @@ async def wait_for_state(engine: LoopEngine, states: set[str], timeout: float =
|
|
|
92
93
|
pytest.fail(f"engine stuck in {engine.state!r}, never reached {states}")
|
|
93
94
|
|
|
94
95
|
|
|
96
|
+
class RespawnReadinessFailureClient:
|
|
97
|
+
"""Minimal client sequence: first session fails, replacement never readies."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, replacement: bool) -> None:
|
|
100
|
+
self.replacement = replacement
|
|
101
|
+
self.events: asyncio.Queue[dict] = asyncio.Queue()
|
|
102
|
+
self.exited = asyncio.Event()
|
|
103
|
+
|
|
104
|
+
async def start(self) -> None:
|
|
105
|
+
if self.replacement:
|
|
106
|
+
raise PiTimeout("no response to 'get_state' within deadline")
|
|
107
|
+
|
|
108
|
+
async def close(self) -> None:
|
|
109
|
+
self.exited.set()
|
|
110
|
+
|
|
111
|
+
async def new_session(self) -> dict:
|
|
112
|
+
raise PiTimeout("no response to 'new_session' within deadline")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
|
|
95
116
|
class EngineHarness:
|
|
96
117
|
"""Boot an engine, run it in the background, always clean it up."""
|
|
97
118
|
|
|
@@ -114,9 +135,38 @@ class EngineHarness:
|
|
|
114
135
|
await self.engine.shutdown()
|
|
115
136
|
|
|
116
137
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
138
|
+
async def test_failed_respawn_readiness_is_a_controlled_retry_then_stop(workspace):
|
|
139
|
+
"""A replacement that never becomes ready must not hit the client assert."""
|
|
140
|
+
created = 0
|
|
141
|
+
|
|
142
|
+
def factory() -> RespawnReadinessFailureClient:
|
|
143
|
+
nonlocal created
|
|
144
|
+
created += 1
|
|
145
|
+
return RespawnReadinessFailureClient(replacement=created > 1)
|
|
146
|
+
|
|
147
|
+
config = make_config(
|
|
148
|
+
workspace,
|
|
149
|
+
"normal",
|
|
150
|
+
max_respawns=1,
|
|
151
|
+
max_error_streak=2,
|
|
152
|
+
error_backoff_base=1.0,
|
|
153
|
+
max_stalls=0,
|
|
154
|
+
)
|
|
155
|
+
engine = LoopEngine(config, ui=RecordingUI(), client_factory=factory)
|
|
156
|
+
assert await engine.boot()
|
|
157
|
+
task = asyncio.create_task(engine.run(auto_start=True))
|
|
158
|
+
try:
|
|
159
|
+
await wait_for_state(engine, {STATE_ERROR}, timeout=5.0)
|
|
160
|
+
assert engine.client is None
|
|
161
|
+
assert engine.error_streak == 2
|
|
162
|
+
finally:
|
|
163
|
+
engine.request_stop()
|
|
164
|
+
await asyncio.wait_for(task, timeout=5.0)
|
|
165
|
+
await engine.shutdown()
|
|
166
|
+
|
|
167
|
+
assert engine.state == STATE_ERROR
|
|
168
|
+
|
|
169
|
+
|
|
120
170
|
|
|
121
171
|
|
|
122
172
|
async def test_plan_runs_to_completion(workspace):
|
|
@@ -126,6 +126,12 @@ async def test_new_session_and_abort():
|
|
|
126
126
|
await collect_until(client, "agent_settled")
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
async def test_start_waits_for_rpc_readiness_before_new_session():
|
|
130
|
+
"""Startup must complete before the first session replacement is sent."""
|
|
131
|
+
async with PiClient(fake_argv("readiness")) as client:
|
|
132
|
+
assert await client.new_session() == {"cancelled": False}
|
|
133
|
+
|
|
134
|
+
|
|
129
135
|
async def test_concurrent_requests_are_correlated():
|
|
130
136
|
"""Interleaved commands must not cross-resolve each other's futures."""
|
|
131
137
|
async with PiClient(fake_argv("normal")) as client:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|