Skip to content

fix(mental-models): last_refreshed_at records the refresh, not the source watermark - #3538

Merged
nicoloboschi merged 3 commits into
mainfrom
fix/mental-model-last-refreshed-at
Aug 17, 2026
Merged

fix(mental-models): last_refreshed_at records the refresh, not the source watermark#3538
nicoloboschi merged 3 commits into
mainfrom
fix/mental-model-last-refreshed-at

Conversation

@nicoloboschi

@nicoloboschi nicoloboschi commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

The bug

POST /banks/{bank}/mental-models/{id}/refresh runs, writes new content, reports completed — and leaves last_refreshed_at untouched. A reporter drove ~6,000 refreshes/day against an intended ~350, for four days, because their scheduler reads that field to answer "have I already refreshed this?" and it never moved.

update_mental_model persisted the refresh's source-data watermark into last_refreshed_at, and _mental_model_processed_watermark clamps that watermark so it never regresses:

return max(newest_in_scope, current_last_refreshed_at)

On a model whose scope gained no new memories, newest_in_scope <= current, so the max is the value already in the column. The refresh wrote it straight back over itself. The document changed; the timestamp did not.

That also explains why it looked non-deterministic ("not universal — some models do get a new last_refreshed_at, in the same bank, in the same window"): models whose scope was being written to had a genuinely newer watermark, so theirs advanced. Nothing to do with consolidation or cron — those were correctly ruled out.

Introduced by #2878, which needed a data watermark and reused the wall-clock column for it.

The fix

One column cannot answer both "did a refresh run?" and "is this behind the data?". Split them:

Field Means Answers
last_refreshed_at Wall-clock time a refresh completed. Meaning restored — the name never changed. "Have I already refreshed this?"
last_memory_seen_at New. Newest in-scope memory the last refresh saw. "Is this behind the data?"

The new column takes over the existing value and the existing computation, unchanged. Staleness, the delta window, the knowledge-tree flag and the reflect agent's is_stale all key off it, so refresh behaviour is identical — this is not a change to when models refresh, only to what the timestamps report.

last_memory_seen_at deliberately mirrors last_memory_write_at on GET /stats, so the cheap whole-list rule reads as a sentence: last_memory_seen_at >= last_memory_write_at → up to date.

A completed refresh always stamps the clock

Including a delta refresh that read the scope, found nothing new, and preserved the content — it ran, and a caller polling for it must see that. This matters for exactly the reported workload: a model with static sources is the one whose refresh has nothing to write. A refresh that failed still stamps neither, so a retry re-reads the same window.

Migration

last_memory_seen_at is backfilled from last_refreshed_at — which today already holds the watermark, so the copy is lossless. No bank changes staleness on deploy: nothing mass-refreshes, nothing mass-stops. The column is nullable and consumers COALESCE back to last_refreshed_at for rows no refresh has stamped yet, so a restored pre-migration backup behaves correctly too. PG + Oracle, both re-runnable.

Control plane

bank-stats-view.tsx implemented the documented "compare last_refreshed_at against last_memory_write_at" rule. Left alone it would have reported every recently-refreshed model as current — a false-fresh freshness card — so it now reads last_memory_seen_at.

Compatibility — read before releasing

Schema: additive. last_memory_seen_at is a new optional field. Nothing removed, no type changed, check-openapi-compatibility passes, old clients ignore it.

Semantics: last_refreshed_at's value changes. This is a deliberate break and needs a note in the release, not a silent ship. A client following the list heuristic documented in v0.9.0 — compare last_refreshed_at against last_memory_write_at — will start reading models as up to date that aren't, until it switches to last_memory_seen_at. False-fresh is the direction that fails quietly, so it should be called out by name.

What makes it the right call rather than a new break:

Period last_refreshed_at meant
inception → 2026-07-21 wall clock (NOW())
v0.8.5 → v0.9.1 (4 weeks, 4 releases) the source watermark ← #2866 / #2878
this PR wall clock again

#2878 needed a data watermark and reused the wall-clock column for it without renaming — that was the break. This reverts it and gives the watermark its own column. The documented compare-rule only landed 2026-08-04 (#3156), so it has been correct for exactly two releases.

The upgrade for anyone affected is one field name, and the replacement ships in the same response.

Tests

  • The reported bug, reproduced: two refreshes over a scope that gained no memories. The watermark is identical both times (correct — the data didn't move); last_refreshed_at must advance anyway. Fails on main.
  • A refresh that preserves content stamps the clock; a failed one stamps neither.
  • The inverse regression: a recent last_refreshed_at must not mask a memory the document has never seen — staleness still keys off what was seen.
  • Updated the delta watermark tests to assert against the column that now carries the watermark.

746 tests pass across the mental-model, delta, dry-run, scheduled-refresh, knowledge-page, consolidation and reflect suites.

Also from the report: which model did this operation refresh?

refresh_mental_model operations return document_id: null and carried no model identifier, so the operations log could not say which model an operation acted on. On a bank with 270 models that makes the log useless for diagnosis — which is why this took a live controlled experiment instead of one query.

The id was always there, in result_metadata — but the operations list exposes no result_metadata at all. So the list gains mental_model_id, surfaced the same way document_id and filename already are. The single-operation read already returns result_metadata and gets no duplicate field.

Still not in this PR

total on GET /banks/{bank}/mental-models. The envelope is {"items": [...]} with limit defaulting to 100 and no count, so a consumer cannot tell "100 models" from "page 1 of 3". One of their clients read 100 of 270 and treated the other 170 as deleted; our own dashboard shows "100 mental models" for that bank for the same reason. Worth its own issue — it changes a widely-consumed envelope.

🤖 Generated with Claude Code

…termark

A refresh persisted its source-data watermark into last_refreshed_at, and that
watermark is clamped so it never regresses. On a model whose scope gained no new
memories the watermark is the value already in the column, so the refresh wrote
it straight back over itself: the document was rewritten, the timestamp never
moved, and a client asking "have I already refreshed this?" refreshed it again on
every tick. One reporter drove ~6,000 refreshes/day against an intended ~350 for
four days before tracing it. It looked non-deterministic because models whose
scope *was* being written to advanced normally.

Split the two meanings the column carried:

- last_memory_seen_at (new) takes over the watermark — the newest in-scope memory
  the last refresh saw. Staleness, the delta window, the knowledge-tree flag and
  the reflect agent's is_stale all key off it, so refresh behaviour is unchanged.
- last_refreshed_at goes back to being what its name says: wall-clock, stamped on
  every refresh that completes. Including one that read the scope, found nothing
  new and preserved the content — it ran, and a caller polling for it must see
  that. A refresh that *failed* still leaves both alone, so a retry re-reads the
  same window.

The migration backfills the new column from last_refreshed_at, which today holds
the watermark, so the copy is lossless and no bank changes staleness on deploy;
consumers COALESCE back to last_refreshed_at for rows not yet stamped.

Also fixes the control plane's freshness card, which implemented the documented
"compare last_refreshed_at against last_memory_write_at" rule and would otherwise
have reported every recently-refreshed model as current.

Reported against bank madrona on api.hindsight.vectorize.io.
Output of ./scripts/generate-clients.sh, not a deliberate dependency change:
testify published 1.12.0 and the generator's go mod tidy now resolves to it,
which drops the transitive requires it no longer needs. verify-generated-files
regenerates and diffs, so the committed files have to match what the generator
produces today.
refresh_mental_model operations return document_id: null and carried no model
identifier, so the operations log could not say which model an operation
refreshed. On a bank with hundreds of models that makes the log useless for
diagnosis — the reporter of the frozen-timestamp bug had to run a live controlled
experiment instead of one query.

The id was always there, in result_metadata, but the operations *list* exposes no
result_metadata at all. So the list gains mental_model_id alongside document_id
and filename, which are surfaced the same way. The single-operation read already
returns result_metadata and needs no second name for the same value.
@nicoloboschi
nicoloboschi force-pushed the fix/mental-model-last-refreshed-at branch from 0113ced to 556d876 Compare August 17, 2026 09:44
nicoloboschi added a commit that referenced this pull request Aug 17, 2026
`verify-generated-files` regenerates the Go client and fails on any PR whose
tree differs from the result. `go mod tidy` now resolves testify to v1.12.0
(released upstream), which also drops go-spew and go-difflib from the indirect
set, so main's committed go.mod/go.sum are stale and every PR trips the job.

Carried here only to unblock CI; it is the generator's own output, not a
hand-edit, and is identical to the same re-sync in #3538.
nicoloboschi added a commit that referenced this pull request Aug 17, 2026
…ueries (#3539)

* fix(api): batch the retention sweep so it stops stalling foreground queries

The hourly retention sweep issued one unbounded
`DELETE FROM <schema>.<table> WHERE started_at < cutoff` per tenant schema.
The maintenance loop runs in every API/worker process with no leader
election, so every pod issued it on the same hourly boundary: two
concurrent 330s+ deletes on `llm_requests` pinned on IO.DataFileRead,
blocking each other on row locks, saturating RDS I/O and inflating recall
from ~0.6s to ~1.8s.

Rather than elect a single sweeper, design the collision out. Deletes now
run in bounded chunks (2000 rows, oldest first off the `started_at` index,
each its own short transaction, 250ms apart) and each chunk claims its rows
with `FOR UPDATE SKIP LOCKED`. Concurrent sweepers therefore take disjoint
chunks instead of waiting on each other, the total work stays the number of
expired rows however many pods join in, and no statement holds row locks for
more than one batch. A per-run chunk ceiling keeps a table that fills faster
than it drains from looping forever; the next tick continues where it left
off.

Deliberately no advisory lock and no leader election — Hindsight runs behind
connection poolers where advisory locks are unreliable.

* chore(go-client): re-sync go.mod/go.sum after testify 1.12.0

`verify-generated-files` regenerates the Go client and fails on any PR whose
tree differs from the result. `go mod tidy` now resolves testify to v1.12.0
(released upstream), which also drops go-spew and go-difflib from the indirect
set, so main's committed go.mod/go.sum are stale and every PR trips the job.

Carried here only to unblock CI; it is the generator's own output, not a
hand-edit, and is identical to the same re-sync in #3538.
@nicoloboschi
nicoloboschi force-pushed the fix/mental-model-last-refreshed-at branch from 4f265e1 to 556d876 Compare August 17, 2026 10:36
@nicoloboschi
nicoloboschi merged commit 8fbdc6b into main Aug 17, 2026
321 of 324 checks passed
JoshFunnell added a commit to JoshFunnell/hindsight that referenced this pull request Aug 18, 2026
… refresh after exact-cap drain

TableBlock (and any later block type) no longer raises TypeError from
_block_content_text during apply_operations; the op is skipped instead
of aborting the refresh.

delta_ops_all_skipped is a deterministic skip at temperature 0, so the
worker marks the op failed instead of retrying the same rejected ops.
The watermark is still unadvanced, so the next scheduled refresh rereads
the window. The fast path still hands all-skipped back to the agentic
loop -- cheap-skipping there would drop facts that never landed.

A consolidation round that processes exactly max_memories_per_round and
empties the queue is treated as the final round so mental-model refresh
still fans out (vectorize-io#3411 leftover).

Tests adapted for last_memory_seen_at (vectorize-io#3538) and the vectorize-io#3424 delta-ops
operation label after merging current main.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant