fix(renderer): reduce team detail refresh churn
This commit is contained in:
parent
bf6370556d
commit
535178a076
3 changed files with 796 additions and 102 deletions
|
|
@ -19,12 +19,43 @@ const logger = createLogger('teamSlice');
|
|||
const TEAM_GET_DATA_TIMEOUT_MS = 30_000;
|
||||
const TEAM_FETCH_TIMEOUT_MS = 30_000;
|
||||
const MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS = 5_000;
|
||||
const TEAM_DATA_IPC_WARN_MS = 350;
|
||||
const TEAM_DATA_SET_WARN_MS = 12;
|
||||
const TEAM_DATA_POST_WARN_MS = 24;
|
||||
const TEAM_DATA_LARGE_MESSAGES = 150;
|
||||
const TEAM_DATA_LARGE_TASKS = 80;
|
||||
const TEAM_REFRESH_BURST_WINDOW_MS = 4_000;
|
||||
const TEAM_REFRESH_BURST_WARN_COUNT = 5;
|
||||
const TEAM_REFRESH_WARN_THROTTLE_MS = 2_000;
|
||||
const MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000;
|
||||
const inFlightTeamDataRequests = new Map<string, Promise<TeamData>>();
|
||||
const inFlightRefreshTeamDataCalls = new Set<string>();
|
||||
const pendingFreshTeamDataRefreshes = new Set<string>();
|
||||
const lastResolvedTeamDataRefreshAtByTeam = new Map<string, number>();
|
||||
let inFlightGlobalTasksRefresh: Promise<void> | null = null;
|
||||
let pendingFreshGlobalTasksRefresh = false;
|
||||
const memberSpawnStatusesIpcBackoffUntilByTeam = new Map<string, number>();
|
||||
const teamRefreshBurstDiagnostics = new Map<
|
||||
string,
|
||||
{ windowStartedAt: number; count: number; lastWarnAt: number }
|
||||
>();
|
||||
const memberSpawnUiEqualLastWarnAtByTeam = new Map<string, number>();
|
||||
type RefreshTeamDataOptions = {
|
||||
withDedup?: boolean;
|
||||
};
|
||||
|
||||
export function isTeamDataRefreshPending(teamName: string): boolean {
|
||||
return (
|
||||
inFlightTeamDataRequests.has(teamName) ||
|
||||
inFlightRefreshTeamDataCalls.has(teamName) ||
|
||||
pendingFreshTeamDataRefreshes.has(teamName)
|
||||
);
|
||||
}
|
||||
|
||||
export function getLastResolvedTeamDataRefreshAt(teamName: string): number | undefined {
|
||||
return lastResolvedTeamDataRefreshAtByTeam.get(teamName);
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
|
@ -92,6 +123,245 @@ function fetchTeamDataFresh(teamName: string): Promise<TeamData> {
|
|||
);
|
||||
}
|
||||
|
||||
function summarizeTeamDataCounts(data: TeamData | null | undefined): {
|
||||
messages: number;
|
||||
tasks: number;
|
||||
members: number;
|
||||
activeMembers: number;
|
||||
processes: number;
|
||||
} {
|
||||
if (!data) {
|
||||
return { messages: 0, tasks: 0, members: 0, activeMembers: 0, processes: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
messages: data.messages.length,
|
||||
tasks: data.tasks.length,
|
||||
members: data.members.length,
|
||||
activeMembers: data.members.filter((member) => !member.removedAt).length,
|
||||
processes: data.processes.length,
|
||||
};
|
||||
}
|
||||
|
||||
function estimateTeamPayloadWeight(data: TeamData): {
|
||||
messageTextChars: number;
|
||||
messageAttachments: number;
|
||||
taskComments: number;
|
||||
taskHistoryEvents: number;
|
||||
taskDescriptionChars: number;
|
||||
} {
|
||||
let messageTextChars = 0;
|
||||
let messageAttachments = 0;
|
||||
for (const message of data.messages) {
|
||||
messageTextChars += (message.text?.length ?? 0) + (message.summary?.length ?? 0);
|
||||
messageAttachments += message.attachments?.length ?? 0;
|
||||
}
|
||||
|
||||
let taskComments = 0;
|
||||
let taskHistoryEvents = 0;
|
||||
let taskDescriptionChars = 0;
|
||||
for (const task of data.tasks) {
|
||||
taskComments += task.comments?.length ?? 0;
|
||||
taskHistoryEvents += task.historyEvents?.length ?? 0;
|
||||
taskDescriptionChars += task.description?.length ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
messageTextChars,
|
||||
messageAttachments,
|
||||
taskComments,
|
||||
taskHistoryEvents,
|
||||
taskDescriptionChars,
|
||||
};
|
||||
}
|
||||
|
||||
function noteTeamRefreshBurst(teamName: string): number {
|
||||
const now = Date.now();
|
||||
const diagnostic = teamRefreshBurstDiagnostics.get(teamName) ?? {
|
||||
windowStartedAt: now,
|
||||
count: 0,
|
||||
lastWarnAt: 0,
|
||||
};
|
||||
|
||||
if (now - diagnostic.windowStartedAt > TEAM_REFRESH_BURST_WINDOW_MS) {
|
||||
diagnostic.windowStartedAt = now;
|
||||
diagnostic.count = 0;
|
||||
}
|
||||
|
||||
diagnostic.count += 1;
|
||||
|
||||
if (
|
||||
diagnostic.count >= TEAM_REFRESH_BURST_WARN_COUNT &&
|
||||
now - diagnostic.lastWarnAt >= TEAM_REFRESH_WARN_THROTTLE_MS
|
||||
) {
|
||||
diagnostic.lastWarnAt = now;
|
||||
logger.warn(
|
||||
`[perf] refreshTeamData burst team=${teamName} count=${diagnostic.count} windowMs=${
|
||||
now - diagnostic.windowStartedAt
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
teamRefreshBurstDiagnostics.set(teamName, diagnostic);
|
||||
return diagnostic.count;
|
||||
}
|
||||
|
||||
function maybeLogTeamDataPerf(params: {
|
||||
phase: 'selectTeam' | 'refreshTeamData';
|
||||
teamName: string;
|
||||
ipcMs: number;
|
||||
setMs: number;
|
||||
postMs: number;
|
||||
totalMs: number;
|
||||
previousData: TeamData | null | undefined;
|
||||
nextData: TeamData;
|
||||
deduped: boolean;
|
||||
reusedInFlightRequest: boolean;
|
||||
burstCount?: number;
|
||||
}): void {
|
||||
const {
|
||||
phase,
|
||||
teamName,
|
||||
ipcMs,
|
||||
setMs,
|
||||
postMs,
|
||||
totalMs,
|
||||
previousData,
|
||||
nextData,
|
||||
deduped,
|
||||
reusedInFlightRequest,
|
||||
burstCount,
|
||||
} = params;
|
||||
|
||||
const nextCounts = summarizeTeamDataCounts(nextData);
|
||||
const previousCounts = summarizeTeamDataCounts(previousData);
|
||||
const largePayload =
|
||||
nextCounts.messages >= TEAM_DATA_LARGE_MESSAGES || nextCounts.tasks >= TEAM_DATA_LARGE_TASKS;
|
||||
const slow =
|
||||
ipcMs >= TEAM_DATA_IPC_WARN_MS ||
|
||||
setMs >= TEAM_DATA_SET_WARN_MS ||
|
||||
postMs >= TEAM_DATA_POST_WARN_MS;
|
||||
|
||||
if (!slow && !largePayload && !reusedInFlightRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadWeight = estimateTeamPayloadWeight(nextData);
|
||||
logger.warn(
|
||||
`[perf] ${phase} team=${teamName} ipc=${ipcMs.toFixed(1)}ms set=${setMs.toFixed(
|
||||
1
|
||||
)}ms post=${postMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms deduped=${deduped} reusedInFlight=${
|
||||
reusedInFlightRequest ? 'yes' : 'no'
|
||||
} burst=${burstCount ?? 1} counts=messages:${previousCounts.messages}->${nextCounts.messages},tasks:${
|
||||
previousCounts.tasks
|
||||
}->${nextCounts.tasks},members:${previousCounts.members}->${nextCounts.members},activeMembers:${
|
||||
previousCounts.activeMembers
|
||||
}->${nextCounts.activeMembers},processes:${previousCounts.processes}->${nextCounts.processes} payload=textChars:${
|
||||
payloadWeight.messageTextChars + payloadWeight.taskDescriptionChars
|
||||
},attachments=${payloadWeight.messageAttachments},taskComments=${
|
||||
payloadWeight.taskComments
|
||||
},historyEvents=${payloadWeight.taskHistoryEvents}`
|
||||
);
|
||||
}
|
||||
|
||||
function areLaunchSummaryCountsEqual(
|
||||
left: PersistedTeamLaunchSummary | undefined,
|
||||
right: PersistedTeamLaunchSummary | undefined
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return left === right;
|
||||
return (
|
||||
left.confirmedCount === right.confirmedCount &&
|
||||
left.pendingCount === right.pendingCount &&
|
||||
left.failedCount === right.failedCount &&
|
||||
left.runtimeAlivePendingCount === right.runtimeAlivePendingCount
|
||||
);
|
||||
}
|
||||
|
||||
function areExpectedMembersEqual(
|
||||
left: readonly string[] | undefined,
|
||||
right: readonly string[] | undefined
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return left === right;
|
||||
if (left.length !== right.length) return false;
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function areMemberSpawnStatusEntriesEqual(
|
||||
left: MemberSpawnStatusEntry | undefined,
|
||||
right: MemberSpawnStatusEntry | undefined
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return left === right;
|
||||
// Renderer equality intentionally ignores raw timing fields that do not change
|
||||
// visible member status. This suppresses heartbeat-only churn in TeamDetailView.
|
||||
return (
|
||||
left.status === right.status &&
|
||||
left.launchState === right.launchState &&
|
||||
left.error === right.error &&
|
||||
left.livenessSource === right.livenessSource &&
|
||||
left.runtimeAlive === right.runtimeAlive &&
|
||||
left.bootstrapConfirmed === right.bootstrapConfirmed &&
|
||||
left.hardFailure === right.hardFailure
|
||||
);
|
||||
}
|
||||
|
||||
function areMemberSpawnStatusesEqual(
|
||||
left: Record<string, MemberSpawnStatusEntry>,
|
||||
right: Record<string, MemberSpawnStatusEntry>
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
if (leftKeys.length !== rightKeys.length) return false;
|
||||
for (const key of leftKeys) {
|
||||
if (!(key in right)) {
|
||||
return false;
|
||||
}
|
||||
if (!areMemberSpawnStatusEntriesEqual(left[key], right[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function areMemberSpawnSnapshotsSemanticallyEqual(
|
||||
left: MemberSpawnStatusesSnapshot | undefined,
|
||||
right: MemberSpawnStatusesSnapshot
|
||||
): boolean {
|
||||
if (!left) return false;
|
||||
return (
|
||||
left.runId === right.runId &&
|
||||
left.teamLaunchState === right.teamLaunchState &&
|
||||
left.launchPhase === right.launchPhase &&
|
||||
left.source === right.source &&
|
||||
areExpectedMembersEqual(left.expectedMembers, right.expectedMembers) &&
|
||||
areLaunchSummaryCountsEqual(left.summary, right.summary) &&
|
||||
areMemberSpawnStatusesEqual(left.statuses, right.statuses)
|
||||
);
|
||||
}
|
||||
|
||||
function maybeLogMemberSpawnUiEqualSuppressed(
|
||||
teamName: string,
|
||||
runId: string | null | undefined
|
||||
): void {
|
||||
const now = Date.now();
|
||||
const lastWarnAt = memberSpawnUiEqualLastWarnAtByTeam.get(teamName) ?? 0;
|
||||
if (now - lastWarnAt < MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS) {
|
||||
return;
|
||||
}
|
||||
memberSpawnUiEqualLastWarnAtByTeam.set(teamName, now);
|
||||
logger.debug(
|
||||
`[perf] member-spawn snapshot suppressed team=${teamName} runId=${runId ?? 'none'} reason=member-spawn-ui-equal`
|
||||
);
|
||||
}
|
||||
|
||||
function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): number {
|
||||
const aTime = Date.parse(a.timestamp);
|
||||
const bTime = Date.parse(b.timestamp);
|
||||
|
|
@ -217,6 +487,7 @@ import type {
|
|||
KanbanColumnId,
|
||||
LeadActivityState,
|
||||
LeadContextUsage,
|
||||
PersistedTeamLaunchSummary,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
MemberSpawnStatusEntry,
|
||||
SendMessageRequest,
|
||||
|
|
@ -1037,22 +1308,48 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
return {};
|
||||
}
|
||||
|
||||
const nextCurrentRuntimeRunIdByTeam =
|
||||
snapshot.runId == null || prev.currentRuntimeRunIdByTeam[teamName] != null
|
||||
? prev.currentRuntimeRunIdByTeam
|
||||
: {
|
||||
...prev.currentRuntimeRunIdByTeam,
|
||||
[teamName]: snapshot.runId,
|
||||
};
|
||||
const hasIgnoredRuntimeEntriesForTeam = Object.values(prev.ignoredRuntimeRunIds).some(
|
||||
(ignoredTeamName) => ignoredTeamName === teamName
|
||||
);
|
||||
const nextIgnoredRuntimeRunIds =
|
||||
snapshot.runId == null || !hasIgnoredRuntimeEntriesForTeam
|
||||
? prev.ignoredRuntimeRunIds
|
||||
: Object.fromEntries(
|
||||
Object.entries(prev.ignoredRuntimeRunIds).filter(
|
||||
([, ignoredTeamName]) => ignoredTeamName !== teamName
|
||||
)
|
||||
);
|
||||
const previousSnapshot = prev.memberSpawnSnapshotsByTeam[teamName];
|
||||
const snapshotChanged = !areMemberSpawnSnapshotsSemanticallyEqual(
|
||||
previousSnapshot,
|
||||
snapshot
|
||||
);
|
||||
|
||||
if (!snapshotChanged) {
|
||||
maybeLogMemberSpawnUiEqualSuppressed(teamName, snapshot.runId);
|
||||
if (
|
||||
nextCurrentRuntimeRunIdByTeam === prev.currentRuntimeRunIdByTeam &&
|
||||
nextIgnoredRuntimeRunIds === prev.ignoredRuntimeRunIds
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
currentRuntimeRunIdByTeam: nextCurrentRuntimeRunIdByTeam,
|
||||
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
currentRuntimeRunIdByTeam:
|
||||
snapshot.runId == null
|
||||
? prev.currentRuntimeRunIdByTeam
|
||||
: {
|
||||
...prev.currentRuntimeRunIdByTeam,
|
||||
[teamName]: prev.currentRuntimeRunIdByTeam[teamName] ?? snapshot.runId,
|
||||
},
|
||||
ignoredRuntimeRunIds:
|
||||
snapshot.runId == null
|
||||
? prev.ignoredRuntimeRunIds
|
||||
: Object.fromEntries(
|
||||
Object.entries(prev.ignoredRuntimeRunIds).filter(
|
||||
([, ignoredTeamName]) => ignoredTeamName !== teamName
|
||||
)
|
||||
),
|
||||
currentRuntimeRunIdByTeam: nextCurrentRuntimeRunIdByTeam,
|
||||
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
|
||||
memberSpawnStatusesByTeam: {
|
||||
...prev.memberSpawnStatusesByTeam,
|
||||
[teamName]: snapshot.statuses,
|
||||
|
|
@ -1194,91 +1491,110 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
},
|
||||
|
||||
fetchAllTasks: async () => {
|
||||
// Guard: prevent concurrent fetches (component mount + centralized init chain)
|
||||
if (get().globalTasksLoading) return;
|
||||
// Show skeleton only on the very first fetch — not on subsequent refreshes
|
||||
// even when the task list is empty (avoids flickering skeleton on every watcher event).
|
||||
const isInitialLoad = !get().globalTasksInitialized;
|
||||
if (isInitialLoad) {
|
||||
set({ globalTasksLoading: true, globalTasksError: null });
|
||||
if (inFlightGlobalTasksRefresh) {
|
||||
pendingFreshGlobalTasksRefresh = true;
|
||||
await inFlightGlobalTasksRefresh;
|
||||
return;
|
||||
}
|
||||
const oldTasks = get().globalTasks;
|
||||
const wasFirst = isFirstFetchAllTasks;
|
||||
isFirstFetchAllTasks = false;
|
||||
try {
|
||||
const tasks = await withTimeout(
|
||||
unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()),
|
||||
TEAM_FETCH_TIMEOUT_MS,
|
||||
'fetchAllTasks'
|
||||
);
|
||||
if (!wasFirst) {
|
||||
const notifyOnClarifications =
|
||||
get().appConfig?.notifications?.notifyOnClarifications ?? true;
|
||||
detectClarificationNotifications(oldTasks, tasks, notifyOnClarifications);
|
||||
detectStatusChangeNotifications(oldTasks, tasks, get().appConfig, get().teamByName);
|
||||
const notifyOnTaskComments = get().appConfig?.notifications?.notifyOnTaskComments ?? true;
|
||||
detectTaskCommentNotifications(oldTasks, tasks, notifyOnTaskComments);
|
||||
const notifyOnTaskCreated = get().appConfig?.notifications?.notifyOnTaskCreated ?? true;
|
||||
detectTaskCreatedNotifications(oldTasks, tasks, notifyOnTaskCreated);
|
||||
const notifyOnAllCompleted =
|
||||
get().appConfig?.notifications?.notifyOnAllTasksCompleted ?? true;
|
||||
detectAllTasksCompletedNotification(oldTasks, tasks, notifyOnAllCompleted);
|
||||
} else {
|
||||
// Initial load — seed the Sets to prevent false notifications on next update
|
||||
for (const task of tasks) {
|
||||
if (task.needsClarification === 'user') {
|
||||
notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`);
|
||||
}
|
||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
|
||||
if (task.reviewState === 'needsFix') {
|
||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:needsFix`);
|
||||
}
|
||||
if (getTaskKanbanColumn(task) === 'approved') {
|
||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
|
||||
}
|
||||
if (getTaskKanbanColumn(task) === 'review') {
|
||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:review`);
|
||||
}
|
||||
// Seed comment keys to prevent false notifications
|
||||
for (const comment of task.comments ?? []) {
|
||||
notifiedCommentKeys.add(`${task.teamName}:${task.id}:${comment.id}`);
|
||||
}
|
||||
// Seed created task keys to prevent false notifications
|
||||
notifiedCreatedTaskKeys.add(`${task.teamName}:${task.id}`);
|
||||
}
|
||||
// Seed all-completed teams
|
||||
const teamTasksMap = new Map<string, GlobalTask[]>();
|
||||
for (const task of tasks) {
|
||||
const list = teamTasksMap.get(task.teamName) ?? [];
|
||||
list.push(task);
|
||||
teamTasksMap.set(task.teamName, list);
|
||||
}
|
||||
for (const [teamName, teamTasks] of teamTasksMap) {
|
||||
if (teamTasks.every((t) => t.status === 'completed' || t.status === 'deleted')) {
|
||||
notifiedAllCompletedTeams.add(teamName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
globalTasks: tasks,
|
||||
globalTasksLoading: false,
|
||||
globalTasksInitialized: true,
|
||||
globalTasksError: null,
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
globalTasksLoading: false,
|
||||
globalTasksInitialized: true,
|
||||
globalTasksError: isInitialLoad
|
||||
? error instanceof IpcError
|
||||
? error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch tasks'
|
||||
: null,
|
||||
});
|
||||
}
|
||||
const runRefresh = async (): Promise<void> => {
|
||||
do {
|
||||
pendingFreshGlobalTasksRefresh = false;
|
||||
|
||||
// Show skeleton only on the very first fetch — not on subsequent refreshes
|
||||
// even when the task list is empty (avoids flickering skeleton on every watcher event).
|
||||
const isInitialLoad = !get().globalTasksInitialized;
|
||||
if (isInitialLoad) {
|
||||
set({ globalTasksLoading: true, globalTasksError: null });
|
||||
}
|
||||
const oldTasks = get().globalTasks;
|
||||
const wasFirst = isFirstFetchAllTasks;
|
||||
isFirstFetchAllTasks = false;
|
||||
try {
|
||||
const tasks = await withTimeout(
|
||||
unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()),
|
||||
TEAM_FETCH_TIMEOUT_MS,
|
||||
'fetchAllTasks'
|
||||
);
|
||||
if (!wasFirst) {
|
||||
const notifyOnClarifications =
|
||||
get().appConfig?.notifications?.notifyOnClarifications ?? true;
|
||||
detectClarificationNotifications(oldTasks, tasks, notifyOnClarifications);
|
||||
detectStatusChangeNotifications(oldTasks, tasks, get().appConfig, get().teamByName);
|
||||
const notifyOnTaskComments =
|
||||
get().appConfig?.notifications?.notifyOnTaskComments ?? true;
|
||||
detectTaskCommentNotifications(oldTasks, tasks, notifyOnTaskComments);
|
||||
const notifyOnTaskCreated = get().appConfig?.notifications?.notifyOnTaskCreated ?? true;
|
||||
detectTaskCreatedNotifications(oldTasks, tasks, notifyOnTaskCreated);
|
||||
const notifyOnAllCompleted =
|
||||
get().appConfig?.notifications?.notifyOnAllTasksCompleted ?? true;
|
||||
detectAllTasksCompletedNotification(oldTasks, tasks, notifyOnAllCompleted);
|
||||
} else {
|
||||
// Initial load — seed the Sets to prevent false notifications on next update
|
||||
for (const task of tasks) {
|
||||
if (task.needsClarification === 'user') {
|
||||
notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`);
|
||||
}
|
||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
|
||||
if (task.reviewState === 'needsFix') {
|
||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:needsFix`);
|
||||
}
|
||||
if (getTaskKanbanColumn(task) === 'approved') {
|
||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
|
||||
}
|
||||
if (getTaskKanbanColumn(task) === 'review') {
|
||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:review`);
|
||||
}
|
||||
// Seed comment keys to prevent false notifications
|
||||
for (const comment of task.comments ?? []) {
|
||||
notifiedCommentKeys.add(`${task.teamName}:${task.id}:${comment.id}`);
|
||||
}
|
||||
// Seed created task keys to prevent false notifications
|
||||
notifiedCreatedTaskKeys.add(`${task.teamName}:${task.id}`);
|
||||
}
|
||||
// Seed all-completed teams
|
||||
const teamTasksMap = new Map<string, GlobalTask[]>();
|
||||
for (const task of tasks) {
|
||||
const list = teamTasksMap.get(task.teamName) ?? [];
|
||||
list.push(task);
|
||||
teamTasksMap.set(task.teamName, list);
|
||||
}
|
||||
for (const [teamName, teamTasks] of teamTasksMap) {
|
||||
if (teamTasks.every((t) => t.status === 'completed' || t.status === 'deleted')) {
|
||||
notifiedAllCompletedTeams.add(teamName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
globalTasks: tasks,
|
||||
globalTasksLoading: false,
|
||||
globalTasksInitialized: true,
|
||||
globalTasksError: null,
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
globalTasksLoading: false,
|
||||
globalTasksInitialized: true,
|
||||
globalTasksError: isInitialLoad
|
||||
? error instanceof IpcError
|
||||
? error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch tasks'
|
||||
: null,
|
||||
});
|
||||
}
|
||||
} while (pendingFreshGlobalTasksRefresh);
|
||||
};
|
||||
|
||||
const request = runRefresh().finally(() => {
|
||||
if (inFlightGlobalTasksRefresh === request) {
|
||||
inFlightGlobalTasksRefresh = null;
|
||||
}
|
||||
});
|
||||
inFlightGlobalTasksRefresh = request;
|
||||
await request;
|
||||
},
|
||||
|
||||
openTeamsTab: () => {
|
||||
|
|
@ -1426,6 +1742,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
},
|
||||
|
||||
selectTeam: async (teamName: string, opts) => {
|
||||
const startedAt = performance.now();
|
||||
const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true;
|
||||
// Guard: prevent duplicate in-flight fetches for the same team.
|
||||
// GlobalTaskDetailDialog + tab navigation can call selectTeam() in quick succession.
|
||||
|
|
@ -1455,6 +1772,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
|
||||
try {
|
||||
const data = await fetchTeamDataDeduped(teamName);
|
||||
const ipcMs = performance.now() - startedAt;
|
||||
// Stale check: user may have switched to another team during the async call
|
||||
if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) {
|
||||
return;
|
||||
|
|
@ -1479,6 +1797,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
set({ teamByName: { ...prevByName, [teamName]: patched } });
|
||||
}
|
||||
|
||||
const setStartedAt = performance.now();
|
||||
set({
|
||||
selectedTeamName: teamName,
|
||||
selectedTeamData: previousData
|
||||
|
|
@ -1490,6 +1809,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
selectedTeamLoading: false,
|
||||
selectedTeamError: null,
|
||||
});
|
||||
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
||||
const setMs = performance.now() - setStartedAt;
|
||||
const postStartedAt = performance.now();
|
||||
const invalidationState = previousData
|
||||
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
||||
: { cacheKeys: [], taskIds: [] };
|
||||
|
|
@ -1499,6 +1821,19 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
if (invalidationState.taskIds.length > 0) {
|
||||
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
|
||||
}
|
||||
const postMs = performance.now() - postStartedAt;
|
||||
maybeLogTeamDataPerf({
|
||||
phase: 'selectTeam',
|
||||
teamName,
|
||||
ipcMs,
|
||||
setMs,
|
||||
postMs,
|
||||
totalMs: performance.now() - startedAt,
|
||||
previousData,
|
||||
nextData: data,
|
||||
deduped: true,
|
||||
reusedInFlightRequest: false,
|
||||
});
|
||||
// Sync tab label with the team's display name from config
|
||||
const displayName = data.config.name || teamName;
|
||||
const allTabs = get().getAllPaneTabs();
|
||||
|
|
@ -1589,26 +1924,34 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
},
|
||||
|
||||
refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => {
|
||||
const startedAt = performance.now();
|
||||
const state = get();
|
||||
if (state.selectedTeamName !== teamName) {
|
||||
return;
|
||||
}
|
||||
inFlightRefreshTeamDataCalls.add(teamName);
|
||||
// Silent refresh — update data without showing loading skeleton.
|
||||
// Only selectTeam() sets loading: true (for initial load).
|
||||
const reusedInFlightRequest =
|
||||
opts?.withDedup === true && inFlightTeamDataRequests.has(teamName);
|
||||
const burstCount = noteTeamRefreshBurst(teamName);
|
||||
if (reusedInFlightRequest) {
|
||||
pendingFreshTeamDataRefreshes.add(teamName);
|
||||
logger.warn(
|
||||
`[perf] refreshTeamData queued-fresh team=${teamName} burst=${burstCount} reason=inFlightDedup`
|
||||
);
|
||||
}
|
||||
try {
|
||||
const previousData = get().selectedTeamData;
|
||||
const data = opts?.withDedup
|
||||
? await fetchTeamDataDeduped(teamName)
|
||||
: await fetchTeamDataFresh(teamName);
|
||||
const ipcMs = performance.now() - startedAt;
|
||||
// Re-check after async: the user might have navigated away.
|
||||
if (get().selectedTeamName !== teamName) {
|
||||
return;
|
||||
}
|
||||
const setStartedAt = performance.now();
|
||||
set({
|
||||
selectedTeamData: previousData
|
||||
? {
|
||||
|
|
@ -1618,6 +1961,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
: data,
|
||||
selectedTeamError: null,
|
||||
});
|
||||
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
||||
const setMs = performance.now() - setStartedAt;
|
||||
const postStartedAt = performance.now();
|
||||
const invalidationState = previousData
|
||||
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
||||
: { cacheKeys: [], taskIds: [] };
|
||||
|
|
@ -1627,6 +1973,20 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
if (invalidationState.taskIds.length > 0) {
|
||||
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
|
||||
}
|
||||
const postMs = performance.now() - postStartedAt;
|
||||
maybeLogTeamDataPerf({
|
||||
phase: 'refreshTeamData',
|
||||
teamName,
|
||||
ipcMs,
|
||||
setMs,
|
||||
postMs,
|
||||
totalMs: performance.now() - startedAt,
|
||||
previousData,
|
||||
nextData: data,
|
||||
deduped: opts?.withDedup === true,
|
||||
reusedInFlightRequest,
|
||||
burstCount,
|
||||
});
|
||||
} catch (error) {
|
||||
if (get().selectedTeamName !== teamName) {
|
||||
return;
|
||||
|
|
@ -1666,6 +2026,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
}
|
||||
set({ selectedTeamError: msg });
|
||||
} finally {
|
||||
inFlightRefreshTeamDataCalls.delete(teamName);
|
||||
if (reusedInFlightRequest && pendingFreshTeamDataRefreshes.delete(teamName)) {
|
||||
void get().refreshTeamData(teamName);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ describe('team change throttling', () => {
|
|||
afterEach(() => {
|
||||
cleanup?.();
|
||||
cleanup = null;
|
||||
vi.mocked(console.warn).mockClear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
|
|
@ -125,7 +126,7 @@ describe('team change throttling', () => {
|
|||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team');
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
||||
|
||||
// List refresh fires at 2000ms
|
||||
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
||||
|
|
@ -161,7 +162,7 @@ describe('team change throttling', () => {
|
|||
|
||||
// Should trigger refreshTeamData at 800ms
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team');
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
||||
});
|
||||
|
||||
it('lead-message does not call fetchAllTasks', async () => {
|
||||
|
|
@ -303,8 +304,8 @@ describe('team change throttling', () => {
|
|||
|
||||
// Both teams should get exactly 1 refresh each
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2);
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team');
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('other-team');
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('other-team', { withDedup: true });
|
||||
});
|
||||
|
||||
it('keeps auto change presence tracking disabled even after selected team data is hydrated', async () => {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,44 @@ function createSliceStore() {
|
|||
}));
|
||||
}
|
||||
|
||||
function createMemberSpawnStatus(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
error: undefined,
|
||||
updatedAt: '2026-03-12T10:00:00.000Z',
|
||||
runtimeAlive: true,
|
||||
livenessSource: 'heartbeat',
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
firstSpawnAcceptedAt: '2026-03-12T09:59:30.000Z',
|
||||
lastHeartbeatAt: '2026-03-12T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMemberSpawnSnapshot(overrides: Record<string, unknown> = {}) {
|
||||
const typedOverrides = overrides as {
|
||||
statuses?: Record<string, ReturnType<typeof createMemberSpawnStatus>>;
|
||||
};
|
||||
return {
|
||||
runId: 'runtime-run',
|
||||
teamLaunchState: 'clean_success',
|
||||
launchPhase: 'finished',
|
||||
expectedMembers: ['alice'],
|
||||
updatedAt: '2026-03-12T10:00:00.000Z',
|
||||
summary: {
|
||||
confirmedCount: 1,
|
||||
pendingCount: 0,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
},
|
||||
source: 'merged',
|
||||
statuses: typedOverrides.statuses ?? { alice: createMemberSpawnStatus() },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('teamSlice actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -88,6 +126,7 @@ describe('teamSlice actions', () => {
|
|||
members: [],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
hoisted.sendMessage.mockResolvedValue({ deliveredToInbox: true, messageId: 'm1' });
|
||||
hoisted.requestReview.mockResolvedValue(undefined);
|
||||
|
|
@ -336,6 +375,7 @@ describe('teamSlice actions', () => {
|
|||
members: [],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -385,6 +425,7 @@ describe('teamSlice actions', () => {
|
|||
members: [],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
|
||||
await store.getState().refreshTeamData('my-team');
|
||||
|
|
@ -419,6 +460,7 @@ describe('teamSlice actions', () => {
|
|||
members: [],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -443,6 +485,7 @@ describe('teamSlice actions', () => {
|
|||
members: [],
|
||||
messages: [{ from: 'team-lead', text: 'Ping', timestamp: '2026-03-01T10:10:00.000Z' }],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
|
||||
await store.getState().refreshTeamData('my-team');
|
||||
|
|
@ -628,6 +671,295 @@ describe('teamSlice actions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('suppresses renderer rewrites when only lastHeartbeatAt changes', async () => {
|
||||
const store = createSliceStore();
|
||||
const previousSnapshot = createMemberSpawnSnapshot();
|
||||
const previousStatuses = previousSnapshot.statuses;
|
||||
|
||||
store.setState({
|
||||
currentRuntimeRunIdByTeam: {
|
||||
'my-team': 'runtime-run',
|
||||
},
|
||||
memberSpawnStatusesByTeam: {
|
||||
'my-team': previousStatuses,
|
||||
},
|
||||
memberSpawnSnapshotsByTeam: {
|
||||
'my-team': previousSnapshot,
|
||||
},
|
||||
});
|
||||
|
||||
hoisted.getMemberSpawnStatuses.mockResolvedValue(
|
||||
createMemberSpawnSnapshot({
|
||||
statuses: {
|
||||
alice: createMemberSpawnStatus({
|
||||
lastHeartbeatAt: '2026-03-12T10:00:09.000Z',
|
||||
}),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await store.getState().fetchMemberSpawnStatuses('my-team');
|
||||
|
||||
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBe(previousStatuses);
|
||||
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBe(previousSnapshot);
|
||||
});
|
||||
|
||||
it('suppresses renderer rewrites when only firstSpawnAcceptedAt changes', async () => {
|
||||
const store = createSliceStore();
|
||||
const previousSnapshot = createMemberSpawnSnapshot();
|
||||
const previousStatuses = previousSnapshot.statuses;
|
||||
|
||||
store.setState({
|
||||
currentRuntimeRunIdByTeam: {
|
||||
'my-team': 'runtime-run',
|
||||
},
|
||||
memberSpawnStatusesByTeam: {
|
||||
'my-team': previousStatuses,
|
||||
},
|
||||
memberSpawnSnapshotsByTeam: {
|
||||
'my-team': previousSnapshot,
|
||||
},
|
||||
});
|
||||
|
||||
hoisted.getMemberSpawnStatuses.mockResolvedValue(
|
||||
createMemberSpawnSnapshot({
|
||||
statuses: {
|
||||
alice: createMemberSpawnStatus({
|
||||
firstSpawnAcceptedAt: '2026-03-12T09:59:35.000Z',
|
||||
}),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await store.getState().fetchMemberSpawnStatuses('my-team');
|
||||
|
||||
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBe(previousStatuses);
|
||||
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBe(previousSnapshot);
|
||||
});
|
||||
|
||||
it('suppresses renderer rewrites when only updatedAt changes', async () => {
|
||||
const store = createSliceStore();
|
||||
const previousSnapshot = createMemberSpawnSnapshot();
|
||||
const previousStatuses = previousSnapshot.statuses;
|
||||
|
||||
store.setState({
|
||||
currentRuntimeRunIdByTeam: {
|
||||
'my-team': 'runtime-run',
|
||||
},
|
||||
memberSpawnStatusesByTeam: {
|
||||
'my-team': previousStatuses,
|
||||
},
|
||||
memberSpawnSnapshotsByTeam: {
|
||||
'my-team': previousSnapshot,
|
||||
},
|
||||
});
|
||||
|
||||
hoisted.getMemberSpawnStatuses.mockResolvedValue(
|
||||
createMemberSpawnSnapshot({
|
||||
updatedAt: '2026-03-12T10:00:11.000Z',
|
||||
statuses: {
|
||||
alice: createMemberSpawnStatus({
|
||||
updatedAt: '2026-03-12T10:00:11.000Z',
|
||||
}),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await store.getState().fetchMemberSpawnStatuses('my-team');
|
||||
|
||||
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBe(previousStatuses);
|
||||
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBe(previousSnapshot);
|
||||
});
|
||||
|
||||
it('rewrites renderer state when runtimeAlive changes', async () => {
|
||||
const store = createSliceStore();
|
||||
const previousSnapshot = createMemberSpawnSnapshot({
|
||||
statuses: {
|
||||
alice: createMemberSpawnStatus({
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
livenessSource: 'process',
|
||||
bootstrapConfirmed: false,
|
||||
}),
|
||||
},
|
||||
teamLaunchState: 'partial_pending',
|
||||
summary: {
|
||||
confirmedCount: 0,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
},
|
||||
});
|
||||
const previousStatuses = previousSnapshot.statuses;
|
||||
|
||||
store.setState({
|
||||
currentRuntimeRunIdByTeam: {
|
||||
'my-team': 'runtime-run',
|
||||
},
|
||||
memberSpawnStatusesByTeam: {
|
||||
'my-team': previousStatuses,
|
||||
},
|
||||
memberSpawnSnapshotsByTeam: {
|
||||
'my-team': previousSnapshot,
|
||||
},
|
||||
});
|
||||
|
||||
const nextSnapshot = createMemberSpawnSnapshot();
|
||||
hoisted.getMemberSpawnStatuses.mockResolvedValue(nextSnapshot);
|
||||
|
||||
await store.getState().fetchMemberSpawnStatuses('my-team');
|
||||
|
||||
expect(store.getState().memberSpawnStatusesByTeam['my-team']).not.toBe(previousStatuses);
|
||||
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual(nextSnapshot.statuses);
|
||||
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toEqual(nextSnapshot);
|
||||
});
|
||||
|
||||
it('rewrites renderer state when error semantics change', async () => {
|
||||
const store = createSliceStore();
|
||||
const previousSnapshot = createMemberSpawnSnapshot({
|
||||
statuses: {
|
||||
alice: createMemberSpawnStatus({
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: false,
|
||||
livenessSource: undefined,
|
||||
bootstrapConfirmed: false,
|
||||
}),
|
||||
},
|
||||
teamLaunchState: 'partial_pending',
|
||||
summary: {
|
||||
confirmedCount: 0,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
},
|
||||
});
|
||||
const previousStatuses = previousSnapshot.statuses;
|
||||
|
||||
store.setState({
|
||||
currentRuntimeRunIdByTeam: {
|
||||
'my-team': 'runtime-run',
|
||||
},
|
||||
memberSpawnStatusesByTeam: {
|
||||
'my-team': previousStatuses,
|
||||
},
|
||||
memberSpawnSnapshotsByTeam: {
|
||||
'my-team': previousSnapshot,
|
||||
},
|
||||
});
|
||||
|
||||
const nextSnapshot = createMemberSpawnSnapshot({
|
||||
teamLaunchState: 'partial_failure',
|
||||
summary: {
|
||||
confirmedCount: 0,
|
||||
pendingCount: 0,
|
||||
failedCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
},
|
||||
statuses: {
|
||||
alice: createMemberSpawnStatus({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
error: 'bootstrap failed',
|
||||
runtimeAlive: false,
|
||||
livenessSource: undefined,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
hoisted.getMemberSpawnStatuses.mockResolvedValue(nextSnapshot);
|
||||
|
||||
await store.getState().fetchMemberSpawnStatuses('my-team');
|
||||
|
||||
expect(store.getState().memberSpawnStatusesByTeam['my-team']).not.toBe(previousStatuses);
|
||||
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual(nextSnapshot.statuses);
|
||||
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toEqual(nextSnapshot);
|
||||
});
|
||||
|
||||
it('rewrites renderer state when top-level launch summary changes', async () => {
|
||||
const store = createSliceStore();
|
||||
const previousSnapshot = createMemberSpawnSnapshot({
|
||||
teamLaunchState: 'partial_pending',
|
||||
summary: {
|
||||
confirmedCount: 0,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
},
|
||||
statuses: {
|
||||
alice: createMemberSpawnStatus({
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
livenessSource: 'process',
|
||||
bootstrapConfirmed: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const previousStatuses = previousSnapshot.statuses;
|
||||
|
||||
store.setState({
|
||||
currentRuntimeRunIdByTeam: {
|
||||
'my-team': 'runtime-run',
|
||||
},
|
||||
memberSpawnStatusesByTeam: {
|
||||
'my-team': previousStatuses,
|
||||
},
|
||||
memberSpawnSnapshotsByTeam: {
|
||||
'my-team': previousSnapshot,
|
||||
},
|
||||
});
|
||||
|
||||
const nextSnapshot = createMemberSpawnSnapshot({
|
||||
teamLaunchState: 'clean_success',
|
||||
summary: {
|
||||
confirmedCount: 1,
|
||||
pendingCount: 0,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
},
|
||||
});
|
||||
hoisted.getMemberSpawnStatuses.mockResolvedValue(nextSnapshot);
|
||||
|
||||
await store.getState().fetchMemberSpawnStatuses('my-team');
|
||||
|
||||
expect(store.getState().memberSpawnStatusesByTeam['my-team']).not.toBe(previousStatuses);
|
||||
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toEqual(nextSnapshot);
|
||||
});
|
||||
|
||||
it('preserves spawn snapshot references while still updating bookkeeping on suppressed snapshots', async () => {
|
||||
const store = createSliceStore();
|
||||
const previousSnapshot = createMemberSpawnSnapshot();
|
||||
const previousStatuses = previousSnapshot.statuses;
|
||||
|
||||
store.setState({
|
||||
ignoredRuntimeRunIds: {
|
||||
'runtime-old': 'my-team',
|
||||
},
|
||||
memberSpawnStatusesByTeam: {
|
||||
'my-team': previousStatuses,
|
||||
},
|
||||
memberSpawnSnapshotsByTeam: {
|
||||
'my-team': previousSnapshot,
|
||||
},
|
||||
});
|
||||
|
||||
hoisted.getMemberSpawnStatuses.mockResolvedValue(
|
||||
createMemberSpawnSnapshot({
|
||||
statuses: {
|
||||
alice: createMemberSpawnStatus({
|
||||
lastHeartbeatAt: '2026-03-12T10:00:09.000Z',
|
||||
}),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await store.getState().fetchMemberSpawnStatuses('my-team');
|
||||
|
||||
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBe('runtime-run');
|
||||
expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBeUndefined();
|
||||
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBe(previousStatuses);
|
||||
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBe(previousSnapshot);
|
||||
});
|
||||
|
||||
it('ignores stale spawn-status fetches after runtime already went offline', async () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
|
|
|
|||
Loading…
Reference in a new issue