Recover a full detail superseded by a status-only read (#2960) - #3004
Merged
Merged
Conversation
A status read takes per-item read authority but can only patch an existing `detailById` row. When it superseded the first, uncached `fetchDetail` for an id, the older body was correctly dropped and no newer body ever arrived: only a TERMINAL status started a fresh full-detail read, so the id stayed uncached through every nonterminal tick. `fetchDetail` now records the id when its read-authority branch drops a body and nothing is cached, and a nonterminal tick re-issues the full GET once under that tick's own `readAuthority`, through `readTriage` for the same deadline, cancellation and late-result rejection as every other ordinary read. The recovery is therefore the newest read for the id, so neither an older status nor an older full body can land on top of it. It writes only `detailById` (`syncSummary: false`, no `upsertSummary`), leaving list membership, order and row fields to the status patch, and it does not relabel a successful status observation when the body read fails - the mark stays set and the watch's own backoff carries the retry. Any cache write clears the mark, which bounds the recovery to one read per gap; `resetForLogout` clears the set with the other per-item maps. Tests: the reproduction probe turned into a regression, plus already-cached supersession issuing no extra GET, a newer foreground body beating the replacement read it races, and a failed replacement retrying silently on the next tick.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Owner
Author
|
Fresh-context independent review at exact head Verified by the reviewer from source (read-only):
Non-blocking notes, recorded here (law 2c):
Merge gate: hosted checks at this head plus the aging floor; merging with a merge commit once green. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Closes #2960.
A status-only triage read takes per-item read authority, but it can only PATCH an existing
detailByIdrow - it never carries a body. So when it superseded the first, uncachedfetchDetailfor an id, the older body was correctly dropped and no newer body ever replaced it:only a terminal status starts a fresh full-detail read, so the id stayed uncached through every
subsequent nonterminal tick.
The fix is a store-local handoff, not a new ordering rule:
fetchDetailrecords the id insupersededDetailIdswhen its read-authority branch drops abody and
detailByIdholds nothing for that id. Checked at resolution time, so a supersessionby another detail read - which caches its own newer body - never marks.
readAuthority, viareadTriage(same ten-second deadline, transport cancellation and late-result rejection as everyother ordinary read). Because it is the current read for the id, neither an older status nor an
older full body can land on top of it.
detailById(syncSummary: false, noupsertSummary): list membership, order androw fields stay with the status patch. Only terminal hydration writes a row, unchanged.
mark stays set and the watch's existing backoff carries the retry.
cacheDetail), which bounds the recovery to one read per gap;resetForLogoutclears the set with the other per-item maps.useInboxOrchestrator.selectItemByIdis not touched: its existingsupersededbranch assumes anewer read owns the id and will deliver a body. This change makes that assumption true at the store
level rather than adding a second recovery path in the composable.
Per the issue, this is not described or treated as a proven user-visible outage: the reviewer of
#2959 found no ordinary UI path to a stranded visible panel (Legacy triage starts from cached detail
and retains it; leaving the view stops the watches; Paper editing consumes the returned body
directly). This closes the store contract gap.
Tests
The reproduction probe from the issue, turned into a regression, plus three ordering guards - all in
src/tests/store/captureStore.polling.spec.ts:re-reads the full body a nonterminal status observation superseded while nothing was cached-the probe. Asserts the older body is still dropped as
superseded, the cache is empty, then thenext tick issues exactly one replacement GET, and later nonterminal ticks stay status-only.
does not re-read a full body when the superseded detail was already cached- no duplicate GET.lets a newer foreground body win over the replacement read it raced- an older replacement bodycannot overwrite newer state, and the settled body stops further replacement reads.
retries a failed replacement read on the next tick without reporting the status check as delayed.Verified failing before the fix (probe:
expected ... 2 times, but got 1; cache stillundefined)and passing after.
Checks run (worktree
C:/wt/td-2960, head969984d7115c8ce032ed1a319d0ab84bd1a912e0, basee84ccfeda)npx vitest --run --maxWorkers=2 src/tests/store/captureStore.spec.ts src/tests/store/captureStore.polling.spec.ts src/tests/store/captureStore.integration.spec.ts src/tests/store/captureStore.demo.spec.ts src/tests/composables/useInboxOrchestrator.spec.ts- 224 passed (220 before + 4 new).npx vitest --run --maxWorkers=2over the Inbox/detail consumer specs (InboxDetailPanel.degraded,primaryActionGuards,degradedMode,InboxView,views/paper/inbox/*,PaperInboxView) - 379 passed in 11 files.npm run typecheck(vue-tsc -b) - clean.npm run build- clean.npx eslint src/store/captureStore.ts src/tests/store/captureStore.polling.spec.ts- clean.node scripts/check-docs-governance.mjs,node scripts/check-doc-links.mjs- both pass (one sentence added todocs/product/INBOX_TRIAGE_POLLING.md,Last Updatedbumped).NOT verified
tests/e2e/inbox-triage-polling.spec.tsneeds a runningstack); the recovery is exercised at the store contract level only.
captureApi, not a realGET /api/capture/items/{id}under a real network.(at most one extra GET per gap, cleared by any cache write) but not measured.