perf: add launch IO governor for team summaries
This commit is contained in:
parent
a652c44794
commit
b187bbcdd0
6 changed files with 977 additions and 42 deletions
|
|
@ -144,6 +144,7 @@ import {
|
|||
type TeamReconcileTrigger,
|
||||
} from './services/team/TeamReconcileDrainScheduler';
|
||||
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
||||
import { LaunchIoGovernor } from './services/team/LaunchIoGovernor';
|
||||
import { getAppIconPath } from './utils/appIcon';
|
||||
import {
|
||||
getClaudeBasePath,
|
||||
|
|
@ -590,6 +591,7 @@ let runtimeProviderManagementFeature: RuntimeProviderManagementFeatureFacade;
|
|||
let memberWorkSyncFeature: MemberWorkSyncFeatureFacade | null = null;
|
||||
let teamDataService: TeamDataService;
|
||||
let teamProvisioningService: TeamProvisioningService;
|
||||
let launchIoGovernor: LaunchIoGovernor | null = null;
|
||||
let cliInstallerService: CliInstallerService;
|
||||
let ptyTerminalService: PtyTerminalService;
|
||||
let httpServer: HttpServer;
|
||||
|
|
@ -826,6 +828,7 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return;
|
||||
const teamName = row.teamName.trim();
|
||||
const detail = typeof row.detail === 'string' ? row.detail : '';
|
||||
launchIoGovernor?.noteTeamChange(row as TeamChangeEvent);
|
||||
memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent);
|
||||
|
||||
if (row.type === 'config') {
|
||||
|
|
@ -1070,6 +1073,10 @@ async function initializeServices(): Promise<void> {
|
|||
// Set notification manager on local context's file watcher
|
||||
localContext.fileWatcher.setNotificationManager(notificationManager);
|
||||
|
||||
launchIoGovernor = new LaunchIoGovernor({
|
||||
logger: createLogger('Service:LaunchIoGovernor'),
|
||||
});
|
||||
|
||||
// Wire file watcher events for local context
|
||||
wireFileWatcherEvents(localContext);
|
||||
|
||||
|
|
@ -1240,6 +1247,7 @@ async function initializeServices(): Promise<void> {
|
|||
});
|
||||
|
||||
const forwardTeamChange = (event: TeamChangeEvent): void => {
|
||||
launchIoGovernor?.noteTeamChange(event);
|
||||
if (event.type === 'config') {
|
||||
if (event.detail === 'config.json') {
|
||||
TeamConfigReader.invalidateTeam(event.teamName);
|
||||
|
|
@ -1437,7 +1445,8 @@ async function initializeServices(): Promise<void> {
|
|||
skillsMutationService,
|
||||
skillsWatcherService,
|
||||
crossTeamService,
|
||||
teamBackupService ?? undefined
|
||||
teamBackupService ?? undefined,
|
||||
launchIoGovernor ?? undefined
|
||||
);
|
||||
registerCodexAccountIpc(ipcMain, codexAccountFeature);
|
||||
registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ import type { McpHealthDiagnosticsService } from '../services/extensions/state/M
|
|||
import type { HttpServer } from '../services/infrastructure/HttpServer';
|
||||
import type { SchedulerService } from '../services/schedule/SchedulerService';
|
||||
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
||||
import type { LaunchIoGovernor } from '../services/team/LaunchIoGovernor';
|
||||
import type { TeamBackupService } from '../services/team/TeamBackupService';
|
||||
|
||||
/**
|
||||
|
|
@ -169,7 +170,8 @@ export function initializeIpcHandlers(
|
|||
skillsMutationService?: SkillsMutationService,
|
||||
skillsWatcherService?: SkillsWatcherService,
|
||||
crossTeamService?: CrossTeamService,
|
||||
teamBackupService?: TeamBackupService
|
||||
teamBackupService?: TeamBackupService,
|
||||
launchIoGovernor?: LaunchIoGovernor
|
||||
): void {
|
||||
// Initialize domain handlers with registry
|
||||
initializeProjectHandlers(registry);
|
||||
|
|
@ -192,7 +194,8 @@ export function initializeIpcHandlers(
|
|||
boardTaskActivityDetailService,
|
||||
boardTaskLogStreamService,
|
||||
boardTaskExactLogsService,
|
||||
boardTaskExactLogDetailService
|
||||
boardTaskExactLogDetailService,
|
||||
launchIoGovernor
|
||||
);
|
||||
initializeConfigHandlers({
|
||||
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
||||
|
|
|
|||
|
|
@ -135,6 +135,10 @@ import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
|||
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
|
||||
import {
|
||||
cloneLaunchIoGovernorPayload,
|
||||
type LaunchIoGovernor,
|
||||
} from '../services/team/LaunchIoGovernor';
|
||||
|
||||
import {
|
||||
validateFromField,
|
||||
|
|
@ -512,6 +516,7 @@ let teamBackupService: TeamBackupService | null = null;
|
|||
let teammateToolTracker: TeammateToolTracker | null = null;
|
||||
let teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
||||
let branchStatusService: BranchStatusService | null = null;
|
||||
let launchIoGovernor: LaunchIoGovernor | null = null;
|
||||
let boardTaskActivityService: BoardTaskActivityService | null = null;
|
||||
let boardTaskActivityDetailService: BoardTaskActivityDetailService | null = null;
|
||||
let boardTaskLogStreamService: BoardTaskLogStreamService | null = null;
|
||||
|
|
@ -564,7 +569,8 @@ export function initializeTeamHandlers(
|
|||
taskActivityDetailService?: BoardTaskActivityDetailService,
|
||||
taskLogStreamService?: BoardTaskLogStreamService,
|
||||
taskExactLogsService?: BoardTaskExactLogsService,
|
||||
taskExactLogDetailService?: BoardTaskExactLogDetailService
|
||||
taskExactLogDetailService?: BoardTaskExactLogDetailService,
|
||||
ioGovernor?: LaunchIoGovernor
|
||||
): void {
|
||||
teamDataService = service;
|
||||
teamProvisioningService = provisioningService;
|
||||
|
|
@ -575,6 +581,7 @@ export function initializeTeamHandlers(
|
|||
teammateToolTracker = toolTracker ?? null;
|
||||
teamLogSourceTracker = logSourceTracker ?? null;
|
||||
branchStatusService = branchTracker ?? null;
|
||||
launchIoGovernor = ioGovernor ?? null;
|
||||
boardTaskActivityService = taskActivityService ?? null;
|
||||
boardTaskActivityDetailService = taskActivityDetailService ?? null;
|
||||
boardTaskLogStreamService = taskLogStreamService ?? null;
|
||||
|
|
@ -896,7 +903,14 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<Te
|
|||
setCurrentMainOp('team:list');
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
return await wrapTeamHandler('list', () => getTeamDataService().listTeams());
|
||||
return await wrapTeamHandler('list', () => {
|
||||
const loadFresh = () => getTeamDataService().listTeams();
|
||||
return launchIoGovernor
|
||||
? launchIoGovernor.runSummaryOperation('teams:list', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
})
|
||||
: loadFresh();
|
||||
});
|
||||
} finally {
|
||||
const ms = Date.now() - startedAt;
|
||||
if (ms >= 1500) {
|
||||
|
|
@ -1851,6 +1865,21 @@ function sendProvisioningProgress(
|
|||
safeSendToRenderer(targetWindow, TEAM_PROVISIONING_PROGRESS, progress);
|
||||
}
|
||||
|
||||
function noteLaunchIntentFailed(teamName: string, source: string): void {
|
||||
if (!launchIoGovernor) {
|
||||
return;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
launchIoGovernor.noteProvisioningProgress({
|
||||
runId: `${source}:failed-before-progress`,
|
||||
teamName,
|
||||
state: 'failed',
|
||||
message: 'Launch failed before provisioning progress',
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
} as TeamProvisioningProgress);
|
||||
}
|
||||
|
||||
async function handleCreateTeam(
|
||||
event: IpcMainInvokeEvent,
|
||||
request: unknown
|
||||
|
|
@ -1861,11 +1890,18 @@ async function handleCreateTeam(
|
|||
}
|
||||
const progressTargetWindow = BrowserWindow.fromWebContents(event.sender);
|
||||
|
||||
return wrapTeamHandler('create', () => {
|
||||
return wrapTeamHandler('create', async () => {
|
||||
addMainBreadcrumb('team', 'create', { teamName: validation.value.teamName });
|
||||
return getTeamProvisioningService().createTeam(validation.value, (progress) => {
|
||||
sendProvisioningProgress(progressTargetWindow, progress);
|
||||
});
|
||||
launchIoGovernor?.noteLaunchIntent(validation.value.teamName, 'create');
|
||||
try {
|
||||
return await getTeamProvisioningService().createTeam(validation.value, (progress) => {
|
||||
launchIoGovernor?.noteProvisioningProgress(progress);
|
||||
sendProvisioningProgress(progressTargetWindow, progress);
|
||||
});
|
||||
} catch (error) {
|
||||
noteLaunchIntentFailed(validation.value.teamName, 'create');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1998,11 +2034,18 @@ async function handleLaunchTeam(
|
|||
members: savedRequest.members,
|
||||
};
|
||||
|
||||
return wrapTeamHandler('create', () =>
|
||||
getTeamProvisioningService().createTeam(createRequest, (progress) => {
|
||||
sendProvisioningProgress(progressTargetWindow, progress);
|
||||
})
|
||||
);
|
||||
return wrapTeamHandler('create', async () => {
|
||||
launchIoGovernor?.noteLaunchIntent(tn, 'draft-launch');
|
||||
try {
|
||||
return await getTeamProvisioningService().createTeam(createRequest, (progress) => {
|
||||
launchIoGovernor?.noteProvisioningProgress(progress);
|
||||
sendProvisioningProgress(progressTargetWindow, progress);
|
||||
});
|
||||
} catch (error) {
|
||||
noteLaunchIntentFailed(tn, 'draft-launch');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const persistedMeta = await teamMetaStore.getMeta(tn).catch(() => null);
|
||||
|
|
@ -2040,33 +2083,41 @@ async function handleLaunchTeam(
|
|||
const launchLimitContext =
|
||||
typeof payload.limitContext === 'boolean' ? payload.limitContext : persistedMeta?.limitContext;
|
||||
|
||||
return wrapTeamHandler('launch', () => {
|
||||
return wrapTeamHandler('launch', async () => {
|
||||
addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! });
|
||||
return getTeamProvisioningService().launchTeam(
|
||||
{
|
||||
teamName: validatedTeamName.value!,
|
||||
cwd,
|
||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||
providerId: launchProviderId,
|
||||
providerBackendId: launchProviderBackendValidation.value,
|
||||
model: rawLaunchModel,
|
||||
effort: effortValidation.value,
|
||||
fastMode: fastModeValidation.value,
|
||||
limitContext: launchLimitContext,
|
||||
clearContext: payload.clearContext === true ? true : undefined,
|
||||
skipPermissions:
|
||||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||||
worktree:
|
||||
typeof payload.worktree === 'string' ? payload.worktree.trim() || undefined : undefined,
|
||||
extraCliArgs:
|
||||
typeof payload.extraCliArgs === 'string'
|
||||
? payload.extraCliArgs.trim() || undefined
|
||||
: undefined,
|
||||
},
|
||||
(progress) => {
|
||||
sendProvisioningProgress(progressTargetWindow, progress);
|
||||
}
|
||||
);
|
||||
launchIoGovernor?.noteLaunchIntent(validatedTeamName.value!, 'launch');
|
||||
try {
|
||||
return await getTeamProvisioningService().launchTeam(
|
||||
{
|
||||
teamName: validatedTeamName.value!,
|
||||
cwd,
|
||||
prompt:
|
||||
typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||
providerId: launchProviderId,
|
||||
providerBackendId: launchProviderBackendValidation.value,
|
||||
model: rawLaunchModel,
|
||||
effort: effortValidation.value,
|
||||
fastMode: fastModeValidation.value,
|
||||
limitContext: launchLimitContext,
|
||||
clearContext: payload.clearContext === true ? true : undefined,
|
||||
skipPermissions:
|
||||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||||
worktree:
|
||||
typeof payload.worktree === 'string' ? payload.worktree.trim() || undefined : undefined,
|
||||
extraCliArgs:
|
||||
typeof payload.extraCliArgs === 'string'
|
||||
? payload.extraCliArgs.trim() || undefined
|
||||
: undefined,
|
||||
},
|
||||
(progress) => {
|
||||
launchIoGovernor?.noteProvisioningProgress(progress);
|
||||
sendProvisioningProgress(progressTargetWindow, progress);
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
noteLaunchIntentFailed(validatedTeamName.value!, 'launch');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3786,7 +3837,14 @@ async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise<IpcResult<
|
|||
setCurrentMainOp('team:getAllTasks');
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
return await wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks());
|
||||
return await wrapTeamHandler('getAllTasks', () => {
|
||||
const loadFresh = () => getTeamDataService().getAllTasks();
|
||||
return launchIoGovernor
|
||||
? launchIoGovernor.runSummaryOperation('teams:getAllTasks', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
})
|
||||
: loadFresh();
|
||||
});
|
||||
} finally {
|
||||
const ms = Date.now() - startedAt;
|
||||
if (ms >= 1500) {
|
||||
|
|
|
|||
366
src/main/services/team/LaunchIoGovernor.ts
Normal file
366
src/main/services/team/LaunchIoGovernor.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
import type {
|
||||
GlobalTask,
|
||||
TeamChangeEvent,
|
||||
TeamProvisioningProgress,
|
||||
TeamSummary,
|
||||
} from '@shared/types';
|
||||
|
||||
export type LaunchIoGovernorOperationKey = 'teams:list' | 'teams:getAllTasks';
|
||||
|
||||
type GovernedPayload = TeamSummary[] | GlobalTask[];
|
||||
type CloneFn<T> = (value: T) => T;
|
||||
|
||||
interface LaunchIoGovernorLogger {
|
||||
debug?: (message: string) => void;
|
||||
warn?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface LaunchIoGovernorOptions {
|
||||
quietWindowMs?: number;
|
||||
maxStaleAgeMs?: number;
|
||||
stuckLaunchPressureMs?: number;
|
||||
warningCooldownMs?: number;
|
||||
now?: () => number;
|
||||
logger?: LaunchIoGovernorLogger;
|
||||
}
|
||||
|
||||
interface ActiveLaunch {
|
||||
teamName: string;
|
||||
source: string;
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface CachedValue<T> {
|
||||
value: T;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
interface OperationState<T> {
|
||||
key: LaunchIoGovernorOperationKey;
|
||||
cache: CachedValue<T> | null;
|
||||
dirty: boolean;
|
||||
generation: number;
|
||||
inFlight: Promise<T> | null;
|
||||
loadFresh: (() => Promise<T>) | null;
|
||||
clone: CloneFn<T> | null;
|
||||
scheduledRefresh: ReturnType<typeof setTimeout> | null;
|
||||
lastWarningAt: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_LAUNCH_IO_QUIET_WINDOW_MS = 3_000;
|
||||
export const DEFAULT_LAUNCH_IO_MAX_STALE_AGE_MS = 15_000;
|
||||
export const DEFAULT_LAUNCH_IO_STUCK_PRESSURE_MS = 10 * 60_000;
|
||||
const DEFAULT_WARNING_COOLDOWN_MS = 10_000;
|
||||
|
||||
const TERMINAL_PROVISIONING_STATES = new Set(['ready', 'failed', 'cancelled', 'disconnected']);
|
||||
|
||||
export function cloneLaunchIoGovernorPayload<T extends GovernedPayload>(value: T): T {
|
||||
if (typeof structuredClone === 'function') {
|
||||
return structuredClone(value);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
export class LaunchIoGovernor {
|
||||
private readonly quietWindowMs: number;
|
||||
private readonly maxStaleAgeMs: number;
|
||||
private readonly stuckLaunchPressureMs: number;
|
||||
private readonly warningCooldownMs: number;
|
||||
private readonly now: () => number;
|
||||
private readonly logger: LaunchIoGovernorLogger;
|
||||
private readonly activeLaunches = new Map<string, ActiveLaunch>();
|
||||
private readonly operations = new Map<
|
||||
LaunchIoGovernorOperationKey,
|
||||
OperationState<GovernedPayload>
|
||||
>();
|
||||
private quietUntil = 0;
|
||||
|
||||
constructor(options: LaunchIoGovernorOptions = {}) {
|
||||
this.quietWindowMs = options.quietWindowMs ?? DEFAULT_LAUNCH_IO_QUIET_WINDOW_MS;
|
||||
this.maxStaleAgeMs = options.maxStaleAgeMs ?? DEFAULT_LAUNCH_IO_MAX_STALE_AGE_MS;
|
||||
this.stuckLaunchPressureMs =
|
||||
options.stuckLaunchPressureMs ?? DEFAULT_LAUNCH_IO_STUCK_PRESSURE_MS;
|
||||
this.warningCooldownMs = options.warningCooldownMs ?? DEFAULT_WARNING_COOLDOWN_MS;
|
||||
this.now = options.now ?? (() => Date.now());
|
||||
this.logger = options.logger ?? {};
|
||||
this.operations.set('teams:list', this.createOperationState('teams:list'));
|
||||
this.operations.set('teams:getAllTasks', this.createOperationState('teams:getAllTasks'));
|
||||
}
|
||||
|
||||
noteLaunchIntent(teamName: string, source = 'unknown'): void {
|
||||
const normalized = this.normalizeTeamName(teamName);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const now = this.now();
|
||||
this.pruneStuckLaunches(now);
|
||||
this.activeLaunches.set(normalized, {
|
||||
teamName: normalized,
|
||||
source,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
this.markDirty('teams:list');
|
||||
this.scheduleDirtyRefreshes(false);
|
||||
}
|
||||
|
||||
noteProvisioningProgress(progress: TeamProvisioningProgress): void {
|
||||
const teamName = this.normalizeTeamName(progress.teamName);
|
||||
if (!teamName) {
|
||||
return;
|
||||
}
|
||||
const now = this.now();
|
||||
this.pruneStuckLaunches(now);
|
||||
this.markDirty('teams:list');
|
||||
|
||||
if (TERMINAL_PROVISIONING_STATES.has(String(progress.state))) {
|
||||
this.activeLaunches.delete(teamName);
|
||||
this.quietUntil = Math.max(this.quietUntil, now + this.quietWindowMs);
|
||||
this.scheduleDirtyRefreshes(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = this.activeLaunches.get(teamName);
|
||||
this.activeLaunches.set(teamName, {
|
||||
teamName,
|
||||
source: existing?.source ?? 'progress',
|
||||
startedAt: existing?.startedAt ?? now,
|
||||
updatedAt: now,
|
||||
});
|
||||
this.scheduleDirtyRefreshes(false);
|
||||
}
|
||||
|
||||
noteTeamChange(event: TeamChangeEvent): void {
|
||||
if (event.type === 'config') {
|
||||
this.markDirty('teams:list');
|
||||
this.markDirty('teams:getAllTasks');
|
||||
} else if (event.type === 'task') {
|
||||
this.markDirty('teams:getAllTasks');
|
||||
}
|
||||
if (this.hasLaunchPressure(this.now())) {
|
||||
this.scheduleDirtyRefreshes(false);
|
||||
}
|
||||
}
|
||||
|
||||
async runSummaryOperation<T extends GovernedPayload>(
|
||||
key: LaunchIoGovernorOperationKey,
|
||||
loadFresh: () => Promise<T>,
|
||||
options: { clone: CloneFn<T> }
|
||||
): Promise<T> {
|
||||
const state = this.getOperationState<T>(key);
|
||||
state.loadFresh = loadFresh;
|
||||
state.clone = options.clone;
|
||||
|
||||
if (this.canServeStale(state)) {
|
||||
if (state.dirty) {
|
||||
this.scheduleDeferredRefresh(key, state, false);
|
||||
}
|
||||
return options.clone(state.cache!.value);
|
||||
}
|
||||
|
||||
return this.runFresh(key, state, false);
|
||||
}
|
||||
|
||||
clearForTests(): void {
|
||||
for (const state of this.operations.values()) {
|
||||
if (state.scheduledRefresh) {
|
||||
clearTimeout(state.scheduledRefresh);
|
||||
}
|
||||
}
|
||||
this.activeLaunches.clear();
|
||||
this.quietUntil = 0;
|
||||
this.operations.clear();
|
||||
this.operations.set('teams:list', this.createOperationState('teams:list'));
|
||||
this.operations.set('teams:getAllTasks', this.createOperationState('teams:getAllTasks'));
|
||||
}
|
||||
|
||||
hasLaunchPressureForTests(): boolean {
|
||||
return this.hasLaunchPressure(this.now());
|
||||
}
|
||||
|
||||
private createOperationState<T extends GovernedPayload>(
|
||||
key: LaunchIoGovernorOperationKey
|
||||
): OperationState<T> {
|
||||
return {
|
||||
key,
|
||||
cache: null,
|
||||
dirty: false,
|
||||
generation: 0,
|
||||
inFlight: null,
|
||||
loadFresh: null,
|
||||
clone: null,
|
||||
scheduledRefresh: null,
|
||||
lastWarningAt: Number.NEGATIVE_INFINITY,
|
||||
};
|
||||
}
|
||||
|
||||
private getOperationState<T extends GovernedPayload>(
|
||||
key: LaunchIoGovernorOperationKey
|
||||
): OperationState<T> {
|
||||
const state = this.operations.get(key);
|
||||
if (!state) {
|
||||
throw new Error(`Unknown launch IO governor operation: ${key}`);
|
||||
}
|
||||
return state as unknown as OperationState<T>;
|
||||
}
|
||||
|
||||
private canServeStale<T extends GovernedPayload>(state: OperationState<T>): boolean {
|
||||
const now = this.now();
|
||||
if (!this.hasLaunchPressure(now) || !state.cache) {
|
||||
return false;
|
||||
}
|
||||
return now - state.cache.cachedAt <= this.maxStaleAgeMs;
|
||||
}
|
||||
|
||||
private async runFresh<T extends GovernedPayload>(
|
||||
key: LaunchIoGovernorOperationKey,
|
||||
state: OperationState<T>,
|
||||
background: boolean
|
||||
): Promise<T> {
|
||||
if (!state.loadFresh || !state.clone) {
|
||||
throw new Error(`Launch IO governor operation ${key} has no loader`);
|
||||
}
|
||||
|
||||
if (state.inFlight) {
|
||||
try {
|
||||
const joined = await state.inFlight;
|
||||
return state.clone(joined);
|
||||
} catch (error) {
|
||||
if (background) {
|
||||
this.warnRefreshFailure(key, state, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const generationAtStart = state.generation;
|
||||
const loadFresh = state.loadFresh;
|
||||
const clone = state.clone;
|
||||
const promise = loadFresh();
|
||||
state.inFlight = promise;
|
||||
|
||||
try {
|
||||
const fresh = await promise;
|
||||
if (state.generation === generationAtStart) {
|
||||
state.cache = {
|
||||
value: clone(fresh),
|
||||
cachedAt: this.now(),
|
||||
};
|
||||
state.dirty = false;
|
||||
}
|
||||
return clone(fresh);
|
||||
} catch (error) {
|
||||
if (background) {
|
||||
this.warnRefreshFailure(key, state, error);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (state.inFlight === promise) {
|
||||
state.inFlight = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private markDirty(key: LaunchIoGovernorOperationKey): void {
|
||||
const state = this.getOperationState(key);
|
||||
state.dirty = true;
|
||||
state.generation += 1;
|
||||
}
|
||||
|
||||
private scheduleDirtyRefreshes(force: boolean): void {
|
||||
for (const [key, state] of this.operations) {
|
||||
if (state.dirty) {
|
||||
this.scheduleDeferredRefresh(key, state, force);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleDeferredRefresh<T extends GovernedPayload>(
|
||||
key: LaunchIoGovernorOperationKey,
|
||||
state: OperationState<T>,
|
||||
force: boolean
|
||||
): void {
|
||||
if (!state.loadFresh || !state.clone) {
|
||||
return;
|
||||
}
|
||||
if (state.scheduledRefresh) {
|
||||
if (!force) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(state.scheduledRefresh);
|
||||
state.scheduledRefresh = null;
|
||||
}
|
||||
|
||||
const delayMs = this.getDelayUntilFreshAllowed(this.now());
|
||||
state.scheduledRefresh = setTimeout(() => {
|
||||
state.scheduledRefresh = null;
|
||||
void this.flushOperation(key);
|
||||
}, delayMs);
|
||||
state.scheduledRefresh.unref?.();
|
||||
}
|
||||
|
||||
private async flushOperation(key: LaunchIoGovernorOperationKey): Promise<void> {
|
||||
const state = this.getOperationState(key);
|
||||
const now = this.now();
|
||||
if (this.hasLaunchPressure(now)) {
|
||||
this.scheduleDeferredRefresh(key, state, true);
|
||||
return;
|
||||
}
|
||||
if (!state.dirty || !state.loadFresh || !state.clone) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.runFresh(key, state, true);
|
||||
} catch {
|
||||
// runFresh already emitted a bounded warning. Keep dirty=true so the next
|
||||
// request or quiet-window timer can retry without losing the last-good cache.
|
||||
}
|
||||
}
|
||||
|
||||
private getDelayUntilFreshAllowed(now: number): number {
|
||||
this.pruneStuckLaunches(now);
|
||||
if (this.activeLaunches.size > 0) {
|
||||
return this.quietWindowMs;
|
||||
}
|
||||
return Math.max(0, this.quietUntil - now);
|
||||
}
|
||||
|
||||
private hasLaunchPressure(now: number): boolean {
|
||||
this.pruneStuckLaunches(now);
|
||||
return this.activeLaunches.size > 0 || now < this.quietUntil;
|
||||
}
|
||||
|
||||
private pruneStuckLaunches(now: number): void {
|
||||
for (const [teamName, launch] of this.activeLaunches) {
|
||||
if (now - launch.updatedAt > this.stuckLaunchPressureMs) {
|
||||
this.activeLaunches.delete(teamName);
|
||||
this.logger.warn?.(
|
||||
`[LaunchIoGovernor] launch pressure expired team=${teamName} source=${launch.source} ageMs=${now - launch.startedAt}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private warnRefreshFailure<T extends GovernedPayload>(
|
||||
key: LaunchIoGovernorOperationKey,
|
||||
state: OperationState<T>,
|
||||
error: unknown
|
||||
): void {
|
||||
const now = this.now();
|
||||
if (now - state.lastWarningAt < this.warningCooldownMs) {
|
||||
return;
|
||||
}
|
||||
state.lastWarningAt = now;
|
||||
const ageMs = state.cache ? now - state.cache.cachedAt : null;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn?.(
|
||||
`[LaunchIoGovernor] deferred refresh failed op=${key} ageMs=${ageMs ?? 'none'} dirty=${state.dirty} activeLaunchCount=${this.activeLaunches.size} error=${errorMessage}`
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeTeamName(teamName: string | undefined | null): string | null {
|
||||
const normalized = teamName?.trim();
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -157,6 +157,7 @@ import {
|
|||
removeTeamHandlers,
|
||||
} from '../../../src/main/ipc/teams';
|
||||
import { ConfigManager } from '../../../src/main/services/infrastructure/ConfigManager';
|
||||
import { LaunchIoGovernor } from '../../../src/main/services/team/LaunchIoGovernor';
|
||||
import { getAppDataPath } from '../../../src/main/utils/pathDecoder';
|
||||
|
||||
describe('ipc teams handlers', () => {
|
||||
|
|
@ -169,6 +170,7 @@ describe('ipc teams handlers', () => {
|
|||
handlers.delete(channel);
|
||||
}),
|
||||
};
|
||||
let launchIoGovernor: LaunchIoGovernor;
|
||||
|
||||
const service = {
|
||||
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
|
||||
|
|
@ -187,6 +189,7 @@ describe('ipc teams handlers', () => {
|
|||
feedRevision: 'rev-1',
|
||||
messages: [] as InboxMessage[],
|
||||
})),
|
||||
getAllTasks: vi.fn(async () => [{ id: 'task-1', teamName: 'my-team', subject: 'Task 1' }]),
|
||||
getMessagesPage: vi.fn(
|
||||
async (..._args: unknown[]): Promise<MessagesPage> => ({
|
||||
messages: [] as InboxMessage[],
|
||||
|
|
@ -322,6 +325,12 @@ describe('ipc teams handlers', () => {
|
|||
beforeEach(() => {
|
||||
handlers.clear();
|
||||
vi.clearAllMocks();
|
||||
service.listTeams.mockReset();
|
||||
service.getAllTasks.mockReset();
|
||||
service.listTeams.mockResolvedValue([{ teamName: 'my-team', displayName: 'My Team' }]);
|
||||
service.getAllTasks.mockResolvedValue([
|
||||
{ id: 'task-1', teamName: 'my-team', subject: 'Task 1' },
|
||||
]);
|
||||
mockGetMembersMeta.mockReset();
|
||||
mockGetMembersMeta.mockResolvedValue([]);
|
||||
mockGetMembersMetaFile.mockReset();
|
||||
|
|
@ -339,6 +348,7 @@ describe('ipc teams handlers', () => {
|
|||
mockTeamDataWorkerClient.findLogsForTask.mockReset();
|
||||
mockTeamDataWorkerClient.invalidateTeamConfig.mockReset();
|
||||
mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset();
|
||||
launchIoGovernor = new LaunchIoGovernor({ quietWindowMs: 100 });
|
||||
initializeTeamHandlers(
|
||||
service as never,
|
||||
provisioningService as never,
|
||||
|
|
@ -352,12 +362,14 @@ describe('ipc teams handlers', () => {
|
|||
boardTaskActivityDetailService as never,
|
||||
boardTaskLogStreamService as never,
|
||||
boardTaskExactLogsService as never,
|
||||
boardTaskExactLogDetailService as never
|
||||
boardTaskExactLogDetailService as never,
|
||||
launchIoGovernor
|
||||
);
|
||||
registerTeamHandlers(ipcMain as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
launchIoGovernor.clearForTests();
|
||||
vi.useRealTimers();
|
||||
setClaudeBasePathOverride(null);
|
||||
});
|
||||
|
|
@ -1071,6 +1083,155 @@ describe('ipc teams handlers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('returns cached TEAM_LIST data under active launch pressure without starting another scan', async () => {
|
||||
const first = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
||||
success: boolean;
|
||||
data: { teamName: string }[];
|
||||
};
|
||||
expect(first.success).toBe(true);
|
||||
expect(first.data).toEqual([{ teamName: 'my-team', displayName: 'My Team' }]);
|
||||
|
||||
service.listTeams.mockResolvedValueOnce([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
|
||||
launchIoGovernor.noteLaunchIntent('my-team', 'test');
|
||||
|
||||
const second = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
||||
success: boolean;
|
||||
data: { teamName: string }[];
|
||||
};
|
||||
|
||||
expect(second.success).toBe(true);
|
||||
expect(second.data).toEqual([{ teamName: 'my-team', displayName: 'My Team' }]);
|
||||
expect(service.listTeams).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns cached TEAM_GET_ALL_TASKS data under active launch pressure without starting another scan', async () => {
|
||||
const first = (await handlers.get(TEAM_GET_ALL_TASKS)!({} as never)) as {
|
||||
success: boolean;
|
||||
data: { id: string }[];
|
||||
};
|
||||
expect(first.success).toBe(true);
|
||||
expect(first.data).toEqual([{ id: 'task-1', teamName: 'my-team', subject: 'Task 1' }]);
|
||||
|
||||
service.getAllTasks.mockResolvedValueOnce([
|
||||
{ id: 'task-2', teamName: 'my-team', subject: 'Task 2' },
|
||||
]);
|
||||
launchIoGovernor.noteLaunchIntent('my-team', 'test');
|
||||
|
||||
const second = (await handlers.get(TEAM_GET_ALL_TASKS)!({} as never)) as {
|
||||
success: boolean;
|
||||
data: { id: string }[];
|
||||
};
|
||||
|
||||
expect(second.success).toBe(true);
|
||||
expect(second.data).toEqual([{ id: 'task-1', teamName: 'my-team', subject: 'Task 1' }]);
|
||||
expect(service.getAllTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps current fresh behavior for TEAM_LIST when launch pressure has no cached data', async () => {
|
||||
launchIoGovernor.clearForTests();
|
||||
launchIoGovernor.noteLaunchIntent('my-team', 'test');
|
||||
|
||||
const result = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
||||
success: boolean;
|
||||
data: { teamName: string }[];
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual([{ teamName: 'my-team', displayName: 'My Team' }]);
|
||||
expect(service.listTeams).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('flushes TEAM_LIST once after terminal provisioning progress quiet window', async () => {
|
||||
vi.useFakeTimers();
|
||||
const first = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
||||
success: boolean;
|
||||
};
|
||||
expect(first.success).toBe(true);
|
||||
|
||||
service.listTeams.mockResolvedValue([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
|
||||
launchIoGovernor.noteLaunchIntent('my-team', 'test');
|
||||
await handlers.get(TEAM_LIST)!({} as never);
|
||||
launchIoGovernor.noteProvisioningProgress({
|
||||
runId: 'run-1',
|
||||
teamName: 'my-team',
|
||||
state: 'ready',
|
||||
message: 'ready',
|
||||
startedAt: '2026-05-02T00:00:00.000Z',
|
||||
updatedAt: '2026-05-02T00:00:00.000Z',
|
||||
} as TeamProvisioningProgress);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await flushMicrotasks();
|
||||
expect(service.listTeams).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not let provisioning status polling activate launch IO stale mode', async () => {
|
||||
const first = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
||||
success: boolean;
|
||||
data: { teamName: string }[];
|
||||
};
|
||||
expect(first.success).toBe(true);
|
||||
|
||||
service.listTeams.mockResolvedValueOnce([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
|
||||
const status = (await handlers.get(TEAM_PROVISIONING_STATUS)!({} as never, 'run-1')) as {
|
||||
success: boolean;
|
||||
};
|
||||
expect(status.success).toBe(true);
|
||||
|
||||
const second = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
||||
success: boolean;
|
||||
data: { teamName: string }[];
|
||||
};
|
||||
expect(second.success).toBe(true);
|
||||
expect(second.data).toEqual([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
|
||||
expect(service.listTeams).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('clears launch IO pressure when create fails before first provisioning progress', async () => {
|
||||
vi.useFakeTimers();
|
||||
const first = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
||||
success: boolean;
|
||||
};
|
||||
expect(first.success).toBe(true);
|
||||
provisioningService.createTeam.mockRejectedValueOnce(new Error('bootstrap failed early'));
|
||||
service.listTeams
|
||||
.mockResolvedValueOnce([{ teamName: 'background-fresh', displayName: 'Background Fresh' }])
|
||||
.mockResolvedValueOnce([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
|
||||
|
||||
const createResult = (await handlers.get(TEAM_CREATE)!(
|
||||
{ sender: { send: vi.fn() } } as never,
|
||||
{
|
||||
teamName: 'my-team',
|
||||
members: [{ name: 'alice' }],
|
||||
cwd: os.tmpdir(),
|
||||
}
|
||||
)) as { success: boolean };
|
||||
expect(createResult.success).toBe(false);
|
||||
vi.mocked(console.error).mockClear();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await flushMicrotasks();
|
||||
const second = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
||||
success: boolean;
|
||||
data: { teamName: string }[];
|
||||
};
|
||||
expect(second.success).toBe(true);
|
||||
expect(second.data).toEqual([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
|
||||
expect(service.listTeams).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('does not route TEAM_GET_MESSAGES_PAGE through the launch IO governor', async () => {
|
||||
launchIoGovernor.noteLaunchIntent('my-team', 'test');
|
||||
|
||||
const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', {
|
||||
limit: 50,
|
||||
})) as { success: boolean; data?: { feedRevision: string } };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.feedRevision).toBe('rev-1');
|
||||
expect(service.getMessagesPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps TEAM_GET_DATA structural and does not expose message transport', async () => {
|
||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||
{
|
||||
|
|
|
|||
338
test/main/services/team/LaunchIoGovernor.test.ts
Normal file
338
test/main/services/team/LaunchIoGovernor.test.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
cloneLaunchIoGovernorPayload,
|
||||
LaunchIoGovernor,
|
||||
} from '../../../../src/main/services/team/LaunchIoGovernor';
|
||||
import type { GlobalTask, TeamProvisioningProgress, TeamSummary } from '../../../../src/shared/types';
|
||||
|
||||
function team(teamName: string): TeamSummary {
|
||||
return { teamName, displayName: teamName } as TeamSummary;
|
||||
}
|
||||
|
||||
function task(id: string): GlobalTask {
|
||||
return { id, teamName: 'team-a', subject: id } as GlobalTask;
|
||||
}
|
||||
|
||||
function progress(teamName: string, state: string): TeamProvisioningProgress {
|
||||
return {
|
||||
runId: `run-${teamName}`,
|
||||
teamName,
|
||||
state,
|
||||
message: state,
|
||||
startedAt: '2026-05-02T00:00:00.000Z',
|
||||
updatedAt: '2026-05-02T00:00:00.000Z',
|
||||
} as TeamProvisioningProgress;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('LaunchIoGovernor', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('runs fresh and caches success when there is no launch pressure', async () => {
|
||||
const governor = new LaunchIoGovernor();
|
||||
const loadFresh = vi.fn(async () => [team('fresh')]);
|
||||
|
||||
const result = await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
|
||||
expect(result).toEqual([team('fresh')]);
|
||||
expect(loadFresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns bounded stale cache under active launch pressure and schedules no duplicate fresh read', async () => {
|
||||
vi.useFakeTimers();
|
||||
let now = 0;
|
||||
const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100 });
|
||||
const loadFresh = vi.fn(async () => [team('old')]);
|
||||
|
||||
await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
loadFresh.mockResolvedValue([team('new')]);
|
||||
|
||||
governor.noteLaunchIntent('team-a', 'launch');
|
||||
const result = await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
|
||||
expect(result).toEqual([team('old')]);
|
||||
expect(loadFresh).toHaveBeenCalledTimes(1);
|
||||
|
||||
now += 99;
|
||||
await vi.advanceTimersByTimeAsync(99);
|
||||
expect(loadFresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('isolates cached payload from caller-side mutations', async () => {
|
||||
const governor = new LaunchIoGovernor();
|
||||
const loadFresh = vi.fn(async () => [team('old')]);
|
||||
|
||||
const first = await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
first[0]!.displayName = 'mutated';
|
||||
|
||||
governor.noteLaunchIntent('team-a', 'launch');
|
||||
const second = await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
|
||||
expect(second).toEqual([team('old')]);
|
||||
expect(loadFresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('runs one fresh read and coalesces callers when pressure has no cache', async () => {
|
||||
const governor = new LaunchIoGovernor();
|
||||
const deferred = createDeferred<TeamSummary[]>();
|
||||
const loadFresh = vi.fn(() => deferred.promise);
|
||||
|
||||
governor.noteLaunchIntent('team-a', 'launch');
|
||||
const first = governor.runSummaryOperation('teams:list', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
const second = governor.runSummaryOperation('teams:list', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
|
||||
expect(loadFresh).toHaveBeenCalledTimes(1);
|
||||
deferred.resolve([team('fresh')]);
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([[team('fresh')], [team('fresh')]]);
|
||||
});
|
||||
|
||||
it('does not serve cache beyond max stale age during launch pressure', async () => {
|
||||
let now = 0;
|
||||
const governor = new LaunchIoGovernor({ now: () => now, maxStaleAgeMs: 100 });
|
||||
const loadFresh = vi.fn(async () => [team('old')]);
|
||||
|
||||
await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
now = 101;
|
||||
loadFresh.mockResolvedValue([team('new')]);
|
||||
governor.noteLaunchIntent('team-a', 'launch');
|
||||
|
||||
await expect(
|
||||
governor.runSummaryOperation('teams:list', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
})
|
||||
).resolves.toEqual([team('new')]);
|
||||
expect(loadFresh).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not cache an in-flight result when a dirty generation arrives before it resolves', async () => {
|
||||
const governor = new LaunchIoGovernor();
|
||||
const deferred = createDeferred<TeamSummary[]>();
|
||||
const loadFresh = vi.fn(() => deferred.promise);
|
||||
|
||||
const first = governor.runSummaryOperation('teams:list', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
governor.noteLaunchIntent('team-a', 'launch');
|
||||
deferred.resolve([team('stale-inflight')]);
|
||||
await expect(first).resolves.toEqual([team('stale-inflight')]);
|
||||
|
||||
loadFresh.mockResolvedValue([team('fresh-after-dirty')]);
|
||||
await expect(
|
||||
governor.runSummaryOperation('teams:list', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
})
|
||||
).resolves.toEqual([team('fresh-after-dirty')]);
|
||||
expect(loadFresh).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('marks config and task changes dirty for the correct summary operations', async () => {
|
||||
const governor = new LaunchIoGovernor();
|
||||
const loadTeams = vi.fn(async () => [team('team-old')]);
|
||||
const loadTasks = vi.fn(async () => [task('task-old')]);
|
||||
|
||||
await governor.runSummaryOperation('teams:list', loadTeams, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
await governor.runSummaryOperation('teams:getAllTasks', loadTasks, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
|
||||
loadTeams.mockResolvedValue([team('team-new')]);
|
||||
loadTasks.mockResolvedValue([task('task-new')]);
|
||||
governor.noteLaunchIntent('team-a', 'launch');
|
||||
governor.noteTeamChange({ type: 'task', teamName: 'team-a', detail: 'task.json' });
|
||||
|
||||
await expect(
|
||||
governor.runSummaryOperation('teams:list', loadTeams, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
})
|
||||
).resolves.toEqual([team('team-old')]);
|
||||
await expect(
|
||||
governor.runSummaryOperation('teams:getAllTasks', loadTasks, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
})
|
||||
).resolves.toEqual([task('task-old')]);
|
||||
expect(loadTeams).toHaveBeenCalledTimes(1);
|
||||
expect(loadTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not start background refresh for dirty events outside launch pressure', async () => {
|
||||
vi.useFakeTimers();
|
||||
const governor = new LaunchIoGovernor({ quietWindowMs: 100 });
|
||||
const loadTeams = vi.fn(async () => [team('old')]);
|
||||
|
||||
await governor.runSummaryOperation('teams:list', loadTeams, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
loadTeams.mockResolvedValue([team('new')]);
|
||||
|
||||
governor.noteTeamChange({ type: 'config', teamName: 'team-a', detail: 'config.json' });
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await flushMicrotasks();
|
||||
expect(loadTeams).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(
|
||||
governor.runSummaryOperation('teams:list', loadTeams, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
})
|
||||
).resolves.toEqual([team('new')]);
|
||||
expect(loadTeams).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not mark global tasks dirty from launch intent alone', async () => {
|
||||
vi.useFakeTimers();
|
||||
let now = 0;
|
||||
const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100 });
|
||||
const loadTeams = vi.fn(async () => [team('old-team')]);
|
||||
const loadTasks = vi.fn(async () => [task('old-task')]);
|
||||
|
||||
await governor.runSummaryOperation('teams:list', loadTeams, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
await governor.runSummaryOperation('teams:getAllTasks', loadTasks, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
loadTeams.mockResolvedValue([team('new-team')]);
|
||||
loadTasks.mockResolvedValue([task('new-task')]);
|
||||
|
||||
governor.noteLaunchIntent('team-a', 'launch');
|
||||
await governor.runSummaryOperation('teams:list', loadTeams, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
await governor.runSummaryOperation('teams:getAllTasks', loadTasks, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
governor.noteProvisioningProgress(progress('team-a', 'ready'));
|
||||
|
||||
now += 100;
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(loadTeams).toHaveBeenCalledTimes(2);
|
||||
expect(loadTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps quiet window after terminal progress and flushes dirty cache once timer expires', async () => {
|
||||
vi.useFakeTimers();
|
||||
let now = 0;
|
||||
const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100 });
|
||||
const loadFresh = vi.fn(async () => [team('old')]);
|
||||
const loadTasks = vi.fn(async () => [task('old')]);
|
||||
|
||||
await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
await governor.runSummaryOperation('teams:getAllTasks', loadTasks, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
loadFresh.mockResolvedValue([team('new')]);
|
||||
loadTasks.mockResolvedValue([task('new')]);
|
||||
|
||||
governor.noteLaunchIntent('team-a', 'launch');
|
||||
await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
await governor.runSummaryOperation('teams:getAllTasks', loadTasks, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
governor.noteTeamChange({ type: 'config', teamName: 'team-a', detail: 'config.json' });
|
||||
governor.noteProvisioningProgress(progress('team-a', 'ready'));
|
||||
|
||||
now += 99;
|
||||
await vi.advanceTimersByTimeAsync(99);
|
||||
await flushMicrotasks();
|
||||
expect(loadFresh).toHaveBeenCalledTimes(1);
|
||||
expect(loadTasks).toHaveBeenCalledTimes(1);
|
||||
|
||||
now += 1;
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await flushMicrotasks();
|
||||
expect(loadFresh).toHaveBeenCalledTimes(2);
|
||||
expect(loadTasks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps launch pressure until all concurrent launches reach terminal states', () => {
|
||||
let now = 0;
|
||||
const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100 });
|
||||
|
||||
governor.noteLaunchIntent('team-a', 'launch');
|
||||
governor.noteLaunchIntent('team-b', 'launch');
|
||||
governor.noteProvisioningProgress(progress('team-a', 'failed'));
|
||||
expect(governor.hasLaunchPressureForTests()).toBe(true);
|
||||
|
||||
governor.noteProvisioningProgress(progress('team-b', 'ready'));
|
||||
expect(governor.hasLaunchPressureForTests()).toBe(true);
|
||||
|
||||
now += 100;
|
||||
expect(governor.hasLaunchPressureForTests()).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves old cache and dirty state when a deferred refresh fails', async () => {
|
||||
vi.useFakeTimers();
|
||||
let now = 0;
|
||||
const logger = { warn: vi.fn() };
|
||||
const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100, logger });
|
||||
const loadFresh = vi.fn(async () => [team('old')]);
|
||||
|
||||
await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
loadFresh.mockRejectedValueOnce(new Error('worker timeout'));
|
||||
|
||||
governor.noteLaunchIntent('team-a', 'launch');
|
||||
governor.noteTeamChange({ type: 'config', teamName: 'team-a', detail: 'config.json' });
|
||||
await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
});
|
||||
governor.noteProvisioningProgress(progress('team-a', 'ready'));
|
||||
|
||||
now += 100;
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('deferred refresh failed'));
|
||||
governor.noteLaunchIntent('team-b', 'launch');
|
||||
await expect(
|
||||
governor.runSummaryOperation('teams:list', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
})
|
||||
).resolves.toEqual([team('old')]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue