phase: 129_sync_chunk_progress
All green. Final report:
**Phase 129 final verification pass — 100% green** (previous work intact; this pass re-verified everything and fixed two pre-existing README inconsistencies)
- Verified on-spec: `LLMClient.embed` per-batch `on_progress` hook; importer `on_chunk_progress` plumbing (text/image/PDF branches; summary embed excluded); `SyncStatus.current_chunk/chunks_total` reset+clear contract; UI `.sync-label-chunk` pinned part with untruncated `title`/`#sync-result`
- `uv run pytest --cov=app -q`: exit 0, ~3041 passed, 0 failed, `app/` coverage **99%** (>90% gate)
- `uv run pytest tests/e2e/test_sync_chunk_progress.py -v --no-cov` (isolated, DB up): **2 passed** (endpoint chunk climb with true `chunk_document` total + UI `· chunk n/m` part)
- `uv run ruff check .`: all checks passed · `uv run pyright`: 0 errors, 0 warnings
- Completion criteria: all 5 ✅ (incl. no behavior change in completed phases — full suite green, CLI/chat no-op paths byte-identical)
- Docs: README.md fixed 2 pre-existing inconsistencies (upload section claimed "unpacks + scans immediately" — phase 90 made it unpack+register only, scan deferred to Sync; troubleshooting claimed "120s client timeout" — actual guard is the 300 s pre-token `TURN_TIMEOUT_MS`). Phase-129 label behavior already documented. AGENTS.md re-verified consistent — no changes
- Removed stray untracked `NOTES.md` (8-byte artifact); no live-infra changes; no commits — all changes left in working tree for the harness
- Next pending phase: `130_chat_answer_clusters`
This commit is contained in:
+326
-27
@@ -38,16 +38,34 @@ file" (remove-when-null) rule is gone.
|
||||
Phase 128 (task 01): the label becomes PARTS — #sync-label ships a
|
||||
main span (the idle text pre-JS) + a hidden count span (the pinned
|
||||
"(n/m)"), the builders return { main, count }, and the single writer
|
||||
setSyncLabel(main, count) lands the parts + the untruncated full
|
||||
label (main + " " + count) on the title + #sync-result; the
|
||||
running-state / settle pins move onto that shape (their labels stay
|
||||
byte-identical as full strings).
|
||||
setSyncLabel(main, count, chunk — the chunk part is phase 129) lands
|
||||
the parts + the untruncated full label (main + " " + count +
|
||||
" " + chunk) on the title + #sync-result; the running-state /
|
||||
settle pins move onto that shape (their labels stay byte-identical
|
||||
as full strings). Phase 129 (task 04): #sync-label ships a THIRD
|
||||
hidden span (the pinned "· chunk n/m" — the in-flight file's
|
||||
embed-batch position), rendered only while a file's embed batch is in
|
||||
flight (the import phase after the first batch — hidden in every
|
||||
other state); the markup pin below pins the exact three-part ship
|
||||
shape.
|
||||
|
||||
Phase 129 (task 03): the status contract gains the in-flight file's
|
||||
EMBED-chunk position — ``current_chunk`` (the 1-based first chunk of
|
||||
the in-flight token-budgeted batch) + ``chunks_total`` (the file's
|
||||
total content chunks): the idle dict pins them 0/0, the running
|
||||
dict's pins gain them, and the new mid-run pair test reads the
|
||||
positions between ticks (1/38 → 13/38) through the runner's own
|
||||
``_chunk_hook`` closure; the convention split is pinned — the chunk
|
||||
position clears at every point the import phase ends (the
|
||||
overview/summaries transitions + both terminals) while the FILE
|
||||
counts keep their final values.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -130,23 +148,31 @@ def test_sync_button_has_icon_and_label_parts() -> None:
|
||||
|
||||
Phase 128 (task 01): #sync-label is PARTS — the MAIN span
|
||||
(.sync-label-main) carries the idle text (the pre-JS label renders
|
||||
from the static markup; the part CSS may ellipsize it), and the
|
||||
COUNT span (.sync-label-count — the pinned "(n/m)" position, never
|
||||
clipped) ships hidden + empty (there is no count at idle). The
|
||||
class/id stay (the module query + the E2E label assertions)."""
|
||||
from the static markup; the part CSS may ellipsize it), the
|
||||
COUNT span (.sync-label-count — the pinned "(n/m)" position,
|
||||
never clipped) ships hidden + empty (there is no count at idle),
|
||||
and — phase 129 (task 04) — the CHUNK span (.sync-label-chunk —
|
||||
the pinned "· chunk n/m" embed-batch position) ships hidden +
|
||||
empty too (no chunk position at idle — the part renders only
|
||||
while a file's embed batch is in flight). The class/id stay (the
|
||||
module query + the E2E label assertions)."""
|
||||
text = _text(SHELL_HTML)
|
||||
btn = text[text.find('id="sync-btn"') : text.find("</button>", text.find('id="sync-btn"'))]
|
||||
assert re.search(r'<svg[^>]*class="sync-icon"[^>]*aria-hidden="true"', btn)
|
||||
# Phase 128 (task 01): the parts markup — the exact shape (the main
|
||||
# span holds the idle text, the count span is hidden + empty).
|
||||
# Phase 128 (task 01) + Phase 129 (task 04): the parts markup —
|
||||
# the exact three-part shape (the main span holds the idle text,
|
||||
# the count + chunk spans are hidden + empty).
|
||||
label = re.search(
|
||||
r'<span\b[^>]*id="sync-label"[^>]*>'
|
||||
r'<span class="sync-label-main">Sync sources</span>'
|
||||
r'<span class="sync-label-count" hidden></span>'
|
||||
r'<span class="sync-label-chunk" hidden></span>'
|
||||
r"</span>",
|
||||
btn,
|
||||
)
|
||||
assert label, ("the #sync-label parts markup (main + hidden count) is missing")
|
||||
assert label, (
|
||||
"the #sync-label parts markup (main + hidden count + hidden chunk) is missing"
|
||||
)
|
||||
assert 'class="sync-label"' in label.group(0)
|
||||
|
||||
|
||||
@@ -299,9 +325,11 @@ def test_sources_js_running_state_is_never_stale() -> None:
|
||||
Phase 128 (task 01): the entry hands the label PARTS (the
|
||||
phase-aware split — sync + status → fmtSyncPhaseLabel, the
|
||||
fall-through → the byte-identical fmtSyncLabel) to the SINGLE
|
||||
writer, setSyncLabel(main, count), which lands the main part, the
|
||||
pinned count part (hidden when absent), and the FULL untruncated
|
||||
label on the button title + #sync-result (A4) — the direct span /
|
||||
writer, setSyncLabel(main, count, chunk — phase 129: the chunk
|
||||
part destructures with a "" default), which lands the main part,
|
||||
the pinned count part (hidden when absent), the pinned chunk part
|
||||
(phase 129 — hidden when absent), and the FULL untruncated label
|
||||
on the button title + #sync-result (A4) — the direct span /
|
||||
title / announcer writes are GONE from the entry (they live in
|
||||
setSyncLabel)."""
|
||||
js = _text(SOURCES_JS)
|
||||
@@ -313,7 +341,7 @@ def test_sources_js_running_state_is_never_stale() -> None:
|
||||
assert "fmtSyncLabel(kind, currentFile, done, total)" in body
|
||||
# …and the single writer lands the parts (A4 in it — the title is
|
||||
# set for EVERY running label: the file-only rule is adjusted away).
|
||||
assert "setSyncLabel(main, count)" in body
|
||||
assert "setSyncLabel(main, count, chunk)" in body
|
||||
assert body.count("setSyncLabel(") == 1, "the entry writes ONLY through the parts writer"
|
||||
assert "syncBtn.title =" not in body, ("the title write lives in setSyncLabel (A4)")
|
||||
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
|
||||
@@ -759,7 +787,14 @@ class _GatedImport:
|
||||
progress-shaped call goes through the real hook closure — the
|
||||
closure under test), parks on a threading gate so the test can read
|
||||
the status mid-run, then returns the canned summary (or raises
|
||||
``fail``)."""
|
||||
``fail``).
|
||||
|
||||
Phase 129 (task 03): optionally ALSO fires the in-flight file's
|
||||
chunk-position pairs (``chunk_pairs``) through the runner's OWN
|
||||
``_chunk_hook`` closure: pair one lands before the main park
|
||||
(readable mid-run), and every later pair lands after the main
|
||||
park + its own park (``chunk_release``), so each position is
|
||||
readable on the status between ticks."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -767,6 +802,8 @@ class _GatedImport:
|
||||
started: threading.Event,
|
||||
release: threading.Event,
|
||||
fail: BaseException | None = None,
|
||||
chunk_pairs: Sequence[tuple[int, int]] = (),
|
||||
chunk_release: threading.Event | None = None,
|
||||
) -> None:
|
||||
self.summary = summary
|
||||
self.started = started
|
||||
@@ -774,6 +811,9 @@ class _GatedImport:
|
||||
self.fail = fail
|
||||
self.hook_calls: list[tuple[str, str, int, int]] = []
|
||||
self.prune_flags: list[bool] = []
|
||||
self.chunk_pairs = list(chunk_pairs)
|
||||
self.chunk_release = chunk_release
|
||||
self.chunk_hook_calls: list[tuple[int, int]] = []
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
@@ -784,6 +824,7 @@ class _GatedImport:
|
||||
limit: int | None = None,
|
||||
session: object = None,
|
||||
progress: Callable[[str, str, int, int], None] | None = None,
|
||||
on_chunk_progress: Callable[[int, int], None] | None = None, # phase 129
|
||||
ignore_by_root: dict[str, list[str]] | None = None, # phase 89
|
||||
include_hidden_by_root: dict[str, bool] | None = None, # phase 105
|
||||
doc_dates_by_root: dict[str, dict[str, datetime]] | None = None, # phase 106
|
||||
@@ -792,8 +833,26 @@ class _GatedImport:
|
||||
if progress is not None:
|
||||
progress("repo", "notes/deep.md", 1, 3)
|
||||
self.hook_calls.append(("repo", "notes/deep.md", 1, 3))
|
||||
self.started.set()
|
||||
await asyncio.to_thread(self.release.wait) # park without freezing the worker loop
|
||||
if on_chunk_progress is not None and self.chunk_pairs:
|
||||
# Phase 129 (task 03): the file's chunk-position pairs,
|
||||
# fired through the runner's OWN hook closure (the closure
|
||||
# under test) — pair one before the main park, each later
|
||||
# pair after the main park + its own park (each park lets
|
||||
# the test read the status mid-run). The runner passes the
|
||||
# hook only when the wiring is intact — a regression to
|
||||
# None skips this branch and the pair tests time out.
|
||||
on_chunk_progress(*self.chunk_pairs[0])
|
||||
self.chunk_hook_calls.append(self.chunk_pairs[0])
|
||||
self.started.set()
|
||||
await asyncio.to_thread(self.release.wait)
|
||||
for pair in self.chunk_pairs[1:]:
|
||||
on_chunk_progress(*pair)
|
||||
self.chunk_hook_calls.append(pair)
|
||||
if self.chunk_release is not None:
|
||||
await asyncio.to_thread(self.chunk_release.wait)
|
||||
else:
|
||||
self.started.set()
|
||||
await asyncio.to_thread(self.release.wait) # park without freezing the worker loop
|
||||
if self.fail is not None:
|
||||
raise self.fail
|
||||
return self.summary
|
||||
@@ -896,9 +955,10 @@ def test_idle_status_pins_full_shape_including_progress_keys(
|
||||
fresh_sync_status: None,
|
||||
) -> None:
|
||||
"""Idle: the three phase-64 progress keys ride along as null/0/0,
|
||||
the four phase-98 phase keys as null/0/0/0, and EVERY pre-existing
|
||||
key is unchanged — the full response dict is pinned, so the
|
||||
current UI and every existing consumer keep working."""
|
||||
the two phase-129 chunk keys as 0/0, the four phase-98 phase keys
|
||||
as null/0/0/0, and EVERY pre-existing key is unchanged — the full
|
||||
response dict is pinned, so the current UI and every existing
|
||||
consumer keep working."""
|
||||
assert sync_api.sync_status() == {
|
||||
"state": "idle",
|
||||
"started_at": None,
|
||||
@@ -908,6 +968,10 @@ def test_idle_status_pins_full_shape_including_progress_keys(
|
||||
"current_file": None,
|
||||
"files_done": 0,
|
||||
"files_total": 0,
|
||||
# Phase 129 (task 03): the chunk-position keys — 0/0 idle
|
||||
# (the dataclass defaults, no special-casing).
|
||||
"current_chunk": 0,
|
||||
"chunks_total": 0,
|
||||
# Phase 98 (task 01): the phase-machine keys — null/0/0/0 idle.
|
||||
"phase": None,
|
||||
"current_summary": None,
|
||||
@@ -938,7 +1002,8 @@ def test_mid_run_status_reports_current_file(
|
||||
assert clone.started.wait(5.0), "the run never reached the clone phase"
|
||||
# Clone/pull prelude: running — but no file yet (A4: bare
|
||||
# "Syncing…") and no phase yet either (D1: the prelude stays
|
||||
# null — the bare label's pin).
|
||||
# null — the bare label's pin), and no chunk position yet
|
||||
# (phase 129: 0/0 before the first embed batch).
|
||||
s = sync_api.sync_status()
|
||||
assert s["state"] == "running"
|
||||
assert s["phase"] is None
|
||||
@@ -946,11 +1011,14 @@ def test_mid_run_status_reports_current_file(
|
||||
assert s["current_summary"] is None
|
||||
assert s["files_done"] == 0 and s["files_total"] == 0
|
||||
assert s["summaries_done"] == 0 and s["summaries_total"] == 0
|
||||
assert s["current_chunk"] == 0 and s["chunks_total"] == 0
|
||||
clone.release.set()
|
||||
assert fake_import.started.wait(5.0), "the run never reached the import"
|
||||
# Import phase: the hook's file is live on the status, and the
|
||||
# phase machine names the phase (the summary span has not
|
||||
# started — its counters are still 0/0).
|
||||
# started — its counters are still 0/0); no embed batch has
|
||||
# fired the chunk hook (this canned import passes no pairs),
|
||||
# so the chunk position stays 0/0.
|
||||
s = sync_api.sync_status()
|
||||
assert s["state"] == "running"
|
||||
assert s["phase"] == "import"
|
||||
@@ -959,6 +1027,7 @@ def test_mid_run_status_reports_current_file(
|
||||
assert s["files_total"] == 3
|
||||
assert s["current_summary"] is None
|
||||
assert s["summaries_done"] == 0 and s["summaries_total"] == 0
|
||||
assert s["current_chunk"] == 0 and s["chunks_total"] == 0
|
||||
fake_import.release.set()
|
||||
finally:
|
||||
clone.release.set()
|
||||
@@ -974,7 +1043,9 @@ def test_mid_run_status_reports_current_file(
|
||||
# Success terminal: current_file null, final file counts retained;
|
||||
# the phase machine clears phase + current_summary — the fake
|
||||
# folder step never fired the summary hook, so the summary counts
|
||||
# stay 0/0 (the keep-final-counts convention keeps what fired).
|
||||
# stay 0/0 (the keep-final-counts convention keeps what fired);
|
||||
# the chunk position clears with the file (phase 129 — it is a
|
||||
# sub-position of a done file, never a keep-final counter).
|
||||
s = sync_api.sync_status()
|
||||
assert s["state"] == "success"
|
||||
assert s["phase"] is None
|
||||
@@ -982,6 +1053,7 @@ def test_mid_run_status_reports_current_file(
|
||||
assert s["current_file"] is None
|
||||
assert s["files_done"] == 1 and s["files_total"] == 3
|
||||
assert s["summaries_done"] == 0 and s["summaries_total"] == 0
|
||||
assert s["current_chunk"] == 0 and s["chunks_total"] == 0
|
||||
assert s["error"] is None
|
||||
assert s["started_at"] is not None and s["finished_at"] is not None
|
||||
|
||||
@@ -993,7 +1065,13 @@ def test_failed_terminal_clears_current_file_keeps_counts(
|
||||
) -> None:
|
||||
"""Terminal (failed): a run that dies inside the import clears
|
||||
``current_file`` but keeps the hook's final counts — the last
|
||||
position is useful context next to the (sanitized) error."""
|
||||
position is useful context next to the (sanitized) error.
|
||||
|
||||
Phase 129 (task 03): the convention split extends to the chunk
|
||||
position — the (about-to-fail) import reports a live pair
|
||||
(1/38), the failed terminal clears it to 0/0 (a sub-position of a
|
||||
file that's done/abandoned), while the FILE counts keep their
|
||||
final values next to the error."""
|
||||
clone = _GatedClone(threading.Event(), threading.Event())
|
||||
fake_import = _GatedImport(
|
||||
ImportSummary(),
|
||||
@@ -1003,6 +1081,7 @@ def test_failed_terminal_clears_current_file_keeps_counts(
|
||||
"embeddings request to https://u:p@aipi.example.com/v1 "
|
||||
"failed: connection refused"
|
||||
),
|
||||
chunk_pairs=[(1, 38)], # phase 129: the live pair before the raise
|
||||
)
|
||||
_patch_sync_seams(monkeypatch, tmp_path, fake_import, clone)
|
||||
|
||||
@@ -1010,11 +1089,15 @@ def test_failed_terminal_clears_current_file_keeps_counts(
|
||||
try:
|
||||
assert clone.started.wait(5.0), "the run never reached the clone phase"
|
||||
clone.release.set()
|
||||
assert fake_import.started.wait(5.0), "the progress hook never fired"
|
||||
# While the (about-to-fail) import is parked: the file is live.
|
||||
assert fake_import.started.wait(5.0), "the chunk pair never landed"
|
||||
# While the (about-to-fail) import is parked: the file is live,
|
||||
# and so is its chunk position (the pair the fake fired through
|
||||
# the runner's own closure).
|
||||
s = sync_api.sync_status()
|
||||
assert s["state"] == "running"
|
||||
assert s["current_file"] == "repo/notes/deep.md"
|
||||
assert s["files_done"] == 1 and s["files_total"] == 3
|
||||
assert s["current_chunk"] == 1 and s["chunks_total"] == 38
|
||||
fake_import.release.set()
|
||||
finally:
|
||||
clone.release.set()
|
||||
@@ -1022,6 +1105,8 @@ def test_failed_terminal_clears_current_file_keeps_counts(
|
||||
thread.join(10.0)
|
||||
assert not thread.is_alive()
|
||||
assert errors == []
|
||||
# Wiring: the pair fired through the runner's own closure.
|
||||
assert fake_import.chunk_hook_calls == [(1, 38)]
|
||||
s = sync_api.sync_status()
|
||||
assert s["state"] == "failed"
|
||||
assert s["current_file"] is None # cleared in the terminal state
|
||||
@@ -1029,8 +1114,222 @@ def test_failed_terminal_clears_current_file_keeps_counts(
|
||||
assert s["current_summary"] is None
|
||||
assert s["files_done"] == 1 and s["files_total"] == 3 # final counts kept
|
||||
assert s["summaries_done"] == 0 and s["summaries_total"] == 0
|
||||
# Phase 129 (task 03): the chunk position clears in the FAILED
|
||||
# terminal too — the convention split, pinned (the FILE counts
|
||||
# above keep their final values, the sub-position does not).
|
||||
assert s["current_chunk"] == 0 and s["chunks_total"] == 0
|
||||
assert s["detail"] == {}
|
||||
error = s["error"] or ""
|
||||
assert "*****@aipi.example.com" in error # credentials masked
|
||||
assert "u:p" not in error
|
||||
assert "connection refused" in error # the reason survives
|
||||
|
||||
|
||||
# ---------- phase 129 (task 03): the in-flight file's embed-chunk ----------
|
||||
# ---------- position (current_chunk / chunks_total) ----------------------
|
||||
#
|
||||
# Same seams as the phase-64 block above: the fake import fires the
|
||||
# runner's OWN ``_chunk_hook`` closure (the task under test) with the
|
||||
# file's chunk-position pairs, parking between pairs so the test reads
|
||||
# the status BETWEEN ticks (the A6 batch-granularity contract: 14 =
|
||||
# the first chunk of the in-flight token-budgeted batch).
|
||||
|
||||
|
||||
def _poll_until(
|
||||
predicate: Callable[[], bool],
|
||||
timeout: float = 5.0,
|
||||
what: str = "condition",
|
||||
) -> None:
|
||||
"""Poll (main thread) until *predicate* — the worker-thread run's
|
||||
state is only observable between its parks."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while not predicate():
|
||||
if time.monotonic() >= deadline:
|
||||
raise AssertionError(f"timed out waiting for {what}")
|
||||
time.sleep(0.01)
|
||||
|
||||
|
||||
def test_mid_run_status_reports_chunk_position_between_ticks(
|
||||
fresh_sync_status: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Phase 129 (task 03): the status carries the in-flight file's
|
||||
embed-chunk position — the hook's (first, total) pair climbs
|
||||
through the file's batches (1/38 → 13/38, read between ticks),
|
||||
paired with the file the ``progress`` hook named; the prelude
|
||||
reports 0/0 (no embed batch yet), and the success terminal clears
|
||||
the position to 0/0 while the FILE counts keep their final values
|
||||
(the convention split — the chunk position is a sub-position of a
|
||||
done file, never a keep-final counter)."""
|
||||
clone = _GatedClone(threading.Event(), threading.Event())
|
||||
import_release = threading.Event()
|
||||
chunk_release = threading.Event()
|
||||
fake_import = _GatedImport(
|
||||
ImportSummary(files=3, added=1, updated=1, unchanged=1),
|
||||
threading.Event(),
|
||||
import_release,
|
||||
chunk_pairs=[(1, 38), (13, 38)], # two batches of a 38-chunk file
|
||||
chunk_release=chunk_release,
|
||||
)
|
||||
_patch_sync_seams(monkeypatch, tmp_path, fake_import, clone)
|
||||
|
||||
thread, errors = _start_run()
|
||||
try:
|
||||
assert clone.started.wait(5.0), "the run never reached the clone phase"
|
||||
# Prelude: the chunk position is 0/0 (no file, no batch yet —
|
||||
# alongside the bare-label pins of the phase-64 test above).
|
||||
s = sync_api.sync_status()
|
||||
assert s["state"] == "running"
|
||||
assert s["current_file"] is None
|
||||
assert s["current_chunk"] == 0 and s["chunks_total"] == 0
|
||||
clone.release.set()
|
||||
assert fake_import.started.wait(5.0), "the first chunk pair never landed"
|
||||
# Import phase, between ticks: pair one is live on the status,
|
||||
# paired with the file the progress hook named (the two hooks
|
||||
# are paired — the position belongs to that file).
|
||||
s = sync_api.sync_status()
|
||||
assert s["state"] == "running"
|
||||
assert s["phase"] == "import"
|
||||
assert s["current_file"] == "repo/notes/deep.md"
|
||||
assert s["files_done"] == 1 and s["files_total"] == 3
|
||||
assert s["current_chunk"] == 1 and s["chunks_total"] == 38
|
||||
# Release pair one: the import fires pair two (13/38 — the
|
||||
# second batch of the 38-chunk file) and parks again.
|
||||
import_release.set()
|
||||
_poll_until(
|
||||
lambda: sync_api.sync_status()["current_chunk"] == 13,
|
||||
what="the second chunk pair on the status",
|
||||
)
|
||||
s = sync_api.sync_status()
|
||||
assert s["state"] == "running"
|
||||
assert s["phase"] == "import"
|
||||
# The position CLIMBED (batch granularity) — total unchanged
|
||||
# (the file's chunk count, not the batch size).
|
||||
assert s["current_chunk"] == 13 and s["chunks_total"] == 38
|
||||
assert s["current_file"] == "repo/notes/deep.md"
|
||||
assert s["files_done"] == 1 and s["files_total"] == 3
|
||||
chunk_release.set()
|
||||
finally:
|
||||
clone.release.set()
|
||||
import_release.set()
|
||||
chunk_release.set()
|
||||
thread.join(10.0)
|
||||
assert not thread.is_alive()
|
||||
assert errors == [], f"the runner raised: {errors!r}"
|
||||
# Wiring: the pairs fired through the runner's OWN closure (the
|
||||
# status reads above came through it; a wiring regression to
|
||||
# None would have skipped the firing branch and timed out).
|
||||
assert fake_import.chunk_hook_calls == [(1, 38), (13, 38)]
|
||||
# Success terminal: the chunk position clears (0/0) ...
|
||||
s = sync_api.sync_status()
|
||||
assert s["state"] == "success"
|
||||
assert s["phase"] is None
|
||||
assert s["current_chunk"] == 0 and s["chunks_total"] == 0
|
||||
# ... while the FILE counts keep their final values (the
|
||||
# convention split, pinned alongside the phase-64 terminal pin).
|
||||
assert s["current_file"] is None
|
||||
assert s["files_done"] == 1 and s["files_total"] == 3
|
||||
|
||||
|
||||
def test_chunk_position_clears_at_phase_transitions_and_terminal(
|
||||
fresh_sync_status: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Phase 129 (task 03): the chunk position is CLEARED (0/0) at
|
||||
every point the import phase ends. This run's summary changes the
|
||||
KB, so BOTH post-import transitions run and are pinned: the
|
||||
import→``"overview"`` transition and the import→``"summaries"``
|
||||
transition (the changed-KB branch) each clear it to 0/0, and the
|
||||
success terminal keeps it 0/0 — through the whole span the FILE
|
||||
counts keep their final values (the convention split: the file
|
||||
counts survive for the result line's context, the chunk position
|
||||
is a sub-position of a file that's done)."""
|
||||
clone = _GatedClone(threading.Event(), threading.Event())
|
||||
import_release = threading.Event()
|
||||
overview_started = threading.Event()
|
||||
overview_release = threading.Event()
|
||||
folders_started = threading.Event()
|
||||
folders_release = threading.Event()
|
||||
fake_import = _GatedImport(
|
||||
ImportSummary(files=3, added=1, updated=1, unchanged=1),
|
||||
threading.Event(),
|
||||
import_release,
|
||||
chunk_pairs=[(7, 38)], # a live pair to clear at each transition
|
||||
)
|
||||
_patch_sync_seams(monkeypatch, tmp_path, fake_import, clone)
|
||||
|
||||
# Park the post-import steps (re-monkeypatched over the seams
|
||||
# helper's no-ops): the test reads the status DURING the
|
||||
# "overview" phase and DURING the "summaries" phase — the two
|
||||
# import-phase-ending transitions the task pins.
|
||||
async def gated_overview(llm: object, session: object = None) -> bool:
|
||||
overview_started.set()
|
||||
await asyncio.to_thread(overview_release.wait)
|
||||
return True
|
||||
|
||||
async def gated_folders(
|
||||
db: object,
|
||||
llm: object,
|
||||
*,
|
||||
skip: bool = False,
|
||||
only_missing: bool = False,
|
||||
changed_docs: Sequence[tuple[str, str]] | None = None, # phase 127
|
||||
on_progress: Callable[[int, int, str, str], None] | None = None,
|
||||
) -> dict[str, int]:
|
||||
folders_started.set()
|
||||
await asyncio.to_thread(folders_release.wait)
|
||||
return {"generated": 0, "failed": 0, "pruned": 0}
|
||||
|
||||
monkeypatch.setattr(sync_api, "regenerate_overview", gated_overview)
|
||||
monkeypatch.setattr(sync_api, "generate_folder_summaries", gated_folders)
|
||||
|
||||
thread, errors = _start_run()
|
||||
try:
|
||||
assert clone.started.wait(5.0), "the run never reached the clone phase"
|
||||
clone.release.set()
|
||||
assert fake_import.started.wait(5.0), "the chunk pair never landed"
|
||||
# Mid-import: the live pair (7/38) is on the status.
|
||||
s = sync_api.sync_status()
|
||||
assert s["phase"] == "import"
|
||||
assert s["current_chunk"] == 7 and s["chunks_total"] == 38
|
||||
# Release the import: it returns (added+updated > 0) → the
|
||||
# phase machine moves to "overview" and the position clears.
|
||||
import_release.set()
|
||||
assert overview_started.wait(5.0), "the run never reached the overview"
|
||||
s = sync_api.sync_status()
|
||||
assert s["state"] == "running"
|
||||
assert s["phase"] == "overview"
|
||||
# CLEARED at the import→overview transition (0/0) ... the file
|
||||
# counts KEEP their final values (the convention split).
|
||||
assert s["current_chunk"] == 0 and s["chunks_total"] == 0
|
||||
assert s["files_done"] == 1 and s["files_total"] == 3
|
||||
overview_release.set()
|
||||
# ... and the changed-KB branch moves to "summaries" (the
|
||||
# second import-phase-ending transition — cleared there too,
|
||||
# idempotently) before parking.
|
||||
assert folders_started.wait(5.0), "the run never reached the summaries"
|
||||
s = sync_api.sync_status()
|
||||
assert s["state"] == "running"
|
||||
assert s["phase"] == "summaries"
|
||||
assert s["current_chunk"] == 0 and s["chunks_total"] == 0
|
||||
assert s["files_done"] == 1 and s["files_total"] == 3
|
||||
folders_release.set()
|
||||
finally:
|
||||
clone.release.set()
|
||||
import_release.set()
|
||||
overview_release.set()
|
||||
folders_release.set()
|
||||
thread.join(10.0)
|
||||
assert not thread.is_alive()
|
||||
assert errors == [], f"the runner raised: {errors!r}"
|
||||
assert fake_import.chunk_hook_calls == [(7, 38)]
|
||||
# Success terminal: still 0/0 (the terminal clears too — same
|
||||
# spots as current_file), file counts kept.
|
||||
s = sync_api.sync_status()
|
||||
assert s["state"] == "success"
|
||||
assert s["phase"] is None
|
||||
assert s["current_file"] is None
|
||||
assert s["current_chunk"] == 0 and s["chunks_total"] == 0
|
||||
assert s["files_done"] == 1 and s["files_total"] == 3
|
||||
|
||||
Reference in New Issue
Block a user