Files
brain-of-reese/.agents/phases/complete/134_sync_cancel/00_phase.md
T
ducoterra fcac876d40
Build and Push Containers / build-and-push-app (push) Successful in 2m26s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 134_sync_cancel
**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`
2026-09-28 18:08:11 -04:00

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.state becomes Literal["idle", "running", "success", "failed", "cancelled"]. It is TERMINAL like success / failed: carries finished_at, clears current_file / phase / current_summary but keeps the run's final files_done / files_total and summary counts (the phase-64/98 keep-final-counts convention), detail = {}, and error = 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/status gains nothing structurally — the state key simply reports "cancelled".
  • POST /api/sync/cancel (same router, so require_admin — 403 anonymous): while a run is in flight → set the module-level _cancel_event (an asyncio.Event, next to _status / _task) AND call _task.cancel() (unblocks whatever await the pipeline is parked on — e.g. a per-file LLM call inside the import) → 202 {"detail": "cancel requested"}. No run in flight (_task None or done) → 409 {"detail": "no sync is running"}. start_sync resets _cancel_event (clear) when it starts a run.
  • Cancellation semantics — cooperative, best-effort (ASSUMPTION A2): _run_sync gains (a) a private _check_cancelled() helper that raises asyncio.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 before import_sources(...), and immediately before the if summary.added + summary.updated > 0: block (covers the overview + summaries spans); and (b) an except asyncio.CancelledError: clause (placed before the existing except Exception): if _cancel_event.is_set() → land the run in the cancelled terminal state + one INFO log line (PLAN §9: sync: cancelled by user state=cancelled), then raise (the task ends cancelled — _task.done() is True, the status object stays authoritative); otherwise (no user cancel — the app-shutdown path) → raise unchanged (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 blocking subprocess.run has 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-btn button in the same sync-control area as #sync-btn (frontend/index.html), hidden by default; secondary button styling consistent with the theme (contrast ≥4.5:1, :focus-visible ring — house UI rules). sources.js rules: SHOWN + ENABLED only while a SYNC run is running (enterSyncRunningState with kind === "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 by settleSyncButton (every terminal/idle settle) and by every applySync* 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 new cancelled branch 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 the failed branch, 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. initSyncButton re-attach: state === "cancelled" → the same neutral settle (a page reload during/right after a cancel shows "Sync cancelled", never a stuck running state).

Tasks

  1. 01_backend_cancel_state.md — the cancelled state, POST /api/sync/cancel, and the cooperative cancel mechanics in _run_sync.
  2. 02_frontend_cancel_button.md — the #sync-cancel-btn UI: show/hide rules, the cancel request lifecycle, the cancelled terminal branch in the poll tree + re-attach.
  3. 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, the CancelledError conversion (event set → cancelled state + re-raise; event unset → re-raise with the state untouched), the SyncStatus reset 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 (with error: 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 the slow_llm proxy 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, 409 no sync is running otherwise, 403 anonymous
  • a cancelled run lands in the cancelled terminal state (error: None, final counts kept) and the status endpoint reports it
  • the app-shutdown cancel contract is preserved (an unrequested CancelledError is never swallowed — a unit test pins it)
  • the UI: the Cancel button is visible exactly while a sync run is running, the cancelled terminal 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-missing green, app/ coverage >90%; uv run pytest tests/e2e/test_sync_cancel.py -v --no-cov green in isolation
  • no behavior change in completed phases (the uncancelled run is byte-identical: same states, same labels, same 409 double-trigger)