piocloop 0.1.1__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.1 → piocloop-0.1.2}/PKG-INFO +1 -1
  2. {piocloop-0.1.1 → piocloop-0.1.2}/pyproject.toml +1 -1
  3. {piocloop-0.1.1 → piocloop-0.1.2}/src/piocloop/loop.py +18 -3
  4. {piocloop-0.1.1 → piocloop-0.1.2}/tests/test_loop.py +53 -3
  5. {piocloop-0.1.1 → piocloop-0.1.2}/.github/workflows/publish.yml +0 -0
  6. {piocloop-0.1.1 → piocloop-0.1.2}/.gitignore +0 -0
  7. {piocloop-0.1.1 → piocloop-0.1.2}/ANALYSIS.md +0 -0
  8. {piocloop-0.1.1 → piocloop-0.1.2}/DESIGN.md +0 -0
  9. {piocloop-0.1.1 → piocloop-0.1.2}/LICENSE +0 -0
  10. {piocloop-0.1.1 → piocloop-0.1.2}/PLAN.md +0 -0
  11. {piocloop-0.1.1 → piocloop-0.1.2}/README.md +0 -0
  12. {piocloop-0.1.1 → piocloop-0.1.2}/skills/README.md +0 -0
  13. {piocloop-0.1.1 → piocloop-0.1.2}/skills/piocloop-setup/SKILL.md +0 -0
  14. {piocloop-0.1.1 → piocloop-0.1.2}/skills/piocloop-setup/assets/PLAN.template.md +0 -0
  15. {piocloop-0.1.1 → piocloop-0.1.2}/skills/piocloop-setup/assets/loop-prompt.template.md +0 -0
  16. {piocloop-0.1.1 → piocloop-0.1.2}/skills/piocloop-setup/references/PLAN-FORMAT.md +0 -0
  17. {piocloop-0.1.1 → piocloop-0.1.2}/skills/piocloop-setup/references/PROMPT-AUTHORING.md +0 -0
  18. {piocloop-0.1.1 → piocloop-0.1.2}/src/piocloop/__init__.py +0 -0
  19. {piocloop-0.1.1 → piocloop-0.1.2}/src/piocloop/__main__.py +0 -0
  20. {piocloop-0.1.1 → piocloop-0.1.2}/src/piocloop/cli.py +0 -0
  21. {piocloop-0.1.1 → piocloop-0.1.2}/src/piocloop/pi_client.py +0 -0
  22. {piocloop-0.1.1 → piocloop-0.1.2}/src/piocloop/pi_events.py +0 -0
  23. {piocloop-0.1.1 → piocloop-0.1.2}/src/piocloop/plan_parser.py +0 -0
  24. {piocloop-0.1.1 → piocloop-0.1.2}/src/piocloop/tui.py +0 -0
  25. {piocloop-0.1.1 → piocloop-0.1.2}/tests/fake_pi.py +0 -0
  26. {piocloop-0.1.1 → piocloop-0.1.2}/tests/test_pi_client.py +0 -0
  27. {piocloop-0.1.1 → piocloop-0.1.2}/tests/test_pi_events.py +0 -0
  28. {piocloop-0.1.1 → piocloop-0.1.2}/tests/test_plan_parser.py +0 -0
  29. {piocloop-0.1.1 → piocloop-0.1.2}/tests/test_skill_assets.py +0 -0
  30. {piocloop-0.1.1 → 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.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.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" }
@@ -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,7 +328,8 @@ 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
 
@@ -350,7 +364,8 @@ class LoopEngine:
350
364
 
351
365
  async def _await_settle(self) -> None:
352
366
  """Bounded wait for `agent_settled`. Cannot hang (DESIGN §4.2)."""
353
- assert self.client is not None
367
+ if self.client is None:
368
+ raise PiExited("pi client is unavailable")
354
369
  cfg = self.config
355
370
  deadline = time.monotonic() + cfg.iteration_timeout
356
371
  idle_polls = 0
@@ -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):
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