Files
6krrt/plans/pending-ops-fixes.md
adlee-was-taken 3523dcf93e docs(plans): give every plan a Status line so the queue is greppable
plans/ held 58 documents and exactly one said whether it was open. The rest
mixed finished work, reviews of shipped work, parked specs and genuinely
pending ones, with nothing distinguishing them, so "how many plans are in
the queue" had no answer short of reading all 58.

Now `grep -H '^Status:' plans/*.md` is the answer:

    50 done   3 in progress   2 planned   2 reference   1 parked

Statuses were derived rather than guessed: CLAUDE.md's own built list and
"What's NOT built yet" section, plus checking the subject exists in the
code. A review of work that shipped counts as done -- it records what was
found, it is not a request for anything. `reference` separates the two docs
that are conventions rather than work items (admin-design-standards,
admin-work-framework), which otherwise read as permanently-open plans.

The vocabulary is deliberately five words. A larger one invites "mostly
done" and "blocked-ish", which is how the directory became unreadable.

test_plans_declare_status.py keeps it from rotting: a new plan without a
marker fails, as does an unknown status, one buried below the eighth line,
or an open status with no reason -- "planned" alone is the state that rots,
since nobody can tell later whether it waits on a decision, a dependency,
or just nobody's turn.

Also updates the sweep plan with what landed and what did not, including
that #9 was not a defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRQXz5SYZYVWscxS1QqF6U
2026-09-08 18:55:16 -04:00

10 KiB

Pending ops fixes found live, 2026-09-06/07

Status: in progress -- WAL applied live; the circuit-breaker and stream findings are open

Three findings from directly operating the router today (not from planning), each caught by actually exercising the system rather than reading the code. Recorded so they don't get lost -- none are urgent, but two are real bugs and one already got applied live.

1. DONE (applied live 2026-09-06 22:48 UTC): router.db moved to WAL journal mode

Symptom: python -m eval_proficiency crashed with sqlite3.OperationalError: database is locked inside log_observation's conn.commit() (dispatcher.py:2156), after successfully completing exactly one full model's 57-task set. The default rollback-journal mode (PRAGMA journal_mode was delete) means a writer briefly blocks every reader and vice versa -- and this router now has real concurrent access from multiple processes hitting one file: the live dispatcher (thousands of writes/hour), the poller/seed-energy timers, admin portal reads, the TUI, ad-hoc CLI diagnostics, and now eval scripts.

Applied: sqlite3 router.db "PRAGMA journal_mode=WAL;" -- a one-time, persistent, file-level change (stored in the DB header, no app code or config change needed, every new connection picks it up automatically). Verified: PRAGMA journal_mode now reports wal, and router.db-shm / router.db-wal auxiliary files exist alongside the main file.

