fix(renderer): reduce team detail refresh churn

This commit is contained in:
iliya 2026-04-09 15:28:35 +03:00
parent bf6370556d
commit 535178a076
3 changed files with 796 additions and 102 deletions

View file

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

View file

@ -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 () => {

View file

@ -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({