fix(inbox): drop pre-logout list snapshots by session epoch and report dropped detail reads - #2659
Conversation
The foreground-batch-triage test installed a persistent mockImplementation returning promises that never resolve. The suite's only reset is vi.clearAllMocks(), which clears recorded calls but keeps implementations, so any later test that triggered an unconfigured detail read hung on a promise that test installed instead of receiving undefined. Three mockImplementationOnce calls cover this test's three reads exactly. The guard test that follows races the surviving implementation against a macrotask: a leaked mockResolvedValue settles and is harmless, a leaked never-resolving one does not. Refs #2640
fetchDetail has three paths that resolve with the body they fetched and write nothing into the caches: the caller's shouldCache opt-out, a session epoch the logout moved, and a write generation a newer mutation moved. It reported none of them, so selectItemById returned true for a dropped read and left selectedItemId pointing at an id detailById holds nothing for. InboxDetailPanel renders exactly that state as "Unable to load capture detail.", for a read that succeeded. The three drop paths collapse into one cached boolean, reported through a new DetailLoadOptions.onCacheOutcome callback. The report is a callback rather than a return value because fetchDetail resolves with the CaptureItem and a view annotates that type, so widening the return would change a surface outside this seam. selectItemById restores the selection the user had and returns false when a dropped read left the store with no detail for the id. A drop that a newer mutation caused keeps the selection: that write cached its own newer body, so the panel has something honest to render. A rejected read still clears the selection outright, as before. Refs #2640
refreshTerminalDetails announced an id as refreshed whenever fetchDetail resolved, which a dropped read also does. refreshedDetailIds is the batch poll's record of ids it reconciled, and for a tracked item beyond the newest-first list cap it is the whole of isComplete's and isObservedTerminal's evidence, so the poll could stop and move the workload badge on pre-batch detail state that no reconciliation had touched. The reporter from the previous commit gates it: the id is announced only when the response actually entered detailById. Refs #2640
The session epoch covered the detail reads but not the list path, so the map clear inverted the summary half of the #2301 guard exactly as it had inverted the detail half. applyBackgroundListSnapshot keeps a locally newer row only while latestSummaryGenerationById outranks the snapshot's observed generation, and resetForLogout clears that map: the read comes back 0, so an older in-flight snapshot outranked a row a newer detail read had moved to a terminal status and pushed it back to Triaging. The batch poll's tick now captures the epoch when it issues its request and carries it in isCurrent(), which already gates both the snapshot and the detail reconciliation that follows it. The two halves of the guard now hold one post-logout contract: a response issued before the reset is dropped, a response issued after it caches normally. The spec that asserted the regressed behaviour as intended is retargeted, and the other half of the contract is pinned alongside it. Refs #2640
The resetForLogout docstring said every detail read captures the epoch, which peekDetail does not. peekDetail writes neither cache, so it is compared against no generation; the detailById write on that path belongs to useInboxOrchestrator.openBoardScopedHashItem, which passes the body to selectItemById as preloadedDetail with cacheSummary false and reaches cacheDetail guarded only by the route-hash re-check. The wording is qualified rather than the capture added: capturing the epoch inside peekDetail would not cover a write that is not peekDetail's to drop, and the guard would have to move into a per-mount composable the logout's route change tears down. cacheSummary false already keeps that path out of items, so no row of a previous session's list can survive it. Refs #2640
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Review record (alpha product-trust lane, review-and-ship round 1 at head c295e1a). Reviewer: one fresh-context independent reviewer subagent (read-only), input = merge-base..head diff plus the worktree at the head. Verdict: SHIP, no CRITICAL or HIGH; two MEDIUMs taken in round 2. Confirmed clean: the session epoch increments exactly once in the tree, inside resetForLogout under the shell's unauthenticated transition, and the docstring's enumeration of the epoch-capturing reads is exhaustive (fetchItems is correctly excluded: its request-id supersession is not cleared by the reset); the batch poll neither dies at logout nor applies a stale-epoch snapshot (the tick's finally reschedules and the next tick re-captures); the currency check is strictly additive; the fetchDetail refactor preserves the drop order; onCacheOutcome fires exactly once on each caching exit and never on the throw path, with an unreporting store reading as cached so every untouched mock keeps passing; the cached-and-current gate in refreshTerminalDetails cannot stall a healthy poll; item 2's isolation is genuine (keepItem with no list row moves only the detail generation); the retargeted spec is red-first and both guard halves now agree; the settles-versus-hangs guard is not timing-sensitive (microtasks drain before any macrotask); the peekDetail docstring claim holds (the orchestrator re-checks the route hash before the preloaded cacheDetail); the return-type widening was correctly avoided (a Paper row editor annotates the awaited result as a CaptureItem); every exact fetchDetail assertion in the tree was updated; no view changed and the #2614 and #2631 seams are untouched. Triage:
Merge gate: round-2 push, one verification pass scoped to the fix diff (the outcome reporting and the re-read are new logic), ci-required green at the round-2 head, aged three minutes, then merge commit. |
…ision Round-2 review, two MEDIUMs. The restore-and-return-false branch was reachable in a live session, not only after a logout. A first open observes write generation 0 for an uncached item; a batch triage that includes it moves that generation, and refreshTerminalDetails skips the item because it has a list row and no cached detail. The read resolved as dropped with detailById still empty and no body cached anywhere, so openItemFromHash stripped the user's deep-link hash and openItemFromList turned the click into a silent no-op that a second click undid. onCacheOutcome now reports which guard rejected the response, not just that one did: cached, superseded, generation, epoch. selectItemById re-reads once on a generation drop that left no detail, keeps the selection on superseded, and restores only on epoch. The additive shape and the unreporting-store-means-cached default are unchanged. The restore now returns activeItemIndex alongside selectedItemId. They drive different things - the active row and aria-activedescendant versus aria-selected and the panel - so restoring one alone desynchronised the list from the panel. peekDetail carries a line saying it accepts onCacheOutcome and never calls it, since it writes neither cache. The orchestrator spec resets fetchDetail per test: an unconsumed mockImplementationOnce survives vi.clearAllMocks() and leaked a queued re-read into the next test. Refs #2640
|
Round-2 verification record (scoped to the fix diff c295e1a..d230b99; read-only pass). Verdict: SHIP for the fix diff, no CRITICAL or HIGH. Confirmed: exactly one re-read (a straight-line if, not a loop), with every outcome of the re-read handled and a throw landing in the existing catch that clears the selection; both reads pass identical options and loadingDetail is cleared in fetchDetail's finally on every path; the re-read writes only store caches and never touches the selection, so a user who clicked elsewhere keeps the newer selection; the outcome ordering preserves the caching decision exactly (cached if and only if shouldCache, epoch equal and generation equal, same short-circuit order); the outcome holder is a per-call local and the callback fires synchronously before fetchDetail resolves; the active-row restore is captured before primeSelection and restored only in the epoch branch under the same guard, and the re-read path cannot re-desync the two halves; refreshTerminalDetails stays fail-closed (#2640 item 2 holds); the peekDetail comment is accurate; the spec's mockReset cannot wipe a default other tests depend on and the three new specs construct the crossing correctly; the diff is three files with no view change and the #2614 and #2631 seams untouched. Recorded, no third round (the two-round ceiling is reached; none is a merge blocker):
Merge gate unchanged: ci-required green at d230b99, aged, merge commit. |
Summary
Five follow-ups from the read-only verification pass on PR #2631's round-2 fixes, all edges of the session epoch that #2631 introduced.
The session epoch covered the detail reads but not the list path, so
resetForLogout()clearinglatestSummaryGenerationByIdinverted the summary half of the #2301 guard exactly as it had inverted the detail half. The batch poll's tick now captures the epoch when it issues its request. Alongside that,fetchDetailnow reports which guard rejected a response, so neitheruseInboxOrchestrator.selectItemByIdnor the batch poll'srefreshedDetailIdscan claim a read succeeded when the store dropped it — and a drop that is recoverable is recovered rather than surfaced.Closes #2640
Refs #2571, PR #2631
Changes
One commit per issue item, plus one round-2 commit.
8f3bc0ca5item 5 — bound the never-resolving detail mock to its own test.captureStore.spec.ts's foreground-batch-triage test installed a persistentmockImplementationreturning promises that never resolve. The suite's only reset isvi.clearAllMocks(), which clears recorded calls but keeps implementations, so any later test that triggered an unconfigured detail read hung on a promise that test installed. ThreemockImplementationOncecalls now cover its three reads exactly. A guard test directly below races the surviving implementation against a macrotask: a leakedmockResolvedValuefrom any earlier test settles and is harmless, a leaked never-resolving one does not.dc6abc8c4item 3 — report a dropped detail read and stop selecting on it.fetchDetailhas three paths that resolve with the body they fetched and write nothing: the caller'sshouldCacheopt-out, a session epoch the logout moved, and a write generation a newer mutation moved. It reported none of them, soselectItemByIdreturnedtruefor a dropped read and leftselectedItemIdpointing at an iddetailByIdholds nothing for — the stateInboxDetailPanelrenders as "Unable to load capture detail.", for a read that succeeded. Reported through a newDetailLoadOptions.onCacheOutcome.The report is a callback, not a return value or an outcome object.
fetchDetailresolves with theCaptureItemandviews/paper/inbox/PaperTriageRowEdit.vue:190annotates that type (const detail: CaptureItem = await captureStore.fetchDetail(...)), so widening the return would have changed a view — outside this issue's scope. A sibling outcome-returning function would have movedselectItemByIdofffetchDetailand silently un-configured the 25-plusfetchDetailmock implementations inInboxView.spec.ts. The callback is additive, every existing caller compiles untouched, and a store that reports nothing is read as having cached, which is the pre-change behaviour.d85a7680aitem 2 — fireonRefreshedonly for a reconciliation that cached.refreshTerminalDetailsannounced an id as refreshed wheneverfetchDetailresolved, which a dropped read also does.refreshedDetailIdsis the batch poll's record of ids it reconciled, and for a tracked item beyond the newest-first list cap it is the whole ofisComplete's andisObservedTerminal's evidence, so the poll could stop and move the workload badge on pre-batch detail state.e2425f3afitem 1 — drop a background list snapshot the logout crossed.pollBatchTriageCompletion'stick()capturessessionEpochwhen it issues its list request and carries it inisCurrent(), which already gates bothapplyBackgroundListSnapshotand the detail reconciliation that follows it. The spec that asserted the regressed behaviour as intended ("nothing stale-high is left to pin the row") is retargeted, and the other half of the contract is pinned beside it.c295e1a02item 4 — state exactly which reads capture the session epoch. TheresetForLogoutdocstring said every detail read captures the epoch, whichpeekDetaildoes not. Wording qualified rather than the capture added; reasoning in Boundaries below.d230b9989round 2 — report the drop REASON and re-read a live generation collision. Two MEDIUMs from review, in one commit.MEDIUM 1. The restore-and-return-false branch was reachable in a live session, not only post-logout. A first open observes write generation 0 for an uncached item; a batch triage that includes the item moves that generation through
recordCaptureWrite, andrefreshTerminalDetailsskips the item because it has a list row and no cached detail. The read therefore resolved as dropped withdetailByIdstill empty and no body cached anywhere — unlike a detail-path write, which caches its own newer body. Round 1 restored the selection there, soopenItemFromHashsilently stripped the user's deep-link hash andopenItemFromListturned the click into a no-op that a second click undid.onCacheOutcomenow carries the reason, exported asDetailCacheOutcome = 'cached' | 'superseded' | 'generation' | 'epoch'— a plain string union, not an object, keeping the callback shape additive and the unreporting-store-means-cacheddefault intact.selectItemByIdre-reads once on agenerationdrop that left no detail for the id, keeps the selection onsuperseded(a newer read for the same id is already the authority), and restores-and-returns-false only onepoch. A second collision inside that window is left to the user's next click rather than looped over.MEDIUM 2. The restore set
selectedItemIdbut notactiveItemIndex. They drive different things — the active row andaria-activedescendantversusaria-selectedand the panel — so restoring one alone desynchronised the list from the panel. Both are captured beforeprimeSelectionand restored together.Also:
peekDetailcarries one line saying it acceptsonCacheOutcomeand never calls it, because it writes neither cache. AnduseInboxOrchestrator.spec.tsnow resetsfetchDetailper test — an unconsumedmockImplementationOncesurvivesvi.clearAllMocks(), and the new re-read cases queue one, which leaked into the next test (it surfaced as a real red during this round).Test plan
All commands run from
frontend/taskdeck-webin the issue worktree.Round-1 red-first, recorded before each fix:
mockImplementationrestored:AssertionError: expected 'hangs' to be 'settles'AssertionError: expected 'dropped' to be 'kept'AssertionError: expected "vi.fn()" to be called 1 times, but got 2 timesAssertionError: expected 'Triaging' to be 'Failed'Round-2 red-first, all four before the fix:
holds the previous selection and active row when a logout dropped the read—AssertionError: expected 'dropped' to be 'kept're-reads once and keeps the deep-link hash when a batch write crossed the read—AssertionError: expected "vi.fn()" to be called 2 times, but got 1 timesre-reads once so a click still opens an item a batch write crossed—AssertionError: expected "vi.fn()" to be called 2 times, but got 1 timesstill treats a rejected detail read as a failure—AssertionError: expected 'broken' to be null(the leaked unconsumedmockImplementationOnce, fixed by the per-test reset)Verified, green at head
d230b9989:npx vitest --run --maxWorkers=2 src/tests/store/captureStore.spec.ts src/tests/composables/useInboxOrchestrator.spec.ts src/tests/views/InboxView.spec.ts src/tests/components/AppShell.spec.ts src/tests/components/AppShell.paperVariant.spec.ts— 5 files, 288 tests passednpx vitest --run --maxWorkers=2 src/tests/store src/tests/composables/useInboxOrchestrator.spec.ts src/tests/views/InboxView.spec.ts src/tests/views/InboxView.paperMode.spec.ts src/tests/views/paper/inbox src/tests/resilience src/tests/property/storeResilience.spec.ts— 61 files, 1181 tests passednpm run typecheck— cleannpx eslint src/store/captureStore.ts src/composables/useInboxOrchestrator.ts src/tests/store/captureStore.spec.ts src/tests/composables/useInboxOrchestrator.spec.ts src/tests/views/InboxView.spec.ts— cleannpm run build— built, PWA precache 142 entriesgit diff --check— cleanNOT verified:
selectItemByIdchanges are proven at the orchestrator level only.Boundaries and risks
The post-logout contract, both halves of the #2301 guard. A response issued before
resetForLogout()is dropped: the detail half byfetchDetail's epoch compare, the summary half by the batch poll tick's epoch inisCurrent(). A response issued after the reset caches normally — the epoch drops what a reset crossed, not everything that follows it. Both halves are pinned by tests in theresetForLogoutdescribe.What
selectItemByIddoes per drop reason, after round 2.cachedopens.supersededkeeps the selection — a newer read for the same id is already the authority.generationwith no detail re-reads once and then behaves as that re-read reports; with a detail present it opens, because the crossing write cached its own newer body.epochwith no detail restores the pre-callselectedItemIdandactiveItemIndexand returnsfalse. A rejected read still clears the selection outright, as before. The drop-and-restore path is therefore reachable only for theepochcase after this change — every live-session drop either opens or recovers by re-reading. The invariant throughout:selectedItemIdis never left pointing at an iddetailByIdholds nothing for.Residual LOW, accepted. A caller-supplied
onCacheOutcomethat throws does so insidefetchDetail'stry, so it would be reported to the user as a fetch failure. Both in-repo callers pass a total one-line assignment, so this is unreachable today; closing it would mean wrapping the callback, which buys nothing for the two call sites that exist.peekDetailaccepting and ignoring the option is now documented in one line at its head rather than left to be inferred.Why
peekDetailwas not given the epoch capture. It writes neither cache. ThedetailByIdwrite on that path is the caller's:useInboxOrchestrator.openBoardScopedHashItemhands the body toselectItemByIdaspreloadedDetailwithcacheSummary: false, reachingcacheDetailguarded only by the route-hash re-check. An epoch capture insidepeekDetailwould not cover a write that is notpeekDetail's to drop; the guard would have to live in the composable, a per-mount surface the logout's route change tears down anyway.cacheSummary: falsekeeps that path out ofitems, so no row of a previous session's list can survive it. That residual is now named in theresetForLogoutdocstring instead of being contradicted by it.Untouched, as scoped. The bounded poll's other guards, the scoped refresh from #2614, the AppShell wiring from #2631, and every view.
fetchItems's request-id supersession is unchanged: item 1 lives entirely in the background poll.Spec-side churn. Nine
toHaveBeenCalledWithassertions acrossuseInboxOrchestrator.spec.tsandInboxView.spec.tsgainedonCacheOutcome: expect.any(Function), becauseselectItemById's options object grew a key and those assertions are exact matches. No assertion was loosened toobjectContaining.Gitignored files in the worktree. Only
frontend/taskdeck-web/dist/andfrontend/taskdeck-web/node_modules/, both regenerable. Nothing was copied out; nothing needs to survive the worktree's removal.