diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 6a7c5c6b..6455953f 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -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>(); +const inFlightRefreshTeamDataCalls = new Set(); const pendingFreshTeamDataRefreshes = new Set(); +const lastResolvedTeamDataRefreshAtByTeam = new Map(); +let inFlightGlobalTasksRefresh: Promise | null = null; +let pendingFreshGlobalTasksRefresh = false; const memberSpawnStatusesIpcBackoffUntilByTeam = new Map(); +const teamRefreshBurstDiagnostics = new Map< + string, + { windowStartedAt: number; count: number; lastWarnAt: number } +>(); +const memberSpawnUiEqualLastWarnAtByTeam = new Map(); 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 { ); } +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, + right: Record +): 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 = (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 = (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(); - 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 => { + 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(); + 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 = (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 = (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 = (set, set({ teamByName: { ...prevByName, [teamName]: patched } }); } + const setStartedAt = performance.now(); set({ selectedTeamName: teamName, selectedTeamData: previousData @@ -1490,6 +1809,9 @@ export const createTeamSlice: StateCreator = (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 = (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 = (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 = (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 = (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 = (set, } set({ selectedTeamError: msg }); } finally { + inFlightRefreshTeamDataCalls.delete(teamName); if (reusedInFlightRequest && pendingFreshTeamDataRefreshes.delete(teamName)) { void get().refreshTeamData(teamName); } diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index 4703a21d..38b1c23f 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -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 () => { diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index e2bea511..46d8ed03 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -77,6 +77,44 @@ function createSliceStore() { })); } +function createMemberSpawnStatus(overrides: Record = {}) { + 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 = {}) { + const typedOverrides = overrides as { + statuses?: Record>; + }; + 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({