From 203cf43cbccee55a0a9f129220091e9eec091e0d Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 19:25:12 +0100 Subject: [PATCH 1/4] fix(review): retire the queue recovery sentence only on a later background 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 --- .../src/composables/useReviewProposals.ts | 156 ++++++++++++++---- 1 file changed, 123 insertions(+), 33 deletions(-) diff --git a/frontend/taskdeck-web/src/composables/useReviewProposals.ts b/frontend/taskdeck-web/src/composables/useReviewProposals.ts index f8a4db190..f6f3b6890 100644 --- a/frontend/taskdeck-web/src/composables/useReviewProposals.ts +++ b/frontend/taskdeck-web/src/composables/useReviewProposals.ts @@ -236,6 +236,23 @@ export function isProposalStale(proposal: ApiProposal, nowMs: number): boolean { return nowMs - createdMs >= STALE_PROPOSAL_MS } +/** + * WHICH disclosure a standing recovery sentence retracts (#2638 item 2). + * + * The two are not interchangeable, because they are retracted on different + * evidence. 'degraded' ends the transient state, which is a claim about the + * RENDERED QUEUE, so its sentence may say the rows on screen are current: it is + * only raised by a read that completed and assigned `proposals.value`. + * 'refused' ends the refusal disclosure, which is a claim about the LIST + * REQUEST alone: it is raised the moment the list leg answers, on a tick whose + * composite read may still bail at the pin leg without replacing the queue, so + * its sentence must say the server is accepting refreshes again and NOTHING + * about the contents (#2214, from PR #2694's round-2 verification: the shared + * sentence's second clause overclaimed for up to two poll intervals on a + * list-success/pin-fail loop). + */ +export type QueueRecoveryKind = 'degraded' | 'refused' + export function useReviewProposals() { const route = useRoute() const router = useRouter() @@ -296,21 +313,54 @@ export function useReviewProposals() { // cannot arrive. Both can stand at once; the surfaces show the refusal, // which is the stronger and more actionable statement. const queueRefreshRefused = ref(false) - // The EVENT that pairs with the state above (#2214). Clearing - // `queueRefreshStale` unmounts the warning, which is silent: a reviewer who + // The EVENT that pairs with the two states above (#2214), and WHICH of them + // it retracts (#2638 item 2). Clearing `queueRefreshStale` or + // `queueRefreshRefused` unmounts the warning, which is silent: a reviewer who // was not looking at that corner is never told the queue is trustworthy - // again, and a screen-reader user is told nothing at all. This is true only - // after a degraded state was actually cleared by a successful read, so an - // ordinary success never announces anything, and it falls back to false at - // the next degraded onset so a second recovery is announced too (a live - // region only speaks when its TEXT changes). The FOLLOWING success also - // retires it, so the sentence lives for about one poll interval rather than - // the rest of the session. + // again, and a screen-reader user is told nothing at all. A kind is set only + // after a disclosure was actually retracted by a successful read, so an + // ordinary success never announces anything, and it falls back to null at the + // next degraded or refusal onset so a second recovery is announced too (a + // live region only speaks when its TEXT changes). // // It is deliberately NOT cleared on a 403: the surfaces already gate this // sentence on `!queueAccessRevoked`, the same guard the warning uses, so the // permission path keeps its single owner. - const queueRefreshRecovered = ref(false) + const queueRefreshRecoveredKind = ref(null) + const queueRefreshRecovered = computed(() => queueRefreshRecoveredKind.value !== null) + // WHEN the sentence was raised, counted in BACKGROUND reads (#2638 item 2). + // + // THE RETIREMENT RULE: a standing recovery sentence is retired by the next + // degraded or refusal onset, or by a BACKGROUND poll success belonging to a + // LATER read than the one that raised it — never by an explicit load. + // + // Explicit loads take the same `recordQueueRefreshSuccess` path and are + // common within one poll interval of a recovery: the post-decision reloads in + // useReviewActions, the batch composables, the board-filter watcher, + // `dismissSettledElsewhereNotice` and the pre-decision refresh barrier all + // call `loadProposals`. A reviewer's already-clicked Approve can therefore + // land ~150 ms after the recovering poll and empty the region, and a polite + // live region whose text is reverted that fast may never be spoken at all + // (#2638 item 2, from PR #2630's verification pass). An explicit load that + // succeeds while the sentence stands leaves it standing; it still RAISES one + // when it is the read that ends a degraded state, which is the #2630 + // behaviour and is unchanged. + // + // Counting background reads rather than wall-clock keeps the composable free + // of teardown state and still gives the sentence about one poll interval of + // life, which is what #2630 intended. + let backgroundQueueReadCount = 0 + let queueRecoveryRaisedAtBackgroundRead: number | null = null + + function raiseQueueRecovery(kind: QueueRecoveryKind) { + queueRefreshRecoveredKind.value = kind + queueRecoveryRaisedAtBackgroundRead = backgroundQueueReadCount + } + + function retireQueueRecovery() { + queueRefreshRecoveredKind.value = null + queueRecoveryRaisedAtBackgroundRead = null + } let latestProposalLoadRequestId = 0 const availableBoards = ref([]) const loadingBoards = ref(false) @@ -704,7 +754,11 @@ export function useReviewProposals() { // An explicit successful load is as trustworthy as a successful poll and // clears any older degraded indication without changing load semantics. // It goes through the same accounting as a successful poll so both exits - // from the degraded state raise the recovery signal (#2214). + // from the degraded state raise the recovery signal (#2214) — but it + // never RETIRES a standing one, because it can land a few hundred + // milliseconds after the poll that raised it and blank the live region + // before it is spoken (#2638 item 2). No `source` is passed: the default + // is 'explicit'. recordQueueRefreshSuccess() // An explicit load that succeeded is proof access is back. const accessWasRevoked = queueAccessRevoked.value @@ -783,10 +837,10 @@ export function useReviewProposals() { consecutiveQueueRefreshRefusals += 1 if (consecutiveQueueRefreshRefusals >= REVIEW_QUEUE_CONSECUTIVE_FAILURE_THRESHOLD) { queueRefreshRefused.value = true - // Same reason as the degraded onset below: a standing "up to date - // again" contradicts the warning beside it, and its unchanged text - // would silence the next real recovery. - queueRefreshRecovered.value = false + // Same reason as the degraded onset below: a standing recovery + // sentence of EITHER kind contradicts the warning beside it, and its + // unchanged text would silence the next real recovery. + retireQueueRecovery() } } else { // Anything that is not another qualifying list refusal interrupts the @@ -806,11 +860,14 @@ export function useReviewProposals() { consecutiveQueueRefreshFailures += 1 if (consecutiveQueueRefreshFailures >= REVIEW_QUEUE_CONSECUTIVE_FAILURE_THRESHOLD) { queueRefreshStale.value = true - // A degraded onset retires the previous recovery announcement. Leaving it - // standing would both contradict the warning beside it and stop the NEXT - // recovery from being announced, because the live region's text would - // never change. - queueRefreshRecovered.value = false + // A degraded onset retires the previous recovery announcement, whichever + // disclosure it retracted. Leaving it standing would both contradict the + // warning beside it and stop the NEXT recovery from being announced, + // because the live region's text would never change. This is the one + // retirement that is deliberately not gated on the read kind (#2638): it + // is not "the sentence has been up long enough", it is "the sentence is + // now false". + retireQueueRecovery() } } @@ -854,34 +911,61 @@ export function useReviewProposals() { // Retracting a disclosure silently is exactly the #2630 defect: the warning // is simply gone on the next render and a reviewer who was not watching // that corner is never told the refusal claim no longer holds. - queueRefreshRecovered.value = true + // + // Its OWN sentence, not the queue one (#2638 item 2). All this evidence + // supports is that the list request is being answered again: the pin leg + // can still fail below and return before `proposals.value = next`, so + // "showing current proposals" would be false for up to two more poll + // intervals (#2214). + raiseQueueRecovery('refused') return true } /** - * The WHOLE composite read landed. `recoveryAlreadyRaised` is passed by the - * poll when `recordQueueListReadSucceeded` already announced a recovery - * earlier in this same read, so the retirement branch below cannot retire the - * sentence its own read just raised. + * The WHOLE composite read landed. + * + * `source` names the read that landed, because only a BACKGROUND one may + * retire a standing recovery sentence (#2638 item 2 — see the retirement rule + * at `backgroundQueueReadCount`). It defaults to 'explicit' so every caller + * that is not the poll is safe by construction. + * + * `recoveryAlreadyRaised` is passed by the poll when + * `recordQueueListReadSucceeded` already announced a recovery earlier in this + * same read, so the retirement branch below cannot retire the sentence its + * own read just raised (#2694). The read counter states the same fact for + * every kind of read; both are kept because they answer different questions — + * "did THIS read raise it" and "was it raised by an EARLIER read". */ - function recordQueueRefreshSuccess(options?: { recoveryAlreadyRaised?: boolean }) { + function recordQueueRefreshSuccess(options?: { + source?: 'poll' | 'explicit' + recoveryAlreadyRaised?: boolean + }) { consecutiveQueueRefreshFailures = 0 // Idempotent: a no-op when the poll already ran it at the list-success // point, and the whole clear when an explicit load lands. const recoveryRaisedThisRead = recordQueueListReadSucceeded() || options?.recoveryAlreadyRaised === true // Only a success that ends a VISIBLE degraded state is a recovery. Setting - // this on every success would make both skins announce every 15 s. + // this on every success would make both skins announce every 15 s. This + // read completed, so `proposals.value` was just replaced and the queue + // sentence's "showing current proposals" is true — including when it + // overwrites a 'refused' kind raised moments earlier in this same read, + // which is the stronger and now-provable statement. if (queueRefreshStale.value) { - queueRefreshRecovered.value = true - } else if (queueRefreshRecovered.value && !recoveryRaisedThisRead) { - // The FOLLOWING success retires the sentence, so it lives for about one - // poll interval instead of the whole session. An announcement is an + raiseQueueRecovery('degraded') + } else if ( + options?.source === 'poll' && + !recoveryRaisedThisRead && + queueRecoveryRaisedAtBackgroundRead !== null && + backgroundQueueReadCount > queueRecoveryRaisedAtBackgroundRead + ) { + // A LATER background success retires the sentence, so it lives for about + // one poll interval instead of the whole session. An announcement is an // event; leaving its text standing indefinitely turns it into a claim // about the present that nothing is re-checking. Clearing it here rather // than on a timer keeps the composable free of teardown state, and the // clear is silent: a live region going empty announces nothing. - queueRefreshRecovered.value = false + retireQueueRecovery() } queueRefreshStale.value = false } @@ -978,6 +1062,11 @@ export function useReviewProposals() { async function refreshProposals(): Promise { // An explicit load is authoritative and about to replace the list wholesale. if (proposalsLoading.value || refreshInFlight) return + // This read's ordinal among background reads (#2638 item 2). Counted at the + // top, before any await, so a recovery raised anywhere inside this read + // belongs to THIS number and only a later read can retire it. Reads that + // return here never started, so they cannot age a standing sentence. + backgroundQueueReadCount += 1 // Snapshot the load counter rather than incrementing it: bumping it here // would make an in-flight `loadProposals` discard its own result AND skip // the `proposalsLoading = false` reset in its finally block, wedging the @@ -1133,7 +1222,7 @@ export function useReviewProposals() { } } proposals.value = next - recordQueueRefreshSuccess({ recoveryAlreadyRaised: listRecoveryRaised }) + recordQueueRefreshSuccess({ source: 'poll', recoveryAlreadyRaised: listRecoveryRaised }) // The queue moved under a reviewer who did not ask for it. Surfaces use // this to notice that the row they were rendering has just been dropped // or reordered away, instead of silently sliding onto another one @@ -1378,6 +1467,7 @@ export function useReviewProposals() { queueRefreshStale, queueRefreshRefused, queueRefreshRecovered, + queueRefreshRecoveredKind, availableBoards, loadingBoards, boardFilterInput, From ae76749f9de736a4a22da2ef0b0ad0af56d0b61c Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 19:25:19 +0100 Subject: [PATCH 2/4] fix(review): give the refusal retraction its own sentence in both skins 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 --- .../taskdeck-web/src/locales/en/review.ts | 10 ++++++++++ .../taskdeck-web/src/locales/es/review.ts | 1 + .../taskdeck-web/src/locales/it/review.ts | 1 + .../src/views/LegacyReviewView.vue | 20 +++++++++++++++++-- .../src/views/paper/PaperReviewView.vue | 20 ++++++++++++++++++- 5 files changed, 49 insertions(+), 3 deletions(-) diff --git a/frontend/taskdeck-web/src/locales/en/review.ts b/frontend/taskdeck-web/src/locales/en/review.ts index a7be01a83..065fef8b9 100644 --- a/frontend/taskdeck-web/src/locales/en/review.ts +++ b/frontend/taskdeck-web/src/locales/en/review.ts @@ -96,6 +96,15 @@ export default { // try. It shares the visible slot with `degraded.body` and takes precedence // over it, and it is also the text of a mounted sr-only live region in each // skin, so keep it speakable as one sentence run. + // + // `refused.recovered` is that failure's own recovery sentence, and it is + // deliberately NARROWER than `degraded.recovered` (#2638 item 2). The refusal + // is retracted the moment the LIST read answers, on a tick whose composite + // read can still fail at the deep-link leg and leave the rendered rows + // untouched, so this sentence must say only that the server is accepting the + // refresh again. Translators: do NOT add a clause about the proposals being + // current or up to date — that is `degraded.recovered`'s job, and saying it + // here overclaimed for up to two poll intervals before this key existed. queue: { degraded: { body: 'This review queue may be out of date. Showing the last available proposals while Taskdeck retries.', @@ -103,6 +112,7 @@ export default { }, refused: { body: 'This review queue has stopped updating. The server is refusing the refresh rather than failing temporarily, so these are the last proposals it confirmed. Reload the page, or check the board filter in the address bar.', + recovered: 'The server is accepting refreshes for this review queue again.', }, }, diff --git a/frontend/taskdeck-web/src/locales/es/review.ts b/frontend/taskdeck-web/src/locales/es/review.ts index 01c040819..397a5b89f 100644 --- a/frontend/taskdeck-web/src/locales/es/review.ts +++ b/frontend/taskdeck-web/src/locales/es/review.ts @@ -48,6 +48,7 @@ export default { }, refused: { body: 'Esta cola de revisión ha dejado de actualizarse. El servidor está rechazando la actualización en lugar de fallar temporalmente, así que estas son las últimas propuestas que confirmó. Recarga la página o revisa el filtro de tablero en la barra de direcciones.', + recovered: 'El servidor vuelve a aceptar las actualizaciones de esta cola de revisión.', }, }, diff --git a/frontend/taskdeck-web/src/locales/it/review.ts b/frontend/taskdeck-web/src/locales/it/review.ts index b42bcb76a..4504a6d23 100644 --- a/frontend/taskdeck-web/src/locales/it/review.ts +++ b/frontend/taskdeck-web/src/locales/it/review.ts @@ -49,6 +49,7 @@ export default { }, refused: { body: 'Questa coda di revisione ha smesso di aggiornarsi. Il server sta rifiutando la richiesta di aggiornamento invece di fallire temporaneamente, quindi queste sono le ultime proposte che ha confermato. Ricarica la pagina oppure controlla il filtro bacheca nella barra degli indirizzi.', + recovered: 'Il server accetta di nuovo gli aggiornamenti di questa coda di revisione.', }, }, diff --git a/frontend/taskdeck-web/src/views/LegacyReviewView.vue b/frontend/taskdeck-web/src/views/LegacyReviewView.vue index eb33eb1d5..6ee586fe6 100644 --- a/frontend/taskdeck-web/src/views/LegacyReviewView.vue +++ b/frontend/taskdeck-web/src/views/LegacyReviewView.vue @@ -31,6 +31,7 @@ const { queueRefreshStale, queueRefreshRefused, queueRefreshRecovered, + queueRefreshRecoveredKind, unavailableProposalId, unavailableProposalMalformed, dismissableProposalIds, @@ -332,14 +333,29 @@ onUnmounted(() => { throughout, withholding its text, because a live region inserted at the same moment its text appears is unreliably announced (#2593). The signal comes from the shared composable, so both skins announce the - same transition with the same sentence (ADR-0038 / #1124). --> + same transition with the same sentence (ADR-0038 / #1124). + + TWO sentences through this one region (#2638 item 2), chosen by the + kind the composable reports: a 'degraded' recovery follows a completed + read and may say the rows are current, while a 'refused' one is raised + as soon as the LIST read answers — a tick that may never replace the + queue — so it says only that the server is accepting refreshes again. + Paper picks the key the same way. -->

{{ queueRefreshRecovered && !queueAccessRevoked ? $t('review.queue.degraded.recovered') : '' }}

+ >{{ + queueRefreshRecovered && !queueAccessRevoked + ? $t( + queueRefreshRecoveredKind === 'refused' + ? 'review.queue.refused.recovered' + : 'review.queue.degraded.recovered', + ) + : '' + }}