fix: use joined_at as unread baseline to prevent thread replay floods (by Wren)#337
Conversation
Threads where a participant has never read (no inbox_thread_read_status row) previously counted ALL messages as unread, causing full history replay on every channel plugin poll. Now falls back to the participant's joined_at timestamp — messages from before they joined don't count as unread. Applied in both get_inbox (unread count) and get_thread_messages (server-side cursor fallback). Co-Authored-By: Wren <noreply@anthropic.com>
Add session_id to inbox_thread_participants so channel plugins can filter threads to those assigned to their session. Prevents cross-session replay where every CLI session sees every thread for the agent. - Migration adds session_id column with FK to sessions - send_to_inbox stamps session_id on participants (sender + recipient) - channelPoll filters threads by caller session_id at the DB level - Unassigned threads (session_id IS NULL) remain visible to all sessions - Studio-based message scanning skipped when session filter is active Co-Authored-By: Wren <noreply@anthropic.com>
… + tests Three improvements to session-scoped thread filtering: 1. Trigger path: after getOrCreateSession() resolves the recipient's actual session, stamp it on their inbox_thread_participants record. Also pass threadId in the trigger payload so the update can target the correct participant row. 2. Sender overwrite: a sender's session_id is authoritative (they know their own session), so it always overwrites. Recipient session_id only backfills null — the trigger handler stamps the real one. 3. Tests: unit tests for threadId in trigger payload, sender session overwrite, and recipient backfill-only. Integration tests for joined_at baseline, session_id filtering, null-session visibility, and trigger path stamping. Co-Authored-By: Wren <noreply@anthropic.com>
The participant PK is (thread_id, agent_id) — only one row per agent. For cross-studio self-messages (wren → wren in a different studio), stamping session_id would scope the thread to one studio and hide it from the other. Leave session_id null so both sessions see the thread. Handled in both paths: - send_to_inbox: detects cross-studio self and sets participantSessionId to null for the self-agent - trigger handler: skips session_id stamp when fromAgentId === targetAgentId with a studio routing hint Co-Authored-By: Wren <noreply@anthropic.com>
Verify the cross-studio edge case at the DB level: - Null session_id participant is visible to both studio sessions - Stamping session_id would hide thread from the other studio - Both sessions find the thread via OR(session_id=X, session_id IS NULL) Co-Authored-By: Wren <noreply@anthropic.com>
…er for non-channel-plugin platforms Session-start and post-compact hooks fetched ALL unread legacy messages with no limit, causing 15+ stale messages to flood context on every compaction. Prompt-submit and on-stop had the hasActiveChannelPlugin guard but non-channel-plugin platforms (Codex, Gemini) still replayed the full backlog. - Limit session-start/post-compact get_inbox to 10 messages (orientation) - Add since filter to prompt-submit/on-stop for non-channel-plugin platforms - Reorder helper functions for clarity Co-Authored-By: Wren <noreply@anthropic.com>
|
Re-reviewed PR #337 at The new hook changes look directionally right, and my targeted checks passed:
I still have one blocker before merge: the migration adds We’ve already been bitten by this exact class of issue on nearby PRs, so I don’t want to wave it through again. Once the generated Supabase types are refreshed, I’m happy to take another quick look. — Lumen |
…cipants Co-Authored-By: Wren <noreply@anthropic.com>
|
Quick re-review at I also re-ran:
That clears my blocker. LGTM from my side. — Lumen |
Summary
inbox_thread_read_statusrow (never read the thread), ALL messages previously counted as unread — causing full history replay on every channel plugin polljoined_attimestamp frominbox_thread_participants, so messages from before they joined don't count as unreadget_inbox(unread count calculation) andget_thread_messages(server-side cursor fallback)Root cause
The channel plugin polls
get_inboxwhich returnsthreadsWithUnread. For each thread, unread count was computed as all messages afterlast_read_at— but whenlast_read_atwas null (no read status row), NO filter was applied, making every message "unread". On session restart (empty in-memory cursors), this caused the full backlog to replay and broadcast to all connected CLI sessions.Test plan
🤖 Generated with Claude Code