fix: prevent CLI session message loss on concurrent saves#1868
fix: prevent CLI session message loss on concurrent saves#1868cursor[bot] wants to merge 2 commits into
Conversation
UnifiedSessionStore.save() wrote the full in-memory session without reloading from disk under lock. When TUI and --interactive (or two processes) shared a session_id, the last writer could drop messages added by the other. Reload from disk under exclusive lock, merge messages by identity, and always load from disk when the session file exists. Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com>
|
@coderabbitai review |
|
/review |
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more β On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
β Action performedReview finished.
|
|
Important Review skippedBot user detected. To trigger a single review, invoke the βοΈ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
π WalkthroughWalkthrough
ChangesConcurrent-safe session persistence
Estimated code review effortπ― 3 (Moderate) | β±οΈ ~25 minutes Possibly related PRs
Suggested reviewers
Poem
π₯ Pre-merge checks | β 5β Passed checks (5 passed)
βοΈ Tip: You can configure your own custom pre-merge checks in the settings. β¨ Finishing Touchesπ§ͺ Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above from Qodo, Coderabbit, and Gemini first β incorporate their findings. Review areas:
|
There was a problem hiding this comment.
Actionable comments posted: 2
π€ Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/praisonai/praisonai/cli/session/unified.py`:
- Around line 151-158: The current merge in the save path (building
messages_by_key from disk.messages and setdefault-ing from incoming.messages
using _message_key) makes saves effectively append-only and silently reverts
destructive in-memory ops like UnifiedSession.clear_messages(); fix by tracking
a persisted baseline/version when loading a session and using it under the same
lock in save to decide how to merge: if the incoming snapshot's base/version
matches the persisted baseline, replace disk.messages with incoming.messages
(preserving incoming order) instead of unioning; if versions differ, perform a
proper three-way reconciliation against the persisted baseline (apply explicit
additions/edits/deletes from incoming relative to the baseline) using
_message_key to identify messages and then update the persisted baseline/version
after a successful write.
- Around line 160-163: The current update in unified.py uses max() to merge
absolute counters (disk.total_input_tokens, disk.total_output_tokens,
disk.total_cost, disk.request_count) which loses concurrent increments; change
update_stats() to record a per-session baseline/version on load (e.g.,
session.version or session.baseline_counters), compute and persist only the
delta under the exclusive lock when saving (read current stored totals, add the
delta from this instance, increment the version), and remove the max()-based
merging; also add a regression test that creates two store instances, both call
update_stats() with different increments then save concurrently to assert totals
are the sum of both deltas and versioning prevents lost updates.
πͺ Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
βΉοΈ Review info
βοΈ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: e90c172e-cd92-47bb-9f30-40f6cccac9c2
π Files selected for processing (2)
src/praisonai/praisonai/cli/session/unified.pysrc/praisonai/tests/unit/cli/test_unified_session.py
| messages_by_key = {self._message_key(m): m for m in disk.messages} | ||
| for msg in incoming.messages: | ||
| messages_by_key.setdefault(self._message_key(msg), msg) | ||
|
|
||
| disk.messages = sorted( | ||
| messages_by_key.values(), | ||
| key=lambda m: m.get("timestamp", ""), | ||
| ) |
There was a problem hiding this comment.
Union-only message merging makes clear_messages() impossible to persist.
This merge starts from disk.messages and only adds missing entries from the incoming snapshot, so any destructive update in memoryβclear_messages(), truncation, or editing/removing a messageβgets silently undone on save. That breaks the existing UnifiedSession.clear_messages() contract and turns save() into append-only behavior.
Suggested direction
Track a persisted base/version for each loaded session and resolve against that base under the lock. Without that baseline, the store cannot distinguish βstale snapshot missing concurrent appendsβ from βcaller intentionally removed messagesβ.
π€ Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/praisonai/praisonai/cli/session/unified.py` around lines 151 - 158, The
current merge in the save path (building messages_by_key from disk.messages and
setdefault-ing from incoming.messages using _message_key) makes saves
effectively append-only and silently reverts destructive in-memory ops like
UnifiedSession.clear_messages(); fix by tracking a persisted baseline/version
when loading a session and using it under the same lock in save to decide how to
merge: if the incoming snapshot's base/version matches the persisted baseline,
replace disk.messages with incoming.messages (preserving incoming order) instead
of unioning; if versions differ, perform a proper three-way reconciliation
against the persisted baseline (apply explicit additions/edits/deletes from
incoming relative to the baseline) using _message_key to identify messages and
then update the persisted baseline/version after a successful write.
| disk.total_input_tokens = max(disk.total_input_tokens, incoming.total_input_tokens) | ||
| disk.total_output_tokens = max(disk.total_output_tokens, incoming.total_output_tokens) | ||
| disk.total_cost = max(disk.total_cost, incoming.total_cost) | ||
| disk.request_count = max(disk.request_count, incoming.request_count) |
There was a problem hiding this comment.
max() still drops concurrent token/cost increments.
If two processes load the same session and both call update_stats(), they each advance from the same base totals. Merging these fields with max() preserves only one branchβs increment, so total_input_tokens, total_output_tokens, total_cost, and request_count will undercount after the second save.
Suggested direction
Persist a per-session baseline/version on load/save and apply deltas under the exclusive lock instead of merging absolute totals with max(). Please add a regression test with two store instances that both call update_stats() before saving.
π€ Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/praisonai/praisonai/cli/session/unified.py` around lines 160 - 163, The
current update in unified.py uses max() to merge absolute counters
(disk.total_input_tokens, disk.total_output_tokens, disk.total_cost,
disk.request_count) which loses concurrent increments; change update_stats() to
record a per-session baseline/version on load (e.g., session.version or
session.baseline_counters), compute and persist only the delta under the
exclusive lock when saving (read current stored totals, add the delta from this
instance, increment the version), and remove the max()-based merging; also add a
regression test that creates two store instances, both call update_stats() with
different increments then save concurrently to assert totals are the sum of both
deltas and versioning prevents lost updates.
|
@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding. Phase 1: Review per AGENTS.md
Phase 2: FIX Valid Issues Phase 3: Final Verdict |
|
Claude finished @MervinPraison's task ββ View job Final Architecture Review - COMPLETED βTodo List:
Phase 1: Architecture Review β PASSEDProtocol-driven: Changes properly located in wrapper layer ( Phase 2: Fixed Valid Issues β COMPLETEDIssue 1 - Append-only merge behavior (CodeRabbit lines 151-158): β FIXED
Issue 2 - max() dropping concurrent increments (CodeRabbit lines 160-163): β FIXED
Phase 3: Final Verdict β APPROVEFiles Modified:
What was NOT touched:
Validation Results:
Architecture Assessment: The fixes are architecturally sound, follow PraisonAI patterns, maintain backward compatibility, and resolve the core concurrency issues without breaking existing functionality. π― RECOMMENDATION: APPROVE & MERGE The PR successfully fixes the CLI session message loss issue with a robust versioning approach that prevents both message loss and counter update loss in concurrent scenarios. |
- Add session versioning (_version, _baseline_counters) for proper merge resolution - Fix append-only merge behavior that silently reverted destructive operations like clear_messages() - Replace max() with delta-based counter merging to prevent concurrent increment loss - Add regression tests for concurrent stats updates and clear_messages persistence - Implement proper three-way merge for version conflicts Addresses CodeRabbit feedback from PR review. Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com>
Bug and impact
UnifiedSessionStore.save()wrote the full in-memory session to disk without reloading under lock first. When two CLI processes (e.g. TUI and--interactive) shared the samesession_id, the last writer could silently drop messages added by the other β chat history data loss.Trigger scenario:
msg1and keeps it in cache.msg2, saves.msg3and saves from stale cache.msg1,msg3βmsg2is lost.Root cause
Commit #1837 added cross-platform file locking to
UnifiedSessionStore, butsave()still performed a blind whole-object overwrite.load()also returned stale cache entries without checking disk.Fix
save().(role, content, timestamp)identity so concurrent appends are preserved.DefaultSessionStorepattern).Validation
msg2lost).test_concurrent_save_preserves_messagesregression test.tests/unit/cli/test_unified_session.pypass.Summary by CodeRabbit
Bug Fixes
Tests