GLOOK-51: stop caching a truncated projects generation as an empty success - #72
Conversation
…ccess
The Integrations team's Current Projects section was blank on every reload,
while smaller teams on the same report were fine.
The model had produced good output. A flat tokenLimit(1500) truncated it
mid-object — Integrations has 14 developers, so it clusters into the most
projects and the longest JSON — JSON.parse threw, the catch returned [], and
service.ts wrote that [] to the cache. Since the lazy top-up only re-runs when
the column is NULL, a single truncation blanked the section permanently. The
request timings show it: 14,037ms for the failing generation, then 12ms and 6ms
for every reload after.
Three defects, each necessary for the outcome:
- The budget was a flat 1500 regardless of team size. It now scales
(2000 + 400/member, capped at 8000). For comparison projects/insights.ts
runs at 12000+ for similar clustering work.
- finish_reason was never read here, so truncation was reported as a parse
error — which reads like a model or prompt problem rather than a budget
one, and sent the investigation to the wrong place. projects/insights.ts
and projects/untracked.ts already do this check; this module never got it.
- Worst of the three: a failure was indistinguishable from a legitimate
"this team has no projects", because both returned []. Unusable output now
raises TeamProjectsUnusableError, and both call sites in service.ts leave
the column NULL so the next load retries. A genuine empty result is still
cached, so the page does not pay for the LLM on every view.
PROMPT_VERSION is bumped to v5-budget. It is part of the cache key
(report_id + team_name + prompt_version), so every already-poisoned row is
invalidated on deploy and existing reports recover without DB surgery.
127 suites / 1250 tests pass; npm run build clean. The existing generator suite
had no coverage of the parse-error path at all, which is why this could regress
silently; the new suite pins truncation, unparseable-but-not-truncated, the
genuine-empty case staying cacheable, and the budget actually being requested.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI's Type-check step failed with TS1501: the `s` flag needs es2018. Replaced with an explicit [\s\S]* , which the pattern didn't need a flag for anyway. Worth recording why this reached CI: `npm run build` type-checks app code but not test files, and I had been treating a clean build as covering `tsc`. It does not. `npx tsc --noEmit` — the exact command CI runs — completes fine when nothing else is competing for memory; my earlier runs were OOM-killed while a container build was in flight, and I substituted the build instead of retrying it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Code review — 3 reviewers (Senior Dev, Sr. Architect, Smartling fullstack profile)The forward-looking half of this change is right: throwing instead of returning 🔴 Blocking — the
|
Review found the recovery half of the previous commit did not work: the teams
this ticket was filed for would have stayed blank after merge.
prompt_version is in the SELECT key but NOT in the table's unique key, which is
`(report_id, team_name)` only. So a bump makes the read miss and the code
regenerate, but the upsert lands on the same physical row. TeamPulseCard mounts
without withProjects, so projectsForDb is null, and
`COALESCE(VALUES(projects), projects)` then PRESERVES the poisoned '[]' while
stamping prompt_version = 'v5-budget'. The row now has a truthy projects, so
the lazy top-up guard never fires again: permanently blank, under a version
claiming to have fixed it. The same COALESCE also defeated the
`projectsForDb = null` half of the fix on any pre-existing row.
The upsert now takes the incoming value verbatim — including NULL — when the
version differs, and keeps COALESCE within a version so the documented
concurrency race is still protected. `VALUES(col)` is rewritten to
`excluded.col` by translateSQL, so it works on both drivers.
Also from review:
- Two comments of mine were factually wrong and are corrected, because these
comments are the incident record for the next reader. projects/insights.ts
and projects/untracked.ts do NOT do the same finish_reason check: insights
reads it only to classify a message after a parse has already failed, and
untracked logs it and continues into the parse. Neither refuses a truncated
response up front. And insights records 12000 as the value that TRUNCATED
for it, now running 32000 — so it argues the 8000 ceiling is conservative,
not that it is adequate. The comment says that now.
- The finish_reason branch was dead on Bedrock: the adapter never copied
stop_reason, so finishReason was always 'unknown' and a Bedrock truncation
fell through to the parse and got reported as unparseable — the exact
misdiagnosis this change claims to remove. Mapped in the adapter, which
also repairs the existing reads in insights.ts and untracked.ts. llm-mock
had the same gap.
- A third path still cached an unusable empty: if the model returns projects
but every one fails the team filter, out is [] and that was persisted as a
success. Following what insights.ts already settled for this exact tension,
it is served but NOT stored — no throw, so no paid retry loop.
generateTeamProjects now returns { projects, cacheable }.
- Both catch sites were untyped, so a TypeError in the enrichment loop or a
DB outage was classified as transient and retried on every card expand.
They now gate on `err instanceof TeamProjectsUnusableError` and let
anything else propagate to the route's 500, which also gives the exported
error type its only consumer.
- Nits: the budget is hoisted so the logged value is provably the requested
one; the impossible Math.max guard and the test defending it are gone; the
budget assertion is a literal 7600 rather than a comparison of the
implementation to itself; and the test mock now mirrors tokenLimit()'s real
provider-dependent shape instead of only max_tokens.
Tests: the review's point that nothing would catch the storage behaviour was
correct — every test targeted projects.ts. The new cache suite drives the REAL
SQLite driver, because a mocked DB would assert that null was passed and pass
while the row still ended up '[]'. It includes a negative control executing the
pre-fix SQL, which demonstrates the poison surviving alongside the new version
stamp.
128 suites / 1258 tests; tsc --noEmit and npm run build both clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review addressedThe blocking finding was correct and it was the worse kind of wrong: the PR claimed to fix GLOOK-51 and would have left every affected team blank, under a version stamp asserting it was fixed. Thank you for verifying it against the schema rather than taking the comment at face value. 🔴 The
|
Fixes GLOOK-51.
What you'd see
The Integrations team's Current Projects section was empty on every reload, while smaller teams on the same report were fine. No error state — just blank.
What actually happened
The model produced good output and we threw it away. From
app.log:Well-formed JSON, cut off mid-object. With
response_format: json_object, the only way to get invalid JSON is truncation — a flattokenLimit(1500)against a 14-developer team.And then it stuck.
JSON.parsethrew → the catch returned[]→service.tscached'[]'. The lazy top-up only re-runs when the column isNULL, so one truncation blanked the section permanently. The timings show it plainly:team=Integrations&withProjects=trueteam=Datatook 10,327 ms and succeeded, which is why only the biggest team was affected.Three defects, each necessary
2000 + 400/member, capped at 8000.projects/insights.tsruns at 12000+ for comparable clustering.finish_reasonnever read here. Truncation was reported as a parse error, which reads like a model/prompt problem rather than a budget one — it sent me looking in the wrong place first.projects/insights.ts:260andprojects/untracked.ts:297already do this check; this module never got it, andinsights.tseven carries a comment about hitting this exact truncation at a previous limit.[]. Unusable output now raisesTeamProjectsUnusableError, and both call sites inservice.tsleave the columnNULLso the next load retries. A genuine empty result is still cached, so the page doesn't pay for the LLM on every view.Recovering existing reports
PROMPT_VERSION→v5-budget. It's part of the cache key (report_id + team_name + prompt_version), so every already-poisoned row is invalidated on deploy. No DB surgery, and it fixes every affected team on every past report, not just Integrations.Cost note: the bump also invalidates healthy pulse summaries, so the first view of each team after deploy regenerates. That's a one-off, and it's the price of not hand-editing rows.
Tests
127 suites / 1250 tests. The existing generator suite had no coverage of the parse-error path at all, which is how this could regress silently. New suite pins: truncation raises and says so; unparseable-but-not-truncated raises differently; the two are reported distinctly; a genuine empty stays a cacheable success; nothing-to-cluster short-circuits without an LLM call; and the scaled budget is actually what gets requested.
🤖 Generated with Claude Code