perf(startup): defer heavy startup work
This commit is contained in:
parent
43afc9f907
commit
a6dd0061a8
10 changed files with 453 additions and 25 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>> {
|
||||
|
|
|
|||
26
src/main/utils/startupTelemetry.ts
Normal file
26
src/main/utils/startupTelemetry.ts
Normal 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`;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
38
test/main/utils/startupTelemetry.test.ts
Normal file
38
test/main/utils/startupTelemetry.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue