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,
|
type TeamReconcileTrigger,
|
||||||
} from './services/team/TeamReconcileDrainScheduler';
|
} from './services/team/TeamReconcileDrainScheduler';
|
||||||
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
||||||
|
import { LaunchIoGovernor } from './services/team/LaunchIoGovernor';
|
||||||
import { getAppIconPath } from './utils/appIcon';
|
import { getAppIconPath } from './utils/appIcon';
|
||||||
import {
|
import {
|
||||||
getClaudeBasePath,
|
getClaudeBasePath,
|
||||||
|
|
@ -590,6 +591,7 @@ let runtimeProviderManagementFeature: RuntimeProviderManagementFeatureFacade;
|
||||||
let memberWorkSyncFeature: MemberWorkSyncFeatureFacade | null = null;
|
let memberWorkSyncFeature: MemberWorkSyncFeatureFacade | null = null;
|
||||||
let teamDataService: TeamDataService;
|
let teamDataService: TeamDataService;
|
||||||
let teamProvisioningService: TeamProvisioningService;
|
let teamProvisioningService: TeamProvisioningService;
|
||||||
|
let launchIoGovernor: LaunchIoGovernor | null = null;
|
||||||
let cliInstallerService: CliInstallerService;
|
let cliInstallerService: CliInstallerService;
|
||||||
let ptyTerminalService: PtyTerminalService;
|
let ptyTerminalService: PtyTerminalService;
|
||||||
let httpServer: HttpServer;
|
let httpServer: HttpServer;
|
||||||
|
|
@ -826,6 +828,7 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return;
|
if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return;
|
||||||
const teamName = row.teamName.trim();
|
const teamName = row.teamName.trim();
|
||||||
const detail = typeof row.detail === 'string' ? row.detail : '';
|
const detail = typeof row.detail === 'string' ? row.detail : '';
|
||||||
|
launchIoGovernor?.noteTeamChange(row as TeamChangeEvent);
|
||||||
memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent);
|
memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent);
|
||||||
|
|
||||||
if (row.type === 'config') {
|
if (row.type === 'config') {
|
||||||
|
|
@ -1070,6 +1073,10 @@ async function initializeServices(): Promise<void> {
|
||||||
// Set notification manager on local context's file watcher
|
// Set notification manager on local context's file watcher
|
||||||
localContext.fileWatcher.setNotificationManager(notificationManager);
|
localContext.fileWatcher.setNotificationManager(notificationManager);
|
||||||
|
|
||||||
|
launchIoGovernor = new LaunchIoGovernor({
|
||||||
|
logger: createLogger('Service:LaunchIoGovernor'),
|
||||||
|
});
|
||||||
|
|
||||||
// Wire file watcher events for local context
|
// Wire file watcher events for local context
|
||||||
wireFileWatcherEvents(localContext);
|
wireFileWatcherEvents(localContext);
|
||||||
|
|
||||||
|
|
@ -1240,6 +1247,7 @@ async function initializeServices(): Promise<void> {
|
||||||
});
|
});
|
||||||
|
|
||||||
const forwardTeamChange = (event: TeamChangeEvent): void => {
|
const forwardTeamChange = (event: TeamChangeEvent): void => {
|
||||||
|
launchIoGovernor?.noteTeamChange(event);
|
||||||
if (event.type === 'config') {
|
if (event.type === 'config') {
|
||||||
if (event.detail === 'config.json') {
|
if (event.detail === 'config.json') {
|
||||||
TeamConfigReader.invalidateTeam(event.teamName);
|
TeamConfigReader.invalidateTeam(event.teamName);
|
||||||
|
|
@ -1437,7 +1445,8 @@ async function initializeServices(): Promise<void> {
|
||||||
skillsMutationService,
|
skillsMutationService,
|
||||||
skillsWatcherService,
|
skillsWatcherService,
|
||||||
crossTeamService,
|
crossTeamService,
|
||||||
teamBackupService ?? undefined
|
teamBackupService ?? undefined,
|
||||||
|
launchIoGovernor ?? undefined
|
||||||
);
|
);
|
||||||
registerCodexAccountIpc(ipcMain, codexAccountFeature);
|
registerCodexAccountIpc(ipcMain, codexAccountFeature);
|
||||||
registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
|
registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ import type { McpHealthDiagnosticsService } from '../services/extensions/state/M
|
||||||
import type { HttpServer } from '../services/infrastructure/HttpServer';
|
import type { HttpServer } from '../services/infrastructure/HttpServer';
|
||||||
import type { SchedulerService } from '../services/schedule/SchedulerService';
|
import type { SchedulerService } from '../services/schedule/SchedulerService';
|
||||||
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
||||||
|
import type { LaunchIoGovernor } from '../services/team/LaunchIoGovernor';
|
||||||
import type { TeamBackupService } from '../services/team/TeamBackupService';
|
import type { TeamBackupService } from '../services/team/TeamBackupService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -169,7 +170,8 @@ export function initializeIpcHandlers(
|
||||||
skillsMutationService?: SkillsMutationService,
|
skillsMutationService?: SkillsMutationService,
|
||||||
skillsWatcherService?: SkillsWatcherService,
|
skillsWatcherService?: SkillsWatcherService,
|
||||||
crossTeamService?: CrossTeamService,
|
crossTeamService?: CrossTeamService,
|
||||||
teamBackupService?: TeamBackupService
|
teamBackupService?: TeamBackupService,
|
||||||
|
launchIoGovernor?: LaunchIoGovernor
|
||||||
): void {
|
): void {
|
||||||
// Initialize domain handlers with registry
|
// Initialize domain handlers with registry
|
||||||
initializeProjectHandlers(registry);
|
initializeProjectHandlers(registry);
|
||||||
|
|
@ -192,7 +194,8 @@ export function initializeIpcHandlers(
|
||||||
boardTaskActivityDetailService,
|
boardTaskActivityDetailService,
|
||||||
boardTaskLogStreamService,
|
boardTaskLogStreamService,
|
||||||
boardTaskExactLogsService,
|
boardTaskExactLogsService,
|
||||||
boardTaskExactLogDetailService
|
boardTaskExactLogDetailService,
|
||||||
|
launchIoGovernor
|
||||||
);
|
);
|
||||||
initializeConfigHandlers({
|
initializeConfigHandlers({
|
||||||
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,10 @@ import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
||||||
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
||||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||||
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
|
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
|
||||||
|
import {
|
||||||
|
cloneLaunchIoGovernorPayload,
|
||||||
|
type LaunchIoGovernor,
|
||||||
|
} from '../services/team/LaunchIoGovernor';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
validateFromField,
|
validateFromField,
|
||||||
|
|
@ -512,6 +516,7 @@ let teamBackupService: TeamBackupService | null = null;
|
||||||
let teammateToolTracker: TeammateToolTracker | null = null;
|
let teammateToolTracker: TeammateToolTracker | null = null;
|
||||||
let teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
let teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
||||||
let branchStatusService: BranchStatusService | null = null;
|
let branchStatusService: BranchStatusService | null = null;
|
||||||
|
let launchIoGovernor: LaunchIoGovernor | null = null;
|
||||||
let boardTaskActivityService: BoardTaskActivityService | null = null;
|
let boardTaskActivityService: BoardTaskActivityService | null = null;
|
||||||
let boardTaskActivityDetailService: BoardTaskActivityDetailService | null = null;
|
let boardTaskActivityDetailService: BoardTaskActivityDetailService | null = null;
|
||||||
let boardTaskLogStreamService: BoardTaskLogStreamService | null = null;
|
let boardTaskLogStreamService: BoardTaskLogStreamService | null = null;
|
||||||
|
|
@ -564,7 +569,8 @@ export function initializeTeamHandlers(
|
||||||
taskActivityDetailService?: BoardTaskActivityDetailService,
|
taskActivityDetailService?: BoardTaskActivityDetailService,
|
||||||
taskLogStreamService?: BoardTaskLogStreamService,
|
taskLogStreamService?: BoardTaskLogStreamService,
|
||||||
taskExactLogsService?: BoardTaskExactLogsService,
|
taskExactLogsService?: BoardTaskExactLogsService,
|
||||||
taskExactLogDetailService?: BoardTaskExactLogDetailService
|
taskExactLogDetailService?: BoardTaskExactLogDetailService,
|
||||||
|
ioGovernor?: LaunchIoGovernor
|
||||||
): void {
|
): void {
|
||||||
teamDataService = service;
|
teamDataService = service;
|
||||||
teamProvisioningService = provisioningService;
|
teamProvisioningService = provisioningService;
|
||||||
|
|
@ -575,6 +581,7 @@ export function initializeTeamHandlers(
|
||||||
teammateToolTracker = toolTracker ?? null;
|
teammateToolTracker = toolTracker ?? null;
|
||||||
teamLogSourceTracker = logSourceTracker ?? null;
|
teamLogSourceTracker = logSourceTracker ?? null;
|
||||||
branchStatusService = branchTracker ?? null;
|
branchStatusService = branchTracker ?? null;
|
||||||
|
launchIoGovernor = ioGovernor ?? null;
|
||||||
boardTaskActivityService = taskActivityService ?? null;
|
boardTaskActivityService = taskActivityService ?? null;
|
||||||
boardTaskActivityDetailService = taskActivityDetailService ?? null;
|
boardTaskActivityDetailService = taskActivityDetailService ?? null;
|
||||||
boardTaskLogStreamService = taskLogStreamService ?? null;
|
boardTaskLogStreamService = taskLogStreamService ?? null;
|
||||||
|
|
@ -896,7 +903,14 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<Te
|
||||||
setCurrentMainOp('team:list');
|
setCurrentMainOp('team:list');
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
try {
|
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 {
|
} finally {
|
||||||
const ms = Date.now() - startedAt;
|
const ms = Date.now() - startedAt;
|
||||||
if (ms >= 1500) {
|
if (ms >= 1500) {
|
||||||
|
|
@ -1851,6 +1865,21 @@ function sendProvisioningProgress(
|
||||||
safeSendToRenderer(targetWindow, TEAM_PROVISIONING_PROGRESS, progress);
|
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(
|
async function handleCreateTeam(
|
||||||
event: IpcMainInvokeEvent,
|
event: IpcMainInvokeEvent,
|
||||||
request: unknown
|
request: unknown
|
||||||
|
|
@ -1861,11 +1890,18 @@ async function handleCreateTeam(
|
||||||
}
|
}
|
||||||
const progressTargetWindow = BrowserWindow.fromWebContents(event.sender);
|
const progressTargetWindow = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
|
||||||
return wrapTeamHandler('create', () => {
|
return wrapTeamHandler('create', async () => {
|
||||||
addMainBreadcrumb('team', 'create', { teamName: validation.value.teamName });
|
addMainBreadcrumb('team', 'create', { teamName: validation.value.teamName });
|
||||||
return getTeamProvisioningService().createTeam(validation.value, (progress) => {
|
launchIoGovernor?.noteLaunchIntent(validation.value.teamName, 'create');
|
||||||
sendProvisioningProgress(progressTargetWindow, progress);
|
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,
|
members: savedRequest.members,
|
||||||
};
|
};
|
||||||
|
|
||||||
return wrapTeamHandler('create', () =>
|
return wrapTeamHandler('create', async () => {
|
||||||
getTeamProvisioningService().createTeam(createRequest, (progress) => {
|
launchIoGovernor?.noteLaunchIntent(tn, 'draft-launch');
|
||||||
sendProvisioningProgress(progressTargetWindow, progress);
|
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);
|
const persistedMeta = await teamMetaStore.getMeta(tn).catch(() => null);
|
||||||
|
|
@ -2040,33 +2083,41 @@ async function handleLaunchTeam(
|
||||||
const launchLimitContext =
|
const launchLimitContext =
|
||||||
typeof payload.limitContext === 'boolean' ? payload.limitContext : persistedMeta?.limitContext;
|
typeof payload.limitContext === 'boolean' ? payload.limitContext : persistedMeta?.limitContext;
|
||||||
|
|
||||||
return wrapTeamHandler('launch', () => {
|
return wrapTeamHandler('launch', async () => {
|
||||||
addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! });
|
addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! });
|
||||||
return getTeamProvisioningService().launchTeam(
|
launchIoGovernor?.noteLaunchIntent(validatedTeamName.value!, 'launch');
|
||||||
{
|
try {
|
||||||
teamName: validatedTeamName.value!,
|
return await getTeamProvisioningService().launchTeam(
|
||||||
cwd,
|
{
|
||||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
teamName: validatedTeamName.value!,
|
||||||
providerId: launchProviderId,
|
cwd,
|
||||||
providerBackendId: launchProviderBackendValidation.value,
|
prompt:
|
||||||
model: rawLaunchModel,
|
typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||||
effort: effortValidation.value,
|
providerId: launchProviderId,
|
||||||
fastMode: fastModeValidation.value,
|
providerBackendId: launchProviderBackendValidation.value,
|
||||||
limitContext: launchLimitContext,
|
model: rawLaunchModel,
|
||||||
clearContext: payload.clearContext === true ? true : undefined,
|
effort: effortValidation.value,
|
||||||
skipPermissions:
|
fastMode: fastModeValidation.value,
|
||||||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
limitContext: launchLimitContext,
|
||||||
worktree:
|
clearContext: payload.clearContext === true ? true : undefined,
|
||||||
typeof payload.worktree === 'string' ? payload.worktree.trim() || undefined : undefined,
|
skipPermissions:
|
||||||
extraCliArgs:
|
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||||||
typeof payload.extraCliArgs === 'string'
|
worktree:
|
||||||
? payload.extraCliArgs.trim() || undefined
|
typeof payload.worktree === 'string' ? payload.worktree.trim() || undefined : undefined,
|
||||||
: undefined,
|
extraCliArgs:
|
||||||
},
|
typeof payload.extraCliArgs === 'string'
|
||||||
(progress) => {
|
? payload.extraCliArgs.trim() || undefined
|
||||||
sendProvisioningProgress(progressTargetWindow, progress);
|
: 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');
|
setCurrentMainOp('team:getAllTasks');
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
try {
|
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 {
|
} finally {
|
||||||
const ms = Date.now() - startedAt;
|
const ms = Date.now() - startedAt;
|
||||||
if (ms >= 1500) {
|
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,
|
removeTeamHandlers,
|
||||||
} from '../../../src/main/ipc/teams';
|
} from '../../../src/main/ipc/teams';
|
||||||
import { ConfigManager } from '../../../src/main/services/infrastructure/ConfigManager';
|
import { ConfigManager } from '../../../src/main/services/infrastructure/ConfigManager';
|
||||||
|
import { LaunchIoGovernor } from '../../../src/main/services/team/LaunchIoGovernor';
|
||||||
import { getAppDataPath } from '../../../src/main/utils/pathDecoder';
|
import { getAppDataPath } from '../../../src/main/utils/pathDecoder';
|
||||||
|
|
||||||
describe('ipc teams handlers', () => {
|
describe('ipc teams handlers', () => {
|
||||||
|
|
@ -169,6 +170,7 @@ describe('ipc teams handlers', () => {
|
||||||
handlers.delete(channel);
|
handlers.delete(channel);
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
let launchIoGovernor: LaunchIoGovernor;
|
||||||
|
|
||||||
const service = {
|
const service = {
|
||||||
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
|
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
|
||||||
|
|
@ -187,6 +189,7 @@ describe('ipc teams handlers', () => {
|
||||||
feedRevision: 'rev-1',
|
feedRevision: 'rev-1',
|
||||||
messages: [] as InboxMessage[],
|
messages: [] as InboxMessage[],
|
||||||
})),
|
})),
|
||||||
|
getAllTasks: vi.fn(async () => [{ id: 'task-1', teamName: 'my-team', subject: 'Task 1' }]),
|
||||||
getMessagesPage: vi.fn(
|
getMessagesPage: vi.fn(
|
||||||
async (..._args: unknown[]): Promise<MessagesPage> => ({
|
async (..._args: unknown[]): Promise<MessagesPage> => ({
|
||||||
messages: [] as InboxMessage[],
|
messages: [] as InboxMessage[],
|
||||||
|
|
@ -322,6 +325,12 @@ describe('ipc teams handlers', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
handlers.clear();
|
handlers.clear();
|
||||||
vi.clearAllMocks();
|
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.mockReset();
|
||||||
mockGetMembersMeta.mockResolvedValue([]);
|
mockGetMembersMeta.mockResolvedValue([]);
|
||||||
mockGetMembersMetaFile.mockReset();
|
mockGetMembersMetaFile.mockReset();
|
||||||
|
|
@ -339,6 +348,7 @@ describe('ipc teams handlers', () => {
|
||||||
mockTeamDataWorkerClient.findLogsForTask.mockReset();
|
mockTeamDataWorkerClient.findLogsForTask.mockReset();
|
||||||
mockTeamDataWorkerClient.invalidateTeamConfig.mockReset();
|
mockTeamDataWorkerClient.invalidateTeamConfig.mockReset();
|
||||||
mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset();
|
mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset();
|
||||||
|
launchIoGovernor = new LaunchIoGovernor({ quietWindowMs: 100 });
|
||||||
initializeTeamHandlers(
|
initializeTeamHandlers(
|
||||||
service as never,
|
service as never,
|
||||||
provisioningService as never,
|
provisioningService as never,
|
||||||
|
|
@ -352,12 +362,14 @@ describe('ipc teams handlers', () => {
|
||||||
boardTaskActivityDetailService as never,
|
boardTaskActivityDetailService as never,
|
||||||
boardTaskLogStreamService as never,
|
boardTaskLogStreamService as never,
|
||||||
boardTaskExactLogsService as never,
|
boardTaskExactLogsService as never,
|
||||||
boardTaskExactLogDetailService as never
|
boardTaskExactLogDetailService as never,
|
||||||
|
launchIoGovernor
|
||||||
);
|
);
|
||||||
registerTeamHandlers(ipcMain as never);
|
registerTeamHandlers(ipcMain as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
launchIoGovernor.clearForTests();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
setClaudeBasePathOverride(null);
|
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 () => {
|
it('keeps TEAM_GET_DATA structural and does not expose message transport', async () => {
|
||||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
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