From c04a259cea7cf63933de9d380a0d6104392a613a Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 26 May 2026 17:35:17 +0300 Subject: [PATCH] fix(context): ignore stale team request scopes --- src/renderer/store/slices/connectionSlice.ts | 5 + src/renderer/store/slices/contextSlice.ts | 34 +- src/renderer/store/slices/teamSlice.ts | 156 +++++-- .../store/utils/contextScopedRequestEpoch.ts | 17 + .../store/contextSliceTeamReset.test.ts | 54 +++ .../store/teamSliceContextRace.test.ts | 420 +++++++++++++++++- 6 files changed, 643 insertions(+), 43 deletions(-) create mode 100644 src/renderer/store/utils/contextScopedRequestEpoch.ts diff --git a/src/renderer/store/slices/connectionSlice.ts b/src/renderer/store/slices/connectionSlice.ts index 126852ec..a59b23e7 100644 --- a/src/renderer/store/slices/connectionSlice.ts +++ b/src/renderer/store/slices/connectionSlice.ts @@ -7,6 +7,7 @@ import { api } from '@renderer/api'; +import { invalidateContextScopedRequestEpoch } from '../utils/contextScopedRequestEpoch'; import { getContextScopedTeamResetState, getFullResetState } from '../utils/stateResetHelpers'; import type { AppState } from '../types'; @@ -71,6 +72,9 @@ export const createConnectionSlice: StateCreator => { try { const status = await api.ssh.disconnect(); + invalidateContextScopedRequestEpoch(); set({ connectionMode: 'local', connectionState: status.state, diff --git a/src/renderer/store/slices/contextSlice.ts b/src/renderer/store/slices/contextSlice.ts index fb7850e2..4b016d27 100644 --- a/src/renderer/store/slices/contextSlice.ts +++ b/src/renderer/store/slices/contextSlice.ts @@ -9,6 +9,11 @@ import { api } from '@renderer/api'; import { contextStorage } from '@renderer/services/contextStorage'; import { draftStorage } from '@renderer/services/draftStorage'; +import { + captureContextScopedRequestEpoch, + invalidateContextScopedRequestEpoch, + isContextScopedRequestEpochCurrent, +} from '../utils/contextScopedRequestEpoch'; import { getContextScopedTeamResetState, getFullResetState } from '../utils/stateResetHelpers'; import type { AppState } from '../types'; @@ -78,6 +83,16 @@ function getEmptyContextState(): Partial { }; } +function isContextSwitchRequestCurrent( + get: () => AppState, + targetContextId: string, + requestEpoch: number +): boolean { + return ( + get().activeContextId === targetContextId && isContextScopedRequestEpochCurrent(requestEpoch) + ); +} + /** * Validate snapshot against fresh data from target context. * Filters invalid tabs, selections, and ensures at-least-one-pane invariant. @@ -261,13 +276,17 @@ export const createContextSlice: StateCreator = // Fetch active context from main process const activeContextId = await api.context.getActive(); const previousContextId = get().activeContextId; + const contextChanged = activeContextId !== previousContextId; + if (contextChanged) { + invalidateContextScopedRequestEpoch(); + } set({ - ...(activeContextId !== previousContextId ? getContextScopedTeamResetState() : {}), + ...(contextChanged ? getContextScopedTeamResetState() : {}), contextSnapshotsReady: true, activeContextId, }); - if (activeContextId !== previousContextId) { + if (contextChanged) { void get().fetchTeams(); void get().fetchAllTasks(); } @@ -320,6 +339,7 @@ export const createContextSlice: StateCreator = contextStorage.loadSnapshot(targetContextId), api.context.switch(targetContextId), ]); + invalidateContextScopedRequestEpoch(); // Step 2: Apply cached snapshot immediately for instant visual feedback if (targetSnapshot) { @@ -364,6 +384,7 @@ export const createContextSlice: StateCreator = targetContextId, }); } + const switchRequestEpoch = captureContextScopedRequestEpoch(); // Step 3: Fetch fresh data in background (slow over SSH) // Wrapped in try/catch so fetch failures don't wipe valid snapshot data. @@ -373,6 +394,9 @@ export const createContextSlice: StateCreator = api.getProjects(), api.getRepositoryGroups(), ]); + if (!isContextSwitchRequestCurrent(get, targetContextId, switchRequestEpoch)) { + return; + } if (targetSnapshot) { // Guard: don't overwrite snapshot data if fetch returned empty @@ -403,6 +427,9 @@ export const createContextSlice: StateCreator = } } catch (fetchError) { console.error('[contextSlice] Background data refresh failed:', fetchError); + if (!isContextSwitchRequestCurrent(get, targetContextId, switchRequestEpoch)) { + return; + } // Keep snapshot data as fallback — don't wipe user's view if (!targetSnapshot) { // No snapshot and fetch failed — finalize switch with empty state @@ -416,6 +443,9 @@ export const createContextSlice: StateCreator = } // Step 4: Fetch notifications in background + if (!isContextSwitchRequestCurrent(get, targetContextId, switchRequestEpoch)) { + return; + } void get().fetchNotifications(); void get().fetchTeams(); void get().fetchAllTasks(); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 04e15f42..423665fc 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -131,6 +131,11 @@ import { import { structurallyShareTeamSnapshot } from '../team/teamSnapshotStructuralSharing'; import { parseToolApprovalSettings } from '../team/teamToolApprovalSettings'; import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics'; +import { + captureContextScopedRequestEpoch, + isContextScopedRequestEpochCurrent, + resetContextScopedRequestEpochForTests, +} from '../utils/contextScopedRequestEpoch'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; import type { @@ -293,6 +298,7 @@ export function __resetTeamSliceModuleStateForTests(): void { clearAllPendingReplyRefreshWaits(); clearAllLastResolvedTeamDataRefreshes(); clearAllTeamLocalStateEpochs(); + resetContextScopedRequestEpochForTests(); clearAllMemberSpawnStatusesIpcBackoffs(); clearAllTeamRefreshBurstDiagnostics(); clearAllMemberSpawnUiEqualLastWarns(); @@ -427,15 +433,56 @@ function drainQueuedFullRefreshAfterThinSettles(teamName: string, get: () => Tea void get().refreshTeamData(teamName, { withDedup: true }); } +interface ContextRequestScope { + contextId: string; + contextEpoch: number; +} + +interface TeamRequestScope extends ContextRequestScope { + teamStateEpoch: number; +} + +function captureContextRequestScope(get: () => AppState): ContextRequestScope { + return { + contextId: get().activeContextId, + contextEpoch: captureContextScopedRequestEpoch(), + }; +} + +function isContextRequestScopeCurrent(get: () => AppState, scope: ContextRequestScope): boolean { + return ( + get().activeContextId === scope.contextId && + isContextScopedRequestEpochCurrent(scope.contextEpoch) + ); +} + +function captureTeamRequestScope(get: () => AppState, teamName: string): TeamRequestScope { + return { + ...captureContextRequestScope(get), + teamStateEpoch: captureTeamLocalStateEpoch(teamName), + }; +} + +function isTeamRequestScopeCurrent( + get: () => AppState, + teamName: string, + scope: TeamRequestScope +): boolean { + return ( + isContextRequestScopeCurrent(get, scope) && + isTeamLocalStateEpochCurrent(teamName, scope.teamStateEpoch) + ); +} + function isSelectedTeamLoadStillCurrent( - get: () => TeamSlice, + get: () => AppState, teamName: string, requestNonce: number, - teamStateEpoch: number + requestScope: TeamRequestScope ): boolean { const state = get(); return ( - isTeamLocalStateEpochCurrent(teamName, teamStateEpoch) && + isTeamRequestScopeCurrent(get, teamName, requestScope) && state.selectedTeamName === teamName && state.selectedTeamLoadNonce === requestNonce && state.selectedTeamData?.teamName === teamName @@ -445,10 +492,10 @@ function isSelectedTeamLoadStillCurrent( function schedulePostPaintTeamEnrichments(params: { teamName: string; requestNonce: number; - teamStateEpoch: number; - get: () => TeamSlice; + requestScope: TeamRequestScope; + get: () => AppState; }): void { - const { teamName, requestNonce, teamStateEpoch, get } = params; + const { teamName, requestNonce, requestScope, get } = params; cancelPostPaintTeamEnrichments(teamName); @@ -459,7 +506,7 @@ function schedulePostPaintTeamEnrichments(params: { postPaintTeamEnrichmentTimers.delete(teamName); void (async () => { - if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) { queuedFullTeamDataRefreshesAfterThin.delete(teamName); return; } @@ -485,7 +532,7 @@ function schedulePostPaintTeamEnrichments(params: { try { const headResult = await get().refreshTeamMessagesHead(teamName); - if (!isSelectedTeamLoadStillCurrent(get, teamName, requestNonce, teamStateEpoch)) { + if (!isSelectedTeamLoadStillCurrent(get, teamName, requestNonce, requestScope)) { return; } if (headResult.feedChanged || isMemberActivityMetaStale(get(), teamName)) { @@ -1228,8 +1275,12 @@ export const createTeamSlice: StateCreator = (set, if (isMemberSpawnStatusesIpcBackoffActive(teamName)) { return; } + const requestScope = captureTeamRequestScope(get, teamName); try { const snapshot = await api.teams.getMemberSpawnStatuses(teamName); + if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) { + return; + } clearMemberSpawnStatusesIpcBackoff(teamName); set((prev) => { if (snapshot.runId != null && prev.ignoredRuntimeRunIds[snapshot.runId] === teamName) { @@ -1292,6 +1343,9 @@ export const createTeamSlice: StateCreator = (set, }; }); } catch (error) { + if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) { + return; + } const message = error instanceof Error ? error.message : String(error); if (message.includes("No handler registered for 'team:memberSpawnStatuses'")) { recordMemberSpawnStatusesIpcRetryBackoff( @@ -1304,8 +1358,12 @@ export const createTeamSlice: StateCreator = (set, }, fetchTeamAgentRuntime: async (teamName: string) => { if (!api.teams?.getTeamAgentRuntime) return; + const requestScope = captureTeamRequestScope(get, teamName); try { const snapshot = await api.teams.getTeamAgentRuntime(teamName); + if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) { + return; + } set((prev) => { if (snapshot.runId != null && prev.ignoredRuntimeRunIds[snapshot.runId] === teamName) { return {}; @@ -1405,7 +1463,7 @@ export const createTeamSlice: StateCreator = (set, // Only effective during initial load (when teamsLoading is set to true below). // Refreshes are already serialized by the throttle timer in onTeamChange. if (get().teamsLoading) return; - const requestContextId = get().activeContextId; + const requestScope = captureContextRequestScope(get); const requestId = ++latestTeamsFetchRequestId; // Only show loading spinner on initial load — avoids flickering when refreshing const isInitialLoad = get().teams.length === 0; @@ -1418,7 +1476,10 @@ export const createTeamSlice: StateCreator = (set, TEAM_FETCH_TIMEOUT_MS, 'fetchTeams' ); - if (get().activeContextId !== requestContextId || latestTeamsFetchRequestId !== requestId) { + if ( + !isContextRequestScopeCurrent(get, requestScope) || + latestTeamsFetchRequestId !== requestId + ) { return; } const teamByName: Record = {}; @@ -1453,7 +1514,10 @@ export const createTeamSlice: StateCreator = (set, }; }); } catch (error) { - if (get().activeContextId !== requestContextId || latestTeamsFetchRequestId !== requestId) { + if ( + !isContextRequestScopeCurrent(get, requestScope) || + latestTeamsFetchRequestId !== requestId + ) { return; } // On refresh failure, keep existing teams visible @@ -1487,7 +1551,7 @@ export const createTeamSlice: StateCreator = (set, if (isInitialLoad) { set({ globalTasksLoading: true, globalTasksError: null }); } - const requestContextId = get().activeContextId; + const requestScope = captureContextRequestScope(get); const oldTasks = get().globalTasks; try { const tasks = await withTimeout( @@ -1495,7 +1559,7 @@ export const createTeamSlice: StateCreator = (set, TEAM_FETCH_TIMEOUT_MS, 'fetchAllTasks' ); - if (get().activeContextId !== requestContextId) { + if (!isContextRequestScopeCurrent(get, requestScope)) { continue; } const notificationState = get(); @@ -1515,7 +1579,7 @@ export const createTeamSlice: StateCreator = (set, globalTasksError: null, }); } catch (error) { - if (get().activeContextId !== requestContextId) { + if (!isContextRequestScopeCurrent(get, requestScope)) { continue; } set({ @@ -2043,6 +2107,7 @@ export const createTeamSlice: StateCreator = (set, }, refreshTeamChangePresence: async (teamName: string) => { + const requestScope = captureTeamRequestScope(get, teamName); const currentTeamData = selectTeamDataForName(get(), teamName); if (!currentTeamData) { return; @@ -2052,6 +2117,9 @@ export const createTeamSlice: StateCreator = (set, const presenceByTaskId = await unwrapIpc('team:getTaskChangePresence', () => api.teams.getTaskChangePresence(teamName) ); + if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) { + return; + } set((state) => { const teamData = selectTeamDataForName(state, teamName); @@ -2099,7 +2167,7 @@ export const createTeamSlice: StateCreator = (set, }, selectTeam: async (teamName: string, opts) => { - const teamStateEpoch = captureTeamLocalStateEpoch(teamName); + const requestScope = captureTeamRequestScope(get, teamName); const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true; // Guard: prevent duplicate in-flight fetches for the same team. // GlobalTaskDetailDialog + tab navigation can call selectTeam() in quick succession. @@ -2132,7 +2200,7 @@ export const createTeamSlice: StateCreator = (set, const data = await fetchTeamDataDeduped(teamName, { includeMemberBranches: false, }); - if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) { queuedFullTeamDataRefreshesAfterThin.delete(teamName); return; } @@ -2266,7 +2334,7 @@ export const createTeamSlice: StateCreator = (set, if ( !opts?.skipProjectAutoSelect && projectPath && - isSelectedTeamLoadStillCurrent(get, teamName, requestNonce, teamStateEpoch) + isSelectedTeamLoadStillCurrent(get, teamName, requestNonce, requestScope) ) { const state = get(); const normalizedTeamPath = normalizePath(projectPath); @@ -2305,7 +2373,7 @@ export const createTeamSlice: StateCreator = (set, schedulePostPaintTeamEnrichments({ teamName, requestNonce, - teamStateEpoch, + requestScope, get, }); } catch (error) { @@ -2316,7 +2384,7 @@ export const createTeamSlice: StateCreator = (set, ); } } catch (error) { - if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) { queuedFullTeamDataRefreshesAfterThin.delete(teamName); return; } @@ -2398,7 +2466,7 @@ export const createTeamSlice: StateCreator = (set, return; } - const teamStateEpoch = captureTeamLocalStateEpoch(teamName); + const requestScope = captureTeamRequestScope(get, teamName); const refreshToken = beginInFlightTeamDataRefresh(teamName); // Silent refresh — update data without showing loading skeleton. // Only selectTeam() sets loading: true (for initial load). @@ -2411,7 +2479,7 @@ export const createTeamSlice: StateCreator = (set, const data = opts?.withDedup ? await fetchTeamDataDeduped(teamName) : await fetchTeamDataFresh(teamName); - if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) { return; } const projectedTeamData = previousData @@ -2462,7 +2530,7 @@ export const createTeamSlice: StateCreator = (set, await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds); } } catch (error) { - if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) { return; } const msg = @@ -2524,7 +2592,11 @@ export const createTeamSlice: StateCreator = (set, set({ selectedTeamError: msg }); } finally { endInFlightTeamDataRefresh(teamName, refreshToken); - if (reusedInFlightRequest && pendingFreshTeamDataRefreshes.delete(teamName)) { + if ( + reusedInFlightRequest && + pendingFreshTeamDataRefreshes.delete(teamName) && + isTeamRequestScopeCurrent(get, teamName, requestScope) + ) { void get().refreshTeamData(teamName); } } @@ -2543,10 +2615,10 @@ export const createTeamSlice: StateCreator = (set, const existingOlderRequest = inFlightTeamMessagesOlderRequests.get(teamName); if (existingOlderRequest) { - const queuedEpoch = captureTeamLocalStateEpoch(teamName); + const queuedScope = captureTeamRequestScope(get, teamName); const queuedRequest: Promise = existingOlderRequest .then(() => { - if (!isTeamLocalStateEpochCurrent(teamName, queuedEpoch)) { + if (!isTeamRequestScopeCurrent(get, teamName, queuedScope)) { return { feedChanged: false, headChanged: false, @@ -2577,7 +2649,7 @@ export const createTeamSlice: StateCreator = (set, current: null, }; requestRef.current = (async (): Promise => { - const teamStateEpoch = captureTeamLocalStateEpoch(teamName); + const requestScope = captureTeamRequestScope(get, teamName); set((state) => ({ teamMessagesByName: { ...state.teamMessagesByName, @@ -2592,7 +2664,7 @@ export const createTeamSlice: StateCreator = (set, const page = await unwrapIpc('team:getMessagesPage', () => api.teams.getMessagesPage(teamName, { limit: 50 }) ); - if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) { return { feedChanged: false, headChanged: false, @@ -2648,7 +2720,7 @@ export const createTeamSlice: StateCreator = (set, feedRevision: page.feedRevision, }; } catch (error) { - if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) { return { feedChanged: false, headChanged: false, @@ -2668,7 +2740,10 @@ export const createTeamSlice: StateCreator = (set, } finally { if (inFlightTeamMessagesHeadRequests.get(teamName) === requestRef.current) { inFlightTeamMessagesHeadRequests.delete(teamName); - if (pendingFreshTeamMessagesHeadRefreshes.delete(teamName)) { + if ( + pendingFreshTeamMessagesHeadRefreshes.delete(teamName) && + isTeamRequestScopeCurrent(get, teamName, requestScope) + ) { void get().refreshTeamMessagesHead(teamName); } } @@ -2681,7 +2756,7 @@ export const createTeamSlice: StateCreator = (set, }, loadOlderTeamMessages: async (teamName: string) => { - const requestedEpoch = captureTeamLocalStateEpoch(teamName); + const requestedScope = captureTeamRequestScope(get, teamName); const existingRequest = inFlightTeamMessagesOlderRequests.get(teamName); if (existingRequest) { return existingRequest; @@ -2690,7 +2765,7 @@ export const createTeamSlice: StateCreator = (set, const existingHeadRequest = inFlightTeamMessagesHeadRequests.get(teamName); if (existingHeadRequest) { await existingHeadRequest; - if (!isTeamLocalStateEpochCurrent(teamName, requestedEpoch)) { + if (!isTeamRequestScopeCurrent(get, teamName, requestedScope)) { return; } } @@ -2698,7 +2773,7 @@ export const createTeamSlice: StateCreator = (set, let entry = getTeamMessagesCacheEntry(get(), teamName); if (!entry.headHydrated) { await get().refreshTeamMessagesHead(teamName); - if (!isTeamLocalStateEpochCurrent(teamName, requestedEpoch)) { + if (!isTeamRequestScopeCurrent(get, teamName, requestedScope)) { return; } entry = getTeamMessagesCacheEntry(get(), teamName); @@ -2710,7 +2785,7 @@ export const createTeamSlice: StateCreator = (set, const requestRef: { current: Promise | null } = { current: null }; requestRef.current = (async (): Promise => { - const teamStateEpoch = captureTeamLocalStateEpoch(teamName); + const requestScope = captureTeamRequestScope(get, teamName); set((state) => ({ teamMessagesByName: { ...state.teamMessagesByName, @@ -2729,7 +2804,7 @@ export const createTeamSlice: StateCreator = (set, limit: 50, }) ); - if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) { return; } @@ -2780,7 +2855,7 @@ export const createTeamSlice: StateCreator = (set, }; }); } catch { - if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) { return; } set((state) => ({ @@ -2818,12 +2893,12 @@ export const createTeamSlice: StateCreator = (set, const requestRef: { current: Promise | null } = { current: null }; requestRef.current = (async (): Promise => { - const teamStateEpoch = captureTeamLocalStateEpoch(teamName); + const requestScope = captureTeamRequestScope(get, teamName); try { const meta = await unwrapIpc('team:getMemberActivityMeta', () => api.teams.getMemberActivityMeta(teamName) ); - if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) { return; } @@ -2857,14 +2932,17 @@ export const createTeamSlice: StateCreator = (set, }; }); } catch (error) { - if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) { return; } throw error; } finally { if (inFlightTeamMemberActivityMetaRequests.get(teamName) === requestRef.current) { inFlightTeamMemberActivityMetaRequests.delete(teamName); - if (pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName)) { + if ( + pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName) && + isTeamRequestScopeCurrent(get, teamName, requestScope) + ) { void get().refreshMemberActivityMeta(teamName); } } diff --git a/src/renderer/store/utils/contextScopedRequestEpoch.ts b/src/renderer/store/utils/contextScopedRequestEpoch.ts new file mode 100644 index 00000000..7715e230 --- /dev/null +++ b/src/renderer/store/utils/contextScopedRequestEpoch.ts @@ -0,0 +1,17 @@ +let contextScopedRequestEpoch = 0; + +export function captureContextScopedRequestEpoch(): number { + return contextScopedRequestEpoch; +} + +export function isContextScopedRequestEpochCurrent(epoch: number): boolean { + return contextScopedRequestEpoch === epoch; +} + +export function invalidateContextScopedRequestEpoch(): void { + contextScopedRequestEpoch += 1; +} + +export function resetContextScopedRequestEpochForTests(): void { + contextScopedRequestEpoch = 0; +} diff --git a/test/renderer/store/contextSliceTeamReset.test.ts b/test/renderer/store/contextSliceTeamReset.test.ts index 6497d6a4..ee004f22 100644 --- a/test/renderer/store/contextSliceTeamReset.test.ts +++ b/test/renderer/store/contextSliceTeamReset.test.ts @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + invalidateContextScopedRequestEpoch, + resetContextScopedRequestEpochForTests, +} from '../../../src/renderer/store/utils/contextScopedRequestEpoch'; + import { createTestStore } from './storeTestUtils'; const apiMock = vi.hoisted(() => ({ @@ -119,6 +124,7 @@ function deferred(): { describe('context slice team/task reset', () => { beforeEach(() => { vi.clearAllMocks(); + resetContextScopedRequestEpochForTests(); contextStorageMock.loadSnapshot.mockResolvedValue(targetSnapshot()); apiMock.context.getActive.mockResolvedValue('local'); apiMock.getProjects.mockResolvedValue(targetSnapshot().projects); @@ -128,6 +134,7 @@ describe('context slice team/task reset', () => { }); afterEach(() => { + resetContextScopedRequestEpochForTests(); vi.restoreAllMocks(); }); @@ -225,6 +232,53 @@ describe('context slice team/task reset', () => { expect(store.getState().isContextSwitching).toBe(false); }); + it('does not apply a slow background project refresh after the context epoch changes again', async () => { + const projectScan = deferred(); + apiMock.getProjects.mockReturnValue(projectScan.promise); + apiMock.getRepositoryGroups.mockResolvedValue([]); + const store = createTestStore(); + const localProject = { + id: 'local-project', + name: 'Local Project', + path: '/local/project', + sessions: [], + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }; + + const switchPromise = store.getState().switchContext('ssh-dev'); + await Promise.resolve(); + await Promise.resolve(); + + expect(store.getState().activeContextId).toBe('ssh-dev'); + expect(store.getState().isContextSwitching).toBe(false); + + invalidateContextScopedRequestEpoch(); + store.setState({ + activeContextId: 'local', + projects: [localProject], + repositoryGroups: [], + isContextSwitching: false, + targetContextId: null, + } as never); + projectScan.resolve([ + { + id: 'late-ssh-project', + name: 'Late SSH Project', + path: '/ssh/late', + sessions: [], + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }, + ]); + await switchPromise; + + expect(store.getState().activeContextId).toBe('local'); + expect(store.getState().projects).toEqual([localProject]); + expect(apiMock.teams.list).not.toHaveBeenCalled(); + expect(apiMock.teams.getAllTasks).not.toHaveBeenCalled(); + }); + it('drops previous-context team and task caches when lazy context initialization changes context', async () => { apiMock.context.getActive.mockResolvedValue('ssh-dev'); const store = createTestStore(); diff --git a/test/renderer/store/teamSliceContextRace.test.ts b/test/renderer/store/teamSliceContextRace.test.ts index a9374ea5..6b430edd 100644 --- a/test/renderer/store/teamSliceContextRace.test.ts +++ b/test/renderer/store/teamSliceContextRace.test.ts @@ -2,9 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { create } from 'zustand'; import { + __getTeamScopedTransientStateForTests, __resetTeamSliceModuleStateForTests, createTeamSlice, } from '../../../src/renderer/store/slices/teamSlice'; +import { invalidateTeamLocalStateEpoch } from '../../../src/renderer/store/team/teamLocalStateEpoch'; +import { invalidateContextScopedRequestEpoch } from '../../../src/renderer/store/utils/contextScopedRequestEpoch'; import type { AppState } from '../../../src/renderer/store/types'; @@ -12,8 +15,17 @@ const apiMock = vi.hoisted(() => ({ teams: { list: vi.fn(), getAllTasks: vi.fn(), + getData: vi.fn(), + getMessagesPage: vi.fn(), + getMemberActivityMeta: vi.fn(), + getMemberSpawnStatuses: vi.fn(), + getTeamAgentRuntime: vi.fn(), + getTaskChangePresence: vi.fn(), showMessageNotification: vi.fn(async () => undefined), }, + review: { + invalidateTaskChangeSummaries: vi.fn(async () => undefined), + }, })); interface TeamSummaryLike { @@ -32,6 +44,69 @@ interface GlobalTaskLike { comments: []; } +interface TeamSnapshotLike { + teamName: string; + config: { + name: string; + projectPath: string; + }; + tasks: Array<{ + id: string; + changePresence?: string; + }>; + members: []; + kanbanState: { + teamName: string; + reviewers: []; + tasks: Record; + }; + processes: []; +} + +const teamSnapshot = ( + teamName: string, + projectPath: string, + tasks: TeamSnapshotLike['tasks'] = [] +): TeamSnapshotLike => ({ + teamName, + config: { + name: teamName, + projectPath, + }, + tasks, + members: [], + kanbanState: { + teamName, + reviewers: [], + tasks: {}, + }, + processes: [], +}); + +const memberSpawnSnapshot = { + runId: 'runtime-run', + statuses: { + lead: { + status: 'online', + launchState: 'confirmed_alive', + }, + }, +}; + +const runtimeSnapshot = { + teamName: 'shared-team', + updatedAt: '2026-03-12T10:00:00.000Z', + runId: 'runtime-run', + members: { + lead: { + memberName: 'lead', + alive: true, + restartable: true, + updatedAt: '2026-03-12T10:00:00.000Z', + }, + }, +}; + vi.mock('@renderer/api', () => ({ api: apiMock, })); @@ -39,12 +114,21 @@ vi.mock('@renderer/api', () => ({ function deferred(): { promise: Promise; resolve: (value: T) => void; + reject: (reason?: unknown) => void; } { let resolve!: (value: T) => void; - const promise = new Promise((innerResolve) => { + let reject!: (reason?: unknown) => void; + const promise = new Promise((innerResolve, innerReject) => { resolve = innerResolve; + reject = innerReject; }); - return { promise, resolve }; + return { promise, resolve, reject }; +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); } function createSliceStore() { @@ -70,6 +154,11 @@ function createSliceStore() { getAllPaneTabs: vi.fn(() => []), warmTaskChangeSummaries: vi.fn(async () => undefined), invalidateTaskChangePresence: vi.fn(), + projects: [], + repositoryGroups: [], + selectedProjectId: null, + selectedWorktreeId: null, + fetchSessionsInitial: vi.fn(async () => undefined), }) as unknown as AppState ); } @@ -79,7 +168,14 @@ describe('team slice context races', () => { __resetTeamSliceModuleStateForTests(); apiMock.teams.list.mockReset(); apiMock.teams.getAllTasks.mockReset(); + apiMock.teams.getData.mockReset(); + apiMock.teams.getMessagesPage.mockReset(); + apiMock.teams.getMemberActivityMeta.mockReset(); + apiMock.teams.getMemberSpawnStatuses.mockReset(); + apiMock.teams.getTeamAgentRuntime.mockReset(); + apiMock.teams.getTaskChangePresence.mockReset(); apiMock.teams.showMessageNotification.mockClear(); + apiMock.review.invalidateTaskChangeSummaries.mockClear(); }); afterEach(() => { @@ -116,6 +212,36 @@ describe('team slice context races', () => { expect(store.getState().teamsLoading).toBe(false); }); + it('ignores a team list response loaded before a context epoch reset with the same context id', async () => { + const store = createSliceStore(); + const localList = deferred(); + apiMock.teams.list.mockReturnValueOnce(localList.promise); + + const fetchPromise = store.getState().fetchTeams(); + expect(store.getState().teamsLoading).toBe(true); + + invalidateContextScopedRequestEpoch(); + store.setState({ + activeContextId: 'local', + teams: [], + teamByName: {}, + teamBySessionId: {}, + teamsLoading: false, + }); + localList.resolve([ + { + teamName: 'old-local-team', + displayName: 'Old Local Team', + projectPath: '/old-local/project', + }, + ]); + await fetchPromise; + + expect(store.getState().teams).toEqual([]); + expect(store.getState().teamByName).toEqual({}); + expect(store.getState().teamsLoading).toBe(false); + }); + it('reruns a pending global task refresh for the current context instead of applying stale tasks', async () => { const store = createSliceStore(); const localTasks = deferred(); @@ -163,4 +289,294 @@ describe('team slice context races', () => { expect(store.getState().globalTasksInitialized).toBe(true); expect(store.getState().globalTasksLoading).toBe(false); }); + + it('ignores global tasks loaded before a context epoch reset with the same context id', async () => { + const store = createSliceStore(); + const localTasks = deferred(); + apiMock.teams.getAllTasks.mockReturnValueOnce(localTasks.promise); + + const fetchPromise = store.getState().fetchAllTasks(); + expect(store.getState().globalTasksLoading).toBe(true); + + invalidateContextScopedRequestEpoch(); + store.setState({ + activeContextId: 'local', + globalTasks: [], + globalTasksLoading: false, + globalTasksInitialized: false, + }); + localTasks.resolve([ + { + id: 'old-local-task', + subject: 'Old local task', + status: 'todo', + teamName: 'old-local-team', + teamDisplayName: 'Old Local Team', + projectPath: '/old-local/project', + comments: [], + }, + ]); + await fetchPromise; + + expect(store.getState().globalTasks).toEqual([]); + expect(store.getState().globalTasksInitialized).toBe(false); + expect(store.getState().globalTasksLoading).toBe(false); + }); + + it('ignores selected team data loaded for a previous context', async () => { + const store = createSliceStore(); + const localData = deferred(); + apiMock.teams.getData.mockReturnValueOnce(localData.promise); + + const selectPromise = store.getState().selectTeam('shared-team'); + expect(store.getState().selectedTeamName).toBe('shared-team'); + + store.setState({ + activeContextId: 'ssh-dev', + selectedTeamName: null, + selectedTeamData: null, + selectedTeamLoading: false, + teamDataCacheByName: {}, + }); + localData.resolve(teamSnapshot('shared-team', '/local/project')); + await selectPromise; + + expect(store.getState().selectedTeamName).toBeNull(); + expect(store.getState().selectedTeamData).toBeNull(); + expect(store.getState().teamDataCacheByName).toEqual({}); + }); + + it('ignores selected team data loaded before a context epoch reset with the same context id', async () => { + const store = createSliceStore(); + const localData = deferred(); + apiMock.teams.getData.mockReturnValueOnce(localData.promise); + + const selectPromise = store.getState().selectTeam('shared-team'); + expect(store.getState().selectedTeamName).toBe('shared-team'); + + invalidateContextScopedRequestEpoch(); + store.setState({ + activeContextId: 'local', + selectedTeamName: null, + selectedTeamData: null, + selectedTeamLoading: false, + teamDataCacheByName: {}, + }); + localData.resolve(teamSnapshot('shared-team', '/old-local/project')); + await selectPromise; + + expect(store.getState().selectedTeamName).toBeNull(); + expect(store.getState().selectedTeamData).toBeNull(); + expect(store.getState().teamDataCacheByName).toEqual({}); + }); + + it('does not let a stale silent team refresh overwrite the current context cache', async () => { + const store = createSliceStore(); + const sshData = teamSnapshot('shared-team', '/ssh/project'); + const localData = deferred(); + apiMock.teams.getData.mockReturnValueOnce(localData.promise); + + const refreshPromise = store.getState().refreshTeamData('shared-team'); + store.setState({ + activeContextId: 'ssh-dev', + teamDataCacheByName: { + 'shared-team': sshData, + }, + } as never); + + localData.resolve(teamSnapshot('shared-team', '/local/project')); + await refreshPromise; + + expect(store.getState().teamDataCacheByName['shared-team']).toBe(sshData); + }); + + it('ignores message head pages loaded for a previous context', async () => { + const store = createSliceStore(); + const localMessages = deferred<{ + messages: []; + feedRevision: string; + nextCursor: null; + hasMore: false; + }>(); + apiMock.teams.getMessagesPage.mockReturnValueOnce(localMessages.promise); + + const refreshPromise = store.getState().refreshTeamMessagesHead('shared-team'); + expect(store.getState().teamMessagesByName['shared-team']).toBeDefined(); + + store.setState({ + activeContextId: 'ssh-dev', + teamMessagesByName: {}, + }); + localMessages.resolve({ + messages: [], + feedRevision: 'local-feed', + nextCursor: null, + hasMore: false, + }); + await refreshPromise; + + expect(store.getState().teamMessagesByName).toEqual({}); + }); + + it('ignores member spawn statuses loaded before a same-context team reset', async () => { + const store = createSliceStore(); + const localStatuses = deferred(); + apiMock.teams.getMemberSpawnStatuses.mockReturnValueOnce(localStatuses.promise); + + const refreshPromise = store.getState().fetchMemberSpawnStatuses('shared-team'); + invalidateTeamLocalStateEpoch('shared-team'); + localStatuses.resolve(memberSpawnSnapshot); + await refreshPromise; + + expect(store.getState().memberSpawnStatusesByTeam).toEqual({}); + expect(store.getState().memberSpawnSnapshotsByTeam).toEqual({}); + expect(store.getState().currentRuntimeRunIdByTeam).toEqual({}); + }); + + it('does not let stale member spawn IPC failures poison the next team scope', async () => { + const store = createSliceStore(); + const staleFailure = deferred(); + apiMock.teams.getMemberSpawnStatuses.mockReturnValueOnce(staleFailure.promise); + + const refreshPromise = store.getState().fetchMemberSpawnStatuses('shared-team'); + invalidateTeamLocalStateEpoch('shared-team'); + staleFailure.reject(new Error("No handler registered for 'team:memberSpawnStatuses'")); + await refreshPromise; + + expect( + __getTeamScopedTransientStateForTests('shared-team').hasMemberSpawnStatusesIpcBackoff + ).toBe(false); + }); + + it('ignores agent runtime snapshots loaded before a same-context team reset', async () => { + const store = createSliceStore(); + const localRuntime = deferred(); + apiMock.teams.getTeamAgentRuntime.mockReturnValueOnce(localRuntime.promise); + + const refreshPromise = store.getState().fetchTeamAgentRuntime('shared-team'); + invalidateTeamLocalStateEpoch('shared-team'); + localRuntime.resolve(runtimeSnapshot); + await refreshPromise; + + expect(store.getState().teamAgentRuntimeByTeam).toEqual({}); + }); + + it('ignores change presence loaded before a same-context team reset', async () => { + const store = createSliceStore(); + const staleData = teamSnapshot('shared-team', '/local/project', [ + { id: 'task-1', changePresence: 'unknown' }, + ]); + const localPresence = deferred<{ 'task-1': 'has_changes' }>(); + apiMock.teams.getTaskChangePresence.mockReturnValueOnce(localPresence.promise); + store.setState({ + selectedTeamName: 'shared-team', + selectedTeamData: staleData, + teamDataCacheByName: { + 'shared-team': staleData, + }, + } as never); + + const refreshPromise = store.getState().refreshTeamChangePresence('shared-team'); + invalidateTeamLocalStateEpoch('shared-team'); + localPresence.resolve({ 'task-1': 'has_changes' }); + await refreshPromise; + + expect(store.getState().selectedTeamData).toBe(staleData); + expect(store.getState().teamDataCacheByName['shared-team']).toBe(staleData); + }); + + it('does not rerun pending full team data refreshes from a stale scope', async () => { + const store = createSliceStore(); + const localData = deferred(); + apiMock.teams.getData + .mockReturnValueOnce(localData.promise) + .mockResolvedValueOnce(teamSnapshot('shared-team', '/unexpected/project')); + + const firstRefresh = store.getState().refreshTeamData('shared-team', { withDedup: true }); + const secondRefresh = store.getState().refreshTeamData('shared-team', { withDedup: true }); + invalidateTeamLocalStateEpoch('shared-team'); + store.setState({ teamDataCacheByName: {} }); + localData.resolve(teamSnapshot('shared-team', '/local/project')); + await Promise.all([firstRefresh, secondRefresh]); + await flushMicrotasks(); + + expect(apiMock.teams.getData).toHaveBeenCalledTimes(1); + }); + + it('does not rerun pending message head refreshes from a stale scope', async () => { + const store = createSliceStore(); + const localMessages = deferred<{ + messages: []; + feedRevision: string; + nextCursor: null; + hasMore: false; + }>(); + apiMock.teams.getMessagesPage.mockReturnValueOnce(localMessages.promise).mockResolvedValueOnce({ + messages: [], + feedRevision: 'unexpected-feed', + nextCursor: null, + hasMore: false, + }); + + const firstRefresh = store.getState().refreshTeamMessagesHead('shared-team'); + const secondRefresh = store.getState().refreshTeamMessagesHead('shared-team'); + invalidateTeamLocalStateEpoch('shared-team'); + store.setState({ teamMessagesByName: {} }); + localMessages.resolve({ + messages: [], + feedRevision: 'local-feed', + nextCursor: null, + hasMore: false, + }); + await Promise.all([firstRefresh, secondRefresh]); + await flushMicrotasks(); + + expect(apiMock.teams.getMessagesPage).toHaveBeenCalledTimes(1); + }); + + it('does not rerun pending member activity meta refreshes from a stale scope', async () => { + const store = createSliceStore(); + const localMeta = deferred<{ + teamName: string; + computedAt: string; + feedRevision: string; + members: Record; + }>(); + apiMock.teams.getMemberActivityMeta.mockReturnValueOnce(localMeta.promise).mockResolvedValueOnce({ + teamName: 'shared-team', + computedAt: '2026-03-12T10:00:01.000Z', + feedRevision: 'unexpected-feed', + members: {}, + }); + store.setState({ + teamMessagesByName: { + 'shared-team': { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: 'feed-1', + nextCursor: null, + hasMore: false, + lastFetchedAt: 0, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + } as never); + + const firstRefresh = store.getState().refreshMemberActivityMeta('shared-team'); + const secondRefresh = store.getState().refreshMemberActivityMeta('shared-team'); + invalidateTeamLocalStateEpoch('shared-team'); + store.setState({ teamMessagesByName: {}, memberActivityMetaByTeam: {} }); + localMeta.resolve({ + teamName: 'shared-team', + computedAt: '2026-03-12T10:00:00.000Z', + feedRevision: 'feed-1', + members: {}, + }); + await Promise.all([firstRefresh, secondRefresh]); + await flushMicrotasks(); + + expect(apiMock.teams.getMemberActivityMeta).toHaveBeenCalledTimes(1); + }); });