**Phase 134 final verification pass — all green** (all 3 task files already in `complete/`; no implementation was missing) - Verified: 5-state `SyncStatus` + `cancelled` terminal (error None, final counts kept), `POST /api/sync/cancel` (202/409/403), cooperative `_check_cancelled` boundaries, shutdown `CancelledError` contract preserved, `#sync-cancel-btn` show/hide + neutral "Sync cancelled" settle, 8-branch poll tree, re-attach — code, unit, integration, and E2E layers all present - `uv run pytest --cov=app --cov-report=term-missing` → **3150 passed**, `app/` coverage **99%** (gate: >90%) - `uv run pytest tests/e2e/test_sync_cancel.py -v --no-cov` (isolated) → **3 passed** (47.6 s) - `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings **Completion criteria:** ① cancel endpoint 202/409/403 ✅ (unit + E2E) ② `cancelled` terminal reported by status, error null, counts kept ✅ (integration `test_cancel_mid_run_lands_in_cancelled_then_resyncs_clean`) ③ unrequested-cancel pin ✅ (`test_unrequested_cancel_is_never_swallowed_and_never_converts`) ④ UI lifecycle + clean re-sync (sentinel row, full 40-file import) ✅ (E2E) ⑤ suite/coverage/E2E gates ✅ ⑥ uncancelled runs byte-identical ✅ (all 3150 pre-existing tests green untouched) - Docs: README.md reviewed in full against code (pages, auth endpoints, every config default vs `app/config.py`, palette 9+8, fail-loud, cancel section) — consistent, no changes needed. AGENTS.md: fixed one pre-existing inaccuracy (rule 2 "move its file" → "move its directory" — phases are directories); everything else verified accurate - No live infrastructure touched; no deviations **Next pending phase:** `135_git_source_branch`
8.9 KiB
Phase 134 — The owner can cancel a running sync from the Sources page
Source: TODO.md L3 — "Need to be able to cancel a sync after I've clicked the button."
Story: n/a (TODO-derived phase, Protocol B append).
Context: app/api/sync.py — SyncStatus (the four-state dataclass: idle / running / success / failed, phase-64 progress fields, phase-98 phase-machine fields), the module-level _status + _task, POST /api/sync (202 / 409 one-sync-at-a-time), GET /api/sync/status, and _run_sync — the in-process asyncio background task: the blocking git prelude (scripts.git_sync.clone_or_pull → subprocess.run, no await inside), then await import_sources(...) (per-file LLM awaits — a cancellation point between files), then await regenerate_overview(...) and await generate_folder_summaries(...) (both only on a changed KB / gap). The module docstring's shutdown contract: CancelledError is deliberately NOT swallowed — app shutdown cancels the task, and swallowing would mask a real stop. import_sources closes its own private session in a finally (leak-safe under a mid-import cancel). frontend/assets/sources.js — the sync-button lifecycle: enterSyncRunningState (live click + re-attach + every running tick), settleSyncButton (every terminal), applySyncSuccess / applySyncFailure / applySyncIdle, startSyncPolling (the 2 s two-job decision tree, 7 branches), initSyncButton (load-time re-attach). frontend/index.html — the #sync-btn markup (the Sources view section). E2E patterns: tests/e2e/test_sync_button.py (per-module app on a dedicated port, the file:// fixture repo, the _wait_sync_done status-poll helper), tests/e2e/slow_llm.py (the delay-injecting proxy that makes a run's duration deterministic), tests/e2e/mock_llm.py. Unit: tests/unit/test_sync_button.py; integration: tests/integration/test_sync_api.py.
Objective
A sync that is already running can be stopped by the owner: while a run is in flight the Sources page shows a Cancel button; one click requests the cancel, the pipeline stops at the next boundary, the run lands in a new cancelled terminal state (reported by the status endpoint and rendered by the UI as a neutral result — cancel is intent, not failure), and the controls settle back to the retry-ready idle state. A subsequent sync starts cleanly (the partial run leaves a consistent KB — the importer's content-hash delta makes the next run idempotent).
Dependencies
133_pdf_screenshot_fallback(todo) — pipeline order only; no code dependency.
Design (shared by all tasks — the executor reads this, not the chat)
- The fifth state —
cancelled(ASSUMPTION A1):SyncStatus.statebecomesLiteral["idle", "running", "success", "failed", "cancelled"]. It is TERMINAL likesuccess/failed: carriesfinished_at, clearscurrent_file/phase/current_summarybut keeps the run's finalfiles_done/files_totaland summary counts (the phase-64/98 keep-final-counts convention),detail = {}, anderror = None(no error string — the UI never treats it as a failure: no error banner, no error modal, just the neutral "Sync cancelled" result line).GET /api/sync/statusgains nothing structurally — thestatekey simply reports"cancelled". POST /api/sync/cancel(samerouter, sorequire_admin— 403 anonymous): while a run is in flight → set the module-level_cancel_event(anasyncio.Event, next to_status/_task) AND call_task.cancel()(unblocks whateverawaitthe pipeline is parked on — e.g. a per-file LLM call inside the import) → 202{"detail": "cancel requested"}. No run in flight (_taskNone or done) → 409{"detail": "no sync is running"}.start_syncresets_cancel_event(clear) when it starts a run.- Cancellation semantics — cooperative, best-effort (ASSUMPTION A2):
_run_syncgains (a) a private_check_cancelled()helper that raisesasyncio.CancelledError()when_cancel_event.is_set(), called at the pipeline boundaries — the top of the per-source loop (before each clone/pull or local-dir check), immediately beforeimport_sources(...), and immediately before theif summary.added + summary.updated > 0:block (covers the overview + summaries spans); and (b) anexcept asyncio.CancelledError:clause (placed before the existingexcept Exception): if_cancel_event.is_set()→ land the run in thecancelledterminal state + one INFO log line (PLAN §9:sync: cancelled by user state=cancelled), thenraise(the task ends cancelled —_task.done()is True, the status object stays authoritative); otherwise (no user cancel — the app-shutdown path) →raiseunchanged (the module docstring's shutdown contract holds: an unrequested cancellation is never swallowed). Consequence the UI/tests may rely on: a git subprocess already running at the cancel moment runs to completion (the blockingsubprocess.runhas no await inside) — the cancel takes effect at the NEXT boundary (after that source's git ops, between files, or before overview/summaries). During the import span the_task.cancel()from the endpoint interrupts at the next per-file await. - The UI (ASSUMPTION A3): a
#sync-cancel-btnbutton in the same sync-control area as#sync-btn(frontend/index.html),hiddenby default; secondary button styling consistent with the theme (contrast ≥4.5:1,:focus-visiblering — house UI rules).sources.jsrules: SHOWN + ENABLED only while a SYNC run is running (enterSyncRunningStatewithkind === "sync"— the upload job is NOT cancellable in this phase, so the cancel button stays hidden while only the upload run drives the button); HIDDEN + DISABLED bysettleSyncButton(every terminal/idle settle) and by everyapplySync*path. Click →requestCancel(): the cancel button disables IMMEDIATELY (never stale, no double request),POST /api/sync/cancel; 403 → the whoami-backstop hide-everything branch (same as the other endpoints); 409 → a harmless race (the run ended between ticks — the poll settles on the next tick, no error copy); 202 → the button stays disabled until the terminal (the poll's newcancelledbranch settles everything); any other non-2xx → the phase-55 neutral retry copy in#sync-result("Couldn't cancel the sync — try again.") + the cancel button re-enables. New poll-tree branch (after thefailedbranch, before the idle branch):syncStatus.state === "cancelled"→ stop polling, settle the main button to "Sync sources",#sync-result= "Sync cancelled" (the aria-live announcer reads it), hide the error banner, hide the cancel button,emitSyncStatus.initSyncButtonre-attach:state === "cancelled"→ the same neutral settle (a page reload during/right after a cancel shows "Sync cancelled", never a stuck running state).
Tasks
01_backend_cancel_state.md— thecancelledstate,POST /api/sync/cancel, and the cooperative cancel mechanics in_run_sync.02_frontend_cancel_button.md— the#sync-cancel-btnUI: show/hide rules, the cancel request lifecycle, thecancelledterminal branch in the poll tree + re-attach.03_e2e_sync_cancel.md— the dedicated Playwright E2E (the slow-LLM cancel window, the API contract, the full UI lifecycle, the clean re-sync after cancel).
Testing & Quality
- Unit: the cancel endpoint's contract (202/409/403),
_check_cancelled, theCancelledErrorconversion (event set →cancelledstate + re-raise; event unset → re-raise with the state untouched), theSyncStatusreset on a fresh start (a stale cancel event cannot bleed into the next run). - Integration: a real run started through the API is cancelled mid-run → the status reports
cancelled(witherror: None); a second run after the cancel completes successfully (the restart is clean). - E2E: one dedicated Playwright file, run in isolation (
tests/e2e/test_sync_cancel.py) — the mock-only app with theslow_llmproxy for a deterministic cancel window. - Coverage: >90% on new/modified code (
app/).
Completion Criteria
POST /api/sync/cancel(admin): 202 while a run is in flight, 409no sync is runningotherwise, 403 anonymous- a cancelled run lands in the
cancelledterminal state (error: None, final counts kept) and the status endpoint reports it - the app-shutdown cancel contract is preserved (an unrequested
CancelledErroris never swallowed — a unit test pins it) - the UI: the Cancel button is visible exactly while a sync run is running, the
cancelledterminal settles to "Sync cancelled" with no error banner/modal, and a re-sync after a cancel succeeds uv run pytest --cov=app --cov-report=term-missinggreen,app/coverage >90%;uv run pytest tests/e2e/test_sync_cancel.py -v --no-covgreen in isolation- no behavior change in completed phases (the uncancelled run is byte-identical: same states, same labels, same 409 double-trigger)