Skip to content

fix(recorder): never serve a task projection older than the store it is built from - #306

Open
FZ2000 wants to merge 1 commit into
mainfrom
fix/task-projection-change-key
Open

FZ2000 wants to merge 1 commit into
mainfrom
fix/task-projection-change-key

Conversation

@FZ2000

@FZ2000 FZ2000 commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

Problem

The /v1 task projection backs the Tasks list, every receipt, attention and the task timeline. It was cached on the events fingerprint alone, with a 30-second expiry. It is built from more than events, so the app could show an answer older than the store:

  • The continuation store. User-confirmed groupings decide which sessions form one Task. A link, unlink or rename records no ledger event, and nothing invalidated the cache — so after grouping two chats, the app kept listing them as two Tasks for up to 30 seconds.
  • The cost, run and Evidence stores, through the shared ledger. The ledger itself rebuilds for those at once (its key folds in their signature, see _ledger_secondary_signature); the projection assembled over it did not. The old comment accepted this as "can lag up to ~60s".

Reproduced on main by the new test test_a_continuation_link_is_visible_to_the_very_next_reader: after a link, /v1/tasks answers total == 2 where the store says 1.

Fix

src/agentacct/api.py

  • The projection is keyed on everything it is built from: the shared ledger's own change key (events + cost/run/Evidence signatures) plus the continuation store's (size, mtime) signature (actions.jsonl is its only persisted state, append-only). The same ledger key is passed down, so the projection and the ledger it is assembled over stay in lockstep within a request.
  • The 30-second bound stays, for one reason only: one input is the wall clock. The weekly-plan shares are calibrated over a window that ends "now", so an unchanged store still has to be recomputed as time passes. It is no longer a staleness allowance for store changes.
  • The background warmer (perf(recorder): rebuild the work projections in the background when the store changes #305) builds this projection too, passing for_reader=False so its own build cannot keep it awake, and its change token now covers the continuation store.

I audited the projection's inputs before changing the key: events, the ledger (and its stashed mechanical checks), cost events and the continuation projection. It does not read ingestion health; the task-identity codec and CSRF token are constant for the process; task_projection, task_outcome and receipt do not read the clock.

Measured

Copy of a live 12,000-event store, real warmer thread, a receipt opened three seconds after a write:

per open (ms) median
main (with #305) 230 · 257 · 384 · 638 · 301 · 247 279 ms
this PR 17 · 21 · 20 · 22 · 22 · 17 20 ms

Tests

tests/test_task_projection_cache.py, 6 tests. Four fail on main:

test on main
a continuation link is visible to the very next reader fails: assert 2 == 1
a secondary-store change rebuilds the projection at once fails: not rebuilt
the warmer builds the projection and is not a reader fails: not built
a continuation change is a change the warmer rebuilds fails: not noticed
an unchanged store reuses the projection across routes passes (preserved)
the wall-clock bound still recomputes on an unchanged store passes (preserved)

Validation

  • pytest tests/ -n 4 --dist loadfile -p no:randomly: 4,502 passed, 1 failed. The one failure, test_display_alignment.py::test_real_titles_are_worth_showing_in_full, also fails on unmodified main on this machine: it measures this Mac's real recorded task titles (19.6% exceed the card budget against a 15% limit) and does not touch this code.
  • No Swift changed. No secrets, no real data in fixtures, no paid calls.

Not addressed

  • When the 30-second wall-clock bound lapses on an unchanged store, the next reader still pays the ~215 ms reassembly, as it does today. Separating the clock-dependent plan-share stamp from the heavy projection would remove that; it is a separate change.

🤖 Generated with Claude Code

…is built from

The /v1 task projection (the Tasks list, every receipt, attention and the
task timeline) was cached on the events fingerprint alone, with a 30 s
expiry. It is built from more than events:

- the continuation store, whose user-confirmed groupings decide which
  sessions form one Task. A link, unlink or rename records no ledger event,
  and nothing invalidated the cache, so the app kept showing the old
  grouping for up to 30 s;
- the cost, run and Evidence stores, through the shared ledger. The ledger
  itself rebuilds for those at once (its key folds in their signature); the
  projection assembled over it did not.

Key the projection on everything it is built from: the shared ledger's own
change key plus the continuation store's signature. The 30 s bound stays,
now only for the one input that is the wall clock: the weekly-plan shares
are calibrated over a window ending "now".

The background warmer builds this projection too (without counting as a
reader), and its change token covers the continuation store. On a copy of a
12,000-event store, a receipt opened three seconds after a write went from
279 ms median to 20 ms.

tests/test_task_projection_cache.py: four of its six tests fail on main,
including "a continuation link is visible to the very next reader"
(main answers 2 Tasks where the store says 1). The other two guard what is
preserved: an unchanged store is reused across routes, and the wall-clock
bound still recomputes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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