2026-08-17 OpenRouter Interceptor Analysis Stuck on Analyzing
What I set out to do
An analysis row in the sidebar sat on “Analyzing…” and never finished. Two diagnostic captures were saved from the sidebar’s camera button; the question was why.
What I actually did
Diagnosed from the capture alone, without a screenshot. The bundle read live from the background store at capture time showed the expanded query fa12771d as completed with the full character analysis in hand. The serialized card HTML, last rendered 55 seconds earlier, still carried <div class="query-pending">Analyzing…</div> on a different query. The analysis had finished; only the UI had not heard.
Root cause: the sidebar’s only completion path was a poll with a ceiling. pollQueryStatus ran 30 ticks at 1 s, then cleared its timer and said nothing more. A 32 KB system prompt through deepseek-v4-flash took longer than 30 s, so the poll expired, the query completed a moment later, and nothing re-rendered until a sidebar reload. runAnalysis in the background wrote the result and saved, but never announced it, and the sidebar’s push listener reloaded captures only, not queries.
A second bug fell out of the same evidence. showAnalysis had set expandedQueryId to the new query, but the carousel rendered the older pending one at “1 of 2” — findIndex had missed, meaning the new query was absent from state.queries at that render. ANALYZE_CAPTURE replied from an unawaited IIFE that had only called saveQuery, so the GET_QUERIES fired 5 ms later beat the write.
A third: the bundle could not have answered the question. recentQueries and recentSidebarCaptures used slice(-20) on lists the background keeps newest-first, so every bundle shipped the twenty oldest of each. Neither query in play was in it; the newest entry was 24 days old.
Fixed all four, TDD, red before green each time:
runAnalysisnow broadcastsANALYSIS_UPDATED {queryId}on both branches, andfeatures/queries.tslistens for it — including for analyses it did not start, which is exactly the reloaded-sidebar case.ANALYZE_CAPTUREawaitssaveQuerybefore replying, mirroringREANALYZE_QUERY.- New
utils/newest.tsnames the invariant the slice violated; both bundle fields use it. - The poll became a 30 s deadline. When it expires the row says “Still analyzing, but the sidebar stopped checking” and offers a Check again button, instead of leaving “Analyzing…” as a permanent lie.
Then a second, unrelated report: the wider chat column and the message formatting only applied after a page refresh. Cause was in manifest.json: four content scripts were matched to *://janitorai.com/chats*. JanitorAI is a single-page app, so opening a chat from anywhere else on the site is a pushState and the browser never re-injects. All four already guard on hostname and observe the body, so broadening the match to the whole site was the entire fix. A new src/content-scripts/injection.test.ts holds the line, and it went red on exactly those four before the change.
977 unit tests and 303 Playwright tests green; dist rebuilt.
Then a type-strictness pass, prompted by asking what Parse, Don’t Validate would say about the code I had just written. Four findings, all implemented:
AnalysisResultdiscarded what its parser knew. The service always returned{character: [content.trim()], plot: [], categories: []}, and the type saidstring[]three times over. One reader existed, doingresult?.character[0] ?? "No analysis available"— a fallback for a state the producer cannot emit. Now{summary: string}.plotandcategorieswere read by nothing.OpenRouterQueryallowed states that cannot happen.statusbeside optionalresultanderror. Now a discriminated union. That deleted both{...query}snapshots (they existed only becauserunAnalysismutated its caller’s object), thedelete query.result; delete query.errorreset, and all three??fallbacks. ESLint’sno-unnecessary-conditionflagged the last one itself, which is the union proving its own worth.- Every id was
string. BrandedCaptureId/QueryId, declared global from the module that parses them so the ambient types can use them without an import. Production churn was five sites, far below my estimate, because ids nearly always arrive through a parser already. The best catch:v.is(AnalysisUpdated, msg)stopped compiling, because valibot’sisnarrows to a schema’s input — where the id is still unbranded.safeParseis what hands back a realQueryId. The distinction enforced itself. stalledQueryIdswas denormalized state I had added that morning. Now resolved into anAnalysisViewunion (completed/error/running/abandoned) before it reaches the component. “Check again under a finished analysis” went from untested to unrepresentable. The DOM test lost itsunfinished()helper, which had existed only to dropresultunderexactOptionalPropertyTypes.
Old queries are dropped rather than upgraded, by choice: the storage variant rejects them and listOf already filters per record. Every stored completed analysis goes, since all of them carry the old result shape.
And then capture.analysis was deleted outright. It was declared on both capture types, carried through the GET_CAPTURES schema, and written in four places — and read by nothing, anywhere. A hand-synced duplicate of the query lifecycle. It is also what showed {7a98f71b, pending} in the morning’s bundle while the query store disagreed, so the field that cost time in the investigation was dead weight the whole time.
992 unit tests and 303 Playwright green.
What was striking
The capture was enough to rebuild the failure rather than guess at it. The decisive comparison was between two things inside one file: what the background store said at capture time versus what the DOM had rendered a minute earlier. No screenshot, no console paste, no reproduction.
And the diagnostic tooling had a bug that would have hidden exactly this class of failure forever — a “recent” list that shipped the oldest entries. Worth remembering that a diagnostic that cannot see live state is worse than none, because it reads as evidence of absence.
The /chats* match pattern is the same shape of mistake in a different register: a rule that is correct about where the feature belongs and wrong about when the browser evaluates it.
The strictness pass kept finding the same bug in different clothes. Every one of the four was a second copy of something: a result shape wider than any value it holds, a status beside fields that restate it, an id whose meaning lives in the variable name, a stalled-list that only means anything cross-referenced against another list. And each had a compensating check somewhere downstream — a ??, a status === "pending" guard, a comment saying “narrowed to error already”. King’s line is that validation discards information, but the sharper version I take from today is that the discarded information does not vanish, it reappears as a check further downstream. Finding the checks is how you find the lost information. The whole audit was: grep for ?? and for comments that explain why something is safe.
Two things I would not have predicted. Branding ids cost five production sites, not the fifty I estimated, because a codebase that parses at its boundaries already funnels values through few construction points — the earlier discipline paid for this one. And v.is versus v.safeParse is the principle in miniature inside the library itself: one answers whether, the other hands back the refined value, and I had been reaching for the validator out of habit.
Top 3 next
- Verify both fixes in the live PWA — start a slow analysis and watch it land without a reload, then navigate into a chat from the home page and check the wider column applies
- Consider whether the 30 s deadline is still the right number now that the push path exists
- Commit; the tree is still dirty (43 files now)
- Decide whether the whole analysis history is worth an upgrade path rather than a drop — one line in
schemas/analysis.tsmapscharacter[0] → summaryif so, and it has to land before the extension is next reloaded
Related
- OpenRouter Interceptor
- Parse, Don’t Validate — the audit’s lens; see also Make Illegal States Unrepresentable
- Parse, Don’t Validate - Alexis King — heuristic 5 (“avoid denormalized mutable state”) is what condemned both
stalledQueryIdsandcapture.analysis - 2026-08-09 OpenRouter Interceptor DeepSeek Flash Alias and a Stale Build — same model, and the stale-
disttrap that applies to verifying today’s build - 2026-08-17 JAI PWA Profile Missing Root Cause — the PWA these content scripts run in