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.
Files changed (30) hide show
  1. {piocloop-0.1.0 → piocloop-0.1.2}/.gitignore +2 -0
  2. {piocloop-0.1.0 → piocloop-0.1.2}/PKG-INFO +1 -1
  3. {piocloop-0.1.0 → piocloop-0.1.2}/pyproject.toml +1 -1
  4. {piocloop-0.1.0 → piocloop-0.1.2}/src/piocloop/cli.py +4 -1
  5. {piocloop-0.1.0 → piocloop-0.1.2}/src/piocloop/loop.py +32 -4
  6. {piocloop-0.1.0 → piocloop-0.1.2}/src/piocloop/pi_client.py +17 -1
  7. {piocloop-0.1.0 → piocloop-0.1.2}/tests/fake_pi.py +5 -1
  8. {piocloop-0.1.0 → piocloop-0.1.2}/tests/test_loop.py +53 -3
  9. {piocloop-0.1.0 → piocloop-0.1.2}/tests/test_pi_client.py +6 -0
  10. {piocloop-0.1.0 → piocloop-0.1.2}/.github/workflows/publish.yml +0 -0
  11. {piocloop-0.1.0 → piocloop-0.1.2}/ANALYSIS.md +0 -0
  12. {piocloop-0.1.0 → piocloop-0.1.2}/DESIGN.md +0 -0
  13. {piocloop-0.1.0 → piocloop-0.1.2}/LICENSE +0 -0
  14. {piocloop-0.1.0 → piocloop-0.1.2}/PLAN.md +0 -0
  15. {piocloop-0.1.0 → piocloop-0.1.2}/README.md +0 -0
  16. {piocloop-0.1.0 → piocloop-0.1.2}/skills/README.md +0 -0
  17. {piocloop-0.1.0 → piocloop-0.1.2}/skills/piocloop-setup/SKILL.md +0 -0
  18. {piocloop-0.1.0 → piocloop-0.1.2}/skills/piocloop-setup/assets/PLAN.template.md +0 -0
  19. {piocloop-0.1.0 → piocloop-0.1.2}/skills/piocloop-setup/assets/loop-prompt.template.md +0 -0
  20. {piocloop-0.1.0 → piocloop-0.1.2}/skills/piocloop-setup/references/PLAN-FORMAT.md +0 -0
  21. {piocloop-0.1.0 → piocloop-0.1.2}/skills/piocloop-setup/references/PROMPT-AUTHORING.md +0 -0
  22. {piocloop-0.1.0 → piocloop-0.1.2}/src/piocloop/__init__.py +0 -0
  23. {piocloop-0.1.0 → piocloop-0.1.2}/src/piocloop/__main__.py +0 -0
  24. {piocloop-0.1.0 → piocloop-0.1.2}/src/piocloop/pi_events.py +0 -0
  25. {piocloop-0.1.0 → piocloop-0.1.2}/src/piocloop/plan_parser.py +0 -0
  26. {piocloop-0.1.0 → piocloop-0.1.2}/src/piocloop/tui.py +0 -0
  27. {piocloop-0.1.0 → piocloop-0.1.2}/tests/test_pi_events.py +0 -0
  28. {piocloop-0.1.0 → piocloop-0.1.2}/tests/test_plan_parser.py +0 -0
  29. {piocloop-0.1.0 → piocloop-0.1.2}/tests/test_skill_assets.py +0 -0
  30. {piocloop-0.1.0 → piocloop-0.1.2}/tests/test_tui.py +0 -0
@@ -49,3 +49,5 @@ Thumbs.db
49
49
  # Project-specific
50
50
  *.log
51
51
  .pi/
52
+ .env.local
53
+ .local.env
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: piocloop
3
- Version: 0.1.0
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.0"
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(False, "--no-session", help="Do not persist pi sessions"),
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
- await self._spawn_client()
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
- assert self.client is not None
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
- await self.client.new_session()
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
- assert self.client is not None
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
- # Happy path
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