You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Split out of #741 rather than widening its PR (#750). Same root cause, different blast radius.
The problem
Each Agent builds its own session manager — SessionFactory.create_session_manager(...) at backend/src/agents/main_agent/session/base_agent.py:172. So when one session is served by two cached agents (an @-mention turn and the plain turns around it), there are two TurnBasedSessionManager instances, each with its own:
Both persist to the same DynamoDB session row. Neither knows the other exists, so update_after_turn from instance B can write a checkpoint derived from a message count instance A has since moved past — and vice versa on the next turn.
#750 fixed the conversation by aliasing the message list across instances. It deliberately did not touch this, because compaction state is not a list you can alias — it is derived per-turn from a message count and a persisted checkpoint.
Why it hasn't bitten yet
Compaction only engages past a token threshold, and @-mentions are days old. The threads that would trigger it are long ones, and nobody has had a long mention-heavy thread yet. That is timing, not safety.
Why it matters more than it looks
The truncation anchor is load-bearing for prompt-cache cost, not just correctness. CLAUDE.md's prompt-cache contract and the byte-stability redesign both hinge on the anchor being stable between compaction-state changes — a mis-restored or clobbered anchor moves the truncation boundary, which rewrites older turns, which is a full prefix re-write at the $2.50/MTok write premium on a 35k–150k prefix. A silent checkpoint clobber is a cost bug before it is ever a correctness bug.
Directions
Share the session manager across agents for one session, the way fix(inference): one session is one conversation, whatever agent runs a turn #750 shares the message list. Most faithful to "one session is one conversation", but the manager holds per-agent wiring (SDK hooks are registered per agent), so this needs checking rather than assuming.
Re-load compaction state on every turn rather than caching it on the instance. _compaction_state_loaded already exists for a related reason (update_after_turn lazy-loads to avoid clobbering persisted state with default zeros) — this would extend that instinct to the two-instance case. Costs a read per turn.
Conditional write on the session row so a stale checkpoint loses instead of clobbering, and the loser re-reads.
(2) is probably the smallest correct thing; (1) is the tidiest if the hook wiring allows it.
Verifying it
Hard to hit by hand — needs a mention inside a thread long enough to cross the compaction threshold. Probably worth an integration test that drives two cache keys against one session past the threshold and asserts the persisted checkpoint advances monotonically, rather than trying to reproduce it live.
Split out of #741 rather than widening its PR (#750). Same root cause, different blast radius.
The problem
Each
Agentbuilds its own session manager —SessionFactory.create_session_manager(...)atbackend/src/agents/main_agent/session/base_agent.py:172. So when one session is served by two cached agents (an@-mention turn and the plain turns around it), there are twoTurnBasedSessionManagerinstances, each with its own:compaction_state(checkpoint,total_summarized_turns, truncation anchor)_compaction_state_loadedmessage_count/_total_message_count_at_init_valid_cutoff_indices,_all_messages_for_summaryBoth persist to the same DynamoDB session row. Neither knows the other exists, so
update_after_turnfrom instance B can write a checkpoint derived from a message count instance A has since moved past — and vice versa on the next turn.#750 fixed the conversation by aliasing the message list across instances. It deliberately did not touch this, because compaction state is not a list you can alias — it is derived per-turn from a message count and a persisted checkpoint.
Why it hasn't bitten yet
Compaction only engages past a token threshold, and
@-mentions are days old. The threads that would trigger it are long ones, and nobody has had a long mention-heavy thread yet. That is timing, not safety.Why it matters more than it looks
The truncation anchor is load-bearing for prompt-cache cost, not just correctness.
CLAUDE.md's prompt-cache contract and the byte-stability redesign both hinge on the anchor being stable between compaction-state changes — a mis-restored or clobbered anchor moves the truncation boundary, which rewrites older turns, which is a full prefix re-write at the $2.50/MTok write premium on a 35k–150k prefix. A silent checkpoint clobber is a cost bug before it is ever a correctness bug.Directions
_compaction_state_loadedalready exists for a related reason (update_after_turnlazy-loads to avoid clobbering persisted state with default zeros) — this would extend that instinct to the two-instance case. Costs a read per turn.(2) is probably the smallest correct thing; (1) is the tidiest if the hook wiring allows it.
Verifying it
Hard to hit by hand — needs a mention inside a thread long enough to cross the compaction threshold. Probably worth an integration test that drives two cache keys against one session past the threshold and asserts the persisted checkpoint advances monotonically, rather than trying to reproduce it live.
Related
docs/specs/compaction byte-stability work;CLAUDE.mdprompt-cache contract🤖 Generated with Claude Code