From c78716025bbb7c2788b66359b297f44ffc9afc0c Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:41:37 +0100 Subject: [PATCH 01/13] test(queue): reproduce cache and session ownership races --- .../tests/store/queueStoreOwnership.spec.ts | 298 ++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts new file mode 100644 index 000000000..0d77dbb6b --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts @@ -0,0 +1,298 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { queueApi } from '../../api/queueApi' +import { useQueueStore } from '../../store/queueStore' +import { useSessionStore } from '../../store/sessionStore' +import type { QueueRequest, QueueStats } from '../../types/queue' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('../../utils/demoMode', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, isDemoMode: false } +}) + +vi.mock('../../api/queueApi', () => ({ + queueApi: { + getUserRequests: vi.fn(), + getRequestsByStatus: vi.fn(), + createRequest: vi.fn(), + cancelRequest: vi.fn(), + processNext: vi.fn(), + getStats: vi.fn(), + }, +})) + +vi.mock('../../api/authApi', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + changePassword: vi.fn(), + refreshToken: vi.fn(), + exchangeOAuthCode: vi.fn(), + exchangeOidcCode: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => toastMocks, +})) + +vi.mock('../../composables/useErrorMapper', () => ({ + getErrorDisplay: (error: unknown, fallback: string) => ({ + message: error instanceof Error ? error.message : fallback, + }), +})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +function token(suffix: string): string { + const body = btoa(JSON.stringify({ exp: 1893456000 })) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, '') + return `header.${body}.${suffix}` +} + +function request(id: string, status: QueueRequest['status'] = 'Pending'): QueueRequest { + return { + id, + userId: 'user-a', + boardId: 'board-a', + requestType: 'Instruction', + status, + errorMessage: null, + createdAt: '2026-09-21T00:00:00Z', + processedAt: status === 'Pending' ? null : '2026-09-21T00:01:00Z', + retryCount: 0, + } +} + +function stats(pendingCount: number): QueueStats { + return { + pendingCount, + processingCount: 1, + completedCount: 2, + failedCount: 3, + } +} + +describe('queueStore async ownership', () => { + let session: ReturnType + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + session = useSessionStore() + session.userId = 'user-a' + session.token = token('old') + store = useQueueStore() + vi.clearAllMocks() + }) + + it('keeps the newest request-list query when user and status reads settle in reverse order', async () => { + const oldUser = deferred() + const newStatus = deferred() + vi.mocked(queueApi.getUserRequests).mockReturnValue(oldUser.promise) + vi.mocked(queueApi.getRequestsByStatus).mockReturnValue(newStatus.promise) + + const oldRequest = store.fetchUserRequests() + const newRequest = store.fetchByStatus('Failed') + + newStatus.resolve([request('new-status', 'Failed')]) + await newRequest + oldUser.resolve([request('old-user')]) + await oldRequest + + expect(store.requests.map(item => item.id)).toEqual(['new-status']) + expect(store.error).toBeNull() + }) + + it('suppresses an older request-list failure after a newer query succeeds', async () => { + const oldUser = deferred() + const newStatus = deferred() + vi.mocked(queueApi.getUserRequests).mockReturnValue(oldUser.promise) + vi.mocked(queueApi.getRequestsByStatus).mockReturnValue(newStatus.promise) + + const oldRequest = store.fetchUserRequests() + const newRequest = store.fetchByStatus('Completed') + + newStatus.resolve([request('new-status', 'Completed')]) + await newRequest + oldUser.reject(new Error('stale user queue failure')) + await expect(oldRequest).rejects.toThrow('stale user queue failure') + + expect(store.requests.map(item => item.id)).toEqual(['new-status']) + expect(store.error).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('does not let an older request read erase a confirmed submission', async () => { + const oldRead = deferred() + const create = deferred() + vi.mocked(queueApi.getUserRequests).mockReturnValue(oldRead.promise) + vi.mocked(queueApi.createRequest).mockReturnValue(create.promise) + + const readRequest = store.fetchUserRequests() + const submitRequest = store.submitRequest({ requestType: 'Instruction', payload: 'Do the work' }) + + create.resolve(request('created')) + await submitRequest + oldRead.resolve([request('existing')]) + await readRequest + + expect(store.requests.map(item => item.id)).toEqual(['created']) + }) + + it('does not let an older request read reinsert a confirmed cancellation', async () => { + const snapshot = [request('cancelled-later'), request('kept')] + const oldRead = deferred() + const cancel = deferred() + store.requests = [...snapshot] + vi.mocked(queueApi.getRequestsByStatus).mockReturnValue(oldRead.promise) + vi.mocked(queueApi.cancelRequest).mockReturnValue(cancel.promise) + + const readRequest = store.fetchByStatus('Pending') + const cancelRequest = store.cancelRequest('cancelled-later') + + cancel.resolve() + await cancelRequest + oldRead.resolve(snapshot) + await readRequest + + expect(store.requests.map(item => item.id)).toEqual(['kept']) + }) + + it('does not install stats captured before a confirmed queue mutation', async () => { + const oldStats = deferred() + const create = deferred() + store.stats = stats(99) + vi.mocked(queueApi.getStats).mockReturnValue(oldStats.promise) + vi.mocked(queueApi.createRequest).mockReturnValue(create.promise) + + const statsRequest = store.fetchStats() + const submitRequest = store.submitRequest({ requestType: 'Instruction', payload: 'Queue it' }) + + create.resolve(request('created')) + await submitRequest + oldStats.resolve(stats(1)) + await statsRequest + + expect(store.stats?.pendingCount).toBe(99) + }) + + it('keeps loading true until independent request and stats operations both settle', async () => { + const requests = deferred() + const queueStats = deferred() + vi.mocked(queueApi.getRequestsByStatus).mockReturnValue(requests.promise) + vi.mocked(queueApi.getStats).mockReturnValue(queueStats.promise) + + const requestsOperation = store.fetchByStatus('Pending') + const statsOperation = store.fetchStats() + expect(store.loading).toBe(true) + + requests.resolve([request('pending')]) + await requestsOperation + expect(store.loading).toBe(true) + + queueStats.resolve(stats(1)) + await statsOperation + expect(store.loading).toBe(false) + }) + + it('clears both surfaces on token rotation and ignores late read and submit successes', async () => { + const oldRead = deferred() + const oldSubmit = deferred() + store.requests = [request('existing')] + store.stats = stats(4) + store.error = 'existing error' + vi.mocked(queueApi.getRequestsByStatus).mockReturnValue(oldRead.promise) + vi.mocked(queueApi.createRequest).mockReturnValue(oldSubmit.promise) + + const readOperation = store.fetchByStatus('Pending') + const submitOperation = store.submitRequest({ requestType: 'Instruction', payload: 'Old session' }) + + session.token = token('new') + const immediate = { + requestIds: store.requests.map(item => item.id), + stats: store.stats, + loading: store.loading, + error: store.error, + } + + oldRead.resolve([request('old-read')]) + oldSubmit.resolve(request('old-submit')) + await Promise.all([readOperation, submitOperation]) + + expect(immediate).toEqual({ requestIds: [], stats: null, loading: false, error: null }) + expect(store.requests).toEqual([]) + expect(store.stats).toBeNull() + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + expect(toastMocks.success).not.toHaveBeenCalled() + }) + + it('suppresses a stale mutation failure after token rotation while preserving rejection', async () => { + const cancel = deferred() + store.requests = [request('old-request')] + vi.mocked(queueApi.cancelRequest).mockReturnValue(cancel.promise) + + const operation = store.cancelRequest('old-request') + session.token = token('new') + cancel.reject(new Error('old-session cancellation failed')) + await expect(operation).rejects.toThrow('old-session cancellation failed') + + expect(store.requests).toEqual([]) + expect(store.error).toBeNull() + expect(store.loading).toBe(false) + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('does not emit a process result toast after credential replacement', async () => { + const processing = deferred() + vi.mocked(queueApi.processNext).mockReturnValue(processing.promise) + + const operation = store.processNext() + session.token = token('new') + processing.resolve(request('processed', 'Completed')) + await operation + + expect(toastMocks.success).not.toHaveBeenCalled() + expect(toastMocks.info).not.toHaveBeenCalled() + expect(store.loading).toBe(false) + }) + + it('exposes a reset boundary that invalidates an in-flight request read', async () => { + const pending = deferred() + store.requests = [request('existing')] + store.stats = stats(3) + vi.mocked(queueApi.getUserRequests).mockReturnValue(pending.promise) + + const operation = store.fetchUserRequests() + const reset = (store as unknown as { $reset?: () => void }).$reset + const hasReset = typeof reset === 'function' + reset?.() + + pending.resolve([request('late')]) + await operation + + expect(hasReset).toBe(true) + expect(store.requests).toEqual([]) + expect(store.stats).toBeNull() + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) +}) From c25855c6aa2710fc930cb24dacd9fa164fc55c52 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:20:23 +0100 Subject: [PATCH 02/13] fix(queue): bind cache operations to credential lifetime --- frontend/taskdeck-web/src/store/queueStore.ts | 210 ++++++++++++++---- 1 file changed, 164 insertions(+), 46 deletions(-) diff --git a/frontend/taskdeck-web/src/store/queueStore.ts b/frontend/taskdeck-web/src/store/queueStore.ts index aabc8bc8a..2cbd8a5a7 100644 --- a/frontend/taskdeck-web/src/store/queueStore.ts +++ b/frontend/taskdeck-web/src/store/queueStore.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { ref, watch } from 'vue' import { queueApi } from '../api/queueApi' import { useToastStore } from './toastStore' import { useSessionStore } from './sessionStore' @@ -16,6 +16,104 @@ export const useQueueStore = defineStore('queue', () => { const loading = ref(false) const error = ref(null) + type ReadLane = 'requests' | 'stats' + + interface OperationOwner { + epoch: number + token: symbol + } + + interface ReadOwner extends OperationOwner { + observedMutationGeneration: number + } + + let credentialEpoch = 0 + let mutationGeneration = 0 + const activeOperations = new Set() + const readOwners = new Map() + + function syncLoading(): void { + loading.value = activeOperations.size > 0 + } + + function beginOperation(label: string): OperationOwner { + const owner = { epoch: credentialEpoch, token: Symbol(label) } + activeOperations.add(owner.token) + error.value = null + syncLoading() + return owner + } + + function ownsCredential(owner: OperationOwner): boolean { + return owner.epoch === credentialEpoch + } + + function finishOperation(owner: OperationOwner): void { + if (!ownsCredential(owner)) return + activeOperations.delete(owner.token) + syncLoading() + } + + function beginRead(lane: ReadLane): ReadOwner { + const previous = readOwners.get(lane) + if (previous?.epoch === credentialEpoch) { + activeOperations.delete(previous.token) + } + + const operation = beginOperation(`read:${lane}`) + const owner = { + ...operation, + observedMutationGeneration: mutationGeneration, + } + readOwners.set(lane, owner) + return owner + } + + function ownsRead(lane: ReadLane, owner: ReadOwner): boolean { + return ownsCredential(owner) + && readOwners.get(lane)?.token === owner.token + && owner.observedMutationGeneration === mutationGeneration + } + + function finishRead(lane: ReadLane, owner: ReadOwner): void { + if (readOwners.get(lane)?.token === owner.token) { + readOwners.delete(lane) + } + finishOperation(owner) + } + + function invalidateRead(lane: ReadLane): void { + const owner = readOwners.get(lane) + if (owner?.epoch === credentialEpoch) { + activeOperations.delete(owner.token) + } + readOwners.delete(lane) + syncLoading() + } + + function recordMutation(): void { + mutationGeneration += 1 + invalidateRead('requests') + invalidateRead('stats') + } + + function $reset(): void { + credentialEpoch += 1 + mutationGeneration = 0 + activeOperations.clear() + readOwners.clear() + requests.value = [] + stats.value = null + loading.value = false + error.value = null + } + + watch( + () => [session.userId, session.token, session.isAuthenticated, session.isDemo], + $reset, + { flush: 'sync' }, + ) + function guardDemoMutation(): never | void { if (isDemoMode) { toast.info('This action is view-only in demo mode.') @@ -25,131 +123,150 @@ export const useQueueStore = defineStore('queue', () => { async function fetchUserRequests() { if (isDemoMode) { - loading.value = true + invalidateRead('requests') error.value = null requests.value = [] - loading.value = false return } + + const owner = beginRead('requests') try { - loading.value = true - error.value = null session.requireUserId('queue operations') - requests.value = await queueApi.getUserRequests() + const result = await queueApi.getUserRequests() + if (ownsRead('requests', owner)) requests.value = result } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to fetch queue requests').message - error.value = msg - toast.error(msg) + if (ownsRead('requests', owner)) { + const msg = getErrorDisplay(e, 'Failed to fetch queue requests').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishRead('requests', owner) } } async function fetchByStatus(status: string) { if (isDemoMode) { - loading.value = true + invalidateRead('requests') error.value = null requests.value = [] - loading.value = false return } + + const owner = beginRead('requests') try { - loading.value = true - error.value = null - requests.value = await queueApi.getRequestsByStatus(status) + const result = await queueApi.getRequestsByStatus(status) + if (ownsRead('requests', owner)) requests.value = result } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to fetch requests by status').message - error.value = msg - toast.error(msg) + if (ownsRead('requests', owner)) { + const msg = getErrorDisplay(e, 'Failed to fetch requests by status').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishRead('requests', owner) } } async function submitRequest(dto: CreateQueueRequestDto) { guardDemoMutation() + const owner = beginOperation('submit-request') try { - loading.value = true - error.value = null session.requireUserId('queue operations') const request = await queueApi.createRequest(dto) + if (!ownsCredential(owner)) return request + + recordMutation() requests.value.push(request) toast.success('Request submitted') return request } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to submit request').message - error.value = msg - toast.error(msg) + if (ownsCredential(owner)) { + const msg = getErrorDisplay(e, 'Failed to submit request').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishOperation(owner) } } async function cancelRequest(requestId: string) { guardDemoMutation() + const owner = beginOperation(`cancel-request:${requestId}`) try { - loading.value = true - error.value = null session.requireUserId('queue operations') await queueApi.cancelRequest(requestId) + if (!ownsCredential(owner)) return + + recordMutation() requests.value = requests.value.filter(r => r.id !== requestId) toast.success('Request cancelled') } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to cancel request').message - error.value = msg - toast.error(msg) + if (ownsCredential(owner)) { + const msg = getErrorDisplay(e, 'Failed to cancel request').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishOperation(owner) } } async function processNext() { guardDemoMutation() + const owner = beginOperation('process-next') try { - loading.value = true - error.value = null const result = await queueApi.processNext() + if (!ownsCredential(owner)) return result + if (result) { + recordMutation() toast.success('Request processed') } else { toast.info('No pending requests') } return result } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to process request').message - error.value = msg - toast.error(msg) + if (ownsCredential(owner)) { + const msg = getErrorDisplay(e, 'Failed to process request').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishOperation(owner) } } async function fetchStats() { if (isDemoMode) { - loading.value = true + invalidateRead('stats') error.value = null stats.value = { pendingCount: 0, processingCount: 0, completedCount: 0, failedCount: 0 } - loading.value = false return } + + const owner = beginRead('stats') try { - loading.value = true - error.value = null - stats.value = await queueApi.getStats() + const result = await queueApi.getStats() + if (ownsRead('stats', owner)) stats.value = result } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to fetch queue stats').message - error.value = msg - toast.error(msg) + if (ownsRead('stats', owner)) { + const msg = getErrorDisplay(e, 'Failed to fetch queue stats').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishRead('stats', owner) } } + return { requests, stats, @@ -161,5 +278,6 @@ export const useQueueStore = defineStore('queue', () => { cancelRequest, processNext, fetchStats, + $reset, } }) From bce57839e8198be24a2c60f922d402f5b1a86518 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:36:54 +0100 Subject: [PATCH 03/13] test(queue): preserve cached data across token refresh --- .../src/tests/store/queueStoreOwnership.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts index 0d77dbb6b..cf87e1c03 100644 --- a/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts @@ -213,7 +213,7 @@ describe('queueStore async ownership', () => { expect(store.loading).toBe(false) }) - it('clears both surfaces on token rotation and ignores late read and submit successes', async () => { + it('preserves loaded queue data on token rotation and ignores late read and submit successes', async () => { const oldRead = deferred() const oldSubmit = deferred() store.requests = [request('existing')] @@ -237,15 +237,15 @@ describe('queueStore async ownership', () => { oldSubmit.resolve(request('old-submit')) await Promise.all([readOperation, submitOperation]) - expect(immediate).toEqual({ requestIds: [], stats: null, loading: false, error: null }) - expect(store.requests).toEqual([]) - expect(store.stats).toBeNull() + expect(immediate).toEqual({ requestIds: ['existing'], stats: stats(4), loading: false, error: null }) + expect(store.requests.map(item => item.id)).toEqual(['existing']) + expect(store.stats).toEqual(stats(4)) expect(store.loading).toBe(false) expect(store.error).toBeNull() expect(toastMocks.success).not.toHaveBeenCalled() }) - it('suppresses a stale mutation failure after token rotation while preserving rejection', async () => { + it('suppresses a stale mutation failure after token rotation while preserving cached data and rejection', async () => { const cancel = deferred() store.requests = [request('old-request')] vi.mocked(queueApi.cancelRequest).mockReturnValue(cancel.promise) @@ -255,7 +255,7 @@ describe('queueStore async ownership', () => { cancel.reject(new Error('old-session cancellation failed')) await expect(operation).rejects.toThrow('old-session cancellation failed') - expect(store.requests).toEqual([]) + expect(store.requests.map(item => item.id)).toEqual(['old-request']) expect(store.error).toBeNull() expect(store.loading).toBe(false) expect(toastMocks.error).not.toHaveBeenCalled() From 53499cbe5779f71391474bf0862cd10783bd3912 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:42:59 +0100 Subject: [PATCH 04/13] fix(queue): preserve cached data across token refresh --- frontend/taskdeck-web/src/store/queueStore.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/frontend/taskdeck-web/src/store/queueStore.ts b/frontend/taskdeck-web/src/store/queueStore.ts index 2cbd8a5a7..74c4f4d84 100644 --- a/frontend/taskdeck-web/src/store/queueStore.ts +++ b/frontend/taskdeck-web/src/store/queueStore.ts @@ -97,23 +97,33 @@ export const useQueueStore = defineStore('queue', () => { invalidateRead('stats') } - function $reset(): void { + function invalidateOperations(): void { credentialEpoch += 1 - mutationGeneration = 0 activeOperations.clear() readOwners.clear() - requests.value = [] - stats.value = null loading.value = false error.value = null } + function $reset(): void { + invalidateOperations() + mutationGeneration = 0 + requests.value = [] + stats.value = null + } + watch( - () => [session.userId, session.token, session.isAuthenticated, session.isDemo], + () => [session.userId, session.isAuthenticated, session.isDemo], $reset, { flush: 'sync' }, ) + watch( + () => session.token, + invalidateOperations, + { flush: 'sync' }, + ) + function guardDemoMutation(): never | void { if (isDemoMode) { toast.info('This action is view-only in demo mode.') From 882fa23d85127c80ffbb828dc851351944107ea1 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:14:14 +0100 Subject: [PATCH 05/13] test(queue): pin cross-lane error ownership --- .../src/tests/store/queueStoreOwnership.spec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts index cf87e1c03..6492b482f 100644 --- a/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts @@ -140,6 +140,23 @@ describe('queueStore async ownership', () => { expect(toastMocks.error).not.toHaveBeenCalled() }) + it('does not let a stats refresh erase the current request-list failure', async () => { + const queueStats = deferred() + vi.mocked(queueApi.getRequestsByStatus).mockRejectedValue(new Error('request list failed')) + vi.mocked(queueApi.getStats).mockReturnValue(queueStats.promise) + + await expect(store.fetchByStatus('Failed')).rejects.toThrow('request list failed') + expect(store.error).toBe('request list failed') + + const statsRequest = store.fetchStats() + const errorAfterStatsStarted = store.error + queueStats.resolve(stats(2)) + await statsRequest + + expect(errorAfterStatsStarted).toBe('request list failed') + expect(store.error).toBe('request list failed') + }) + it('does not let an older request read erase a confirmed submission', async () => { const oldRead = deferred() const create = deferred() From ff2c580cd5468e83fae51a6161cf97ffb9420885 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:23:28 +0100 Subject: [PATCH 06/13] fix(queue): scope shared error receipts to their operation --- frontend/taskdeck-web/src/store/queueStore.ts | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/frontend/taskdeck-web/src/store/queueStore.ts b/frontend/taskdeck-web/src/store/queueStore.ts index 74c4f4d84..91e3ae298 100644 --- a/frontend/taskdeck-web/src/store/queueStore.ts +++ b/frontend/taskdeck-web/src/store/queueStore.ts @@ -17,10 +17,12 @@ export const useQueueStore = defineStore('queue', () => { const error = ref(null) type ReadLane = 'requests' | 'stats' + type OperationScope = ReadLane | 'mutation' interface OperationOwner { epoch: number token: symbol + scope: OperationScope } interface ReadOwner extends OperationOwner { @@ -29,6 +31,7 @@ export const useQueueStore = defineStore('queue', () => { let credentialEpoch = 0 let mutationGeneration = 0 + let errorOwner: OperationOwner | null = null const activeOperations = new Set() const readOwners = new Map() @@ -36,10 +39,21 @@ export const useQueueStore = defineStore('queue', () => { loading.value = activeOperations.size > 0 } - function beginOperation(label: string): OperationOwner { - const owner = { epoch: credentialEpoch, token: Symbol(label) } - activeOperations.add(owner.token) + function clearErrorForScope(scope: OperationScope): void { + if (errorOwner && errorOwner.epoch === credentialEpoch && errorOwner.scope !== scope) return + errorOwner = null error.value = null + } + + function publishError(owner: OperationOwner, message: string): void { + errorOwner = owner + error.value = message + } + + function beginOperation(label: string, scope: OperationScope): OperationOwner { + const owner = { epoch: credentialEpoch, token: Symbol(label), scope } + activeOperations.add(owner.token) + clearErrorForScope(scope) syncLoading() return owner } @@ -60,7 +74,7 @@ export const useQueueStore = defineStore('queue', () => { activeOperations.delete(previous.token) } - const operation = beginOperation(`read:${lane}`) + const operation = beginOperation(`read:${lane}`, lane) const owner = { ...operation, observedMutationGeneration: mutationGeneration, @@ -101,6 +115,7 @@ export const useQueueStore = defineStore('queue', () => { credentialEpoch += 1 activeOperations.clear() readOwners.clear() + errorOwner = null loading.value = false error.value = null } @@ -134,7 +149,7 @@ export const useQueueStore = defineStore('queue', () => { async function fetchUserRequests() { if (isDemoMode) { invalidateRead('requests') - error.value = null + clearErrorForScope('requests') requests.value = [] return } @@ -147,7 +162,7 @@ export const useQueueStore = defineStore('queue', () => { } catch (e: unknown) { if (ownsRead('requests', owner)) { const msg = getErrorDisplay(e, 'Failed to fetch queue requests').message - error.value = msg + publishError(owner, msg) toast.error(msg) } throw e @@ -159,7 +174,7 @@ export const useQueueStore = defineStore('queue', () => { async function fetchByStatus(status: string) { if (isDemoMode) { invalidateRead('requests') - error.value = null + clearErrorForScope('requests') requests.value = [] return } @@ -171,7 +186,7 @@ export const useQueueStore = defineStore('queue', () => { } catch (e: unknown) { if (ownsRead('requests', owner)) { const msg = getErrorDisplay(e, 'Failed to fetch requests by status').message - error.value = msg + publishError(owner, msg) toast.error(msg) } throw e @@ -182,7 +197,7 @@ export const useQueueStore = defineStore('queue', () => { async function submitRequest(dto: CreateQueueRequestDto) { guardDemoMutation() - const owner = beginOperation('submit-request') + const owner = beginOperation('submit-request', 'mutation') try { session.requireUserId('queue operations') const request = await queueApi.createRequest(dto) @@ -195,7 +210,7 @@ export const useQueueStore = defineStore('queue', () => { } catch (e: unknown) { if (ownsCredential(owner)) { const msg = getErrorDisplay(e, 'Failed to submit request').message - error.value = msg + publishError(owner, msg) toast.error(msg) } throw e @@ -206,7 +221,7 @@ export const useQueueStore = defineStore('queue', () => { async function cancelRequest(requestId: string) { guardDemoMutation() - const owner = beginOperation(`cancel-request:${requestId}`) + const owner = beginOperation(`cancel-request:${requestId}`, 'mutation') try { session.requireUserId('queue operations') await queueApi.cancelRequest(requestId) @@ -218,7 +233,7 @@ export const useQueueStore = defineStore('queue', () => { } catch (e: unknown) { if (ownsCredential(owner)) { const msg = getErrorDisplay(e, 'Failed to cancel request').message - error.value = msg + publishError(owner, msg) toast.error(msg) } throw e @@ -229,7 +244,7 @@ export const useQueueStore = defineStore('queue', () => { async function processNext() { guardDemoMutation() - const owner = beginOperation('process-next') + const owner = beginOperation('process-next', 'mutation') try { const result = await queueApi.processNext() if (!ownsCredential(owner)) return result @@ -244,7 +259,7 @@ export const useQueueStore = defineStore('queue', () => { } catch (e: unknown) { if (ownsCredential(owner)) { const msg = getErrorDisplay(e, 'Failed to process request').message - error.value = msg + publishError(owner, msg) toast.error(msg) } throw e @@ -256,7 +271,7 @@ export const useQueueStore = defineStore('queue', () => { async function fetchStats() { if (isDemoMode) { invalidateRead('stats') - error.value = null + clearErrorForScope('stats') stats.value = { pendingCount: 0, processingCount: 0, completedCount: 0, failedCount: 0 } return } @@ -268,7 +283,7 @@ export const useQueueStore = defineStore('queue', () => { } catch (e: unknown) { if (ownsRead('stats', owner)) { const msg = getErrorDisplay(e, 'Failed to fetch queue stats').message - error.value = msg + publishError(owner, msg) toast.error(msg) } throw e From 46d1cca22474c26d05be83adfd94f0fe96a29789 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:33:44 +0100 Subject: [PATCH 07/13] test(queue): retry empty initial reads after token refresh --- .../tests/store/queueStoreOwnership.spec.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts index 6492b482f..607ed0a6d 100644 --- a/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts @@ -262,6 +262,48 @@ describe('queueStore async ownership', () => { expect(toastMocks.success).not.toHaveBeenCalled() }) + it('retries empty initial request and stats reads after same-user token rotation', async () => { + const oldRequests = deferred() + const freshRequests = deferred() + const oldStats = deferred() + const freshStats = deferred() + vi.mocked(queueApi.getRequestsByStatus) + .mockReturnValueOnce(oldRequests.promise) + .mockReturnValueOnce(freshRequests.promise) + vi.mocked(queueApi.getStats) + .mockReturnValueOnce(oldStats.promise) + .mockReturnValueOnce(freshStats.promise) + + const requestsOperation = store.fetchByStatus('Failed') + const statsOperation = store.fetchStats() + session.token = token('new') + + expect(queueApi.getRequestsByStatus).toHaveBeenCalledTimes(2) + expect(queueApi.getRequestsByStatus).toHaveBeenNthCalledWith(2, 'Failed') + expect(queueApi.getStats).toHaveBeenCalledTimes(2) + expect(store.requests).toEqual([]) + expect(store.stats).toBeNull() + expect(store.loading).toBe(true) + + oldRequests.resolve([request('old-token', 'Failed')]) + oldStats.resolve(stats(1)) + await Promise.all([requestsOperation, statsOperation]) + + expect(store.requests).toEqual([]) + expect(store.stats).toBeNull() + expect(store.loading).toBe(true) + expect(store.error).toBeNull() + + freshRequests.resolve([request('fresh-token', 'Failed')]) + freshStats.resolve(stats(7)) + await vi.waitFor(() => { + expect(store.requests.map(item => item.id)).toEqual(['fresh-token']) + expect(store.stats?.pendingCount).toBe(7) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + }) + it('suppresses a stale mutation failure after token rotation while preserving cached data and rejection', async () => { const cancel = deferred() store.requests = [request('old-request')] From 77a73df062713153c3f9c62fc7b8c1a928d9efb4 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:47:05 +0100 Subject: [PATCH 08/13] fix(queue): retry empty active reads after token refresh --- frontend/taskdeck-web/src/store/queueStore.ts | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/frontend/taskdeck-web/src/store/queueStore.ts b/frontend/taskdeck-web/src/store/queueStore.ts index 91e3ae298..48354dc7b 100644 --- a/frontend/taskdeck-web/src/store/queueStore.ts +++ b/frontend/taskdeck-web/src/store/queueStore.ts @@ -18,6 +18,7 @@ export const useQueueStore = defineStore('queue', () => { type ReadLane = 'requests' | 'stats' type OperationScope = ReadLane | 'mutation' + type ReadRetry = () => Promise interface OperationOwner { epoch: number @@ -34,6 +35,7 @@ export const useQueueStore = defineStore('queue', () => { let errorOwner: OperationOwner | null = null const activeOperations = new Set() const readOwners = new Map() + const readRetries = new Map() function syncLoading(): void { loading.value = activeOperations.size > 0 @@ -68,7 +70,7 @@ export const useQueueStore = defineStore('queue', () => { syncLoading() } - function beginRead(lane: ReadLane): ReadOwner { + function beginRead(lane: ReadLane, retry: ReadRetry): ReadOwner { const previous = readOwners.get(lane) if (previous?.epoch === credentialEpoch) { activeOperations.delete(previous.token) @@ -80,6 +82,7 @@ export const useQueueStore = defineStore('queue', () => { observedMutationGeneration: mutationGeneration, } readOwners.set(lane, owner) + readRetries.set(lane, retry) return owner } @@ -92,6 +95,7 @@ export const useQueueStore = defineStore('queue', () => { function finishRead(lane: ReadLane, owner: ReadOwner): void { if (readOwners.get(lane)?.token === owner.token) { readOwners.delete(lane) + readRetries.delete(lane) } finishOperation(owner) } @@ -102,6 +106,7 @@ export const useQueueStore = defineStore('queue', () => { activeOperations.delete(owner.token) } readOwners.delete(lane) + readRetries.delete(lane) syncLoading() } @@ -115,11 +120,33 @@ export const useQueueStore = defineStore('queue', () => { credentialEpoch += 1 activeOperations.clear() readOwners.clear() + readRetries.clear() errorOwner = null loading.value = false error.value = null } + function retryEmptyActiveReads(): void { + const requestRetry = readOwners.has('requests') && requests.value.length === 0 + ? readRetries.get('requests') + : undefined + const statsRetry = readOwners.has('stats') && stats.value === null + ? readRetries.get('stats') + : undefined + + invalidateOperations() + if (requestRetry) { + void requestRetry().catch(() => { + // The retried store action owns current error/toast state. + }) + } + if (statsRetry) { + void statsRetry().catch(() => { + // The retried store action owns current error/toast state. + }) + } + } + function $reset(): void { invalidateOperations() mutationGeneration = 0 @@ -135,7 +162,7 @@ export const useQueueStore = defineStore('queue', () => { watch( () => session.token, - invalidateOperations, + retryEmptyActiveReads, { flush: 'sync' }, ) @@ -154,7 +181,7 @@ export const useQueueStore = defineStore('queue', () => { return } - const owner = beginRead('requests') + const owner = beginRead('requests', fetchUserRequests) try { session.requireUserId('queue operations') const result = await queueApi.getUserRequests() @@ -179,7 +206,7 @@ export const useQueueStore = defineStore('queue', () => { return } - const owner = beginRead('requests') + const owner = beginRead('requests', () => fetchByStatus(status)) try { const result = await queueApi.getRequestsByStatus(status) if (ownsRead('requests', owner)) requests.value = result @@ -276,7 +303,7 @@ export const useQueueStore = defineStore('queue', () => { return } - const owner = beginRead('stats') + const owner = beginRead('stats', fetchStats) try { const result = await queueApi.getStats() if (ownsRead('stats', owner)) stats.value = result From e068ce7e3f905002b83b7e2f77f4ee2207f33f79 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 22:35:30 +0100 Subject: [PATCH 09/13] fix(queue): join token-refresh read retries --- frontend/taskdeck-web/src/store/queueStore.ts | 62 ++++++++++++++----- .../tests/store/queueStoreOwnership.spec.ts | 46 ++++++++++---- 2 files changed, 79 insertions(+), 29 deletions(-) diff --git a/frontend/taskdeck-web/src/store/queueStore.ts b/frontend/taskdeck-web/src/store/queueStore.ts index 48354dc7b..ea5223c9a 100644 --- a/frontend/taskdeck-web/src/store/queueStore.ts +++ b/frontend/taskdeck-web/src/store/queueStore.ts @@ -36,6 +36,7 @@ export const useQueueStore = defineStore('queue', () => { const activeOperations = new Set() const readOwners = new Map() const readRetries = new Map() + const successorReads = new Map>() function syncLoading(): void { loading.value = activeOperations.size > 0 @@ -126,22 +127,32 @@ export const useQueueStore = defineStore('queue', () => { error.value = null } - function retryEmptyActiveReads(): void { - const requestRetry = readOwners.has('requests') && requests.value.length === 0 - ? readRetries.get('requests') + async function awaitSuccessor(owner: ReadOwner): Promise { + const successor = successorReads.get(owner.token) + if (!successor) return false + + try { + await successor + } finally { + successorReads.delete(owner.token) + } + return true + } + + function retryActiveReads(): void { + const requestRetry = readOwners.has('requests') + ? { owner: readOwners.get('requests')!, retry: readRetries.get('requests') } : undefined - const statsRetry = readOwners.has('stats') && stats.value === null - ? readRetries.get('stats') + const statsRetry = readOwners.has('stats') + ? { owner: readOwners.get('stats')!, retry: readRetries.get('stats') } : undefined invalidateOperations() - if (requestRetry) { - void requestRetry().catch(() => { - // The retried store action owns current error/toast state. - }) - } - if (statsRetry) { - void statsRetry().catch(() => { + for (const entry of [requestRetry, statsRetry]) { + if (!entry?.retry) continue + const successor = entry.retry() + successorReads.set(entry.owner.token, successor) + void successor.catch(() => { // The retried store action owns current error/toast state. }) } @@ -149,6 +160,7 @@ export const useQueueStore = defineStore('queue', () => { function $reset(): void { invalidateOperations() + successorReads.clear() mutationGeneration = 0 requests.value = [] stats.value = null @@ -162,7 +174,7 @@ export const useQueueStore = defineStore('queue', () => { watch( () => session.token, - retryEmptyActiveReads, + retryActiveReads, { flush: 'sync' }, ) @@ -185,12 +197,18 @@ export const useQueueStore = defineStore('queue', () => { try { session.requireUserId('queue operations') const result = await queueApi.getUserRequests() - if (ownsRead('requests', owner)) requests.value = result + if (ownsRead('requests', owner)) { + requests.value = result + } else { + await awaitSuccessor(owner) + } } catch (e: unknown) { if (ownsRead('requests', owner)) { const msg = getErrorDisplay(e, 'Failed to fetch queue requests').message publishError(owner, msg) toast.error(msg) + } else if (await awaitSuccessor(owner)) { + return } throw e } finally { @@ -209,12 +227,18 @@ export const useQueueStore = defineStore('queue', () => { const owner = beginRead('requests', () => fetchByStatus(status)) try { const result = await queueApi.getRequestsByStatus(status) - if (ownsRead('requests', owner)) requests.value = result + if (ownsRead('requests', owner)) { + requests.value = result + } else { + await awaitSuccessor(owner) + } } catch (e: unknown) { if (ownsRead('requests', owner)) { const msg = getErrorDisplay(e, 'Failed to fetch requests by status').message publishError(owner, msg) toast.error(msg) + } else if (await awaitSuccessor(owner)) { + return } throw e } finally { @@ -306,12 +330,18 @@ export const useQueueStore = defineStore('queue', () => { const owner = beginRead('stats', fetchStats) try { const result = await queueApi.getStats() - if (ownsRead('stats', owner)) stats.value = result + if (ownsRead('stats', owner)) { + stats.value = result + } else { + await awaitSuccessor(owner) + } } catch (e: unknown) { if (ownsRead('stats', owner)) { const msg = getErrorDisplay(e, 'Failed to fetch queue stats').message publishError(owner, msg) toast.error(msg) + } else if (await awaitSuccessor(owner)) { + return } throw e } finally { diff --git a/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts index 607ed0a6d..165436f58 100644 --- a/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts @@ -232,11 +232,14 @@ describe('queueStore async ownership', () => { it('preserves loaded queue data on token rotation and ignores late read and submit successes', async () => { const oldRead = deferred() + const freshRead = deferred() const oldSubmit = deferred() store.requests = [request('existing')] store.stats = stats(4) store.error = 'existing error' - vi.mocked(queueApi.getRequestsByStatus).mockReturnValue(oldRead.promise) + vi.mocked(queueApi.getRequestsByStatus) + .mockReturnValueOnce(oldRead.promise) + .mockReturnValueOnce(freshRead.promise) vi.mocked(queueApi.createRequest).mockReturnValue(oldSubmit.promise) const readOperation = store.fetchByStatus('Pending') @@ -252,9 +255,10 @@ describe('queueStore async ownership', () => { oldRead.resolve([request('old-read')]) oldSubmit.resolve(request('old-submit')) + freshRead.resolve([request('existing')]) await Promise.all([readOperation, submitOperation]) - expect(immediate).toEqual({ requestIds: ['existing'], stats: stats(4), loading: false, error: null }) + expect(immediate).toEqual({ requestIds: ['existing'], stats: stats(4), loading: true, error: null }) expect(store.requests.map(item => item.id)).toEqual(['existing']) expect(store.stats).toEqual(stats(4)) expect(store.loading).toBe(false) @@ -287,21 +291,37 @@ describe('queueStore async ownership', () => { oldRequests.resolve([request('old-token', 'Failed')]) oldStats.resolve(stats(1)) + freshRequests.resolve([request('fresh-token', 'Failed')]) + freshStats.resolve(stats(7)) await Promise.all([requestsOperation, statsOperation]) - expect(store.requests).toEqual([]) - expect(store.stats).toBeNull() - expect(store.loading).toBe(true) + expect(store.requests.map(item => item.id)).toEqual(['fresh-token']) + expect(store.stats?.pendingCount).toBe(7) + expect(store.loading).toBe(false) expect(store.error).toBeNull() + }) - freshRequests.resolve([request('fresh-token', 'Failed')]) - freshStats.resolve(stats(7)) - await vi.waitFor(() => { - expect(store.requests.map(item => item.id)).toEqual(['fresh-token']) - expect(store.stats?.pendingCount).toBe(7) - expect(store.loading).toBe(false) - expect(store.error).toBeNull() - }) + it('retries an active status read with cached rows and joins the replacement', async () => { + const oldRead = deferred() + const freshRead = deferred() + store.requests = [request('previous', 'Completed')] + vi.mocked(queueApi.getRequestsByStatus) + .mockReturnValueOnce(oldRead.promise) + .mockReturnValueOnce(freshRead.promise) + + const operation = store.fetchByStatus('Pending') + session.token = token('new') + + expect(queueApi.getRequestsByStatus).toHaveBeenCalledTimes(2) + expect(queueApi.getRequestsByStatus).toHaveBeenNthCalledWith(2, 'Pending') + + oldRead.reject(new Error('old-token failure')) + freshRead.resolve([request('fresh', 'Pending')]) + + await expect(operation).resolves.toBeUndefined() + expect(store.requests.map(item => item.id)).toEqual(['fresh']) + expect(store.error).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() }) it('suppresses a stale mutation failure after token rotation while preserving cached data and rejection', async () => { From a81daa520632e3292ff34c7b3632b7cc2eba7a9e Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 22:36:48 +0100 Subject: [PATCH 10/13] test(queue): cover failed token-refresh retry --- .../tests/store/queueStoreOwnership.spec.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts index 165436f58..363053527 100644 --- a/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts @@ -324,6 +324,25 @@ describe('queueStore async ownership', () => { expect(toastMocks.error).not.toHaveBeenCalled() }) + it('surfaces a replacement read failure and clears loading after token rotation', async () => { + const oldRead = deferred() + const freshRead = deferred() + store.requests = [request('previous', 'Completed')] + vi.mocked(queueApi.getRequestsByStatus) + .mockReturnValueOnce(oldRead.promise) + .mockReturnValueOnce(freshRead.promise) + + const operation = store.fetchByStatus('Pending') + session.token = token('new') + oldRead.resolve([request('old-token', 'Pending')]) + freshRead.reject(new Error('replacement failed')) + + await expect(operation).rejects.toThrow('replacement failed') + expect(store.loading).toBe(false) + expect(store.error).toBe('replacement failed') + expect(toastMocks.error).toHaveBeenCalledTimes(1) + }) + it('suppresses a stale mutation failure after token rotation while preserving cached data and rejection', async () => { const cancel = deferred() store.requests = [request('old-request')] From bf19518e40549306f3a88cf7c715f961e51fd67a Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 23:28:43 +0100 Subject: [PATCH 11/13] fix(queue): reconcile stale mutations after token refresh --- frontend/taskdeck-web/src/store/queueStore.ts | 40 ++++++++++-- .../tests/store/queueStoreOwnership.spec.ts | 61 +++++++++++++++++++ 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/frontend/taskdeck-web/src/store/queueStore.ts b/frontend/taskdeck-web/src/store/queueStore.ts index ea5223c9a..e45e75141 100644 --- a/frontend/taskdeck-web/src/store/queueStore.ts +++ b/frontend/taskdeck-web/src/store/queueStore.ts @@ -24,6 +24,7 @@ export const useQueueStore = defineStore('queue', () => { epoch: number token: symbol scope: OperationScope + userId: string | null } interface ReadOwner extends OperationOwner { @@ -54,7 +55,7 @@ export const useQueueStore = defineStore('queue', () => { } function beginOperation(label: string, scope: OperationScope): OperationOwner { - const owner = { epoch: credentialEpoch, token: Symbol(label), scope } + const owner = { epoch: credentialEpoch, token: Symbol(label), scope, userId: session.userId } activeOperations.add(owner.token) clearErrorForScope(scope) syncLoading() @@ -158,6 +159,28 @@ export const useQueueStore = defineStore('queue', () => { } } + async function reconcileStaleMutation(owner: OperationOwner): Promise { + // A same-user token rotation can let the server commit a mutation after this + // store retires its operation owner. Refresh both affected read lanes under + // the replacement credential so a successful write is not hidden by stale + // request rows or counts. Identity changes and logout must not read for the + // retired session. + if (ownsCredential(owner) + || owner.userId === null + || owner.userId !== session.userId + || !session.isAuthenticated + || session.isDemo) { + return + } + + try { + await Promise.all([fetchUserRequests(), fetchStats()]) + } catch { + // Each read owns its error/toast state. The mutation already succeeded, + // so a reconciliation failure must not turn it into a false write error. + } + } + function $reset(): void { invalidateOperations() successorReads.clear() @@ -252,7 +275,10 @@ export const useQueueStore = defineStore('queue', () => { try { session.requireUserId('queue operations') const request = await queueApi.createRequest(dto) - if (!ownsCredential(owner)) return request + if (!ownsCredential(owner)) { + await reconcileStaleMutation(owner) + return request + } recordMutation() requests.value.push(request) @@ -276,7 +302,10 @@ export const useQueueStore = defineStore('queue', () => { try { session.requireUserId('queue operations') await queueApi.cancelRequest(requestId) - if (!ownsCredential(owner)) return + if (!ownsCredential(owner)) { + await reconcileStaleMutation(owner) + return + } recordMutation() requests.value = requests.value.filter(r => r.id !== requestId) @@ -298,7 +327,10 @@ export const useQueueStore = defineStore('queue', () => { const owner = beginOperation('process-next', 'mutation') try { const result = await queueApi.processNext() - if (!ownsCredential(owner)) return result + if (!ownsCredential(owner)) { + if (result) await reconcileStaleMutation(owner) + return result + } if (result) { recordMutation() diff --git a/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts index 363053527..553c8c3c3 100644 --- a/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts @@ -240,6 +240,8 @@ describe('queueStore async ownership', () => { vi.mocked(queueApi.getRequestsByStatus) .mockReturnValueOnce(oldRead.promise) .mockReturnValueOnce(freshRead.promise) + vi.mocked(queueApi.getUserRequests).mockResolvedValue([request('existing')]) + vi.mocked(queueApi.getStats).mockResolvedValue(stats(4)) vi.mocked(queueApi.createRequest).mockReturnValue(oldSubmit.promise) const readOperation = store.fetchByStatus('Pending') @@ -359,6 +361,65 @@ describe('queueStore async ownership', () => { expect(toastMocks.error).not.toHaveBeenCalled() }) + it('reconciles a successful stale submission after same-user token rotation', async () => { + const pendingSubmit = deferred() + const freshRequests = deferred() + const freshStats = deferred() + const existing = request('existing') + const created = request('created') + store.requests = [existing] + store.stats = stats(1) + vi.mocked(queueApi.createRequest).mockReturnValue(pendingSubmit.promise) + vi.mocked(queueApi.getUserRequests).mockReturnValue(freshRequests.promise) + vi.mocked(queueApi.getStats).mockReturnValue(freshStats.promise) + + const operation = store.submitRequest({ requestType: 'Instruction', payload: 'Queue it' }) + session.token = token('new') + pendingSubmit.resolve(created) + + await vi.waitFor(() => { + expect(queueApi.getUserRequests).toHaveBeenCalledTimes(1) + expect(queueApi.getStats).toHaveBeenCalledTimes(1) + }) + + freshRequests.resolve([existing, created]) + freshStats.resolve(stats(2)) + await expect(operation).resolves.toEqual(created) + + expect(store.requests.map(item => item.id)).toEqual(['existing', 'created']) + expect(store.stats?.pendingCount).toBe(2) + expect(toastMocks.success).not.toHaveBeenCalled() + }) + + it('reconciles a successful stale cancellation after same-user token rotation', async () => { + const pendingCancel = deferred() + const freshRequests = deferred() + const freshStats = deferred() + const existing = request('existing') + store.requests = [existing] + store.stats = stats(1) + vi.mocked(queueApi.cancelRequest).mockReturnValue(pendingCancel.promise) + vi.mocked(queueApi.getUserRequests).mockReturnValue(freshRequests.promise) + vi.mocked(queueApi.getStats).mockReturnValue(freshStats.promise) + + const operation = store.cancelRequest('existing') + session.token = token('new') + pendingCancel.resolve() + + await vi.waitFor(() => { + expect(queueApi.getUserRequests).toHaveBeenCalledTimes(1) + expect(queueApi.getStats).toHaveBeenCalledTimes(1) + }) + + freshRequests.resolve([]) + freshStats.resolve(stats(0)) + await expect(operation).resolves.toBeUndefined() + + expect(store.requests).toEqual([]) + expect(store.stats?.pendingCount).toBe(0) + expect(toastMocks.success).not.toHaveBeenCalled() + }) + it('does not emit a process result toast after credential replacement', async () => { const processing = deferred() vi.mocked(queueApi.processNext).mockReturnValue(processing.promise) From 2ded16d87cd359a9406d919aee8cbbab3b38fc5e Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 23:32:35 +0100 Subject: [PATCH 12/13] fix(queue): guard token retries after logout --- frontend/taskdeck-web/src/store/queueStore.ts | 5 +++++ .../tests/store/queueStoreOwnership.spec.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/frontend/taskdeck-web/src/store/queueStore.ts b/frontend/taskdeck-web/src/store/queueStore.ts index e45e75141..47b70b0cd 100644 --- a/frontend/taskdeck-web/src/store/queueStore.ts +++ b/frontend/taskdeck-web/src/store/queueStore.ts @@ -141,6 +141,11 @@ export const useQueueStore = defineStore('queue', () => { } function retryActiveReads(): void { + if (!session.isAuthenticated || session.userId === null || session.isDemo) { + $reset() + return + } + const requestRetry = readOwners.has('requests') ? { owner: readOwners.get('requests')!, retry: readRetries.get('requests') } : undefined diff --git a/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts index 553c8c3c3..207e084a0 100644 --- a/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts @@ -420,6 +420,25 @@ describe('queueStore async ownership', () => { expect(toastMocks.success).not.toHaveBeenCalled() }) + it('resets rather than retrying a read under a cleared identity', async () => { + const pendingRead = deferred() + store.requests = [request('existing')] + vi.mocked(queueApi.getUserRequests).mockReturnValue(pendingRead.promise) + + const operation = store.fetchUserRequests() + session.token = null + session.userId = null + + expect(queueApi.getUserRequests).toHaveBeenCalledTimes(1) + expect(store.requests).toEqual([]) + expect(store.stats).toBeNull() + expect(store.loading).toBe(false) + + pendingRead.resolve([request('late')]) + await operation + expect(store.requests).toEqual([]) + }) + it('does not emit a process result toast after credential replacement', async () => { const processing = deferred() vi.mocked(queueApi.processNext).mockReturnValue(processing.promise) From ff2bdf89eaa96530c6a74f196599047e78d23f65 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Tue, 22 Sep 2026 00:14:57 +0100 Subject: [PATCH 13/13] fix: preserve queue query after token refresh --- frontend/taskdeck-web/src/store/queueStore.ts | 34 ++++++++++++--- .../tests/store/queueStoreOwnership.spec.ts | 43 ++++++++++++++++++- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/frontend/taskdeck-web/src/store/queueStore.ts b/frontend/taskdeck-web/src/store/queueStore.ts index 47b70b0cd..ebabed5f6 100644 --- a/frontend/taskdeck-web/src/store/queueStore.ts +++ b/frontend/taskdeck-web/src/store/queueStore.ts @@ -38,6 +38,7 @@ export const useQueueStore = defineStore('queue', () => { const readOwners = new Map() const readRetries = new Map() const successorReads = new Map>() + let activeRequestRetry: ReadRetry | null = null function syncLoading(): void { loading.value = activeOperations.size > 0 @@ -128,6 +129,14 @@ export const useQueueStore = defineStore('queue', () => { error.value = null } + function canReportMutationFailure(owner: OperationOwner): boolean { + return ownsCredential(owner) + || (owner.userId !== null + && owner.userId === session.userId + && session.isAuthenticated + && !session.isDemo) + } + async function awaitSuccessor(owner: ReadOwner): Promise { const successor = successorReads.get(owner.token) if (!successor) return false @@ -179,7 +188,15 @@ export const useQueueStore = defineStore('queue', () => { } try { - await Promise.all([fetchUserRequests(), fetchStats()]) + const refreshes: Promise[] = [] + if (!readOwners.has('requests')) { + const refreshRequests = activeRequestRetry ?? fetchUserRequests + refreshes.push(refreshRequests()) + } + if (!readOwners.has('stats')) { + refreshes.push(fetchStats()) + } + await Promise.all(refreshes) } catch { // Each read owns its error/toast state. The mutation already succeeded, // so a reconciliation failure must not turn it into a false write error. @@ -189,6 +206,7 @@ export const useQueueStore = defineStore('queue', () => { function $reset(): void { invalidateOperations() successorReads.clear() + activeRequestRetry = null mutationGeneration = 0 requests.value = [] stats.value = null @@ -221,7 +239,9 @@ export const useQueueStore = defineStore('queue', () => { return } - const owner = beginRead('requests', fetchUserRequests) + const retry = fetchUserRequests + activeRequestRetry = retry + const owner = beginRead('requests', retry) try { session.requireUserId('queue operations') const result = await queueApi.getUserRequests() @@ -252,7 +272,9 @@ export const useQueueStore = defineStore('queue', () => { return } - const owner = beginRead('requests', () => fetchByStatus(status)) + const retry = () => fetchByStatus(status) + activeRequestRetry = retry + const owner = beginRead('requests', retry) try { const result = await queueApi.getRequestsByStatus(status) if (ownsRead('requests', owner)) { @@ -290,7 +312,7 @@ export const useQueueStore = defineStore('queue', () => { toast.success('Request submitted') return request } catch (e: unknown) { - if (ownsCredential(owner)) { + if (canReportMutationFailure(owner)) { const msg = getErrorDisplay(e, 'Failed to submit request').message publishError(owner, msg) toast.error(msg) @@ -316,7 +338,7 @@ export const useQueueStore = defineStore('queue', () => { requests.value = requests.value.filter(r => r.id !== requestId) toast.success('Request cancelled') } catch (e: unknown) { - if (ownsCredential(owner)) { + if (canReportMutationFailure(owner)) { const msg = getErrorDisplay(e, 'Failed to cancel request').message publishError(owner, msg) toast.error(msg) @@ -345,7 +367,7 @@ export const useQueueStore = defineStore('queue', () => { } return result } catch (e: unknown) { - if (ownsCredential(owner)) { + if (canReportMutationFailure(owner)) { const msg = getErrorDisplay(e, 'Failed to process request').message publishError(owner, msg) toast.error(msg) diff --git a/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts index 207e084a0..92918b0a0 100644 --- a/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts @@ -345,7 +345,7 @@ describe('queueStore async ownership', () => { expect(toastMocks.error).toHaveBeenCalledTimes(1) }) - it('suppresses a stale mutation failure after token rotation while preserving cached data and rejection', async () => { + it('reports a same-user mutation failure after token rotation', async () => { const cancel = deferred() store.requests = [request('old-request')] vi.mocked(queueApi.cancelRequest).mockReturnValue(cancel.promise) @@ -356,6 +356,22 @@ describe('queueStore async ownership', () => { await expect(operation).rejects.toThrow('old-session cancellation failed') expect(store.requests.map(item => item.id)).toEqual(['old-request']) + expect(store.error).toBe('old-session cancellation failed') + expect(store.loading).toBe(false) + expect(toastMocks.error).toHaveBeenCalledWith('old-session cancellation failed') + }) + + it('suppresses a mutation failure after identity replacement', async () => { + const cancel = deferred() + store.requests = [request('old-request')] + vi.mocked(queueApi.cancelRequest).mockReturnValue(cancel.promise) + + const operation = store.cancelRequest('old-request') + session.userId = 'other-user' + cancel.reject(new Error('old-session cancellation failed')) + await expect(operation).rejects.toThrow('old-session cancellation failed') + + expect(store.requests).toEqual([]) expect(store.error).toBeNull() expect(store.loading).toBe(false) expect(toastMocks.error).not.toHaveBeenCalled() @@ -420,6 +436,31 @@ describe('queueStore async ownership', () => { expect(toastMocks.success).not.toHaveBeenCalled() }) + it('reconciles a stale mutation using the active status query', async () => { + const pendingSubmit = deferred() + const existing = request('existing', 'Failed') + const created = request('created', 'Failed') + const freshStats = stats(2) + store.requests = [existing] + store.stats = stats(1) + vi.mocked(queueApi.getRequestsByStatus) + .mockResolvedValueOnce([existing]) + .mockResolvedValueOnce([existing, created]) + vi.mocked(queueApi.getStats).mockResolvedValue(freshStats) + vi.mocked(queueApi.createRequest).mockReturnValue(pendingSubmit.promise) + + await store.fetchByStatus('Failed') + const operation = store.submitRequest({ requestType: 'Instruction', payload: 'Queue it' }) + session.token = token('new') + pendingSubmit.resolve(created) + + await expect(operation).resolves.toEqual(created) + expect(queueApi.getRequestsByStatus).toHaveBeenNthCalledWith(2, 'Failed') + expect(queueApi.getUserRequests).not.toHaveBeenCalled() + expect(store.requests.map(item => item.id)).toEqual(['existing', 'created']) + expect(store.stats).toEqual(freshStats) + }) + it('resets rather than retrying a read under a cleared identity', async () => { const pendingRead = deferred() store.requests = [request('existing')]