fix(accounting): carry the background review's tokens across the persistence boundary - #83866
fix(accounting): carry the background review's tokens across the persistence boundary#83866gtyler wants to merge 1 commit into
Conversation
|
Pushed a second commit — the first one was necessary but not sufficient, and the way it failed is worth recording. With only
That combination is why this was invisible for weeks: the writes failed on every run, loudly enough to log and quietly enough that nobody saw it. The second commit:
Diagnosed by temporarily raising that log on a live deployment and reading one cron pass: Anyone else closing per-run state at turn end has the same race — that is the general form of this bug, and Verification on a live cron is still in progress; I will follow up with the post-fix numbers rather than leave this claiming a result it has not shown. |
|
This was generated by AI during triage. Summary: Problems:
Solution: Evidenceno deterministic fact backs this claim — model belief, not executed or read evidence Checked against |
|
Verified on a live cron, as promised above — first pass with both commits loaded:
Same cron, eight hours apart. Downstream consumers agree too: the three run rows the pass writes sum to $0.69394 on 3,636,830 tokens — exactly Over 9 days of the same workload the writes that were being dropped came to $2.56 of $23.11 (−11.09%), 54 of 56 passes affected, 257 of 1,740 calls never recorded. One note for reviewers on the second commit's ordering: the cron runner now waits before |
…istence boundary The background memory/skill review forks an AIAgent that shares the parent's session_id for prompt-cache warmth, and is deliberately persistence-isolated (_persist_disabled, _session_db = None) so its harness turn can never land in the user's real session — the curator-takeover bug. That isolation is right for MESSAGES and wrong for COUNTERS. The provider bills the review's calls and the observability backend files them under that same session id, so leaving them out of state.db makes the session's own cost read low. Measured against OpenAI's per-call Responses usage as ground truth on a scheduled workload: the review is the trailing 4-9 API calls of every pass and 10-15% of a gpt-5.4-mini pass's real cost, 27-29% of a claude-sonnet-4-6 one. Over 9 days: 11.09% under, 54 of 56 passes affected, 257 of 1,740 calls never recorded. Anything reading state.db for cost — dashboards, reconcilers, /insights, the agent's own end-of-run line — was low by that much. _token_accounting_db / _token_accounting_session_id is a narrow channel for counters only. conversation_loop accounts there when set, and only the OWNER of a store may create its row, so a borrowed store is written to but never created on. _persist_disabled still hard-stops every message and lazy-open path, so the isolation is untouched — tests/test_background_review_session_isolation.py passes unchanged, and the new tests assert the isolation attributes alongside the accounting ones so the two cannot drift apart silently. Second half of the problem: the cron runner closes the shared SessionDB in its finally block right after end_session(), while the review — spawned AT turn end — is still writing for another 40-100s. close() set _conn = None underneath it and every write died on 'NoneType' object has no attribute 'execute'. Caught per-call and logged at debug, so the only visible symptom was the under-count. - _spawn_background_review keeps the thread handle; wait_for_background_review() joins it with a bound so a wedged review cannot hang a scheduler. _background_review_agent (cross-turn cancellation) can interrupt a review but gives no way to join one, and cancelling is wrong where its writes are wanted. - The cron runner waits before titling/ending/closing, so ended_at covers the whole pass and close() — which drains the token queue — is the last write. - The token-persistence failure log is WARNING, not debug. Its own comment said 'silent loss here is the root cause of undercounted analytics' — right about the consequence, wrong about the level. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
04f2924 to
adf6a59
Compare
|
Force-pushed a rewrite, and the earlier version of this PR was wrong — flagging that explicitly rather than letting it disappear into the diff. It gave the review fork the parent's The cause was mundane and worth naming: I branched from a The rewrite keeps the isolation completely intact and carries only counters across it, via Also worth stating plainly about the "verified on a live cron" numbers in my earlier comment: that deployment runs an older build with no The second half of the fix (thread handle, waiting before the cron tears down the store, and promoting that swallowed One note on how it composes with |
fix(accounting): carry the background review's tokens across the persistence boundary
|
The bug
The background memory/skill review forks an
AIAgentthat shares the parent'ssession_idfor prompt-cache warmth, and is deliberately persistence-isolated:That is right for messages — writing the harness turn into the user's real session is exactly the bug that isolation exists to stop.
It is wrong for counters. The provider bills the review's calls and the observability backend files them under that same
session_id, so leaving them out ofstate.dbmakes the session's own cost read low. Nothing errors; the number is just quietly short.How short
Measured per call against OpenAI's own
GET /v1/responses/{id}usage — ground truth, not an estimate — on a scheduled workload. The review is the trailing 4-9 API calls of every pass, and the session row came up short by exactly those calls: fresh input, cached input, output and reasoning all four matching to the token.api_call_countgpt-5.4-minigpt-5.4-miniclaude-sonnet-4-6claude-sonnet-4-6Over 9 days on one workload: −11.09%, 54 of 56 passes affected, 257 of 1,740 calls never recorded. The share is largest where the foreground turn is short, because the review re-sends the full conversation — each of its calls re-bills the whole prompt.
The fix — counters only, across the boundary
_token_accounting_db/_token_accounting_session_id: a narrow channel set on the fork in addition to the isolation, never instead of it.conversation_loopaccounts there when set, and only the owner of a store may create its row (_acct_owned) — a borrowed store is written to, never created on._persist_disabledstill hard-stops_flush_messages_to_session_db,_ensure_db_sessionand_get_session_db_for_recall, so no message path is re-armed.tests/test_background_review_session_isolation.pypasses unchanged, and the new tests assert the isolation attributes right next to the accounting ones so the two cannot drift apart silently.Second half: the store is closed while the review is still writing
Even with the channel wired, the writes died — on a deployment they surfaced as:
cron/scheduler.pycloses the sharedSessionDBin itsfinally, right afterend_session(). The review is spawned when the turn ends and keeps writing for another 40-100s, soclose()sets_conn = Noneunderneath a live thread. Caught per-call and logged atdebug— invisible, so the only symptom was the under-count._spawn_background_reviewkeeps the thread handle;wait_for_background_review()joins it with a bound so a wedged review cannot hang a scheduler._background_review_agentcan interrupt a review but gives no way to join one, and cancelling is the wrong move where its writes are wanted — the two compose: cancel for live sessions, wait for batch ones.ended_atcovers the whole pass andclose()— which drains the token queue — is genuinely the last write. A timed-out wait warns and proceeds.logger.debug("Token persistence failed…")→logger.warning. Its own comment already said "silent loss here is the root cause of undercounted analytics" — right about the consequence, wrong about the level.Verified on a live cron
api_call_countstate.dbtokensSame cron, eight hours apart. Downstream agrees too: the three rows the pass writes sum to $0.69394 on 3,636,830 tokens — exactly
state.db.(That deployment runs an older build without
_persist_disabled, so the shape verified there is the earlier one. The redesign here is verified by test, including upstream's own isolation suite; the measurements and the closed-store failure mode are unchanged by the reshaping.)Tests
tests/run_agent/test_background_review_token_accounting.py— the fork accounts on the parent's store and session id (read off a real fork, not asserted against source text); the channel is not a back door into the message log; the handle exists and the wait is bounded. Each asserts the isolation attributes alongside the accounting ones.Green: my tests +
tests/test_background_review_session_isolation.py+tests/run_agent/test_background_review.py= 19 passed.tests/cronfailure set byte-identical tomain(6 pre-existing, 581 passed).On the broad sweep (
tests/run_agent+tests/agent+hermes_state,-p no:randomly) this venv has a lot of pre-existing environmental noise, so the number alone is meaningless — what matters is the comparison against a pristinemainworktree with the identical command:mainZero failures unique to this branch; the two extras on
mainare concurrency/timing flakes.Note for other owners of per-run state
Anything that disposes of shared state at turn end has this race, not just cron — the review outlives the turn by design.
wait_for_background_review()is the handle for it.🤖 Generated with Claude Code