Skip to content

fix(mental-models): last_refreshed_at reflects refresh time, not the source watermark - #3532

Open
benfrank241 wants to merge 1 commit into
mainfrom
fix/mental-model-refresh-timestamp
Open

fix(mental-models): last_refreshed_at reflects refresh time, not the source watermark#3532
benfrank241 wants to merge 1 commit into
mainfrom
fix/mental-model-refresh-timestamp

Conversation

@benfrank241

Copy link
Copy Markdown
Member

Problem

mental_models.last_refreshed_at served two purposes at once: the wall-clock time of the last refresh and the source-data watermark (the newest in-scope memory) that staleness keys off. When a model's source memories are static, the column never advances even though refreshes keep rewriting content — so any time-based scheduler that reads last_refreshed_at to decide "have I already refreshed this?" concludes it never happened and re-refreshes the model indefinitely.

This is a general correctness/fairness issue: under a workload where some models have static sources, those models get refreshed on every scheduler tick.

Fix

Split the two meanings:

  • Add last_refreshed_source_watermark (new nullable column) as the dedicated source-data watermark that staleness / "due for refresh" keys off.
  • Revert last_refreshed_at to a true wall-clock timestamp that advances on every content-writing refresh.
  • Backfill the new column from the current last_refreshed_at (which today holds the watermark), so staleness behaviour is unchanged across the migration. Consumers COALESCE(last_refreshed_source_watermark, last_refreshed_at) for rows not yet stamped.

Changes

  • update_mental_model: content path always stamps last_refreshed_at = NOW() and writes the watermark to the new column; the no-op (no new facts) path advances only the watermark.
  • compute_mental_model_is_stale, _may_need_refresh, _mental_model_processed_watermark, and delta created_after key off COALESCE(source_watermark, last_refreshed_at) — preserving "don't re-refresh when there's no new data".
  • Both fields exposed on MentalModelResponse; is_stale docs point list consumers at the watermark.
  • Alembic migration (PG + Oracle) adds + backfills the column.

Tests

  • last_refreshed_at advances on a content-writing refresh whose source watermark is unchanged (the reported bug).
  • Staleness still keys off the source watermark, so a recent last_refreshed_at does not mask a genuinely new memory (guards the inverse never-refresh regression).

🤖 Generated with Claude Code

…source watermark

mental_models.last_refreshed_at served two purposes: the wall-clock time of the
last refresh AND the source-data watermark (newest in-scope memory) that
staleness keys off. When a model's source memories are static, the column never
advanced even though refreshes kept rewriting content, so any time-based
scheduler reading last_refreshed_at to decide "already refreshed?" re-refreshed
the model indefinitely.

Split the two meanings: add last_refreshed_source_watermark as the dedicated
watermark that staleness/"due for refresh" keys off, and revert last_refreshed_at
to a true wall-clock timestamp that advances on every content-writing refresh.
The new column is backfilled from the current last_refreshed_at (which today
holds the watermark), so staleness is unchanged across the migration; consumers
COALESCE to last_refreshed_at for rows not yet stamped.

- update_mental_model: content path always stamps last_refreshed_at = NOW() and
  writes the watermark to the new column; the no-op path advances only the
  watermark.
- compute_mental_model_is_stale, _may_need_refresh, _mental_model_processed_watermark
  and delta created_after key off COALESCE(source_watermark, last_refreshed_at),
  preserving the "don't re-refresh when there's no new data" behaviour.
- Both fields exposed in MentalModelResponse; is_stale guidance points list
  consumers at the watermark.

Tests: last_refreshed_at advances on a content refresh with an unchanged
watermark; staleness still keys off the watermark (no never-refresh regression).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nicoloboschi

Copy link
Copy Markdown
Collaborator

Diagnosis here is right, and the column split is the right shape. I've opened #3538 with the same approach plus the consumer/CI work this needs, so closing this one out — the details, in case they're useful:

The Oracle name trap. last_refreshed_source_watermark doesn't end in _at, so it falls through to the DB_TYPE_VARCHAR branch of the RETURNING out-bind table in engine/db/oracle.py:703 — a TIMESTAMP WITH TIME ZONE fetched into a VARCHAR out-bind, in all three write paths (update_mental_model, clear_mental_model, _insert_pinned_mental_model), and _row_to_mental_model then calls .isoformat() on it. test-api-oracle is gated behind the oracle-tests label, so CI wouldn't have caught it. #3538 names the column last_memory_seen_at so it classifies itself.

The no-op path. _execute_mental_model_refresh returns content_preserved_no_new_facts and calls update_mental_model with no content, so it takes the branch this PR deliberately leaves last_refreshed_at alone in. That's the branch a static-source model in delta mode actually takes — which is the workload in the report — so the frozen timestamp survives for exactly those models. #3538 stamps the clock whenever a refresh completes (failed ones still stamp neither), which also fixes the control plane's refresh-completion polling: mental-models-view.tsx:196 and mental-model-detail-modal.tsx:776 watch last_refreshed_at for 120s and would otherwise show a false "refresh timed out" on a successful no-op refresh.

Control plane freshness. bank-stats-view.tsx:726 implements the documented "compare last_refreshed_at against last_memory_write_at" rule verbatim. Once last_refreshed_at becomes a wall clock it reports recently-refreshed models as current whether or not they've seen the data — false-fresh, the direction that fails quietly.

Generated files. Adding a field to MentalModelResponse drifts 6 files; verify-generated-files was red here for that reason (generate-openapi.sh, generate-clients.sh, generate-docs-skill.sh).

Thanks for chasing this down — the root cause read was the hard part.

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.

2 participants