perf(startup): defer heavy startup work

This commit is contained in:
777genius 2026-05-25 23:14:59 +03:00
parent 43afc9f907
commit a6dd0061a8
10 changed files with 453 additions and 25 deletions

View file

@ -191,6 +191,10 @@ import {
markRendererUnavailable,
safeSendToRenderer,
} from './utils/safeWebContentsSend';
import {
captureStartupMemorySnapshot,
formatStartupMemorySnapshot,
} from './utils/startupTelemetry';
import { syncTelemetryFlag } from './sentry';
import { setCodexRuntimeMainWindow } from './ipc/codexRuntime';
import {
@ -236,7 +240,12 @@ import {
} from './services';
import type { FileChangeEvent } from '@main/types';
import type { AppStartupStatus, AppStartupStep, TeamChangeEvent } from '@shared/types';
import type {
AppStartupMemorySnapshot,
AppStartupStatus,
AppStartupStep,
TeamChangeEvent,
} from '@shared/types';
const logger = createLogger('App');
const appStartedAtMs = Date.now();
@ -936,12 +945,14 @@ const STARTUP_CLI_WARMUP_DELAY_MS = 90_000;
const STARTUP_BACKGROUND_SERVICE_DELAY_MS = 5_000;
const STARTUP_RECOVERY_CONCURRENCY = 1;
const appStartupStartedAt = Date.now();
const initialStartupMemory = captureStartupMemorySnapshot();
let appStartupSteps: AppStartupStep[] = [
{
phase: 'boot',
message: 'Starting Agent Teams AI...',
startedAt: appStartupStartedAt,
updatedAt: appStartupStartedAt,
memoryAtStart: initialStartupMemory,
},
];
let appStartupStatus: AppStartupStatus = {
@ -951,6 +962,7 @@ let appStartupStatus: AppStartupStatus = {
error: null,
startedAt: appStartupStartedAt,
updatedAt: appStartupStartedAt,
memory: initialStartupMemory,
steps: appStartupSteps,
};
@ -1001,7 +1013,11 @@ function cloneStartupSteps(): AppStartupStep[] {
return appStartupSteps.map((step) => ({ ...step }));
}
function updateStartupTimeline(update: Partial<AppStartupStatus>, now: number): void {
function updateStartupTimeline(
update: Partial<AppStartupStatus>,
now: number,
memory: AppStartupMemorySnapshot
): void {
if (!update.phase && !update.message) {
return;
}
@ -1015,12 +1031,14 @@ function updateStartupTimeline(update: Partial<AppStartupStatus>, now: number):
current.finishedAt = now;
current.durationMs = now - current.startedAt;
current.updatedAt = now;
current.memoryAtEnd = memory;
}
appStartupSteps.push({
phase,
message,
startedAt: now,
updatedAt: now,
memoryAtStart: memory,
});
if (appStartupSteps.length > 32) {
appStartupSteps = appStartupSteps.slice(-32);
@ -1031,7 +1049,7 @@ function updateStartupTimeline(update: Partial<AppStartupStatus>, now: number):
}
}
function finishCurrentStartupStep(now: number): void {
function finishCurrentStartupStep(now: number, memory: AppStartupMemorySnapshot): void {
const current = appStartupSteps[appStartupSteps.length - 1];
if (!current || current.finishedAt) {
return;
@ -1039,20 +1057,30 @@ function finishCurrentStartupStep(now: number): void {
current.finishedAt = now;
current.durationMs = now - current.startedAt;
current.updatedAt = now;
current.memoryAtEnd = memory;
}
function publishStartupStatus(update: Partial<AppStartupStatus>): void {
const now = Date.now();
updateStartupTimeline(update, now);
const memory = captureStartupMemorySnapshot();
updateStartupTimeline(update, now, memory);
if (update.ready === true || update.error) {
finishCurrentStartupStep(now);
finishCurrentStartupStep(now, memory);
}
appStartupStatus = {
...appStartupStatus,
...update,
updatedAt: now,
memory,
steps: cloneStartupSteps(),
};
if (update.phase || update.ready === true || update.error) {
logger.info(
`[startup] phase=${appStartupStatus.phase} ready=${appStartupStatus.ready} elapsedMs=${
now - appStartupStartedAt
} ${formatStartupMemorySnapshot(memory)}`
);
}
safeSendToRenderer(mainWindow, APP_STARTUP_PROGRESS, appStartupStatus);
}

View file

@ -14,11 +14,19 @@ export interface WatcherLifecycle {
isCurrent: () => boolean;
}
export interface PollSnapshotResult {
files: Map<string, string>;
cycleComplete: boolean;
deleteSafe?: boolean;
}
type PollSnapshot = Map<string, string> | PollSnapshotResult;
export interface CrossPlatformFileChangeSourceOptions {
name: string;
pollIntervalMs: number;
createWatcher?: (lifecycle: WatcherLifecycle) => Promise<CloseableWatcher> | CloseableWatcher;
collectPollSnapshot: () => Promise<Map<string, string>>;
collectPollSnapshot: () => Promise<PollSnapshot>;
emitPolledChange: (eventType: PollingChangeEventType, relativePath: string) => void;
isOwnerActive: () => boolean;
isWatchLimitError: (error: unknown) => boolean;
@ -34,6 +42,7 @@ export class CrossPlatformFileChangeSource {
private pollingGenerationInProgress: number | null = null;
private pollingPrimed = false;
private pollSnapshot = new Map<string, string>();
private partialPollSnapshot = new Map<string, string>();
private closedGeneration: number | null = null;
private rejectedGeneration: number | null = null;
private generation = 0;
@ -180,6 +189,7 @@ export class CrossPlatformFileChangeSource {
this.pollingGenerationInProgress = null;
this.pollingPrimed = false;
this.pollSnapshot.clear();
this.partialPollSnapshot.clear();
const timer = this.pollingTimer;
this.pollingTimer = null;
@ -265,34 +275,51 @@ export class CrossPlatformFileChangeSource {
}
private async pollForChanges(expectedGeneration: number): Promise<void> {
const nextSnapshot = await this.options.collectPollSnapshot();
const nextSnapshot = normalizePollSnapshot(await this.options.collectPollSnapshot());
if (expectedGeneration !== this.generation || !this.options.isOwnerActive()) {
return;
}
this.mergePartialPollSnapshot(nextSnapshot.files);
if (!this.pollingPrimed) {
logger.info(`${this.options.name} polling baseline captured`);
this.pollSnapshot = nextSnapshot;
this.pollingPrimed = true;
if (nextSnapshot.cycleComplete) {
logger.info(`${this.options.name} polling baseline captured`);
this.pollSnapshot = this.partialPollSnapshot;
this.partialPollSnapshot = new Map();
this.pollingPrimed = true;
}
return;
}
for (const [relativePath, fingerprint] of nextSnapshot) {
for (const [relativePath, fingerprint] of nextSnapshot.files) {
const previous = this.pollSnapshot.get(relativePath);
if (previous === undefined) {
this.options.emitPolledChange('rename', relativePath);
} else if (previous !== fingerprint) {
this.options.emitPolledChange('change', relativePath);
}
this.pollSnapshot.set(relativePath, fingerprint);
}
for (const relativePath of this.pollSnapshot.keys()) {
if (!nextSnapshot.has(relativePath)) {
this.options.emitPolledChange('rename', relativePath);
if (nextSnapshot.cycleComplete) {
const completedSnapshot = this.partialPollSnapshot;
if (nextSnapshot.deleteSafe !== false) {
for (const relativePath of this.pollSnapshot.keys()) {
if (!completedSnapshot.has(relativePath)) {
this.options.emitPolledChange('rename', relativePath);
}
}
}
this.pollSnapshot = completedSnapshot;
this.partialPollSnapshot = new Map();
}
}
this.pollSnapshot = nextSnapshot;
private mergePartialPollSnapshot(files: Map<string, string>): void {
for (const [relativePath, fingerprint] of files) {
this.partialPollSnapshot.set(relativePath, fingerprint);
}
}
private async closeWatcher(watcher: CloseableWatcher): Promise<void> {
@ -318,3 +345,14 @@ export class CrossPlatformFileChangeSource {
);
}
}
function normalizePollSnapshot(snapshot: PollSnapshot): PollSnapshotResult {
if (snapshot instanceof Map) {
return {
files: snapshot,
cycleComplete: true,
deleteSafe: true,
};
}
return snapshot;
}

View file

@ -33,7 +33,10 @@ import { projectPathResolver } from '../discovery/ProjectPathResolver';
import { errorDetector } from '../error/ErrorDetector';
import { ConfigManager } from './ConfigManager';
import { CrossPlatformFileChangeSource } from './CrossPlatformFileChangeSource';
import {
CrossPlatformFileChangeSource,
type PollSnapshotResult,
} from './CrossPlatformFileChangeSource';
import { type DataCache } from './DataCache';
import { LocalFileSystemProvider } from './LocalFileSystemProvider';
import { type NotificationManager } from './NotificationManager';
@ -52,6 +55,10 @@ const WATCHER_RETRY_MS = 2000;
const TEAMS_POLL_INTERVAL_MS = 1000;
/** Poll interval for task files, which can be much larger than team metadata/inboxes */
const TASKS_POLL_INTERVAL_MS = 3000;
/** Bound each projects polling slice so fallback/SSH mode cannot rescan huge histories every tick. */
const PROJECTS_POLL_PROJECT_SLICE_BUDGET = 64;
/** Soft cap: a single large project can exceed this, but broad trees are split across ticks. */
const PROJECTS_POLL_FILE_SOFT_BUDGET = 1024;
/** Interval for periodic catch-up scan to detect missed fs.watch events */
const CATCH_UP_INTERVAL_MS = 30_000;
/** Only catch-up scan files modified within this window */
@ -106,6 +113,10 @@ export class FileWatcher extends EventEmitter {
private catchUpCursor = 0;
/** Consecutive catch-up stat timeouts per file. */
private catchUpStatFailures = new Map<string, number>();
/** Cursor for chunked project polling snapshots. */
private projectsPollCursor = 0;
/** Whether the current project polling cycle has already been split across ticks. */
private projectsPollCycleChunked = false;
/** Polling interval for projects fallback and SSH mode. */
private static readonly SSH_POLL_INTERVAL_MS = 3000;
/** Files currently being processed (concurrency guard) */
@ -302,6 +313,8 @@ export class FileWatcher extends EventEmitter {
this.lastProcessedSize.clear();
this.activeSessionFiles.clear();
this.catchUpStatFailures.clear();
this.projectsPollCursor = 0;
this.projectsPollCycleChunked = false;
this.processingInProgress.clear();
this.pendingReprocess.clear();
@ -683,15 +696,30 @@ export class FileWatcher extends EventEmitter {
this.changeSources.projects.startPolling();
}
private async collectProjectsPollSnapshot(): Promise<Map<string, string>> {
private async collectProjectsPollSnapshot(): Promise<PollSnapshotResult> {
const snapshot = new Map<string, string>();
const projectDirs = await this.readProviderSnapshotDir(this.projectsPath);
const projectDirs = (await this.readProviderSnapshotDir(this.projectsPath))
.filter((entry) => entry.isDirectory())
.sort((left, right) => left.name.localeCompare(right.name));
for (const projectDir of projectDirs) {
if (!projectDir.isDirectory()) {
continue;
}
if (projectDirs.length === 0) {
this.projectsPollCursor = 0;
this.projectsPollCycleChunked = false;
return { files: snapshot, cycleComplete: true, deleteSafe: true };
}
if (this.projectsPollCursor >= projectDirs.length) {
this.projectsPollCursor = 0;
this.projectsPollCycleChunked = false;
}
let index = this.projectsPollCursor;
let visitedProjects = 0;
let collectedFiles = 0;
while (visitedProjects < projectDirs.length) {
const projectDir = projectDirs[index];
const sizeBefore = snapshot.size;
const projectPath = path.join(this.projectsPath, projectDir.name);
const entries = await this.readProviderSnapshotDir(projectPath);
for (const entry of entries) {
@ -726,9 +754,31 @@ export class FileWatcher extends EventEmitter {
);
}
}
collectedFiles += snapshot.size - sizeBefore;
visitedProjects += 1;
index = (index + 1) % projectDirs.length;
if (index === 0) {
const deleteSafe = !this.projectsPollCycleChunked;
this.projectsPollCursor = 0;
this.projectsPollCycleChunked = false;
return { files: snapshot, cycleComplete: true, deleteSafe };
}
if (
visitedProjects >= PROJECTS_POLL_PROJECT_SLICE_BUDGET ||
collectedFiles >= PROJECTS_POLL_FILE_SOFT_BUDGET
) {
this.projectsPollCursor = index;
this.projectsPollCycleChunked = true;
return { files: snapshot, cycleComplete: false };
}
}
return snapshot;
this.projectsPollCursor = 0;
this.projectsPollCycleChunked = false;
return { files: snapshot, cycleComplete: true, deleteSafe: true };
}
private async collectTodosPollSnapshot(): Promise<Map<string, string>> {

View file

@ -0,0 +1,26 @@
import type { AppStartupMemorySnapshot } from '@shared/types';
export type MemoryUsageReader = () => NodeJS.MemoryUsage;
export function captureStartupMemorySnapshot(
readMemoryUsage: MemoryUsageReader = () => process.memoryUsage()
): AppStartupMemorySnapshot {
const memory = readMemoryUsage();
return {
rssBytes: memory.rss,
heapUsedBytes: memory.heapUsed,
heapTotalBytes: memory.heapTotal,
externalBytes: memory.external,
arrayBuffersBytes: memory.arrayBuffers,
};
}
export function formatStartupMemorySnapshot(memory: AppStartupMemorySnapshot): string {
return `rss=${formatMiB(memory.rssBytes)} heap=${formatMiB(memory.heapUsedBytes)}/${formatMiB(
memory.heapTotalBytes
)} external=${formatMiB(memory.externalBytes)}`;
}
function formatMiB(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(1)}MiB`;
}

View file

@ -100,6 +100,8 @@ const TASK_LOG_ACTIVITY_PULSE_MS = 3_500;
const STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS = 30_000;
const STARTUP_PROVIDER_STATUS_MIN_DELAY_MS = 2_000;
const STARTUP_PROVIDER_STATUS_MAX_DELAY_MS = 30_000;
const STARTUP_GLOBAL_TASKS_MIN_DELAY_MS = 5_000;
const STARTUP_GLOBAL_TASKS_MAX_DELAY_MS = 30_000;
const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet<TeamProvisioningProgress['state']> =
new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']);
export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout';
@ -216,6 +218,8 @@ export function initializeNotificationListeners(): () => void {
let cliStatusTimer: ReturnType<typeof setTimeout> | null = null;
let runtimeStatusTimer: ReturnType<typeof setTimeout> | null = null;
let deferredProviderStatusCleanup: (() => void) | null = null;
let deferredGlobalTasksCleanup: (() => void) | null = null;
let disposed = false;
useStore.getState().subscribeProvisioningProgress();
cleanupFns.push(() => {
useStore.getState().unsubscribeProvisioningProgress();
@ -286,18 +290,33 @@ export function initializeNotificationListeners(): () => void {
runtimeStatusTimer = null;
}, STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS);
// Remaining visible startup fetches have no data dependency on each other.
// Keep immediately visible startup data first; global task aggregation can
// scan all team task files, so hydrate it after first paint/idle.
await Promise.all([
useStore.getState().fetchAllTasks(),
useStore.getState().fetchTeams(),
useStore.getState().fetchNotifications(),
useStore.getState().fetchSchedules(),
]);
if (disposed) {
return;
}
deferredGlobalTasksCleanup = scheduleStartupIdleTask(
() => {
deferredGlobalTasksCleanup = null;
void useStore.getState().fetchAllTasks();
},
{
minDelayMs: STARTUP_GLOBAL_TASKS_MIN_DELAY_MS,
maxDelayMs: STARTUP_GLOBAL_TASKS_MAX_DELAY_MS,
}
);
})();
cleanupFns.push(() => {
disposed = true;
if (cliStatusTimer) clearTimeout(cliStatusTimer);
if (runtimeStatusTimer) clearTimeout(runtimeStatusTimer);
if (deferredProviderStatusCleanup) deferredProviderStatusCleanup();
if (deferredGlobalTasksCleanup) deferredGlobalTasksCleanup();
});
// TODO(task-change-presence): re-enable this only after the board uses a bounded
// batch/priority presence pipeline. The old one-task-per-tick poll was accurate

View file

@ -337,6 +337,7 @@ export interface AppStartupStatus {
startedAt: number;
updatedAt: number;
steps?: AppStartupStep[];
memory?: AppStartupMemorySnapshot;
}
export interface AppStartupStep {
@ -346,6 +347,8 @@ export interface AppStartupStep {
updatedAt: number;
finishedAt?: number;
durationMs?: number;
memoryAtStart?: AppStartupMemorySnapshot;
memoryAtEnd?: AppStartupMemorySnapshot;
}
export interface AppStartupAPI {
@ -353,6 +356,14 @@ export interface AppStartupAPI {
onProgress: (callback: (status: AppStartupStatus) => void) => () => void;
}
export interface AppStartupMemorySnapshot {
rssBytes: number;
heapUsedBytes: number;
heapTotalBytes: number;
externalBytes: number;
arrayBuffersBytes?: number;
}
// =============================================================================
// Context API
// =============================================================================

View file

@ -595,4 +595,126 @@ describe('CrossPlatformFileChangeSource', () => {
source.stop();
active = false;
});
it('builds a silent startup baseline across incomplete polling cycles', async () => {
let active = true;
const emitted: Array<[string, string]> = [];
const collectPollSnapshot = vi
.fn()
.mockResolvedValueOnce({
files: new Map([['a.jsonl', '1']]),
cycleComplete: false,
})
.mockResolvedValueOnce({
files: new Map([['b.jsonl', '1']]),
cycleComplete: true,
})
.mockResolvedValueOnce({
files: new Map([
['a.jsonl', '2'],
['b.jsonl', '1'],
]),
cycleComplete: true,
});
const source = new CrossPlatformFileChangeSource({
name: 'test-source',
pollIntervalMs: 1000,
collectPollSnapshot,
emitPolledChange: (eventType, relativePath) => emitted.push([eventType, relativePath]),
isOwnerActive: () => active,
isWatchLimitError: () => false,
requestRetry: vi.fn(),
});
await source.pollOnce();
expect(source.isPollingPrimed).toBe(false);
await source.pollOnce();
expect(source.isPollingPrimed).toBe(true);
expect(emitted).toEqual([]);
await source.pollOnce();
expect(emitted).toEqual([['change', 'a.jsonl']]);
source.stop();
active = false;
});
it('does not emit deletes from incomplete polling snapshots', async () => {
let active = true;
const emitted: Array<[string, string]> = [];
const collectPollSnapshot = vi
.fn()
.mockResolvedValueOnce(
new Map([
['a.jsonl', '1'],
['b.jsonl', '1'],
])
)
.mockResolvedValueOnce({
files: new Map([['a.jsonl', '1']]),
cycleComplete: false,
})
.mockResolvedValueOnce({
files: new Map<string, string>(),
cycleComplete: true,
});
const source = new CrossPlatformFileChangeSource({
name: 'test-source',
pollIntervalMs: 1000,
collectPollSnapshot,
emitPolledChange: (eventType, relativePath) => emitted.push([eventType, relativePath]),
isOwnerActive: () => active,
isWatchLimitError: () => false,
requestRetry: vi.fn(),
});
await source.pollOnce();
await source.pollOnce();
expect(emitted).toEqual([]);
await source.pollOnce();
expect(emitted).toEqual([['rename', 'b.jsonl']]);
source.stop();
active = false;
});
it('suppresses deletes when a completed polling cycle is not delete-safe', async () => {
let active = true;
const emitted: Array<[string, string]> = [];
const collectPollSnapshot = vi
.fn()
.mockResolvedValueOnce(
new Map([
['a.jsonl', '1'],
['b.jsonl', '1'],
])
)
.mockResolvedValueOnce({
files: new Map([['a.jsonl', '1']]),
cycleComplete: false,
})
.mockResolvedValueOnce({
files: new Map<string, string>(),
cycleComplete: true,
deleteSafe: false,
});
const source = new CrossPlatformFileChangeSource({
name: 'test-source',
pollIntervalMs: 1000,
collectPollSnapshot,
emitPolledChange: (eventType, relativePath) => emitted.push([eventType, relativePath]),
isOwnerActive: () => active,
isWatchLimitError: () => false,
requestRetry: vi.fn(),
});
await source.pollOnce();
await source.pollOnce();
await source.pollOnce();
expect(emitted).toEqual([]);
source.stop();
active = false;
});
});

View file

@ -641,6 +641,70 @@ describe('FileWatcher', () => {
watcher.stop();
});
it('chunks broad project polling baselines and still emits changes after priming', async () => {
const projectsDir = '/virtual/projects';
const todosDir = '/virtual/todos';
const projectNames = Array.from({ length: 65 }, (_, index) =>
`encoded-project-${String(index).padStart(3, '0')}`
);
const fileState = new Map(projectNames.map((name) => [name, { size: 10, mtimeMs: 1000 }]));
const fsProvider = {
type: 'local' as const,
exists: vi.fn().mockResolvedValue(true),
readFile: vi.fn().mockResolvedValue(''),
stat: vi.fn().mockResolvedValue({
size: 10,
mtimeMs: 1000,
birthtimeMs: 1000,
isFile: () => true,
isDirectory: () => false,
}),
readdir: vi.fn(async (dirPath: string) => {
if (dirPath === projectsDir) {
return projectNames.map((name) => createFsDirent(name, 'directory'));
}
const projectName = path.basename(dirPath);
const state = fileState.get(projectName);
if (state) {
return [createFsDirent('session-1.jsonl', 'file', state)];
}
return [];
}),
createReadStream: vi.fn(() => Readable.from([])),
dispose: vi.fn(),
};
const dataCache = new DataCache(50, 10, false);
const watcher = new FileWatcher(dataCache, projectsDir, todosDir, fsProvider);
const events: unknown[] = [];
watcher.on('file-change', (event) => events.push(event));
setWatcherActive(watcher);
const projectsSource = getChangeSource(watcher, 'projects');
await projectsSource.pollOnce();
expect(projectsSource.isPollingPrimed).toBe(false);
expect(events).toEqual([]);
await projectsSource.pollOnce();
expect(projectsSource.isPollingPrimed).toBe(true);
expect(events).toEqual([]);
fileState.set(projectNames[0], { size: 12, mtimeMs: 2000 });
await projectsSource.pollOnce();
await vi.advanceTimersByTimeAsync(100);
expect(events).toContainEqual({
type: 'change',
path: path.join(projectsDir, projectNames[0], 'session-1.jsonl'),
projectId: projectNames[0],
sessionId: 'session-1',
isSubagent: false,
});
watcher.stop();
});
it('treats SSH not-found subagent directories as empty during project polling', async () => {
const projectsDir = '/remote/projects';
const todosDir = '/remote/todos';

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import {
captureStartupMemorySnapshot,
formatStartupMemorySnapshot,
} from '../../../src/main/utils/startupTelemetry';
describe('startupTelemetry', () => {
it('captures only stable numeric memory fields', () => {
const snapshot = captureStartupMemorySnapshot(() => ({
rss: 128 * 1024 * 1024,
heapTotal: 64 * 1024 * 1024,
heapUsed: 32 * 1024 * 1024,
external: 8 * 1024 * 1024,
arrayBuffers: 4 * 1024 * 1024,
}));
expect(snapshot).toEqual({
rssBytes: 134217728,
heapUsedBytes: 33554432,
heapTotalBytes: 67108864,
externalBytes: 8388608,
arrayBuffersBytes: 4194304,
});
});
it('formats rss and heap values for startup logs', () => {
expect(
formatStartupMemorySnapshot({
rssBytes: 128 * 1024 * 1024,
heapUsedBytes: 32 * 1024 * 1024,
heapTotalBytes: 64 * 1024 * 1024,
externalBytes: 8 * 1024 * 1024,
arrayBuffersBytes: 4 * 1024 * 1024,
})
).toBe('rss=128.0MiB heap=32.0MiB/64.0MiB external=8.0MiB');
});
});

View file

@ -202,6 +202,38 @@ describe('team change throttling', () => {
expect(getRepositoryGroupsSpy).not.toHaveBeenCalled();
});
it('defers the initial global task fetch until the startup idle window', async () => {
const fetchAllTasksSpy = vi.fn(async () => undefined);
useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never);
cleanup?.();
cleanup = initializeNotificationListeners();
await vi.advanceTimersByTimeAsync(0);
expect(fetchAllTasksSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(4_999);
expect(fetchAllTasksSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(fetchAllTasksSpy).toHaveBeenCalledTimes(1);
});
it('cancels the deferred initial global task fetch during listener cleanup', async () => {
const fetchAllTasksSpy = vi.fn(async () => undefined);
useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never);
cleanup?.();
cleanup = initializeNotificationListeners();
await vi.advanceTimersByTimeAsync(0);
cleanup();
cleanup = null;
await vi.advanceTimersByTimeAsync(30_000);
expect(fetchAllTasksSpy).not.toHaveBeenCalled();
});
it('allows next refresh after throttle window passes', async () => {
const state = useStore.getState();
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');