Skip to content

Memory review cadence is a single global state file, so concurrent sessions share and corrupt each other's counters #1711

Description

@jacobo-ortiz

Version: v7.1.1

The defect

hooks/MemoryReviewFire.hook.ts keeps the entire memory-review cadence in one file with no session scoping:

LIFEOS/MEMORY/OBSERVABILITY/review-state.json

{
  "turn_count_since_last_review": 6,
  "last_review_at": "...",
  "last_message_at": "...",
  "pending_review": false,
  "schema_version": 1
}

The hook fires on every primary-session Stop, does a read-modify-write on that file, and decides whether to spawn the reviewer from turn_threshold (default 8) and min_minutes_between (default 30).

With one session this is correct. With N concurrent sessions it fails two ways:

  1. The counter is shared. Every session's Stop increments the same turn_count_since_last_review. Eight parallel sessions cross the threshold roughly eight times faster than intended, and the 30-minute gate then throttles all of them together. Curation frequency stops tracking any individual conversation, and a long conversation can go uncurated while a burst of short ones consumes the budget.

  2. Read-modify-write is unsynchronised. loadState() reads, the handler mutates, saveState() writes a temp file and renames. The rename is atomic; the read-modify-write around it is not. Concurrent Stop events lose updates.

Why it is separate from #1495

#1495 was about which transcript the reviewer reads. This is about when it runs at all. Fixing the transcript pinning still leaves the cadence global, so a session can be pinned correctly and simply never reach its own threshold because other sessions keep resetting the shared counter.

Repro

  1. Open two Claude Code sessions in the same install.
  2. watch -n1 cat ~/.claude/LIFEOS/MEMORY/OBSERVABILITY/review-state.json
  3. Take turns in either session.

turn_count_since_last_review advances on turns from both, and nothing in the file distinguishes them.

Measured on an install that normally runs several sessions in parallel: 88 reviewer fires in 11 days, against a much larger number of sessions per day.

Suggested direction

Scope the cadence per session, keyed on the session_id the harness already passes on stdin at Stop. The codebase already uses exactly this pattern elsewhere: hooks/MemoryTurnStart.hook.ts keeps its injection state at LIFEOS/MEMORY/STATE/memory-inject/<session_id>.json.

Keeping the existing global file as a mirror preserves the statusline and MemoryHealthCheck, both of which read that exact path. I patched my install that way (per-session file drives the decision, global file still written for the readers) and it works, so happy to send a PR if the direction is right.

Note that raising the effective curation rate is the point, but it does raise inference volume: with N active sessions the ceiling goes from one review per min_minutes_between to N. That may warrant a separate global rate cap.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions