fix(mental-models): last_refreshed_at records the refresh, not the source watermark - #3538
Merged
Merged
Conversation
…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
force-pushed
the
fix/mental-model-last-refreshed-at
branch
from
August 17, 2026 09:44
0113ced to
556d876
Compare
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
force-pushed
the
fix/mental-model-last-refreshed-at
branch
from
August 17, 2026 10:36
4f265e1 to
556d876
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
POST /banks/{bank}/mental-models/{id}/refreshruns, writes new content, reportscompleted— and leaveslast_refreshed_atuntouched. 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_modelpersisted the refresh's source-data watermark intolast_refreshed_at, and_mental_model_processed_watermarkclamps that watermark so it never regresses: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:
last_refreshed_atlast_memory_seen_atThe 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_staleall 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_atdeliberately mirrorslast_memory_write_atonGET /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_atis backfilled fromlast_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 consumersCOALESCEback tolast_refreshed_atfor 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.tsximplemented the documented "comparelast_refreshed_atagainstlast_memory_write_at" rule. Left alone it would have reported every recently-refreshed model as current — a false-fresh freshness card — so it now readslast_memory_seen_at.Compatibility — read before releasing
Schema: additive.
last_memory_seen_atis a new optional field. Nothing removed, no type changed,check-openapi-compatibilitypasses, 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 — comparelast_refreshed_atagainstlast_memory_write_at— will start reading models as up to date that aren't, until it switches tolast_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:
last_refreshed_atmeantNOW())#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
last_refreshed_atmust advance anyway. Fails onmain.last_refreshed_atmust not mask a memory the document has never seen — staleness still keys off what was seen.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_modeloperations returndocument_id: nulland 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 noresult_metadataat all. So the list gainsmental_model_id, surfaced the same waydocument_idandfilenamealready are. The single-operation read already returnsresult_metadataand gets no duplicate field.Still not in this PR
totalonGET /banks/{bank}/mental-models. The envelope is{"items": [...]}withlimitdefaulting 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