fix(review): bound the post-revision truth refresh and keep it retryable - #2576
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Review gate (Codex credits exhausted, SC-9): one fresh-context adversarial reviewer on head Findings by bin:
Evidence at the reviewed head (from the implementation run): 394 tests across six Review files (331 in the three touched files, 319 before), locale parity, typecheck, ESLint, build, diff check; 5 of 5 new composable specs and the two stall specs red against the pre-change sources. Not verified: Playwright, real-network deadline behaviour. Merge after the fix commit ages and |
|
Round 2: scoped second-pass fresh-context review of the fix diff (d80fb7e..35b260b), verdict SHIP. Confirmed: the epoch delete is now gated in series on the attempt generation, a refreshed outcome (which needs the full identity re-verification) and a re-read of the epoch, so a save landing mid-attempt keeps its newer barrier and the attempt returns superseded silently (safe direction, terminates); the guard extension is behaviour-preserving on screen and only withholds the three reports once the reviewer moved; unmount bumps the generation before dispose so the aborted attempt's finally writes nothing; dispose cannot tear down the shared core batch on a normal ending because the wait's abort listener is removed in the race's finally; skipRetry reaches only the barrier's queue read and deep-link leg (the poll and the plain loads are pinned by an untouched arity assertion); all four new specs are red against the round-1 sources for the right reason; four files touched, none of useProposalRevisions.ts, ReviewDecisionRail.vue, ReviewMain.vue or usePaperReviewSelectors.ts. Three LOWs, tracked on #2581, none actioned: an unmount during the evidence leg can trigger the same-action retry's six fresh GETs that nobody aborts (network only, no state write); with skipRetry the deep-link leg can surface review.toast.loadProposalFailed on a single transient blip while the barrier outcome stays safe; one comment sentence about withholding is exact only for the reachable cases. Round count: 2. Merge after ci-required is green at 35b260b and the head has aged; the Smart CI / Plan red on this head is the shadow lane's stacked-base planner shape (#2562) and is advisory. |
…oked too Review round 2. The round-1 gate covered only the loading state. A current-scope 403 sets queueAccessRevoked AND clears the queue, so the announcement changed from a real count to 0 — a change, therefore spoken — while the panel beside it said the queue was gone and had stopped updating. Same defect one branch over, and the same in the Paper rail whenever the revoked state clears the queue. Both skins now gate on whether the count is a real count: Legacy on proposalsLoading or queueAccessRevoked, the rail on a second optional queueUnavailable prop alongside loading. Two props rather than one derived boolean, so Paper passes its two real states and the reason survives at the call site; both stay optional and defaulted, so PaperReviewView still needs no edit. The rail prop doc now says the Paper wiring is pending on #2214 and blocked on #2576, rather than reading as though the defect were closed.
PR #2576 has merged, so PaperReviewView.vue is free and the deferred half of this PR can land. The rail element now passes the two states it already had to hand, closing the gap this PR opened deliberately: Paper no longer announces "0 proposals awaiting review." under its own loading state, nor when a 403 clears the queue beside the access-revoked panel. Only the two attributes on the ReviewQueueRail element are added; the barrier region #2576 changed in the same file is untouched. The rail's prop docs drop the wiring-pending pointer now that both skins are wired.
Summary
The post-revision truth barrier (PR #2448) holds the shared decision lock across two reads: one authoritative proposal-queue read and the six core evidence reads for the exact revision key. Neither read had a caller-owned deadline. A leg that never answered held
applyGuardBusyandrevisionReviewRefreshBusyforever, so the decision rail stayed disabled, the keymap stayed inert, and the reviewer had no path back to their own decision and no explanation. This bounds the whole attempt and makes every non-landed ending retryable.Barrier sequence before this change:
await loadProposalsWithOutcome()runs with no signal and no deadline.landedreturns silently, with no persistent guidance.await selectors.waitForCoreBatch(id, revisionIdentity)runs with no caller signal and no deadline.failedshows one message;supersededandunavailableare silent.finallynever ran and the rail never unlocked.Barrier sequence after this change:
AbortControllerand one deadline (POST_REVISION_REVIEW_DEADLINE_MS, 12 s) covering both reads.refreshed,failed,timed-out,abortedorsuperseded.refreshedrequires the same identity, status, expiry, defer state and effective revision on the re-read DTO, a fully landed exact-key selector batch, and a matching epoch. Only then is the epoch deleted.failedandtimed-outshow distinct persistent notes and toasts.abortedandsupersededstay silent, assupersededdid before.The deadline both aborts the shared controller and resolves a race marker. Aborting alone is not enough, because a transport that ignores cancellation is exactly the stall being guarded against.
Preview == Apply, the explicit approve and explicit execute separation, and the requirement of a second explicit action after a clean refresh are unchanged. No approve or execute request is issued by any barrier path.
Closes #2460
Changes
frontend/taskdeck-web/src/views/paper/PaperReviewView.vue— barrier region only. AddsPOST_REVISION_REVIEW_DEADLINE_MSwith a comment in the spirit ofBOARD_REQUEST_TIMEOUT_MS,startRevisionReviewAttempt()(one controller plus one deadline per attempt), the five-memberRevisionReviewRefreshOutcome, an attempt generation guard, and a reason-keyed unavailable map so timed out and failed get different copy.refreshRevisionReviewBeforeApplyis split intorunRevisionReviewRefresh(decides the outcome),applyRevisionReviewOutcome(acts on it) and the lock or focus wrapper. The identity, status, expiry, defer and epoch checks are carried over unchanged.frontend/taskdeck-web/src/composables/useReviewProposals.ts—loadProposalsWithOutcome(options?)accepts a caller signal, forwards it toautomationApi.getProposalsand to the deep-link leg, and reports the newabortedmember instead offailed. An aborted read raises no toast, does not become the rendered queue, and does not clear the stale-queue indication. The second argument is forwarded only when a signal is supplied, so every existing call site keeps its exact one-argument shape.frontend/taskdeck-web/src/composables/usePaperReviewSelectors.ts—waitForCoreBatch(id, identity, options?)accepts a caller signal, adds theabortedoutcome member, and cancels the reads it was holding open. The cancellation is scoped to the waited key so an abandoned wait cannot tear down a batch the reviewer has since moved to. The same-action retry of fix(review): retry unavailable evidence in the same action #2528 is factored intorunCoreBatchWithSameActionRetryand is otherwise unchanged.frontend/taskdeck-web/src/locales/{en,it,es}/review.ts— addsreview.toast.revisionReviewTimedOutin all three languages.PaperReviewView.spec.ts,useReviewProposals.spec.tsandusePaperReviewSelectors.spec.ts.No API layer change was needed:
automationApialready took{ signal, skipRetry }andproposalDeepReviewApialready took{ signal }.Test plan
Verified, from
frontend/taskdeck-webafternpm ci:npx vitest --run --maxWorkers=2 src/tests/views/paper/review/PaperReviewView.spec.ts src/tests/composables/useReviewProposals.spec.ts src/tests/composables/usePaperReviewSelectors.spec.ts src/tests/composables/useProposalRevisions.spec.ts src/tests/views/paper/review/ReviewDecisionRail.spec.ts src/tests/views/paper/review/ReviewMain.spec.ts— 6 files, 394 tests passed (319 in the three touched files before this change, 165 + 121 + 45 in them now).npx vitest --run --maxWorkers=2 src/tests/i18n/catalogs.spec.ts src/tests/guards/nativeBrowserDialogs.spec.ts src/tests/guards/primaryActionGuards.spec.ts— 3 files, 28 tests passed (locale parity for the new key).npm run typecheck— clean.npx eslintover every changed file — clean.npm run build— succeeded.git diff --check— clean.New specs and their pre-change state. Source files were reverted to the base commit
8c51120with the specs left in place, and the suite re-run:Red against the pre-change code (5 of 5 composable specs, 2 of 5 view specs):
useReviewProposals: reports a caller-aborted explicit load as aborted rather than failed; does not issue an explicit load whose caller has already given up; reports an aborted deep-link leg as aborted and raises no lookup error.usePaperReviewSelectors: reports a caller-cancelled wait as aborted and stops the reads it held open (pre-change it hung for the full 5 s test timeout); reports a wait whose caller has already given up as aborted without reading.PaperReviewView: releases the rail on its deadline when the authoritative queue read never answers; releases the rail on its deadline when a core evidence read never answers. Both cover stalled transport with fake timers, the retry succeeding on the next explicit action, late-response suppression after the timeout, and no approve or execute before the successful second explicit action.Green against the pre-change code, added as regression guards rather than as proofs of new behaviour:
PaperReviewView: names a partial core evidence failure as a failure, never as a timeout; names a total core evidence failure as a failure and retains the barrier; disarms its deadline once the refresh lands, so no late timeout note appears. The failure behaviour itself shipped in fix(review): require authoritative refresh after revisions #2448 and fix(review): retry unavailable evidence in the same action #2528; what is new in these is the assertion that failure copy and timeout copy never substitute for each other, and that all six reads rejecting behaves like one rejecting.usePaperReviewSelectors: still reports a genuine read failure as failed when a signal is supplied; does not cancel the batch the reviewer moved to when an abandoned wait is cancelled. The second was written after the scoping fix in commitd80fb7eand is red against the intermediate unscoped version, not against the base.NOT verified:
tests/e2e/review-proposals.spec.ts, which holds the fix(review): require authoritative refresh after revisions #2448 browser proof. It needs a running stack, which this worktree does not have. The spec was not edited and the data-testids it uses (decision-apply,decision-lock-note,paper-review-evidence-unavailable) are unchanged.Boundaries and risks
useProposalRevisions.ts(fix(review): keep revision metadata truthful across an in-flight GET #2565),ReviewDecisionRail.vueandReviewMain.vue(fix(review): associate the refresh-lock reason with every disabled decision #2568), and the e2e spec.activeRevisionReviewUnavailablestays a boolean computed precisely so theevidence-unavailableprops intoReviewMainkeep their current shape; the new reason only feeds the note text insidePaperReviewView.vue. The lock-reason id reaching the rail is the onePaperReviewView.vuealready passes.PaperReviewView.vueis large; only the barrier region was changed. No opportunistic refactors.loadProposalsWithOutcomeandwaitForCoreBatchgained an optional argument and a new outcome member. Every existing caller passes no options and so cannot observeaborted; the exhaustive branches in the view were updated. ThegetProposalscall keeps its exact one-argument shape when no signal is supplied, which is whatautomationApi.spec.tspins.selectors.loadingtrue until that promise resolves, becausePromise.allSettledcannot resolve earlier. That is an honest reflection of a read that never came back; it does not gate the rail, which is released by the deadline, and the barrier note states that no decision was made.docs/STATUS.mddoes not enumerate at that granularity.Round 2
Review verdict was SHIP with no CRITICAL or HIGH; both invariants held under attack. This round fixes the three MEDIUM findings and the two LOW takes on this PR's own new paths. Head
35b260b25.MEDIUM 1 — an unattributable barrier report could be read as an instruction to decide on another proposal.
applyRevisionReviewOutcometoasted unconditionally while only the note was guarded by "is this still the active proposal". The queue rail is not disabled by the decision lock, so a reviewer could select B while A's barrier was reading, then see "Refreshing the review took too long ... Choose the current action again to retry" over B. Following that on B, which has no barrier, falls straight through tohandleApproveProposal(B). The note and the toast are now one report behind one guard, and the guard covers the refreshed path too: "Check the current evidence, then choose the action again" read on B is the same hazard. Withholding costs nothing, because the barrier state is written separately from the report, so returning to A still refreshes.MEDIUM 2 — a barrier attempt outlived its route.
onUnmountedstopped the clock, the poll and the collaboration read but left the attempt running, so itssetTimeoutfired after navigation and reported on whatever page the reviewer had moved to.dispose()also only cleared the timer, so an early failed or superseded ending left its reads running.dispose()now aborts the controller as well, the current attempt is tracked inactiveRevisionReviewAttempt, andonUnmountedbumps the attempt generation first and then disposes, so nothing can be applied after unmount.MEDIUM 3 — retries could spend the deadline and turn a recoverable failure into a reported timeout. The barrier's queue read and its deep-link leg now pass
skipRetry: truealongside the signal, matching the background poll. WithMAX_RETRIES3 and 1 s doubling backoff, a single transient failure could otherwise consume roughly 7 s of the 12 s cap, and aRetry-Aftercan exceed it outright.The six core evidence reads were left retrying, and the constant's doc comment now states that assumption instead of implying retries are skipped everywhere. Two reasons, as invited:
proposalDeepReviewApi.RequestOptionscarries onlysignaland each of the seven methods hardcodes{ signal: options?.signal }, so a per-call opt-out means changing the shared API surface; and more decisively,ensureCoreBatchreuses a batch the automatic watcher may already have started for the same key, which is the common path here because the queue read lands a new revision identity and triggers that watcher. A per-call flag would therefore apply only when the barrier happened to be the batch's creator, making the same user action retry or not depending on a race. Uniform retrying is the better of the two, so the budget documents that it must still cover one interceptor retry pass on the evidence leg.LOW take — the barrier clear now sits behind the attempt-generation guard.
revisionReviewRefreshEpochs.delete(key)ran insiderunRevisionReviewRefresh, ahead of the generation check. The runner now decides the outcome and writes nothing; the newclearRevisionReviewBarrierperforms the delete in the caller, after the generation check, and re-reads the epoch rather than trusting the verification block, so a save that landed while the attempt was resolving cannot have its newer barrier dropped. Unreachable today, ordering hygiene as noted.LOW take — the deep-link leg now has an exact-args assertion that it receives the same signal and
skipRetry, mirroring thegetProposalsone.Not changed, per the review: the note keyed to the pre-save revision identity vanishing when the 15 s poll lands the new identity and leaving a stale map entry, and
runCoreBatchWithSameActionRetryre-issuing an uncancellable six-read batch after a deadline abort.Round 2 test plan
Verified, from
frontend/taskdeck-web:npx vitest --run --maxWorkers=2 src/tests/views/paper/review/PaperReviewView.spec.ts src/tests/composables/useReviewProposals.spec.ts src/tests/composables/usePaperReviewSelectors.spec.ts src/tests/composables/useProposalRevisions.spec.ts src/tests/views/paper/review/ReviewDecisionRail.spec.ts src/tests/views/paper/review/ReviewMain.spec.ts src/tests/i18n/catalogs.spec.ts— 7 files, 416 tests passed. The six-file set alone was 394 in round 1 and is 398 now;catalogs.spec.tsadds 18.npm run typecheck— clean.npx eslintover every changed file — clean.npm run build— succeeded.git diff --check— clean.Four new specs, all red against the round-1 head
d80fb7eb7with the source files reverted and the specs left in place:PaperReviewView: reports nothing when the reviewer moved to another proposal before the deadline — arms A, stalls the queue read, selects B, fires the deadline, asserts no toast and no note on B, then returns to A and asserts its barrier still refreshes instead of approving.PaperReviewView: cancels an in-flight attempt on unmount so its deadline never reports — asserts the barrier's signal is aborted by the unmount and that advancing twice the deadline afterwards produces no toast at all.useReviewProposals: forwards a caller opt-out of the shared retry interceptor.useReviewProposals: reports an aborted deep-link leg as aborted and raises no lookup error — extended with the exact-args assertion ongetProposal.Still NOT verified, unchanged from round 1: Playwright
tests/e2e/review-proposals.spec.ts(needs a running stack, not edited), the full frontend vitest suite, the backend, and any real-network behaviour of the deadline or ofskipRetry. Every stall in these specs is a test double that never settles.