fix(context): ignore stale team request scopes

This commit is contained in:
777genius 2026-05-26 17:35:17 +03:00
parent 255fa5aa47
commit c04a259cea
6 changed files with 643 additions and 43 deletions

View file

@ -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<AppState, [], [], ConnectionSli
try {
const status = await api.ssh.connect(config);
if (status.state === 'connected') {
invalidateContextScopedRequestEpoch();
}
set({
connectionMode: status.state === 'connected' ? 'ssh' : 'local',
connectionState: status.state,
@ -134,6 +138,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
disconnectSsh: async (): Promise<void> => {
try {
const status = await api.ssh.disconnect();
invalidateContextScopedRequestEpoch();
set({
connectionMode: 'local',
connectionState: status.state,

View file

@ -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<AppState> {
};
}
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<AppState, [], [], ContextSlice> =
// 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<AppState, [], [], ContextSlice> =
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<AppState, [], [], ContextSlice> =
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<AppState, [], [], ContextSlice> =
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<AppState, [], [], ContextSlice> =
}
} 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<AppState, [], [], ContextSlice> =
}
// Step 4: Fetch notifications in background
if (!isContextSwitchRequestCurrent(get, targetContextId, switchRequestEpoch)) {
return;
}
void get().fetchNotifications();
void get().fetchTeams();
void get().fetchAllTasks();

View file

@ -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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (set,
TEAM_FETCH_TIMEOUT_MS,
'fetchTeams'
);
if (get().activeContextId !== requestContextId || latestTeamsFetchRequestId !== requestId) {
if (
!isContextRequestScopeCurrent(get, requestScope) ||
latestTeamsFetchRequestId !== requestId
) {
return;
}
const teamByName: Record<string, TeamSummary> = {};
@ -1453,7 +1514,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (set,
globalTasksError: null,
});
} catch (error) {
if (get().activeContextId !== requestContextId) {
if (!isContextRequestScopeCurrent(get, requestScope)) {
continue;
}
set({
@ -2043,6 +2107,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (set,
schedulePostPaintTeamEnrichments({
teamName,
requestNonce,
teamStateEpoch,
requestScope,
get,
});
} catch (error) {
@ -2316,7 +2384,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
);
}
} catch (error) {
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) {
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
return;
}
@ -2398,7 +2466,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (set,
const existingOlderRequest = inFlightTeamMessagesOlderRequests.get(teamName);
if (existingOlderRequest) {
const queuedEpoch = captureTeamLocalStateEpoch(teamName);
const queuedScope = captureTeamRequestScope(get, teamName);
const queuedRequest: Promise<RefreshTeamMessagesHeadResult> = existingOlderRequest
.then(() => {
if (!isTeamLocalStateEpochCurrent(teamName, queuedEpoch)) {
if (!isTeamRequestScopeCurrent(get, teamName, queuedScope)) {
return {
feedChanged: false,
headChanged: false,
@ -2577,7 +2649,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
current: null,
};
requestRef.current = (async (): Promise<RefreshTeamMessagesHeadResult> => {
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
const requestScope = captureTeamRequestScope(get, teamName);
set((state) => ({
teamMessagesByName: {
...state.teamMessagesByName,
@ -2592,7 +2664,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (set,
const requestRef: { current: Promise<void> | null } = { current: null };
requestRef.current = (async (): Promise<void> => {
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
const requestScope = captureTeamRequestScope(get, teamName);
set((state) => ({
teamMessagesByName: {
...state.teamMessagesByName,
@ -2729,7 +2804,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
limit: 50,
})
);
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) {
return;
}
@ -2780,7 +2855,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
};
});
} catch {
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) {
return;
}
set((state) => ({
@ -2818,12 +2893,12 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const requestRef: { current: Promise<void> | null } = { current: null };
requestRef.current = (async (): Promise<void> => {
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<AppState, [], [], TeamSlice> = (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);
}
}

View file

@ -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;
}

View file

@ -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<T>(): {
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<unknown[]>();
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();

View file

@ -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<string, never>;
};
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<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value: T) => void;
const promise = new Promise<T>((innerResolve) => {
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((innerResolve, innerReject) => {
resolve = innerResolve;
reject = innerReject;
});
return { promise, resolve };
return { promise, resolve, reject };
}
async function flushMicrotasks(): Promise<void> {
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<TeamSummaryLike[]>();
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<GlobalTaskLike[]>();
@ -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<GlobalTaskLike[]>();
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<TeamSnapshotLike>();
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<TeamSnapshotLike>();
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<TeamSnapshotLike>();
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<typeof memberSpawnSnapshot>();
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<never>();
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<typeof runtimeSnapshot>();
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<TeamSnapshotLike>();
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<string, never>;
}>();
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);
});
});