Skip to content

Compaction state forks the same way conversation history did: two session managers, one DynamoDB row #751

Description

@philmerrell

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:

  • compaction_state (checkpoint, total_summarized_turns, truncation anchor)
  • _compaction_state_loaded
  • message_count / _total_message_count_at_init
  • _valid_cutoff_indices, _all_messages_for_summary

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

  1. 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.
  2. 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.
  3. 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.

Related

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions