Skip to content

fix(review): the recovery sentence survives an explicit load, and a refusal retraction says only that refreshes are accepted again - #2702

Merged
Chris0Jeky merged 6 commits into
mainfrom
issue-2638/recovery-retire
Sep 5, 2026
Merged

fix(review): the recovery sentence survives an explicit load, and a refusal retraction says only that refreshes are accepted again#2702
Chris0Jeky merged 6 commits into
mainfrom
issue-2638/recovery-retire

Conversation

@Chris0Jeky

@Chris0Jeky Chris0Jeky commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Summary

The review queue's recovery sentence was retired by the next successful read of any kind. Explicit loads take the same recordQueueRefreshSuccess path and are common right after a recovery (post-decision reloads in useReviewActions, the batch composables, the board-filter watcher, dismissSettledElsewhereNotice, the pre-decision refresh barrier), so a reviewer's already-clicked Approve landing a few hundred milliseconds after the recovering poll emptied the polite live region before it could be spoken. The sentence is now retired only by a later background poll success, or by the degraded or refusal onset, which is where the sentence becomes false rather than merely old.

The refusal retraction also gets its own sentence. It is raised the moment the LIST read answers, on a tick whose composite read can still fail at the deep-link leg and return before the queue is replaced, so the shared #2630 sentence's second clause ("Showing current proposals") was unproven for up to two further poll intervals. review.queue.refused.recovered says only that the server is accepting refreshes again and nothing about the queue's contents.

Refs #2638 (item 2; items 1 and 3 are parked on the D-15 layout decision and are not touched here), Refs #2214 (the refusal-retraction copy follow-up recorded from PR #2694's round 2), Refs PRs #2630, #2694.

Changes

Commit 1, the retirement rule (src/composables/useReviewProposals.ts):

Commit 2, the refusal sentence (src/locales/{en,it,es}/review.ts, src/views/paper/PaperReviewView.vue, src/views/LegacyReviewView.vue):

  • New key review.queue.refused.recovered in the three catalogs, with a translator note saying the clause about the proposals being current belongs to degraded.recovered and must not be repeated here.
  • The view change is exactly the key each hoisted region reads: both skins now pick review.queue.refused.recovered when the kind is 'refused' and keep review.queue.degraded.recovered otherwise. The regions stay always mounted, keep their role, aria-live and aria-atomic, and keep the fix(review): localize the degraded-queue warning, announce recovery, keep it visible #2630 hoisting above the branch pair; nothing else in either view moved.

Commit 3, the specs (useReviewProposals.spec.ts, ReviewView.spec.ts, PaperReviewView.spec.ts).

Behaviour deliberately kept: when a fully successful read ends a visible degraded state, the queue sentence wins over a 'refused' kind raised earlier in that same read, because that read did replace the rendered rows.

Test plan

Verified, from frontend/taskdeck-web:

  • Red first against the unmodified sources (implementation parked, specs in place), npx vitest --run --maxWorkers=2 src/tests/composables/useReviewProposals.spec.ts: 4 failed / 161. "is not retired by an explicit load inside the poll interval" and "is not retired by an explicit load that follows a FAILED background tick" both failed at expect(rp.queueRefreshRecovered.value).toBe(true) after the explicit load (expected false to be true, the defect itself); "announces the retraction with the refusal sentence, not the queue sentence" and "says nothing about the queue when the pin leg strands the composite read" failed on rp.queueRefreshRecoveredKind being undefined.
  • Red first in both skins, same run of the five specs: ReviewView.spec.ts > discloses a list read the server keeps refusing and the new PaperReviewView.spec.ts > announces a refusal retraction with the refusal sentence, not the queue one each failed with the region rendering "This review queue is up to date again. Showing current proposals." where the refusal sentence is expected. 6 failed / 450 across the five files.
  • Green after the fix and again after merging origin/main 5b34beb: npx vitest --run --maxWorkers=2 src/tests/composables/useReviewProposals.spec.ts src/tests/views/paper/review/PaperReviewView.spec.ts src/tests/views/ReviewView.spec.ts src/tests/views/paper/review/PaperReviewView.language.spec.ts src/tests/i18n/catalogs.spec.ts: Test Files 5 passed, Tests 450 passed. The catalog parity guard covers the new key, the Paper language spec pins the Italian degraded recovery unchanged, and the fix(review): disclose refused poll refreshes, name a malformed link, and make the explicit-path outcome consistent #2694 explicit-path outcome matrix stays green.
  • Collateral sweep, 7 files / 77 passed: ReviewView.coverage.spec.ts, PaperReviewActiveProposalStability.spec.ts, PaperReviewMembershipFilter.spec.ts, ReviewQueueRail.spec.ts, workspaceReviewRoute.spec.ts, nativeBrowserDialogs.spec.ts, primaryActionGuards.spec.ts.
  • npm run typecheck clean, npx eslint clean on the nine changed files, git diff --check clean.