Follow-up housekeeping in this same change: .gitignore's *.db pattern doesn't match router.db-shm / router.db-wal (they don't end in .db) -- added explicit patterns for both so the new auxiliary files can never get swept into a git add -A.

Why this was safe to do live: WAL mode is a database-file property, not a live-connection setting -- flipping it doesn't require restarting the dispatcher service, and it was applied at a moment with no other active writer (the eval run had already crashed, so nothing was mid-transaction).

Not yet verified: whether the eval that crashed under delete mode now completes cleanly under WAL against the same concurrent load. First real test of that is the resumed eval run this same investigation kicked off.

2. Circuit breaker is blind to mid-stream upstream breaks

Symptom: gemma-4-31b broke mid-stream twice in ~3 hours (upstream_stream_broken, dispatcher.py:4419, "Response ended prematurely" after 212s and 129s) -- NeuralWatt's own infrastructure apparently has some timeout/limit that gemma-4-31b's unusually slow completions (avg 29.8s vs. 3-7s for every other model on this router; max successful duration 138.6s) occasionally exceeds. ~2 of 22-31 requests (7-9%) in the observed window.

The actual gap, not the symptom: circuit_breaker.record_success(current_model, provider) (dispatcher.py:4228) fires as soon as the upstream responds with a non-error HTTP status -- which happens immediately for a streaming request, before any body has streamed. The upstream_stream_broken handler lives in a separate code block (the SSE generator, dispatcher.py:4405-4420), which only runs after that success was already recorded. So a model that returns 200 and then dies mid-stream is permanently invisible to the one mechanism built to route around unreliable models -- no amount of mid-stream failures for a given model will ever trip its breaker.

Proposed fix: call circuit_breaker.record_failure(current_model, provider, ...) from inside the upstream_stream_broken exception handler too, not only on a bad initial status code. Needs a decision on whether a mid-stream break should count the same as a full request failure for backoff purposes, or a lighter weight -- a stream that produced partial (billable) output before dying is arguably a different severity than one that never started at all.

Not yet decided: whether gemma-4-31b's measured unreliability should also factor into scoring/proficiency somehow, separate from the circuit breaker (which is about availability, not quality). Parked as a separate, smaller question -- the circuit-breaker gap is the concrete bug; scoring for reliability is a judgment call, not a bug fix.

3. dispatcher.py's startup checks run on ANY import, not just when serving

Symptom, discovered while running eval_proficiency.py for an unrelated reason: the eval process allocated 1.7GB of GPU memory and took a real, unexplained delay before its first output -- despite eval_proficiency.py having nothing to do with classification. eval_proficiency.py imports dispatcher.py only for two small helpers (extract_telemetry, log_observation).

Root cause: dispatcher.py:471 (_ensure_tables()) and dispatcher.py:492 (_ensure_classifier_mode_ready()) are both called at bare module level -- unindented, outside any function, outside any if __name__ == "__main__" or FastAPI startup-event guard. They run unconditionally the instant anything imports dispatcher.py, for any reason.

_ensure_tables() is probably benign for another DB-touching script (it's idempotent schema migration), if a little wasteful. _ensure_classifier_mode_ready() is the real risk: when classifier.mode: local_encoder is configured, it calls local_encoder.ensure_available(...), which re-raises on a missing transformers/torch install. That means any unrelated script importing dispatcher.py -- eval_proficiency.py, router_cli.py, seed_energy.py, anything -- can now crash on import alone, in an environment that never needed the classifier, purely because the live service's config happens to have local_encoder mode on. It didn't bite today only because this exact venv happens to have torch installed now.

Proposed fix: move _ensure_classifier_mode_ready() (and probably _ensure_tables(), for the same reason, even though its current risk is lower) into a proper FastAPI @app.on_event("startup") handler -- this project doesn't use that pattern anywhere yet, so it's a new but standard addition -- so they only run when the service actually starts serving requests, never on a bare import by another script.

4. docs/pinch.md keeps losing its protected_max_chars section

Symptom: the protected_max_chars documentation (config example, table row, two prose references) has gone missing from the shared checkout's working tree five separate times on 2026-09-06/07: once blocking a git pull, once mid-/review-work, once found "pre-existing" at the start of a later agent session, and twice more after being restored.

Not a real regression: the content is correct in every commit on origin/main -- verified each time with git show origin/main:docs/pinch.md. This is purely a working-tree artifact.

Leading hypothesis, formed 2026-09-07: the shared checkout is routinely left sitting on a feature branch while agents work in it (observed on fix/admin-profiles-page-polish and feat/cloud-fallback-admin-control after their PRs merged). Branch state moving under an agent mid-session would explain a file reverting to an older revision without any commit doing it. The fix is probably operational -- return the shared checkout to main after each merge -- rather than a code change.

Restore (cannot be done from a worktree-isolated session, see project-direct-implementation-workflow): git checkout -- docs/pinch.md, or extract the blob and command cp it, verifying with git hash-object.

5. A test hardcodes the ambient classifier default instead of setting it

Symptom: tests/test_classifier_modes_dispatch.py::test_default_mode_is_local_llm_and_behaves_as_before asserts classifier.mode == "local_llm" by relying on whatever the ambient config resolves to, rather than constructing a config with that mode explicitly. It fails on any machine whose config.local.yaml overlay picks a non-default mode -- which is exactly this deployment (classifier.mode: local_encoder).

Confirmed as an overlay artifact, 2026-09-07: the same suite run from a worktree with no config.local.yaml passes 1521/1521, including this test and the 8 siblings in test_classifier_backoff.py / test_gaming_mode.py that fail alongside it in the shared checkout. Nothing is actually broken; the tests just read ambient config.

Proposed fix: construct the test's own RouterConfig with classifier.mode="local_llm" set explicitly. Small, standalone, no urgency -- but it currently makes "run the full suite" produce 9 red herrings for every agent working in the shared checkout, which has already cost time twice.

6. The profile update endpoint cannot rename, but the modal offers to

Symptom: admin/frontend/profiles.html's edit modal presents an editable "Profile name" box. Changing it and saving reports success and silently keeps the old name.

Root cause: _ProfileUpdateBody (src/admin.py) has no name field at all, and _profile_block_dict() does body.model_dump(exclude={"name"}), so a submitted name is dropped before it reaches _persist_profile(name, ...) -- which uses the path name. Renaming is delete-plus-create by design (the endpoint's own docstring refers to "rename-by-delete"), but nothing in the UI says so.

Found live 2026-09-07 while adding the inline editor, which initially followed the submitted name after save and landed the detail pane on a profile that did not exist. The inline editor now renders the name readonly with a note pointing at duplicate-then-delete; the modal was deliberately left alone and still shows the misleading editable box.

Proposed fix: make the modal's name field readonly when editing (it must stay editable for create/duplicate, which use a different endpoint), with the same explanation the inline editor carries. Alternatively, support rename server-side -- a bigger change, since it means moving a key in the overlay and deciding what happens if the new name collides with a built-in.

Ordering

None of these block each other. #1 is already done. #2, #3, #5 and #6 are all small, independent, well-understood fixes. #4 is operational rather than a code change, and #5 is worth doing early only because those 9 phantom failures mislead every agent that runs the suite in the shared checkout.