Usage instrumentation + externalize chat session state to Postgres - #50
Merged
Conversation
Every keep-vs-cut decision on the roadmap so far has been a guess. Adds an append-only usage_events table + record_usage() helper, logged from every agent tool call (agent/main.py's _run_tools) and every UI-only mutation that bypasses the agent entirely (expense/income edit/delete, CSV export, budget set/delete in api/server.py). Chat requests now carry an optional `source` tag (chip:<label>, command:<cmd>, or omitted for freeform typing) threaded from Chat.jsx, so a suggestion-chip click is distinguishable from someone typing the same words — the two are identical server-side otherwise. No third-party analytics vendor: this is household financial data, so the log stays in our own Postgres, records only structural event names/ sources, and never message content or expense/income descriptions. scripts/usage_report.py gives a simple breakdown for periodic review instead of building a dashboard UI nobody's asked for yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qz6JBNFwhgem5GY3i2BEMY
Chat history lived in an in-process dict keyed by user_id, so every redeploy or restart silently dropped every in-progress conversation — users would just see the agent "forget" everything with no error. Adds a chat_sessions table (one row per user, messages as JSONB) and load/save/clear functions in agent/db.py; chat()/stream_chat() now load at the start of a turn and save after every mutation point, so a crash mid-turn is recoverable the same way _repair_dangling_tool_use already recovers in-process failures — now across restarts too, which was the actual point of this change. Two things fell out of doing this properly rather than as a drop-in storage swap: - Assistant turns previously stored live Anthropic SDK response objects directly. Persisting requires plain dicts, so content blocks are now normalized via a duck-typed _serialize_block() at append time. That also exposed a latent bug in _repair_dangling_tool_use: its type check used getattr(), which only ever worked because in-memory sessions were never reloaded from a serialized form. Fixed to dict-style access. - A naive load-then-save round trip can lose messages if the same user fires two overlapping requests (double-tap, two tabs) — today's in-memory dict can't, since both requests share the same list object. Added a per-user threading.Lock around each turn to close the same-instance case, which is the actual deployment; true cross-instance locking would need a DB-level lock and isn't worth it before this scales past a household. tests/test_agent.py's session-corruption tests previously poked main._sessions directly and used made-up user_ids; chat_sessions has a real FK to users like every other user-scoped table, so those are migrated to a new user_id_factory fixture and to seeding state via db.save_chat_session instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qz6JBNFwhgem5GY3i2BEMY
derekl-beep
pushed a commit
that referenced
this pull request
Jul 16, 2026
main moved forward with PRs #50 (usage instrumentation + session persistence) and #51 (proactive nudges) since this branch was cut. Conflicts were both cases of two features adding adjacent schema/config blocks — resolved by keeping both sides, not choosing one: - agent/db.py: usage_events + chat_sessions table creation (from main) alongside savings_goals (from this branch). - tests/conftest.py and scripts/seed_e2e_data.py: TRUNCATE lists needed every new table name from both sides, not just one. No logic changes beyond the merge itself.
derekl-beep
pushed a commit
that referenced
this pull request
Jul 16, 2026
main moved forward with PRs #50 (usage instrumentation + session persistence) and #51 (proactive nudges) since this branch was cut. The one real conflict was tests/test_agent.py, where both this branch (a Sentry capture_exception test) and main (serialize_block/session_lock tests) added new tests at the same location — kept both, no logic changes beyond the merge itself.
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.
Summary
Two related backend infra changes, from the "Now" tier of a prioritized team review of the app (instrumentation + session durability were flagged as the two highest-value/lowest-risk items to unblock everything else).
Usage instrumentation
usage_eventstable +record_usage()helper — every tool call gets logged (_run_tools), plus every chat/UI mutation that bypasses the agent entirely (expense/income edit/delete, CSV export, budget set/delete).sourcetag (chip:<label>,command:<cmd>, or omitted for freeform typing) threaded fromChat.jsx, so a suggestion-chip click is distinguishable from someone typing the same words.scripts/usage_report.pygives a simple breakdown for periodic review instead of building a dashboard UI nobody's asked for yet.Session state externalization
user_id, so every redeploy/restart silently dropped every in-progress conversation. Adds achat_sessionstable (JSONB messages, one row per user);chat()/stream_chat()load at the start of a turn and save after every mutation point, so a crash mid-turn is recoverable across restarts the same way_repair_dangling_tool_usealready recovered in-process failures._serialize_block()at append time — this also exposed a latent bug in_repair_dangling_tool_use's type check (getattr()-based, which only ever worked because in-memory sessions were never reloaded from a serialized form). Fixed to dict-style access.threading.Lockaround each turn so two overlapping requests from the same user (double-tap, two tabs) can't silently clobber each other's saved history — this only covers same-instance concurrency (the actual deployment today); true cross-instance locking is a scale problem for later.tests/test_agent.py's session-corruption tests previously pokedmain._sessionsdirectly and used made-upuser_ids;chat_sessionshas a real FK touserslike every other user-scoped table, so those are migrated to a newuser_id_factoryfixture.Test plan
uv run pytest tests/— 180 passeduv run ruff check .— cleannpm run build/npm run lint— cleannpx playwright test— 78 passed, mobile + desktop (chat-loop internals changed, so the whole suite was run, not just targeted specs)🤖 Generated with Claude Code
https://claude.ai/code/session_01Qz6JBNFwhgem5GY3i2BEMY
Generated by Claude Code