fix(context): ignore stale team request scopes
This commit is contained in:
parent
255fa5aa47
commit
c04a259cea
6 changed files with 643 additions and 43 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
src/renderer/store/utils/contextScopedRequestEpoch.ts
Normal file
17
src/renderer/store/utils/contextScopedRequestEpoch.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue