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:
@@ -2337,17 +2337,47 @@ def test_image_position_clears_at_phase_transitions_and_terminal(
|
||||
# wake, which a parked loop never receives); the loop is drained
|
||||
# afterwards so the runner's terminal step (the
|
||||
# ``except CancelledError`` conversion) lands before the test reads
|
||||
# the status.
|
||||
# the status. Because the action owns the loop thread, waiting for
|
||||
# run progress inside it goes through ``_pump_until`` — phase 179
|
||||
# (task 02) moved the runner's git ops into ``asyncio.to_thread``, so
|
||||
# the run no longer reaches the import gate inside the one loop
|
||||
# iteration that created it and a blocking ``Event.wait()`` here
|
||||
# deadlocks the loop the run needs to advance.
|
||||
# * ``_start_run_via_api`` — the OLD house pattern (blocking threading
|
||||
# gates; the loop thread may park inside a sync span — the
|
||||
# clone/``file_commit_dates``) with the main thread orchestrating
|
||||
# the event set / releases.
|
||||
# gates, the loop turning on its own thread) with the main thread
|
||||
# orchestrating the event set / releases — the actions run OUTSIDE the
|
||||
# loop, so plain ``Event.wait()`` is fine there.
|
||||
|
||||
|
||||
def _pump_until(
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
event: threading.Event,
|
||||
*,
|
||||
why: str,
|
||||
timeout: float = 5.0,
|
||||
) -> None:
|
||||
"""Wait for *event* while KEEPING the run's loop turning (phase 179,
|
||||
task 02).
|
||||
|
||||
The loop-thread actions of :func:`_run_via_start_sync` need run
|
||||
progress, and that progress is now produced by coroutines plus
|
||||
``asyncio.to_thread`` completions this very thread has to service —
|
||||
pre-fix the prelude (including the blocking git calls) ran to the
|
||||
import gate before the action was ever reached, so a plain
|
||||
``Event.wait()`` could not deadlock. Pump the loop instead: same
|
||||
wait, same thread, no deadlock."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while not event.is_set():
|
||||
if time.monotonic() >= deadline:
|
||||
raise AssertionError(why)
|
||||
loop.run_until_complete(asyncio.sleep(0.01))
|
||||
|
||||
|
||||
def _fast_clone(url: str, dest: Path | str, branch: str | None = None) -> Path:
|
||||
"""A non-gated ``clone_or_pull`` — the loop thread must STAY FREE
|
||||
for the loop-thread action, so nothing in the prelude may block it
|
||||
(the import's gate is an ``asyncio.to_thread`` park — loop-safe).
|
||||
for the loop-thread action, so nothing in the prelude blocks here
|
||||
(the import's gate is an ``asyncio.to_thread`` park — loop-safe,
|
||||
and since phase 179 so are the runner's git calls).
|
||||
Phase 135: accepts the row's branch pin (the third argument —
|
||||
``None`` for this file's rows)."""
|
||||
dest = Path(dest)
|
||||
@@ -2445,7 +2475,7 @@ def test_cancel_is_409_idle_and_after_a_terminal_run(
|
||||
|
||||
# A full (uncancelled) run to the success terminal ...
|
||||
def act(loop: asyncio.AbstractEventLoop) -> None:
|
||||
assert fake_import.started.wait(5.0), "the run never reached the import"
|
||||
_pump_until(loop, fake_import.started, why="the run never reached the import")
|
||||
fake_import.release.set()
|
||||
task = sync_api._task
|
||||
assert task is not None
|
||||
@@ -2490,7 +2520,7 @@ def test_cancel_endpoint_stops_the_in_flight_run(
|
||||
_patch_sync_seams(monkeypatch, tmp_path, fake_import, _fast_clone)
|
||||
|
||||
def act(loop: asyncio.AbstractEventLoop) -> dict[str, str]:
|
||||
assert fake_import.started.wait(5.0), "the run never reached the import"
|
||||
_pump_until(loop, fake_import.started, why="the run never reached the import")
|
||||
# Phase 152 (task 04): the live image position in flight when
|
||||
# the cancel converts was fired through the runner's OWN
|
||||
# _image_hook closure (image_pairs[0] — the fake fired it
|
||||
@@ -2559,7 +2589,7 @@ def test_unrequested_cancel_is_never_swallowed_and_never_converts(
|
||||
_patch_sync_seams(monkeypatch, tmp_path, fake_import, _fast_clone)
|
||||
|
||||
def act(loop: asyncio.AbstractEventLoop) -> None:
|
||||
assert fake_import.started.wait(5.0), "the run never reached the import"
|
||||
_pump_until(loop, fake_import.started, why="the run never reached the import")
|
||||
assert sync_api._cancel_event.is_set() is False
|
||||
task = sync_api._task
|
||||
assert task is not None
|
||||
@@ -2615,7 +2645,7 @@ def test_fresh_run_clears_a_stale_cancel_event(
|
||||
_patch_sync_seams(monkeypatch, tmp_path, first_import, _fast_clone)
|
||||
|
||||
def act1(loop: asyncio.AbstractEventLoop) -> dict[str, str]:
|
||||
assert first_import.started.wait(5.0), "run 1 never reached the import"
|
||||
_pump_until(loop, first_import.started, why="run 1 never reached the import")
|
||||
return loop.run_until_complete(sync_api.cancel_sync())
|
||||
|
||||
try:
|
||||
@@ -2640,7 +2670,7 @@ def test_fresh_run_clears_a_stale_cancel_event(
|
||||
monkeypatch.setattr(sync_api, "import_sources", second_import)
|
||||
|
||||
def act2(loop: asyncio.AbstractEventLoop) -> None:
|
||||
assert second_import.started.wait(5.0), "run 2 never reached the import"
|
||||
_pump_until(loop, second_import.started, why="run 2 never reached the import")
|
||||
# start_sync cleared the stale event before the task was
|
||||
# created — by the import park it is definitely clear.
|
||||
assert sync_api._cancel_event.is_set() is False
|
||||
|
||||
Reference in New Issue
Block a user