NOT verified: Playwright and a real screen-reader run, so the claim that the sentence now survives long enough to be spoken rests on the composable's state, not on an announcement observed by assistive technology. The Italian and Spanish wording for the new key is this lane's, not native-reviewed (OUTSTANDING_TASKS #1770). No browser check of either skin.

Boundaries and risks

Owned and touched: useReviewProposals.ts, the three review catalogs (review.queue.* only), useReviewProposals.spec.ts, and the two review views plus their specs for the region's key alone. Not touched: the sticky layout seam (issue items 1 and 3, D-15), the receipt recorders, the diff pane, useReviewActions.ts (its reload calls are consumers of the fix, not part of it).

Risks: a recovery sentence now stands until a later background read succeeds, so if the poll is stopped or suspended while a sentence is up, it stays up until the surface unmounts; the visible warnings are unaffected and the sentence is gated on !queueAccessRevoked in both skins. Two live regions can still speak within one interval when a refusal and a degraded state resolve in sequence, which is the #2214 double-announcement item already tracked there.

Round 2

One MEDIUM from the fresh-context review, fixed at 32c46790c. raiseQueueRecovery stamped every raise with the ordinal of the last STARTED background read. For a poll raise that is the read currently running, which is correct, but the #2630 explicit-clear path (loadProposalsWithOutcome into recordQueueRefreshSuccess) raises BETWEEN ticks, so the stamp named a read that had already finished and the very next tick satisfied count > stamp. A post-decision reload that ended the degraded state 14.9 s into a 15 s cycle was therefore blanked about 100 ms later: the same defect this PR exists to close, with the roles swapped. Not a regression against main, but the comment claimed a mechanism that only held for poll-raised sentences.

The stamping rule is now explicit and set by source. A poll raise is stamped with the read it was raised in, so it survives that read and retires on the next later poll success. An explicit raise is stamped with the read that has not started yet, so it survives the next poll success and retires on the one after. Either raise gets at least one full poll interval, and the comment at backgroundQueueReadCount states both halves. The degraded and refusal onsets still retire immediately for both stamps, because there the sentence is false rather than merely old. source is threaded through recordQueueListReadSucceeded so a refusal retraction is stamped by the same rule.

Two comment-only LOWs taken in the same commit. The queue-sentence upgrade is now justified by a visible degraded state having ended rather than by the read completing, and the refusal-only case (a fully successful poll clearing a refusal with no degraded state keeps the refusal sentence) is named as a deliberate under-claim that says nothing false. The retirement comment now names the reachability-limited gap: a hash-pinned by-id read failing every tick with a status that is neither transient nor a pin-level outcome (405, 410 or 409, which this route is documented not to emit) records no further composite success, so only an onset retires a standing sentence now that an explicit success no longer bounds it.

Red first against the round-1 head, npx vitest --run --maxWorkers=2 src/tests/composables/useReviewProposals.spec.ts: 1 failed / 163. "gives an EXPLICIT-load recovery a full interval before a poll can retire it" failed with "expected false to be true" at the assertion after the FIRST poll tick, which is the defect. Its companion pin, "still retires an explicit-load recovery immediately at a degraded onset", was green before and after by design: it guards the onset path against this fix.

Green at the round-2 head: the same five-spec command as above gives Test Files 5 passed, Tests 452 passed, run before and again after merging origin/main 27b0e9191 (PR #2700, the inbox composer, disjoint from every file here). npm run typecheck clean, npx eslint clean on the nine changed files, git diff --check clean.

NOT verified is unchanged from round 1: no Playwright, no screen-reader run, no browser check, and the it/es wording for the new key is this lane's rather than native-reviewed.

…round poll

The #2630 recovery sentence was retired by the next successful read of any
kind, and explicit loads take the same recordQueueRefreshSuccess path: a
post-decision reload, a batch composable, the board-filter watcher or the
pre-decision barrier landing a few hundred milliseconds after the recovering
poll blanked the polite live region before it could be spoken (#2638 item 2).

recordQueueRefreshSuccess now takes the read kind and retires only when a
BACKGROUND poll success belongs to a later read than the one that raised the
sentence, counted by a background-read ordinal on the composable. The degraded
and refusal onsets still retire it, because there the sentence is false rather
than merely old. Explicit loads still RAISE one when they end a degraded state,
and the #2694 list-scoped raise and its recoveryAlreadyRaised guard are
unchanged, as are #2445's composite semantics for the transient counter.

The signal also carries WHICH disclosure it retracts (degraded or refused), so
the surfaces can say the narrower thing for a refusal retraction.

Refs #2638, #2214
A refusal disclosure is retracted the moment the LIST read answers, on a tick
whose composite read can still fail at the deep-link leg and return before the
queue is replaced. Both skins announced that with the queue sentence, whose
second clause ('Showing current proposals') was therefore unproven for up to
two further poll intervals (#2214, recorded from PR #2694's round 2).

review.queue.refused.recovered says only that the server is accepting refreshes
again, in en, it and es. Each skin's hoisted sr-only recovery region picks the
key from the kind the composable reports; the regions stay always mounted and
hoisted above the branch pair exactly as #2630 left them.

Refs #2638, #2214
Composable, red first against the unmodified sources: a poll recovery followed
by an explicit load inside the interval left the sentence retired (expected
false to be true, twice, including the case where a failed background tick
sits between the two), and the refusal retraction had no kind to report
(queueRefreshRecoveredKind undefined, twice). 4 failed / 161.

Views, red first the same way: the Legacy refusal-retraction case rendered the
queue sentence where the refusal one belongs, and the new Paper case did too
(each 'expected ... to be undefined', the key not existing yet).

Green after the fix: 5 files / 450 across the composable spec, both view specs,
the Paper language spec and the catalog parity guard.

Refs #2638, #2214
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Review record (alpha product-trust lane, review-and-ship round 1 at head 94da4aa68).

Reviewer: one fresh-context independent reviewer subagent (read-only; Codex credits exhausted, SC-9), given the worktree at the head and the merge-base diff. Verdict: SHIP, no merge-blocking items; one MEDIUM fixed in round 2 because it is the defect class this PR closes, with the roles swapped.

Lenses that found nothing: the read counter is incremented exactly once per started background read, before any await, with the in-flight flag held synchronously, and never on the explicit path (refreshProposals has no production caller but the poll); a list-leg raise in read N stamps N so the same read's composite success cannot retire it; superseded or aborted reads can only delay retirement; the counter is per mount and never reset, so no stale stamp wedges it; both onsets are the only writers of their flags and both retire either kind, so a refused raise cannot overwrite a standing degraded sentence; the degraded raise is reached only after the rows were replaced on both paths, so "Showing current proposals" is true wherever spoken; both skins render one always-mounted hoisted region with identical conditions, role and aria attributes and nothing else renders either sentence; the three catalogs are structurally parallel and the parity spec covers them; queueRefreshRecovered is a computed no consumer writes.

Findings, triaged once:

  • MEDIUM, fixed in round 2: an explicit-load-raised recovery sentence is stamped with the last started background read, so the very next poll tick retires it, possibly milliseconds after it gained text (three transient failures, a post-decision reload succeeding late in the cycle, the poll firing right after); not a regression, but the comment claimed one interval of life for every raise. An explicit raise now survives the next background success and retires on the one after, with a red-first spec and the rule stated.
  • LOW, comment taken in round 2: the rationale for upgrading a refused kind to degraded in one read is broader than the branch that implements it (the upgrade is gated on a degraded state having ended); the refusal-only case under-claims, never falsely.
  • LOW, comment taken in round 2: a pin leg failing with 405 or 410 (which this route does not emit) would leave a standing sentence unretired for the session now that an explicit success no longer bounds it; named beside the retirement rule.

Round count: 2 after the fix push. Merge gate: CI green at the fix head, the three-minute age, and one read-only verification pass scoped to the fix diff (the fix changes composable logic).

Round-2 review finding, MEDIUM: raiseQueueRecovery stamped every raise with
the ordinal of the last STARTED background read. That is the running read for a
poll raise, but for the #2630 explicit-clear path (loadProposalsWithOutcome ->
recordQueueRefreshSuccess) it names a read that already finished, so the very
next tick satisfied count > stamp and retired the sentence. A post-decision
reload that ends the degraded state 14.9 s into a 15 s cycle was blanked 100 ms
later: the defect this PR exists to close, with the roles swapped.

An explicit raise is now stamped with the read that has not started yet, so it
survives the next poll success and retires on the one after. Either raise
therefore gets at least one full interval, and the comment states both halves
instead of claiming one interval for all of them. The onsets still retire
immediately for both, because there the sentence is false rather than old.
'source' is threaded through recordQueueListReadSucceeded so a refusal
retraction is stamped by the same rule.

Red first against the round-1 head: 'gives an EXPLICIT-load recovery a full
interval before a poll can retire it' failed 'expected false to be true' at the
first poll tick, 1 failed / 163. The companion onset pin was green before and
after, which is the point of it.

Also two comment-only round-2 LOWs: the queue-sentence upgrade is justified by
the degraded state having ended rather than by the read completing, with the
refusal-only case named as a deliberate under-claim; and the retirement comment
now names the reachability-limited case where a pin leg failing every tick with
405/410/409 (which this route does not emit) leaves a sentence standing until an
onset, now that an explicit success no longer bounds it.

Refs #2638, #2214
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Round-2 record (alpha product-trust lane; fix head 0a00c6ded, one fix commit on top of 94da4aa68 plus a merge of main 27b0e9191, which touches only the inbox composer files of PR #2700).

Fixed: raiseQueueRecovery(kind, source) stamps by source: a poll raise is stamped with the read it was raised in and retires on the next later poll success; an explicit raise is stamped with the read that has not started yet, so it survives the next poll success and retires on the one after; either way the sentence has at least one full poll interval, and the onsets still retire either immediately because there the sentence is false rather than old. The source is threaded through the list-scoped refusal retraction so it is stamped by the same rule. Comments: the rule stated in both halves; the queue-sentence upgrade documented as spoken only when a completed read ended a visible degraded state, with the refusal-only case a deliberate under-claim; the reachability-limited 405/410/409 pin loop named beside the retirement rule.

Red-first: with the round-1 implementation parked and the new spec in place, "gives an explicit-load recovery a full interval before a poll can retire it" failed at the assertion after the first poll tick (the round-1 head retired it on the very next tick); the companion onset pin was green before and after by design.

Verified at the fix head: the five named suites 5 files, 452 passed (run before and after the merge of main); npm run typecheck clean; eslint on the changed files clean; git diff --check clean.

Not verified: no browser or screen-reader run; the it/es wording is the lane's (#1770).

Merge gate remaining: CI green at 0a00c6ded, the three-minute age, and the read-only verification pass scoped to the fix diff (composable logic changed), running now. Round count: 2.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Round-2 verification record (scoped to the fix diff 94da4aa68..0a00c6ded, the composable and its spec; read-only pass, fresh context).

Verdict: SHIP for the fix diff, no CRITICAL, HIGH or MEDIUM.

What it checked: the stamp arithmetic (an explicit raise between reads N and N+1 stamps N+1, survives read N+1's success and retires at N+2; a poll raise in read N still retires at N+1); the in-flight case cannot lengthen the life beyond two intervals because an explicit load supersedes the in-flight background read synchronously, so the next success is N+2 and retirement N+3, the same envelope as an explicit load landing right after a fast read; the poll cannot start between an explicit load's start and its raise because the loading flag blocks it before the counter bump, so "count + 1" always names a read that has not started; the refusal retraction's source parameter is required with both call sites explicit and the default source is the safe direction; both onsets retire unconditionally; the new spec is a genuine regression guard (on the round-1 code the stamp would be 3 and read 4 would retire), the onset case is a fresh run to the threshold, and the four round-1 recovery and refusal cases stay green with the new stamps; the comments match the branches; the round-2 diff is the two files and the PR's full diff is the nine files.

Findings, triaged once at the two-round ceiling, both recorded on #2638:

  • LOW: the strengthened gloss "at least one full poll interval of life" is stronger than the behaviour: reads are not one per interval (a visibility change triggers an immediate read, so two rapid tab switches can retire a sentence within one interval), and when the intervening read fails or is superseded an explicit raise is retired by the next poll success rather than the one after. The precise rule in the first half of the comment (retirement by a poll success belonging to a read later than the stamp) is exactly what the code does; the gloss and the matching spec comment should say "at least one later background success", not "one full interval".
  • LOW: an explicit list success ending a refusal (the refusal kind raised with the explicit stamp) is asserted by no spec; the arithmetic is shared, so the risk is small.

Round count: 2. Merge gate remaining: CI green at 0a00c6ded and the three-minute age. The Codex connector's usage-limit notice on this PR (18:26Z) is classified once as informational (SC-9).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant