Repository navigation
Unread dot comes back after every open when its saved watermark is newer than any saved row #19688
Description
Existing issues
- I searched open issues and this is not already reported.
KiroCrew version
Found on main at de2d143. Seen on a 0.8.0-rc.1 build.
Release channel
Insider (prerelease)
How is it installed?
From source
Platform
Not platform-specific
What happened
One session's unread dot cannot be cleared. Opening the session clears it, and it lights again a moment later. Mark as read does the same. Every reload brings it back.
The dot's shared unread record (mc-unread-shared) holds a watermark newer than the session's last_ts. markSlotRead clears this window's badge but keeps the shared record, because last_ts does not cover it (dashboardSlice.ts L1346). The read is relayed at last_ts, and the gateway sends it back to every owner window, the sender included. In this window remoteSlotRead finds the shared watermark uncovered and lights the badge again (L1365-L1370). On reload, restoreUnreadBadges restores it from the same record (L288).
Builds before #15014 recorded such a watermark when a turn finished: last_ts was then the turn's unsaved done row, which a gateway restart drops. #15014 stops new ones, but a record saved before it stays in the browser's localStorage, and no read on main can cover it.
Expected: opening, or marking as read, a session with nothing in flight clears its dot for good.
Steps to reproduce
Saved under website/src/test/, this fails on main with expected [ 's1' ] to not include 's1':
import { it, expect } from 'vitest'
import { createTestStore } from './helpers'
import dashboardReducer, { sseConnected, sseSlots, markSlotRead, remoteSlotRead, restoreUnreadSince, restoreUnreadBadges } from '../store/dashboardSlice'
import type { RootState } from '../store'
it('a read of an idle session keeps its dot cleared', () => {
// The record a build before #15014 left: the watermark is the turn's
// unsaved `done` row, a few ms after the transcript tail.
localStorage.setItem('mc-unread-shared', JSON.stringify({ s1: '2026-10-09T22:33:35.611204+00:00' }))
const since = restoreUnreadSince()
const store = createTestStore({ dashboard: {
...dashboardReducer(undefined, { type: '@@test/init' }),
unreadSince: since, unreadSlots: restoreUnreadBadges(since),
} as RootState['dashboard'] })
store.dispatch(sseConnected())
store.dispatch(sseSlots([{ key: 's1', messages: 166, running: false, last_ts: '2026-10-09T22:33:35.542839+00:00' }]))
// What opening the session or Mark as read does: the local read, then the
// gateway's rebroadcast of the relayed read reaching this window too.
store.dispatch(markSlotRead('s1'))
store.dispatch(remoteSlotRead({ slot: 's1', readTs: '2026-10-09T22:33:35.542839+00:00' }))
expect(store.getState().dashboard.unreadSlots).not.toContain('s1')
})In a browser: open a session whose dot came from a build before #15014 and has not cleared since the upgrade. The dot goes out, then comes back.
Activity
akushonkamen commentedon Oct 13, 2026
Confirmed the root cause and picking this up.
In
website/src/store/dashboardSlice.ts,markSlotRead(~L1345) only feedsslot.last_tsintoclearSharedUnreadIfCovered, so a stalemc-unread-sharedwatermark written before #15014 (pointing at a row that was dropped on gateway restart) can never be covered by any read.remoteSlotRead(~L1365-1371) then adopts the surviving watermark back intounreadSinceand re-lights the badge (echo fan-in), andrestoreUnreadBadges(~L288-295) restores it from localStorage on every refresh — the dot never retires.Plan: retire the orphan shared watermark when the local slot is fully caught up (
running: false), while preserving the lag-window protection, theMANUAL_UNREADsentinel, and the newest-ts-wins persist semantics. Repro test first, then fix.Note: this investigation is assisted by an AI coding agent (Claude), supervised and verified by me.
KiroCrewCaptain commentedon Oct 13, 2026
Thanks for the report. This issue has been triaged by Crew Captain and will be assigned to a maintainer, who will follow up here.
akushonkamen commentedon Oct 13, 2026
PR is up: #19782 (rebased onto current main, fec23af). The issue's repro is red on main (2 failed | 1 passed,
expected [ 's1' ] to not include 's1') and green with the fix (3 passed (3)); the running-slot lag-window protection case is included and stays green.fix(dashboard): an idle read retires an orphan shared unread watermark
fix(dashboard): an idle read retires an orphan shared unread watermark
fix(dashboard): an idle read retires an orphan shared unread watermark
dodgeblaster commentedon Oct 20, 2026
Triage (T1/T2 sweep). Confirmed this is a live T1 bug on current main (
355dd9027f):markSlotReadnever retires an uncovered orphanmc-unread-sharedwatermark, so a pre-#15014 record newer than every saved row re-lights the dot on every open and reload. Two open fork PRs fix it; recommending one.Recommend #19782 (akushonkamen). Surgical 2-file, +134/-4 change that matches this issue's own diagnosis and ships the issue's exact repro. It retires the orphan only on an idle slot with a proven live view (
!running && connected && slotsLive), andslotsLiveis cleared by connect/disconnect/HTTP-fetch so a reconnect gap or stale HTTP reply can't erase a sibling's newer watermark. Design/First-Principles/UX/Opus lanes PASS. Its only blocker is one GPT 6.1 BLOCKING residual (connected-but-lagging-tab race, security/data-loss class, worst case one lost badge) that needs a maintainer to adjudicate — the writer-side override path doesn't apply to a security-class finding.Not #19779 (unstablebrainiac) for this fix: it's a much larger 9-file, +1296/-38 change that introduces a permanent new browser protocol (
mc-unread-orphaned/mc-unread-arrival). Its PR Readiness is green, but it carries a CHANGES_REQUESTED and is explicitly held for a maintainer product decision (keep the permanent compat layer vs a one-time versioned migration). That's a design call, not a defect — appropriate to decide separately, but heavier than this bug needs.(#20020 is a different bug — unread dots dropped when a restart serves a partial slot list — not this issue.)
Both are cross-repo forks, so neither can be merged by the sweep (fork merge needs the author + a maintainer; no admin bypass). Next step is a maintainer: adjudicate the GPT residual on #19782 (preferred), or choose the #19779 protocol direction.