chore(team): instrument refresh fanout diagnostics

This commit is contained in:
777genius 2026-05-03 11:23:45 +03:00
parent e21d89a057
commit 7e55fdd9cd
6 changed files with 619 additions and 11 deletions

View file

@ -47,6 +47,10 @@ import {
isTeamDataRefreshPending,
selectTeamDataForName,
} from './slices/teamSlice';
import {
noteTeamRefreshFanout,
type TeamRefreshFanoutOperation,
} from './teamRefreshFanoutDiagnostics';
import { createUISlice } from './slices/uiSlice';
import { createUpdateSlice } from './slices/updateSlice';
@ -250,6 +254,8 @@ export function initializeNotificationListeners(): () => void {
let teamListRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let globalTasksRefreshTimer: ReturnType<typeof setTimeout> | null = null;
const pendingTeamListRefreshDiagnostics = new Map<string, Set<string>>();
const pendingGlobalTasksRefreshDiagnostics = new Map<string, Set<string>>();
const SESSION_REFRESH_DEBOUNCE_MS = 150;
const PROJECT_REFRESH_DEBOUNCE_MS = 300;
const TEAM_REFRESH_THROTTLE_MS = 800;
@ -257,6 +263,53 @@ export function initializeNotificationListeners(): () => void {
const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500;
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
const buildTeamChangeFanoutReason = (eventType: string): string => `event:${eventType}`;
const addPendingGlobalRefreshDiagnostic = (
pending: Map<string, Set<string>>,
teamName: string,
reason: string
): void => {
const reasons = pending.get(teamName) ?? new Set<string>();
reasons.add(reason);
pending.set(teamName, reasons);
};
const drainPendingGlobalRefreshDiagnostics = (
pending: Map<string, Set<string>>,
operation: TeamRefreshFanoutOperation
): void => {
const entries = Array.from(pending.entries());
pending.clear();
for (const [teamName, reasons] of entries) {
for (const reason of reasons) {
noteTeamRefreshFanout({
teamName,
surface: 'team-change-listener',
phase: 'executed',
reason,
operation,
});
}
}
};
const noteGlobalRefreshScheduled = (
pending: Map<string, Set<string>>,
teamName: string | null | undefined,
reason: string,
operation: TeamRefreshFanoutOperation,
coalesced: boolean
): void => {
if (!teamName) {
return;
}
addPendingGlobalRefreshDiagnostic(pending, teamName, reason);
noteTeamRefreshFanout({
teamName,
surface: 'team-change-listener',
phase: coalesced ? 'coalesced' : 'scheduled',
reason,
operation,
});
};
const refreshTrackedTeamMessages = async (teamName: string): Promise<void> => {
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
return;
@ -278,11 +331,26 @@ export function initializeNotificationListeners(): () => void {
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
return;
}
const existingTimer = memberSpawnRefreshTimers.get(teamName);
noteTeamRefreshFanout({
teamName,
surface: 'team-change-listener',
phase: existingTimer ? 'coalesced' : 'scheduled',
reason: 'event:member-spawn',
operation: 'fetchMemberSpawnStatuses',
});
if (memberSpawnRefreshTimers.has(teamName)) {
return;
}
const timer = setTimeout(() => {
memberSpawnRefreshTimers.delete(teamName);
noteTeamRefreshFanout({
teamName,
surface: 'team-change-listener',
phase: 'executed',
reason: 'event:member-spawn',
operation: 'fetchMemberSpawnStatuses',
});
void useStore.getState().fetchMemberSpawnStatuses(teamName);
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
memberSpawnRefreshTimers.set(teamName, timer);
@ -291,24 +359,57 @@ export function initializeNotificationListeners(): () => void {
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
return;
}
const existingTimer = teamAgentRuntimeRefreshTimers.get(teamName);
noteTeamRefreshFanout({
teamName,
surface: 'team-change-listener',
phase: existingTimer ? 'coalesced' : 'scheduled',
reason: 'event:member-spawn',
operation: 'fetchTeamAgentRuntime',
});
if (teamAgentRuntimeRefreshTimers.has(teamName)) {
return;
}
const timer = setTimeout(() => {
teamAgentRuntimeRefreshTimers.delete(teamName);
noteTeamRefreshFanout({
teamName,
surface: 'team-change-listener',
phase: 'executed',
reason: 'event:member-spawn',
operation: 'fetchTeamAgentRuntime',
});
void useStore.getState().fetchTeamAgentRuntime(teamName);
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
teamAgentRuntimeRefreshTimers.set(teamName, timer);
};
const scheduleTrackedTeamMessageRefresh = (teamName: string | null | undefined): void => {
const scheduleTrackedTeamMessageRefresh = (
teamName: string | null | undefined,
reason: 'event:inbox' | 'event:lead-message'
): void => {
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
return;
}
const existingTimer = teamMessageRefreshTimers.get(teamName);
noteTeamRefreshFanout({
teamName,
surface: 'team-change-listener',
phase: existingTimer ? 'coalesced' : 'scheduled',
reason,
operation: 'fetchTeamMessageHead',
});
if (teamMessageRefreshTimers.has(teamName)) {
return;
}
const timer = setTimeout(() => {
teamMessageRefreshTimers.delete(teamName);
noteTeamRefreshFanout({
teamName,
surface: 'team-change-listener',
phase: 'executed',
reason,
operation: 'fetchTeamMessageHead',
});
void refreshTrackedTeamMessages(teamName);
}, TEAM_REFRESH_THROTTLE_MS);
teamMessageRefreshTimers.set(teamName, timer);
@ -700,7 +801,16 @@ export function initializeNotificationListeners(): () => void {
teamMessageFallbackPollInFlight = true;
try {
await Promise.allSettled(
Array.from(teamNames, (teamName) => refreshTrackedTeamMessages(teamName))
Array.from(teamNames, (teamName) => {
noteTeamRefreshFanout({
teamName,
surface: 'pending-reply-fallback',
phase: 'executed',
reason: 'pending-reply:fallback-poll',
operation: 'fetchTeamMessageHead',
});
return refreshTrackedTeamMessages(teamName);
})
);
} finally {
teamMessageFallbackPollInFlight = false;
@ -1213,7 +1323,7 @@ export function initializeNotificationListeners(): () => void {
}
if (event.type === 'inbox') {
scheduleTrackedTeamMessageRefresh(event.teamName);
scheduleTrackedTeamMessageRefresh(event.teamName, 'event:inbox');
return;
}
@ -1224,7 +1334,7 @@ export function initializeNotificationListeners(): () => void {
return;
}
seedCurrentRunIdIfMissing();
scheduleTrackedTeamMessageRefresh(event.teamName);
scheduleTrackedTeamMessageRefresh(event.teamName, 'event:lead-message');
return;
}
@ -1232,22 +1342,47 @@ export function initializeNotificationListeners(): () => void {
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
return;
}
if (teamPresenceRefreshTimers.has(event.teamName)) {
const existingTimer = teamPresenceRefreshTimers.get(event.teamName);
noteTeamRefreshFanout({
teamName: event.teamName,
surface: 'team-change-listener',
phase: existingTimer ? 'coalesced' : 'scheduled',
reason: 'event:log-source-change',
operation: 'refreshTaskChangePresence',
});
if (existingTimer) {
return;
}
const timer = setTimeout(() => {
teamPresenceRefreshTimers.delete(event.teamName);
const current = useStore.getState();
noteTeamRefreshFanout({
teamName: event.teamName,
surface: 'team-change-listener',
phase: 'executed',
reason: 'event:log-source-change',
operation: 'refreshTaskChangePresence',
});
void current.refreshTeamChangePresence(event.teamName);
}, TEAM_PRESENCE_REFRESH_THROTTLE_MS);
teamPresenceRefreshTimers.set(event.teamName, timer);
return;
}
const eventReason = buildTeamChangeFanoutReason(event.type);
// Throttled refresh of summary list (keeps TeamListView current without flooding).
noteGlobalRefreshScheduled(
pendingTeamListRefreshDiagnostics,
event.teamName,
eventReason,
'fetchTeams',
teamListRefreshTimer != null
);
if (!teamListRefreshTimer) {
teamListRefreshTimer = setTimeout(() => {
teamListRefreshTimer = null;
drainPendingGlobalRefreshDiagnostics(pendingTeamListRefreshDiagnostics, 'fetchTeams');
void useStore.getState().fetchTeams();
}, TEAM_LIST_REFRESH_THROTTLE_MS);
}
@ -1255,11 +1390,24 @@ export function initializeNotificationListeners(): () => void {
const shouldRefreshGlobalTasks = event.type === 'task' || event.type === 'config';
// Throttled refresh of global tasks list for sidebar.
if (shouldRefreshGlobalTasks && !globalTasksRefreshTimer) {
globalTasksRefreshTimer = setTimeout(() => {
globalTasksRefreshTimer = null;
void useStore.getState().fetchAllTasks();
}, GLOBAL_TASKS_REFRESH_THROTTLE_MS);
if (shouldRefreshGlobalTasks) {
noteGlobalRefreshScheduled(
pendingGlobalTasksRefreshDiagnostics,
event.teamName,
eventReason,
'fetchAllTasks',
globalTasksRefreshTimer != null
);
if (!globalTasksRefreshTimer) {
globalTasksRefreshTimer = setTimeout(() => {
globalTasksRefreshTimer = null;
drainPendingGlobalRefreshDiagnostics(
pendingGlobalTasksRefreshDiagnostics,
'fetchAllTasks'
);
void useStore.getState().fetchAllTasks();
}, GLOBAL_TASKS_REFRESH_THROTTLE_MS);
}
}
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
@ -1268,13 +1416,38 @@ export function initializeNotificationListeners(): () => void {
// Per-team throttle (not debounce): keep at most one pending detail refresh per team.
// Debounce would delay indefinitely while inbox messages keep arriving.
if (teamRefreshTimers.has(event.teamName)) {
const selectedForRefresh = useStore.getState().selectedTeamName === event.teamName;
const activeTabForRefresh = getFocusedVisibleTeamName() === event.teamName;
const existingDetailTimer = teamRefreshTimers.get(event.teamName);
noteTeamRefreshFanout({
teamName: event.teamName,
surface: 'team-change-listener',
phase: existingDetailTimer ? 'coalesced' : 'scheduled',
reason: eventReason,
operation: 'refreshTeamData',
eventType: event.type,
selected: selectedForRefresh,
visible: true,
activeTab: activeTabForRefresh,
});
if (existingDetailTimer) {
return;
}
const timer = setTimeout(() => {
teamRefreshTimers.delete(event.teamName);
const current = useStore.getState();
noteTeamRefreshFanout({
teamName: event.teamName,
surface: 'team-change-listener',
phase: 'executed',
reason: eventReason,
operation: 'refreshTeamData',
eventType: event.type,
selected: current.selectedTeamName === event.teamName,
visible: isTeamVisibleInAnyPane(event.teamName),
activeTab: getFocusedVisibleTeamName() === event.teamName,
});
void current.refreshTeamData(event.teamName, { withDedup: true });
}, TEAM_REFRESH_THROTTLE_MS);
teamRefreshTimers.set(event.teamName, timer);
@ -1301,10 +1474,12 @@ export function initializeNotificationListeners(): () => void {
clearTimeout(teamListRefreshTimer);
teamListRefreshTimer = null;
}
pendingTeamListRefreshDiagnostics.clear();
if (globalTasksRefreshTimer) {
clearTimeout(globalTasksRefreshTimer);
globalTasksRefreshTimer = null;
}
pendingGlobalTasksRefreshDiagnostics.clear();
});
}
}

View file

@ -20,6 +20,7 @@ import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics';
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
import type { AppState } from '../types';
@ -4887,6 +4888,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
if (isCanonicalRun && becameConfigReady) {
const state = get();
if (isVisibleInActiveTeamSurface(state, progress.teamName)) {
const willSelectTeam =
state.selectedTeamName === progress.teamName && state.selectedTeamData == null;
noteTeamRefreshFanout({
teamName: progress.teamName,
surface: 'provisioning-progress',
phase: 'scheduled',
reason: 'provisioning:config-ready',
operation: willSelectTeam ? 'selectTeam' : 'refreshTeamData',
selected: state.selectedTeamName === progress.teamName,
visible: true,
});
if (state.selectedTeamName === progress.teamName && state.selectedTeamData == null) {
void state.selectTeam(progress.teamName, { allowReloadWhileProvisioning: true });
} else {
@ -4939,8 +4951,27 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
}
if (isCanonicalRun && (progress.state === 'ready' || progress.state === 'disconnected')) {
const terminalReason =
progress.state === 'ready'
? 'provisioning:terminal-ready'
: 'provisioning:terminal-disconnected';
noteTeamRefreshFanout({
teamName: progress.teamName,
surface: 'provisioning-progress',
phase: 'scheduled',
reason: terminalReason,
operation: 'fetchTeams',
});
void get().fetchTeams();
if (hydratedVisibleTeam) {
noteTeamRefreshFanout({
teamName: progress.teamName,
surface: 'provisioning-progress',
phase: 'skipped',
reason: 'provisioning:already-hydrated-visible-team',
operation: 'refreshTeamData',
visible: true,
});
return;
}
@ -4951,6 +4982,15 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
// If the user already opened the team tab, reload team data now that
// config.json is guaranteed to exist.
noteTeamRefreshFanout({
teamName: progress.teamName,
surface: 'provisioning-progress',
phase: 'scheduled',
reason: terminalReason,
operation: state.selectedTeamName === progress.teamName ? 'selectTeam' : 'refreshTeamData',
selected: state.selectedTeamName === progress.teamName,
visible: true,
});
if (state.selectedTeamName === progress.teamName) {
void state.selectTeam(progress.teamName);
} else {

View file

@ -0,0 +1,148 @@
export type TeamRefreshFanoutSurface =
| 'team-change-listener'
| 'provisioning-progress'
| 'pending-reply-fallback'
| 'manual-refresh';
export type TeamRefreshFanoutPhase = 'scheduled' | 'coalesced' | 'executed' | 'skipped';
export type TeamRefreshFanoutOperation =
| 'fetchTeams'
| 'fetchAllTasks'
| 'refreshTeamData'
| 'selectTeam'
| 'fetchTeamMessageHead'
| 'fetchMemberSpawnStatuses'
| 'fetchTeamAgentRuntime'
| 'refreshTaskChangePresence';
export interface TeamRefreshFanoutNote {
teamName: string;
surface: TeamRefreshFanoutSurface;
phase: TeamRefreshFanoutPhase;
reason: string;
operation: TeamRefreshFanoutOperation;
eventType?: string;
tabId?: string;
selected?: boolean;
visible?: boolean;
activeTab?: boolean;
}
export interface TeamRefreshFanoutRecentNote {
at: number;
surface: TeamRefreshFanoutSurface;
phase: TeamRefreshFanoutPhase;
reason: string;
operation: TeamRefreshFanoutOperation;
eventType?: string;
tabId?: string;
selected?: boolean;
visible?: boolean;
activeTab?: boolean;
}
export interface TeamRefreshFanoutSnapshot {
counts: Record<string, number>;
recent: TeamRefreshFanoutRecentNote[];
lastAt: number;
}
interface TeamRefreshFanoutBucket {
counts: Record<string, number>;
recent: TeamRefreshFanoutRecentNote[];
lastAt: number;
}
export const MAX_TEAM_REFRESH_DIAGNOSTIC_TEAMS = 100;
export const MAX_TEAM_REFRESH_DIAGNOSTIC_RECENT_NOTES = 50;
const buckets = new Map<string, TeamRefreshFanoutBucket>();
function createEmptyBucket(): TeamRefreshFanoutBucket {
return {
counts: {},
recent: [],
lastAt: 0,
};
}
function ensureTeamBucket(teamName: string): TeamRefreshFanoutBucket {
if (!buckets.has(teamName) && buckets.size >= MAX_TEAM_REFRESH_DIAGNOSTIC_TEAMS) {
const oldestKey = buckets.keys().next().value as string | undefined;
if (oldestKey) {
buckets.delete(oldestKey);
}
}
let bucket = buckets.get(teamName);
if (!bucket) {
bucket = createEmptyBucket();
buckets.set(teamName, bucket);
}
return bucket;
}
function cloneBucket(
bucket: TeamRefreshFanoutBucket | undefined
): TeamRefreshFanoutSnapshot | null {
if (!bucket) {
return null;
}
return {
counts: { ...bucket.counts },
recent: bucket.recent.map((note) => ({ ...note })),
lastAt: bucket.lastAt,
};
}
export function buildTeamRefreshFanoutCountKey(note: TeamRefreshFanoutNote): string {
return `${note.surface}:${note.reason}:${note.operation}:${note.phase}`;
}
export function noteTeamRefreshFanout(note: TeamRefreshFanoutNote): void {
if (!note.teamName || !note.reason || !note.operation) {
return;
}
const bucket = ensureTeamBucket(note.teamName);
const key = buildTeamRefreshFanoutCountKey(note);
const now = Date.now();
bucket.counts[key] = (bucket.counts[key] ?? 0) + 1;
bucket.lastAt = now;
bucket.recent.push({
at: now,
surface: note.surface,
phase: note.phase,
reason: note.reason,
operation: note.operation,
eventType: note.eventType,
tabId: note.tabId,
selected: note.selected,
visible: note.visible,
activeTab: note.activeTab,
});
if (bucket.recent.length > MAX_TEAM_REFRESH_DIAGNOSTIC_RECENT_NOTES) {
bucket.recent.splice(0, bucket.recent.length - MAX_TEAM_REFRESH_DIAGNOSTIC_RECENT_NOTES);
}
}
export function getTeamRefreshFanoutSnapshotForTests(
teamName?: string
): TeamRefreshFanoutSnapshot | Record<string, TeamRefreshFanoutSnapshot> | null {
if (teamName) {
return cloneBucket(buckets.get(teamName));
}
return Object.fromEntries(
Array.from(buckets.entries(), ([key, bucket]) => [key, cloneBucket(bucket)])
) as Record<string, TeamRefreshFanoutSnapshot>;
}
export function __resetTeamRefreshFanoutDiagnosticsForTests(): void {
buckets.clear();
}

View file

@ -63,6 +63,11 @@ vi.mock('@renderer/api', () => ({
import { initializeNotificationListeners, useStore } from '../../../src/renderer/store';
import { __resetTeamSliceModuleStateForTests } from '../../../src/renderer/store/slices/teamSlice';
import {
__resetTeamRefreshFanoutDiagnosticsForTests,
getTeamRefreshFanoutSnapshotForTests,
type TeamRefreshFanoutSnapshot,
} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics';
import { api } from '@renderer/api';
describe('team change throttling', () => {
@ -71,6 +76,7 @@ describe('team change throttling', () => {
beforeEach(async () => {
vi.useFakeTimers();
__resetTeamSliceModuleStateForTests();
__resetTeamRefreshFanoutDiagnosticsForTests();
const fetchTeams = vi.fn(async () => undefined);
const fetchMemberSpawnStatuses = vi.fn(async () => undefined);
const refreshTeamData = vi.fn(async () => undefined);
@ -117,6 +123,7 @@ describe('team change throttling', () => {
cleanup?.();
cleanup = null;
__resetTeamSliceModuleStateForTests();
__resetTeamRefreshFanoutDiagnosticsForTests();
vi.mocked(console.warn).mockClear();
vi.useRealTimers();
});
@ -163,6 +170,48 @@ describe('team change throttling', () => {
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2);
});
it('keeps process events on the existing structural refresh path and records fanout', async () => {
const state = useStore.getState();
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
hoisted.onTeamChangeCb?.({}, { type: 'process', teamName: 'my-team' });
await vi.advanceTimersByTimeAsync(800);
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
const snapshot = getTeamRefreshFanoutSnapshotForTests(
'my-team'
) as TeamRefreshFanoutSnapshot | null;
expect(
snapshot?.counts['team-change-listener:event:process:refreshTeamData:scheduled']
).toBe(1);
expect(snapshot?.counts['team-change-listener:event:process:refreshTeamData:executed']).toBe(
1
);
});
it('keeps task and config events on the existing global task refresh path', async () => {
const fetchAllTasksSpy = vi.fn(async () => undefined);
useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never);
hoisted.onTeamChangeCb?.({}, { type: 'task', teamName: 'my-team' });
hoisted.onTeamChangeCb?.({}, { type: 'config', teamName: 'my-team' });
await vi.advanceTimersByTimeAsync(500);
expect(fetchAllTasksSpy).toHaveBeenCalledTimes(1);
const snapshot = getTeamRefreshFanoutSnapshotForTests(
'my-team'
) as TeamRefreshFanoutSnapshot | null;
expect(snapshot?.counts['team-change-listener:event:task:fetchAllTasks:scheduled']).toBe(1);
expect(snapshot?.counts['team-change-listener:event:config:fetchAllTasks:coalesced']).toBe(1);
expect(snapshot?.counts['team-change-listener:event:task:fetchAllTasks:executed']).toBe(1);
expect(snapshot?.counts['team-change-listener:event:config:fetchAllTasks:executed']).toBe(1);
});
it('lead-message refreshes message head only, not team list, tasks, or structural detail', async () => {
const state = useStore.getState();
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');

View file

@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
__resetTeamRefreshFanoutDiagnosticsForTests,
buildTeamRefreshFanoutCountKey,
getTeamRefreshFanoutSnapshotForTests,
MAX_TEAM_REFRESH_DIAGNOSTIC_RECENT_NOTES,
MAX_TEAM_REFRESH_DIAGNOSTIC_TEAMS,
noteTeamRefreshFanout,
type TeamRefreshFanoutSnapshot,
} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics';
function snapshotFor(teamName: string): TeamRefreshFanoutSnapshot {
const snapshot = getTeamRefreshFanoutSnapshotForTests(teamName);
expect(snapshot).not.toBeNull();
return snapshot as TeamRefreshFanoutSnapshot;
}
describe('teamRefreshFanoutDiagnostics', () => {
beforeEach(() => {
vi.useFakeTimers();
__resetTeamRefreshFanoutDiagnosticsForTests();
});
afterEach(() => {
__resetTeamRefreshFanoutDiagnosticsForTests();
vi.useRealTimers();
});
it('records scheduled and executed fanout counts separately', () => {
const scheduled = {
teamName: 'team-a',
surface: 'team-change-listener',
phase: 'scheduled',
reason: 'event:process',
operation: 'refreshTeamData',
} as const;
const executed = {
...scheduled,
phase: 'executed',
} as const;
noteTeamRefreshFanout(scheduled);
noteTeamRefreshFanout(executed);
const snapshot = snapshotFor('team-a');
expect(snapshot.counts[buildTeamRefreshFanoutCountKey(scheduled)]).toBe(1);
expect(snapshot.counts[buildTeamRefreshFanoutCountKey(executed)]).toBe(1);
});
it('records coalesced notes separately from scheduled notes', () => {
const scheduled = {
teamName: 'team-a',
surface: 'team-change-listener',
phase: 'scheduled',
reason: 'event:member-spawn',
operation: 'fetchMemberSpawnStatuses',
} as const;
const coalesced = {
...scheduled,
phase: 'coalesced',
} as const;
noteTeamRefreshFanout(scheduled);
noteTeamRefreshFanout(coalesced);
noteTeamRefreshFanout(coalesced);
const snapshot = snapshotFor('team-a');
expect(snapshot.counts[buildTeamRefreshFanoutCountKey(scheduled)]).toBe(1);
expect(snapshot.counts[buildTeamRefreshFanoutCountKey(coalesced)]).toBe(2);
});
it('caps recent notes per team', () => {
for (let index = 0; index < MAX_TEAM_REFRESH_DIAGNOSTIC_RECENT_NOTES + 5; index += 1) {
noteTeamRefreshFanout({
teamName: 'team-a',
surface: 'team-change-listener',
phase: 'scheduled',
reason: `event:${index}`,
operation: 'refreshTeamData',
});
}
const snapshot = snapshotFor('team-a');
expect(snapshot.recent).toHaveLength(MAX_TEAM_REFRESH_DIAGNOSTIC_RECENT_NOTES);
expect(snapshot.recent[0]?.reason).toBe('event:5');
});
it('caps team buckets by evicting the oldest bucket', () => {
for (let index = 0; index < MAX_TEAM_REFRESH_DIAGNOSTIC_TEAMS + 1; index += 1) {
noteTeamRefreshFanout({
teamName: `team-${index}`,
surface: 'team-change-listener',
phase: 'scheduled',
reason: 'event:process',
operation: 'refreshTeamData',
});
}
expect(getTeamRefreshFanoutSnapshotForTests('team-0')).toBeNull();
expect(
getTeamRefreshFanoutSnapshotForTests(`team-${MAX_TEAM_REFRESH_DIAGNOSTIC_TEAMS}`)
).not.toBeNull();
});
it('reset clears all diagnostic state', () => {
noteTeamRefreshFanout({
teamName: 'team-a',
surface: 'team-change-listener',
phase: 'scheduled',
reason: 'event:process',
operation: 'refreshTeamData',
});
__resetTeamRefreshFanoutDiagnosticsForTests();
expect(getTeamRefreshFanoutSnapshotForTests('team-a')).toBeNull();
expect(getTeamRefreshFanoutSnapshotForTests()).toEqual({});
});
it('ignores invalid empty team or reason values', () => {
noteTeamRefreshFanout({
teamName: '',
surface: 'team-change-listener',
phase: 'scheduled',
reason: 'event:process',
operation: 'refreshTeamData',
});
noteTeamRefreshFanout({
teamName: 'team-a',
surface: 'team-change-listener',
phase: 'scheduled',
reason: '',
operation: 'refreshTeamData',
});
expect(getTeamRefreshFanoutSnapshotForTests()).toEqual({});
});
});

View file

@ -12,6 +12,11 @@ import {
selectResolvedMemberForTeamName,
selectResolvedMembersForTeamName,
} from '../../../src/renderer/store/slices/teamSlice';
import {
__resetTeamRefreshFanoutDiagnosticsForTests,
getTeamRefreshFanoutSnapshotForTests,
type TeamRefreshFanoutSnapshot,
} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics';
const hoisted = vi.hoisted(() => ({
list: vi.fn(),
@ -198,6 +203,7 @@ describe('teamSlice actions', () => {
beforeEach(() => {
vi.clearAllMocks();
__resetTeamSliceModuleStateForTests();
__resetTeamRefreshFanoutDiagnosticsForTests();
hoisted.list.mockResolvedValue([]);
hoisted.getData.mockResolvedValue(createTeamSnapshot());
hoisted.getMessagesPage.mockResolvedValue({
@ -238,6 +244,57 @@ describe('teamSlice actions', () => {
hoisted.skipMemberForLaunch.mockResolvedValue(undefined);
});
it('records terminal provisioning fanout diagnostics without changing visible graph hydrate behavior', () => {
const store = createSliceStore();
const fetchTeams = vi.fn(async () => undefined);
const refreshTeamData = vi.fn(async () => undefined);
store.setState({
fetchTeams,
refreshTeamData,
selectedTeamName: 'other-team',
selectedTeamData: createTeamSnapshot({
teamName: 'other-team',
config: { name: 'Other Team' },
}),
paneLayout: {
focusedPaneId: 'pane-default',
panes: [
{
id: 'pane-default',
widthFraction: 1,
tabs: [{ id: 'graph-my-team', type: 'graph', teamName: 'my-team', label: 'Graph' }],
activeTabId: 'graph-my-team',
},
],
},
});
store.getState().onProvisioningProgress({
runId: 'run-ready',
teamName: 'my-team',
state: 'ready',
message: 'Ready',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:01.000Z',
} as never);
expect(fetchTeams).toHaveBeenCalledTimes(1);
expect(refreshTeamData).toHaveBeenCalledTimes(1);
expect(refreshTeamData).toHaveBeenCalledWith('my-team', { withDedup: true });
const snapshot = getTeamRefreshFanoutSnapshotForTests(
'my-team'
) as TeamRefreshFanoutSnapshot | null;
expect(
snapshot?.counts['provisioning-progress:provisioning:terminal-ready:fetchTeams:scheduled']
).toBe(1);
expect(
snapshot?.counts[
'provisioning-progress:provisioning:terminal-ready:refreshTeamData:scheduled'
]
).toBe(1);
});
it('maps inbox verify failure to user-friendly text', async () => {
const store = createSliceStore();
hoisted.sendMessage.mockRejectedValue(new Error('Failed to verify inbox write'));