diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 05e02473..bc24198f 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -58,6 +58,12 @@ import { isMemberSpawnStatusesIpcBackoffActive, recordMemberSpawnStatusesIpcRetryBackoff, } from '../team/teamMemberSpawnStatusBackoff'; +import { + clearAllMemberSpawnUiEqualLastWarns, + clearMemberSpawnUiEqualLastWarn, + hasMemberSpawnUiEqualLastWarn, + shouldLogMemberSpawnUiEqualSuppressed, +} from '../team/teamMemberSpawnUiEqualWarningThrottle'; import { areInboxMessageArraysEquivalent, clearTeamMessageSelectorCaches, @@ -184,7 +190,6 @@ const teamRefreshBurstDiagnostics = new Map< string, { windowStartedAt: number; count: number; lastWarnAt: number } >(); -const memberSpawnUiEqualLastWarnAtByTeam = new Map(); interface RefreshTeamDataOptions { withDedup?: boolean; } @@ -249,7 +254,7 @@ export function __resetTeamSliceModuleStateForTests(): void { clearAllTeamLocalStateEpochs(); clearAllMemberSpawnStatusesIpcBackoffs(); teamRefreshBurstDiagnostics.clear(); - memberSpawnUiEqualLastWarnAtByTeam.clear(); + clearAllMemberSpawnUiEqualLastWarns(); resolvedMembersSelectorCache.clear(); resolvedMemberSelectorCache.clear(); clearTeamMessageSelectorCaches(); @@ -282,7 +287,7 @@ function clearTeamScopedTransientState(teamName: string): void { clearLastResolvedTeamDataRefreshAt(teamName); clearMemberSpawnStatusesIpcBackoff(teamName); teamRefreshBurstDiagnostics.delete(teamName); - memberSpawnUiEqualLastWarnAtByTeam.delete(teamName); + clearMemberSpawnUiEqualLastWarn(teamName); clearTeamScopedSelectorCaches(teamName); } @@ -668,7 +673,7 @@ export function __getTeamScopedTransientStateForTests(teamName: string): { hasCurrentLocalStateEpoch: hasTeamLocalStateEpoch(teamName), hasMemberSpawnStatusesIpcBackoff: hasMemberSpawnStatusesIpcBackoff(teamName), hasTeamRefreshBurstDiagnostics: teamRefreshBurstDiagnostics.has(teamName), - hasMemberSpawnUiEqualLastWarn: memberSpawnUiEqualLastWarnAtByTeam.has(teamName), + hasMemberSpawnUiEqualLastWarn: hasMemberSpawnUiEqualLastWarn(teamName), }; } @@ -957,12 +962,14 @@ 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) { + if ( + !shouldLogMemberSpawnUiEqualSuppressed( + teamName, + 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` ); diff --git a/src/renderer/store/team/teamMemberSpawnUiEqualWarningThrottle.ts b/src/renderer/store/team/teamMemberSpawnUiEqualWarningThrottle.ts new file mode 100644 index 00000000..65e9a92a --- /dev/null +++ b/src/renderer/store/team/teamMemberSpawnUiEqualWarningThrottle.ts @@ -0,0 +1,30 @@ +const memberSpawnUiEqualLastWarnAtByTeam = new Map(); + +export function getMemberSpawnUiEqualLastWarnAt(teamName: string): number | undefined { + return memberSpawnUiEqualLastWarnAtByTeam.get(teamName); +} + +export function hasMemberSpawnUiEqualLastWarn(teamName: string): boolean { + return memberSpawnUiEqualLastWarnAtByTeam.has(teamName); +} + +export function shouldLogMemberSpawnUiEqualSuppressed( + teamName: string, + throttleMs: number, + now = Date.now() +): boolean { + const lastWarnAt = memberSpawnUiEqualLastWarnAtByTeam.get(teamName) ?? 0; + if (now - lastWarnAt < throttleMs) { + return false; + } + memberSpawnUiEqualLastWarnAtByTeam.set(teamName, now); + return true; +} + +export function clearMemberSpawnUiEqualLastWarn(teamName: string): void { + memberSpawnUiEqualLastWarnAtByTeam.delete(teamName); +} + +export function clearAllMemberSpawnUiEqualLastWarns(): void { + memberSpawnUiEqualLastWarnAtByTeam.clear(); +} diff --git a/test/renderer/store/teamMemberSpawnUiEqualWarningThrottle.test.ts b/test/renderer/store/teamMemberSpawnUiEqualWarningThrottle.test.ts new file mode 100644 index 00000000..7025a836 --- /dev/null +++ b/test/renderer/store/teamMemberSpawnUiEqualWarningThrottle.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + clearAllMemberSpawnUiEqualLastWarns, + clearMemberSpawnUiEqualLastWarn, + getMemberSpawnUiEqualLastWarnAt, + hasMemberSpawnUiEqualLastWarn, + shouldLogMemberSpawnUiEqualSuppressed, +} from '../../../src/renderer/store/team/teamMemberSpawnUiEqualWarningThrottle'; + +afterEach(() => { + vi.useRealTimers(); + clearAllMemberSpawnUiEqualLastWarns(); +}); + +describe('teamMemberSpawnUiEqualWarningThrottle', () => { + it('preserves the existing zero fallback boundary for unknown teams', () => { + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 1_999)).toBe(false); + expect(hasMemberSpawnUiEqualLastWarn('my-team')).toBe(false); + + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 2_000)).toBe(true); + expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(2_000); + }); + + it('throttles repeated warnings until the boundary is reached', () => { + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000)).toBe(true); + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 11_999)).toBe(false); + expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(10_000); + + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 12_000)).toBe(true); + expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(12_000); + }); + + it('tracks teams independently', () => { + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000)).toBe(true); + expect(shouldLogMemberSpawnUiEqualSuppressed('other-team', 2_000, 10_500)).toBe(true); + + expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(10_000); + expect(getMemberSpawnUiEqualLastWarnAt('other-team')).toBe(10_500); + }); + + it('uses Date.now by default for production callers', () => { + vi.setSystemTime(new Date('2026-05-22T07:30:00.000Z')); + + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000)).toBe(true); + + expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe( + new Date('2026-05-22T07:30:00.000Z').getTime() + ); + }); + + it('clears one team without touching other teams', () => { + shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000); + shouldLogMemberSpawnUiEqualSuppressed('other-team', 2_000, 10_500); + + clearMemberSpawnUiEqualLastWarn('my-team'); + + expect(hasMemberSpawnUiEqualLastWarn('my-team')).toBe(false); + expect(getMemberSpawnUiEqualLastWarnAt('other-team')).toBe(10_500); + }); + + it('clears all tracked warnings', () => { + shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000); + shouldLogMemberSpawnUiEqualSuppressed('other-team', 2_000, 10_500); + + clearAllMemberSpawnUiEqualLastWarns(); + + expect(hasMemberSpawnUiEqualLastWarn('my-team')).toBe(false); + expect(hasMemberSpawnUiEqualLastWarn('other-team')).toBe(false); + }); +});