# Resumable self-test — 0.74.0

**Shipped:** 2026-08-25 · Prompted by Abe: "I keep hitting cloudflare timeout
errors. How can we avoid this? Do we need to chunk it?"

## The problem

`station_run_self_test scope=all` runs ~90 QA groups in one request. On a live
staging site behind Cloudflare that request takes longer than the gateway's
~30-second budget, so the edge returns 502 and the caller sees a timeout —
even though the run itself may still be executing server-side (or was killed
mid-write). One request per full run does not survive a proxy in the middle.

## The fix: chunk the run, keep the state, never lie about partial results

The same budget+state pattern Guardian's site-wide fixers already use,
applied to `DVC_QA::run()`:

- **CHUNK_BUDGET = 20s** — safely under a 30s gateway. The dispatch loop
  checks the deadline before each group; at least one group always runs per
  call (`$ran_any`), so a pathological single group can't wedge the run into
  a zero-progress loop.
- **STATE_OPT `dvc_qa_run_state`** — on pause, the run persists
  `{id, scope, want, cursor, total, results, tmp, snaps, writes, started}`
  and refreshes the RUN_LOCK carrying the same id. On the next call with a
  matching lock+state id and the same scope, the run resumes at the cursor
  with all accumulated results, temp fixtures, write counters, and
  pre-run snapshots restored.
- **Cleanup only on the final chunk.** Probe pages, QA snapshots, and the
  state/lock pair are deleted only when the run actually finishes. A fresh
  run finding stale state without a live lock purges it (sweep_probes +
  purge_qa_snapshots) before starting.
- **A runner-level throwable forces finish**, so a crash never leaves a
  zombie paused state holding the lock.

## Honest partial reporting

A paused run is loud about it in both surfaces:

- **MCP** (`station_run_self_test`): `status: "running"`,
  `progress: "37/91 group slices"`, and a NOT FINISHED note instructing the
  caller to call again with the same scope to continue. `status: "finished"`
  only on the real end. The tool description states the contract: keep
  calling until finished; anything else is partial.
- **wp-admin QA tab**: a warning banner — "Self-test paused at N/M group
  slices" — with a **Continue self-test** button, and the summary headline
  reads "%d checks passing so far — run not finished" instead of a final
  count.

Mismatched scope against a paused run errors (`dvc_qa_paused_mismatch`)
rather than silently discarding someone else's half-finished run; a second
caller during a live run still gets the busy error.

## Verification state

Gates green (275 files, v0.74.0; php -l + node --check + package gate).
Pending live: scope=all on Unlock Button staging should now complete across
2–3 continue calls with no 502, ending in a single finished summary that
also validates 0.72.0 heading mining, 0.73.0 template-H1 probe, and 0.73.1
dynamic-link classification.
