phase: 179_git_clone_server_freeze
All gates green; working tree holds exactly the phase's changes for the harness to commit. ## Phase 179 — Final verification report - **Verified (all 3 tasks already complete in `complete/`)**: repro + CONFIRMED verdict, `asyncio.to_thread` fix, dedicated E2E suite. - `app/api/sync.py::_run_sync`: both git ops (`clone_or_pull`, `file_commit_dates`) now `await asyncio.to_thread(...)`; phase-134 boundary note + module docstring document worker-thread-runs-to-completion and the A3 DB-calls-stay-on-loop note. - **Tests / lint / coverage** (full suite 100% green): - `uv run pytest tests/integration/test_sync_git_freeze.py -v` → **4 passed** (post-fix probe <1.5 s during 3 s stub) - `uv run pytest tests/e2e/test_sync_git_freeze.py -v --no-cov` → **2 passed in 25.65 s in isolation** (wall guard 120 s holds) - `uv run pytest --cov=app --cov-report=term-missing` → **4173 passed**, `app/` coverage **99%** (`app/api/sync.py` 100%) - `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors** - **Completion criteria**: ① integration probe green + verdict (CONFIRMED, mechanism, measured 3.04 s→0.003 s) in docstring — **met**; ② off-loop git ops + boundary/A3 notes — **met**; ③ full suite green, coverage 99% >90%, ruff+pyright clean — **met**; ④ E2E green in isolation (responsive-poll + cancel-during-clone contracts) — **met**; ⑤ atomic commit + move to `complete/` — **harness-owned** (pipeline rule overrides per-phase self-commit; all changes staged-ready in the working tree). - **Documentation**: full README (1526 l) + AGENTS.md review — phase-179 bullets present and accurate (verified against fixture constants, `.env.example` fully covered by the config reference, pages table matches `app/main.py` shell routes, quick-reference commands/artifacts exist); **consistent — no further changes needed**. - **Notable**: task-03 pipeline sessions died on context overflow but their harness validation passed; this pass independently re-ran every gate to confirm. No live infra touched; no git index commands run. - **Next pending phase**: `180_markdown_inline_links_images`.
This commit is contained in:
@@ -280,6 +280,33 @@ def _poll(client: TestClient, want: str, timeout: float = 5.0) -> dict:
|
||||
raise AssertionError(f"sync did not reach {want!r} within {timeout}s")
|
||||
|
||||
|
||||
def _wait_phase(client: TestClient, want: str, timeout: float = 5.0) -> dict:
|
||||
"""Poll ``GET /api/sync/status`` until ``phase == want`` (the phase
|
||||
machine, phase 98 task 01), failing loudly if the run leaves
|
||||
``running`` first.
|
||||
|
||||
Phase 179 (task 02) is why this exists: the sync's git ops now run
|
||||
OFF the event loop (``asyncio.to_thread``), so how far the run has
|
||||
progressed by the time the client issues its NEXT request is
|
||||
genuinely concurrent — pre-fix the spawned task reached the import
|
||||
span before the trigger's own response went out, and a test that
|
||||
asserted a mid-run phase silently depended on that. Tests wait for
|
||||
the phase instead."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
body = client.get("/api/sync/status").json()
|
||||
assert body["state"] == "running", (
|
||||
f"run left state {body['state']!r} before phase {want!r}: {body}"
|
||||
)
|
||||
if body["phase"] == want:
|
||||
return body
|
||||
if time.monotonic() >= deadline:
|
||||
raise AssertionError(
|
||||
f"sync did not report phase {want!r} within {timeout}s: {body}"
|
||||
)
|
||||
time.sleep(0.02)
|
||||
|
||||
|
||||
class FakeImportSources:
|
||||
"""Records every ``import_sources`` call; returns a canned summary."""
|
||||
|
||||
@@ -626,8 +653,10 @@ def test_double_trigger_while_running_returns_409(
|
||||
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||
_stub_probe(monkeypatch)
|
||||
# The in-flight run takes a while (asyncio.sleep) so the second POST
|
||||
# lands while it is still running.
|
||||
fake_import = FakeImportSources(ImportSummary(files=1, added=1), delay=0.5)
|
||||
# lands while it is still running (phase 179: 1.5 s, not 0.5 s — the
|
||||
# run now yields the loop at its git ops, so the window the second
|
||||
# POST and the phase wait fall into is wider on purpose).
|
||||
fake_import = FakeImportSources(ImportSummary(files=1, added=1), delay=1.5)
|
||||
monkeypatch.setattr(sync_api, "import_sources", fake_import)
|
||||
monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True))
|
||||
monkeypatch.setattr(sync_api, "generate_folder_summaries", FakeFolderSummaries())
|
||||
@@ -639,7 +668,10 @@ def test_double_trigger_while_running_returns_409(
|
||||
assert r.status_code == 409
|
||||
assert r.json() == {"detail": "a sync is already running"}
|
||||
|
||||
body = sync_client.get("/api/sync/status").json()
|
||||
# Phase 179 (task 02): WAIT for the import phase — the run's
|
||||
# progress is concurrent now that the git ops left the loop, so the
|
||||
# next request no longer inherits a run that already ran past them.
|
||||
body = _wait_phase(sync_client, "import")
|
||||
assert body["state"] == "running"
|
||||
assert body["started_at"] is not None
|
||||
assert body["finished_at"] is None
|
||||
@@ -647,7 +679,6 @@ def test_double_trigger_while_running_returns_409(
|
||||
# Phase 98 (task 01): the in-flight run (parked in the fake
|
||||
# import's delay) reports the import phase — the summary span has
|
||||
# not started, so its counters are 0/0.
|
||||
assert body["phase"] == "import"
|
||||
assert body["current_summary"] is None
|
||||
assert body["summaries_done"] == 0 and body["summaries_total"] == 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user