perf: add launch IO governor for team summaries

This commit is contained in:
777genius 2026-05-02 22:14:08 +03:00
parent a652c44794
commit b187bbcdd0
6 changed files with 977 additions and 42 deletions

View file

@ -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);

View file

@ -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,

View file

@ -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) {

View 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;
}
}

View file

@ -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([
{

View 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')]);
});
});