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

View file

@ -14,11 +14,19 @@ export interface WatcherLifecycle {
isCurrent: () => boolean; isCurrent: () => boolean;
} }
export interface PollSnapshotResult {
files: Map<string, string>;
cycleComplete: boolean;
deleteSafe?: boolean;
}
type PollSnapshot = Map<string, string> | PollSnapshotResult;
export interface CrossPlatformFileChangeSourceOptions { export interface CrossPlatformFileChangeSourceOptions {
name: string; name: string;
pollIntervalMs: number; pollIntervalMs: number;
createWatcher?: (lifecycle: WatcherLifecycle) => Promise<CloseableWatcher> | CloseableWatcher; createWatcher?: (lifecycle: WatcherLifecycle) => Promise<CloseableWatcher> | CloseableWatcher;
collectPollSnapshot: () => Promise<Map<string, string>>; collectPollSnapshot: () => Promise<PollSnapshot>;
emitPolledChange: (eventType: PollingChangeEventType, relativePath: string) => void; emitPolledChange: (eventType: PollingChangeEventType, relativePath: string) => void;
isOwnerActive: () => boolean; isOwnerActive: () => boolean;
isWatchLimitError: (error: unknown) => boolean; isWatchLimitError: (error: unknown) => boolean;
@ -34,6 +42,7 @@ export class CrossPlatformFileChangeSource {
private pollingGenerationInProgress: number | null = null; private pollingGenerationInProgress: number | null = null;
private pollingPrimed = false; private pollingPrimed = false;
private pollSnapshot = new Map<string, string>(); private pollSnapshot = new Map<string, string>();
private partialPollSnapshot = new Map<string, string>();
private closedGeneration: number | null = null; private closedGeneration: number | null = null;
private rejectedGeneration: number | null = null; private rejectedGeneration: number | null = null;
private generation = 0; private generation = 0;
@ -180,6 +189,7 @@ export class CrossPlatformFileChangeSource {
this.pollingGenerationInProgress = null; this.pollingGenerationInProgress = null;
this.pollingPrimed = false; this.pollingPrimed = false;
this.pollSnapshot.clear(); this.pollSnapshot.clear();
this.partialPollSnapshot.clear();
const timer = this.pollingTimer; const timer = this.pollingTimer;
this.pollingTimer = null; this.pollingTimer = null;
@ -265,34 +275,51 @@ export class CrossPlatformFileChangeSource {
} }
private async pollForChanges(expectedGeneration: number): Promise<void> { 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()) { if (expectedGeneration !== this.generation || !this.options.isOwnerActive()) {
return; return;
} }
this.mergePartialPollSnapshot(nextSnapshot.files);
if (!this.pollingPrimed) { if (!this.pollingPrimed) {
logger.info(`${this.options.name} polling baseline captured`); if (nextSnapshot.cycleComplete) {
this.pollSnapshot = nextSnapshot; logger.info(`${this.options.name} polling baseline captured`);
this.pollingPrimed = true; this.pollSnapshot = this.partialPollSnapshot;
this.partialPollSnapshot = new Map();
this.pollingPrimed = true;
}
return; return;
} }
for (const [relativePath, fingerprint] of nextSnapshot) { for (const [relativePath, fingerprint] of nextSnapshot.files) {
const previous = this.pollSnapshot.get(relativePath); const previous = this.pollSnapshot.get(relativePath);
if (previous === undefined) { if (previous === undefined) {
this.options.emitPolledChange('rename', relativePath); this.options.emitPolledChange('rename', relativePath);
} else if (previous !== fingerprint) { } else if (previous !== fingerprint) {
this.options.emitPolledChange('change', relativePath); this.options.emitPolledChange('change', relativePath);
} }
this.pollSnapshot.set(relativePath, fingerprint);
} }
for (const relativePath of this.pollSnapshot.keys()) { if (nextSnapshot.cycleComplete) {
if (!nextSnapshot.has(relativePath)) { const completedSnapshot = this.partialPollSnapshot;
this.options.emitPolledChange('rename', relativePath); 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> { 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 { errorDetector } from '../error/ErrorDetector';
import { ConfigManager } from './ConfigManager'; import { ConfigManager } from './ConfigManager';
import { CrossPlatformFileChangeSource } from './CrossPlatformFileChangeSource'; import {
CrossPlatformFileChangeSource,
type PollSnapshotResult,
} from './CrossPlatformFileChangeSource';
import { type DataCache } from './DataCache'; import { type DataCache } from './DataCache';
import { LocalFileSystemProvider } from './LocalFileSystemProvider'; import { LocalFileSystemProvider } from './LocalFileSystemProvider';
import { type NotificationManager } from './NotificationManager'; import { type NotificationManager } from './NotificationManager';
@ -52,6 +55,10 @@ const WATCHER_RETRY_MS = 2000;
const TEAMS_POLL_INTERVAL_MS = 1000; const TEAMS_POLL_INTERVAL_MS = 1000;
/** Poll interval for task files, which can be much larger than team metadata/inboxes */ /** Poll interval for task files, which can be much larger than team metadata/inboxes */
const TASKS_POLL_INTERVAL_MS = 3000; 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 */ /** Interval for periodic catch-up scan to detect missed fs.watch events */
const CATCH_UP_INTERVAL_MS = 30_000; const CATCH_UP_INTERVAL_MS = 30_000;
/** Only catch-up scan files modified within this window */ /** Only catch-up scan files modified within this window */
@ -106,6 +113,10 @@ export class FileWatcher extends EventEmitter {
private catchUpCursor = 0; private catchUpCursor = 0;
/** Consecutive catch-up stat timeouts per file. */ /** Consecutive catch-up stat timeouts per file. */
private catchUpStatFailures = new Map<string, number>(); 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. */ /** Polling interval for projects fallback and SSH mode. */
private static readonly SSH_POLL_INTERVAL_MS = 3000; private static readonly SSH_POLL_INTERVAL_MS = 3000;
/** Files currently being processed (concurrency guard) */ /** Files currently being processed (concurrency guard) */
@ -302,6 +313,8 @@ export class FileWatcher extends EventEmitter {
this.lastProcessedSize.clear(); this.lastProcessedSize.clear();
this.activeSessionFiles.clear(); this.activeSessionFiles.clear();
this.catchUpStatFailures.clear(); this.catchUpStatFailures.clear();
this.projectsPollCursor = 0;
this.projectsPollCycleChunked = false;
this.processingInProgress.clear(); this.processingInProgress.clear();
this.pendingReprocess.clear(); this.pendingReprocess.clear();
@ -683,15 +696,30 @@ export class FileWatcher extends EventEmitter {
this.changeSources.projects.startPolling(); this.changeSources.projects.startPolling();
} }
private async collectProjectsPollSnapshot(): Promise<Map<string, string>> { private async collectProjectsPollSnapshot(): Promise<PollSnapshotResult> {
const snapshot = new Map<string, string>(); 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 (projectDirs.length === 0) {
if (!projectDir.isDirectory()) { this.projectsPollCursor = 0;
continue; 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 projectPath = path.join(this.projectsPath, projectDir.name);
const entries = await this.readProviderSnapshotDir(projectPath); const entries = await this.readProviderSnapshotDir(projectPath);
for (const entry of entries) { 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>> { 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_RUNTIME_STATUS_IDLE_DELAY_MS = 30_000;
const STARTUP_PROVIDER_STATUS_MIN_DELAY_MS = 2_000; const STARTUP_PROVIDER_STATUS_MIN_DELAY_MS = 2_000;
const STARTUP_PROVIDER_STATUS_MAX_DELAY_MS = 30_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']> = const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet<TeamProvisioningProgress['state']> =
new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']); new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']);
export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout'; 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 cliStatusTimer: ReturnType<typeof setTimeout> | null = null;
let runtimeStatusTimer: ReturnType<typeof setTimeout> | null = null; let runtimeStatusTimer: ReturnType<typeof setTimeout> | null = null;
let deferredProviderStatusCleanup: (() => void) | null = null; let deferredProviderStatusCleanup: (() => void) | null = null;
let deferredGlobalTasksCleanup: (() => void) | null = null;
let disposed = false;
useStore.getState().subscribeProvisioningProgress(); useStore.getState().subscribeProvisioningProgress();
cleanupFns.push(() => { cleanupFns.push(() => {
useStore.getState().unsubscribeProvisioningProgress(); useStore.getState().unsubscribeProvisioningProgress();
@ -286,18 +290,33 @@ export function initializeNotificationListeners(): () => void {
runtimeStatusTimer = null; runtimeStatusTimer = null;
}, STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS); }, 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([ await Promise.all([
useStore.getState().fetchAllTasks(),
useStore.getState().fetchTeams(), useStore.getState().fetchTeams(),
useStore.getState().fetchNotifications(), useStore.getState().fetchNotifications(),
useStore.getState().fetchSchedules(), 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(() => { cleanupFns.push(() => {
disposed = true;
if (cliStatusTimer) clearTimeout(cliStatusTimer); if (cliStatusTimer) clearTimeout(cliStatusTimer);
if (runtimeStatusTimer) clearTimeout(runtimeStatusTimer); if (runtimeStatusTimer) clearTimeout(runtimeStatusTimer);
if (deferredProviderStatusCleanup) deferredProviderStatusCleanup(); if (deferredProviderStatusCleanup) deferredProviderStatusCleanup();
if (deferredGlobalTasksCleanup) deferredGlobalTasksCleanup();
}); });
// TODO(task-change-presence): re-enable this only after the board uses a bounded // 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 // batch/priority presence pipeline. The old one-task-per-tick poll was accurate

View file

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

View file

@ -595,4 +595,126 @@ describe('CrossPlatformFileChangeSource', () => {
source.stop(); source.stop();
active = false; 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(); 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 () => { it('treats SSH not-found subagent directories as empty during project polling', async () => {
const projectsDir = '/remote/projects'; const projectsDir = '/remote/projects';
const todosDir = '/remote/todos'; 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(); 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 () => { it('allows next refresh after throttle window passes', async () => {
const state = useStore.getState(); const state = useStore.getState();
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');