diff --git a/src/main/index.ts b/src/main/index.ts index b8d9a205..e7aae28b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { // 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 { }); 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 { skillsMutationService, skillsWatcherService, crossTeamService, - teamBackupService ?? undefined + teamBackupService ?? undefined, + launchIoGovernor ?? undefined ); registerCodexAccountIpc(ipcMain, codexAccountFeature); registerRecentProjectsIpc(ipcMain, recentProjectsFeature); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index b7636889..da77da67 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 80bf626b..49002931 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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 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 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) { diff --git a/src/main/services/team/LaunchIoGovernor.ts b/src/main/services/team/LaunchIoGovernor.ts new file mode 100644 index 00000000..c7fafa1e --- /dev/null +++ b/src/main/services/team/LaunchIoGovernor.ts @@ -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 = (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 { + value: T; + cachedAt: number; +} + +interface OperationState { + key: LaunchIoGovernorOperationKey; + cache: CachedValue | null; + dirty: boolean; + generation: number; + inFlight: Promise | null; + loadFresh: (() => Promise) | null; + clone: CloneFn | null; + scheduledRefresh: ReturnType | 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(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(); + private readonly operations = new Map< + LaunchIoGovernorOperationKey, + OperationState + >(); + 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( + key: LaunchIoGovernorOperationKey, + loadFresh: () => Promise, + options: { clone: CloneFn } + ): Promise { + const state = this.getOperationState(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( + key: LaunchIoGovernorOperationKey + ): OperationState { + return { + key, + cache: null, + dirty: false, + generation: 0, + inFlight: null, + loadFresh: null, + clone: null, + scheduledRefresh: null, + lastWarningAt: Number.NEGATIVE_INFINITY, + }; + } + + private getOperationState( + key: LaunchIoGovernorOperationKey + ): OperationState { + const state = this.operations.get(key); + if (!state) { + throw new Error(`Unknown launch IO governor operation: ${key}`); + } + return state as unknown as OperationState; + } + + private canServeStale(state: OperationState): boolean { + const now = this.now(); + if (!this.hasLaunchPressure(now) || !state.cache) { + return false; + } + return now - state.cache.cachedAt <= this.maxStaleAgeMs; + } + + private async runFresh( + key: LaunchIoGovernorOperationKey, + state: OperationState, + background: boolean + ): Promise { + 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( + key: LaunchIoGovernorOperationKey, + state: OperationState, + 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 { + 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( + key: LaunchIoGovernorOperationKey, + state: OperationState, + 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; + } +} diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index acbbf9ff..ae41c0da 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -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 => ({ 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([ { diff --git a/test/main/services/team/LaunchIoGovernor.test.ts b/test/main/services/team/LaunchIoGovernor.test.ts new file mode 100644 index 00000000..1b989282 --- /dev/null +++ b/test/main/services/team/LaunchIoGovernor.test.ts @@ -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(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function flushMicrotasks(): Promise { + 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(); + 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(); + 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')]); + }); +});