diff --git a/docs/superpowers/plans/2026-06-21-cyclone-pipeline-agent.md b/docs/superpowers/plans/2026-06-21-cyclone-pipeline-agent.md index 4c8ccdb..286408e 100644 --- a/docs/superpowers/plans/2026-06-21-cyclone-pipeline-agent.md +++ b/docs/superpowers/plans/2026-06-21-cyclone-pipeline-agent.md @@ -2392,13 +2392,32 @@ async def test_phase1_preflight_passes_when_backend_healthy(tmp_path: Path): @pytest.mark.asyncio +@respx.mock async def test_phase1_preflight_fails_fast_if_browser_down(tmp_path: Path): """If the Vite dev server is not reachable, phase 1 fails-fast with BrowserNotReachableError before phase 2 even tries.""" from cyclone_pipeline.exceptions import BrowserNotReachableError + respx.get("http://127.0.0.1:8000/api/health").mock( + return_value=Response(200, json={ + "status": "ok", "version": "0.1.0", + "db": {"ok": True}, + "scheduler": {"running": True, "interval_s": 60}, + "pubsub": {}, "batch": {}, + }) + ) + respx.get("http://127.0.0.1:8000/api/admin/scheduler/status").mock( + return_value=Response(200, json={ + "running": True, "interval_s": 60, + "sftp_block": "co_medicaid", + }) + ) + # Make the head request fail with a connection error + respx.head("http://127.0.0.1:9999/").mock( + side_effect=httpx.ConnectError("Connection refused") + ) cfg = PipelineConfig( api_base="http://127.0.0.1:8000", - browser_base="http://127.0.0.1:9999", # nothing listens here + browser_base="http://127.0.0.1:9999", run_dir=tmp_path / "runs", headless=True, ) @@ -2526,11 +2545,15 @@ class CyclonePipeline: f"Backend unhealthy: {snap.model_dump()}" ) sched = await self._client.scheduler_status() - # Verify the browser-side URL is reachable + # Verify the browser-side URL is reachable. We use the same + # httpx.AsyncClient as the API calls so tests can mock the + # head request via `respx`. try: - async with httpx.AsyncClient(timeout=5.0) as http: - r = await http.head(self.cfg.browser_base + "/") - r.raise_for_status() + assert self._client._http is not None # type: ignore[attr-defined] + r = await self._client._http.head( # type: ignore[attr-defined] + self.cfg.browser_base + "/" + ) + r.raise_for_status() except httpx.HTTPError as e: raise BrowserNotReachableError(self.cfg.browser_base) from e result = PhaseResult( @@ -2950,6 +2973,10 @@ Append to `cyclone_pipeline/pipeline.py`: self.run_state.pending_data["submission_filename"] = ( result_obj.filename ) + # Record the moment phase 4 completed; phase 7 uses this as + # the `since` for `processed-files` so we only see inbound + # files from the MFT scheduler *after* our submit. + self.run_state.pending_data["phase4_at"] = _now_iso() log.info( "phase4_submit_ok", submission_id=result_obj.submission_id, @@ -3418,7 +3445,7 @@ Append to `cyclone_pipeline/pipeline.py`: t0 = time.monotonic() assert self._client is not None since = self.run_state.pending_data.get( - "submission_filename_set_at", self.run_state.started_at + "phase4_at", self.run_state.started_at ) files = await self._client.processed_files(since=since) html_files = [f for f in files if f.kind == "html"] @@ -3442,10 +3469,8 @@ Append to `cyclone_pipeline/pipeline.py`: result_detail=self._result_detail(), started_at=self.run_state.started_at, finished_at=_now_iso(), - duration_s=( - _iso_to_monotonic(self.run_state.started_at) - - _iso_to_monotonic(self.run_state.started_at) + - sum(p.duration_s for p in self.run_state.completed_phases) + duration_s=sum( + p.duration_s for p in self.run_state.completed_phases ), phases=list(self.run_state.completed_phases), expected_835_by=expected_835, @@ -3548,18 +3573,6 @@ def _next_monday_iso() -> str: return now_mt.date().isoformat() days_ahead = (7 - weekday) % 7 or 7 return (now_mt + timedelta(days=days_ahead)).date().isoformat() - - -def _iso_to_monotonic(iso: str) -> float: - """Best-effort ISO→monotonic. Returns 0.0 on parse error so the - report's `duration_s` is at least non-negative.""" - try: - from datetime import datetime - return datetime.fromisoformat( - iso.replace("Z", "+00:00") - ).timestamp() - except (ValueError, AttributeError): - return 0.0 ``` - [ ] **Step 4: Run test to verify it passes**