name: jcstream-sweep-debugger
description: Use when investigating why a JCStream sweep didn't produce fresh data — the roster count is flat, the changelog is empty, the site rendered stale photos, or the GitHub Actions sweep workflow showed exit-0 but no commit. Covers the silent degraded-roster fallback in scraper/sweep.py, the health thresholds in sweep_guards.py, and how to read changelog.json / history.json / current.json together. Trigger phrases: "sweep didn't update", "stuck count", "no changes in changelog", "scraper looks idle", "stale photos", "partial sweep", "sweep bailed", "sweep timed out", "nameless records", "detail watchdog".
JCStream sweep debugger
You diagnose sweep failures. The most common failure mode is silent: sweep_looks_healthy (in scraper/sweep_guards.py; the underscored name in scraper/sweep.py is a back-compat alias) returned False, sweep.py kept the last-good data/current.json, and the workflow exited 0 with no commit. The site looks fine but is stale.
The triage flow
1. Did the sweep workflow run at all?
Check GitHub Actions for the most recent sweep run:
git log --oneline -20 | grep "data+site: sweep"
Gaps in the timeline (>30 min) mean the cron didn't fire — that's a GH Actions / repo-permissions problem, not a scraper problem.
2. Did the scraper bail on the health check?
The workflow log will show the sweep's sweep_looks_healthy decision. Without log access, infer from data:
- Compare today's
data/current.json inmate_count to yesterday's: if it's identical for >2 cycles, the gate likely tripped.
- Tail
data/changelog.json: if the most recent timestamp_utc is >1 hour old, no new events.
3a. Which list-side threshold tripped?
From scraper/sweep_guards.py:
| Threshold | Meaning |
|---|
SWEEP_MAX_FAILED_FRACTION = 0.10 | >10% of surname fetches errored → bail |
SWEEP_MIN_ROSTER_FRACTION = 0.5 | new roster <50% of last cycle → bail |
SWEEP_BOOTSTRAP_FLOOR = 50 | first-ever sweep needs ≥50 inmates → bail |
To distinguish:
- High error rate → HCSO rate-limited the scraper. Look at the workflow log for HTTP 429 / generic-block-page hits. The fix is usually time (wait an hour) — not a code change.
- Roster collapse → HCSO published a degraded list (frequent during a system migration). Check
https://www.hcso.org/justice-center-services/inmate-search/ manually.
- Bootstrap floor → a brand-new deploy with
data/current.json missing. Run a manual sweep with workflow_dispatch.
3b. Did the detail-page watchdog trip?
Even when the list sweep stays green, check_detail_watchdog (scraper/sweep_guards.py, called at scraper/sweep.py) can refuse the cycle if the per-inmate detail pages stop yielding names or photos. This is the path that fires on "stale photos but fresh roster count" / "nameless inmates".
From scraper/sweep_guards.py:
| Threshold | Tier | Meaning |
|---|
DETAIL_WATCHDOG_MIN_SAMPLE = 10 | gate | below this many detail attempts the watchdog is silent (small samples are noisy) |
DETAIL_WATCHDOG_NAME_FLOOR = 0.70 | WARN | <70% of detail attempts parsed a name → log warning, still write |
DETAIL_WATCHDOG_PHOTO_FLOOR = 0.50 | WARN | <50% of detail attempts yielded a photo → log warning, still write |
DETAIL_WATCHDOG_BLOCK_MIN_SAMPLE = 100 + DETAIL_WATCHDOG_BLOCK_NAME_FLOOR = 0.60 | BLOCK | both must hold (≥100 attempts AND <60% named) → roster_ok=False, keep last-good |
A WARN-tier trip leaves the roster fresh but the warnings in the workflow log are the early signal that HCSO's detail-page HTML is shifting (parser regression imminent). A BLOCK-tier trip looks identical to a list-side bail from the outside — exit 0, no commit.
3c. Other silent-bail / partial-write paths in scraper/sweep.py
| Path | Where | Symptom |
|---|
| Wall-clock cap | SWEEP_WALLCLOCK_HARD_CAP_S = 22 * 60 at scraper/sweep.py, enforced at scraper/sweep.py | sweep finishes after ~22 min with a partial roster persisted; diff/changelog are skipped (clean_finish=False), so the count moves but no events appear |
| Checkpoint guard | scraper/sweep.py | mid-sweep save_current checkpoints are skipped when in-memory roster is <50% of previous; a stuck count can come from this even though the final-write path looks fine — log line: "checkpoint skipped at N/M details" |
| Corrupt-snapshot bail | scraper/sweep.py (SnapshotCorruptError) | returns 0 immediately; the broken data/current.json is left in place for inspection — log line: "refusing sweep: data/current.json is unreadable" |
save_current failure | scraper/sweep.py (OSError) | disk-full / atomic-rename failure: snapshot unchanged, changelog and prune both skipped — log line: "save_current failed (...); skipping changelog and prune" |
| Photo prune skip | scraper/sweep_guards.py (PHOTO_PRUNE_MAX_FRACTION = 0.5) | when >50% of stored photos would be deleted in one cycle, prune is skipped wholesale — symptom is "photos for released inmates aren't disappearing" — log line: "photo prune would remove N/M photos (>50%) - skipping prune" |
Atomic write contract: data/current.json is written via tmp + os.replace in scraper/store.py (_atomic_write_text), so a half-written snapshot is never published — a stale current.json is always intact, never truncated.
4. Cross-check the data files
| File | What it tells you |
|---|
data/current.json | Latest accepted roster snapshot. generated_utc is when the snapshot was written (not when the sweep ran — those diverge during a bailed sweep). |
data/changelog.json | Append-only log of booked/released/updated events. Length grows on every accepted sweep. |
data/history.json | Daily roster-size + churn counts. Stubby today (≈200 bytes). Owned by web/build.py (_update_history), not by the sweep — a stale history.json points at the build/Pages workflow, not the scraper. |
| Workflow logs (Actions) | Per-surname HTTP status, error count, gate decision. |
If current.json.generated_utc is recent but changelog.json is unchanged → the sweep was accepted but no events flipped (genuinely quiet day) or the sweep tripped the wall-clock cap and the diff was skipped (check the log for "wall-clock cap reached").
If current.json.generated_utc is stale and changelog.json is stale → the gate has been tripping. Read the recent workflow logs.
When to file a code fix
- Threshold is too tight for the actual error envelope (HCSO consistently returns 8% errors and the floor is 10%) → tune
SWEEP_MAX_FAILED_FRACTION in sweep_guards.py with telemetry to back it up.
- A new HCSO HTML quirk is causing parsing failures → fix the parser, don't relax the gate.
- The bootstrap floor is biting a legitimate restart → drop a known-good
data/current.json into place rather than weakening the check.
When NOT to file a code fix
- HCSO is rate-limiting → wait it out, this is normal.
- HCSO is publishing a degraded list → the silent fallback is protecting the site; let it.
Anti-patterns
- Lowering
SWEEP_MIN_ROSTER_FRACTION to make a bad sweep go through.
- Editing
data/current.json by hand.
- Re-running the workflow without diagnosing — you'll get the same answer.
Verify a fix
python -m pytest -q tests/test_sweep.py