From 4385b0c6793ca0051096fc173cb18befb312f095 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 2 May 2026 20:24:46 +0300 Subject: [PATCH 1/5] perf(team): cache team data reads safely --- .../adapters/output/TeamTaskAgendaSource.ts | 2 +- .../createMemberWorkSyncFeature.ts | 14 +- src/main/index.ts | 120 +++++++-- src/main/ipc/teams.ts | 126 ++++++--- .../services/infrastructure/FileWatcher.ts | 3 +- src/main/services/team/CrossTeamService.ts | 59 ++-- src/main/services/team/TeamConfigReader.ts | 182 ++++++++++++- src/main/services/team/TeamDataService.ts | 30 ++- .../services/team/TeamDataWorkerClient.ts | 121 ++++++++- src/main/services/team/TeamFsWorkerClient.ts | 44 +++ .../services/team/TeamMemberLogsFinder.ts | 4 +- .../team/TeamMemberRuntimeAdvisoryService.ts | 2 +- .../services/team/TeamMessageFeedService.ts | 70 ++++- .../services/team/TeamProvisioningService.ts | 94 +++++-- src/main/services/team/TeamTaskReader.ts | 64 ++++- src/main/services/team/teamDataWorkerTypes.ts | 15 +- src/main/workers/team-data-worker.ts | 23 +- src/main/workers/team-fs-worker.ts | 47 +++- src/shared/types/team.ts | 2 + .../main/createMemberWorkSyncFeature.test.ts | 30 ++- test/main/ipc/teams.test.ts | 226 ++++++++++++++++ .../infrastructure/FileWatcher.test.ts | 18 ++ .../services/team/CrossTeamService.test.ts | 64 ++++- .../services/team/TeamConfigReader.test.ts | 255 +++++++++++++++++- .../services/team/TeamDataService.test.ts | 115 +++++++- .../team/TeamDataWorkerClient.test.ts | 91 +++++++ .../team/TeamFsWorker.integration.test.ts | 10 +- .../team/TeamMessageFeedService.test.ts | 79 ++++++ .../team/TeamProvisioningService.test.ts | 36 +++ .../team/TeamProvisioningServiceRelay.test.ts | 11 + .../main/services/team/TeamTaskReader.test.ts | 62 +++++ 31 files changed, 1841 insertions(+), 178 deletions(-) create mode 100644 test/main/services/team/TeamTaskReader.test.ts diff --git a/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts b/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts index e8d79580..b1b01a5a 100644 --- a/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts +++ b/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts @@ -22,7 +22,7 @@ import type { TeamTaskReader } from '@main/services/team/TeamTaskReader'; import type { TeamMember } from '@shared/types'; export interface TeamTaskAgendaSourceDeps { - configReader: TeamConfigReader; + configReader: Pick; taskReader: TeamTaskReader; kanbanManager: TeamKanbanManager; membersMetaStore: TeamMembersMetaStore; diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index abf29074..1d758fc5 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -124,8 +124,18 @@ export function createMemberWorkSyncFeature(deps: { }): MemberWorkSyncFeatureFacade { const clock = new SystemClockAdapter(); const hash = new NodeHashAdapter(); + const configReaderForReadOnlySync = { + listTeams: () => + typeof deps.configReader.listTeams === 'function' + ? deps.configReader.listTeams() + : Promise.resolve([]), + getConfig: (teamName: string) => + typeof deps.configReader.getConfigSnapshot === 'function' + ? deps.configReader.getConfigSnapshot(teamName) + : deps.configReader.getConfig(teamName), + }; const agendaSource = new TeamTaskAgendaSource({ - configReader: deps.configReader, + configReader: configReaderForReadOnlySync, taskReader: deps.taskReader, kanbanManager: deps.kanbanManager, membersMetaStore: deps.membersMetaStore, @@ -150,7 +160,7 @@ export function createMemberWorkSyncFeature(deps: { const runtimeTurnSettledTargetResolver = deps.runtimeTurnSettledTargetResolver ?? new TeamRuntimeTurnSettledTargetResolver({ - teamSource: deps.configReader, + teamSource: configReaderForReadOnlySync, membersMetaStore: deps.membersMetaStore, }); const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths); diff --git a/src/main/index.ts b/src/main/index.ts index 8fe3d823..b8d9a205 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -67,6 +67,7 @@ import { resolveAgentTeamsMcpLaunchSpec, TeamMcpConfigBuilder, } from '@main/services/team/TeamMcpConfigBuilder'; +import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver'; import { killTrackedCliProcesses } from '@main/utils/childProcess'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { @@ -609,6 +610,8 @@ let shutdownComplete = false; const startupTimers = new Set>(); const SHUTDOWN_STEP_TIMEOUT_MS = 5_000; +const STARTUP_RECOVERY_DELAY_MS = 10_000; +const STARTUP_RECOVERY_CONCURRENCY = 1; function isShutdownStarted(): boolean { return shutdownComplete || shutdownPromise !== null; @@ -626,6 +629,23 @@ function scheduleStartupTask(action: () => void, delayMs: number): void { startupTimers.add(timer); } +async function runStartupJobsBounded( + items: readonly T[], + concurrency: number, + run: (item: T) => Promise +): Promise { + const workerCount = Math.max(1, Math.min(concurrency, items.length)); + const workers = Array.from({ length: workerCount }, async (_, workerIndex) => { + for (let index = workerIndex; index < items.length; index += workerCount) { + if (isShutdownStarted()) { + return; + } + await run(items[index]!); + } + }); + await Promise.allSettled(workers); +} + function clearStartupTimers(): void { for (const timer of startupTimers) { clearTimeout(timer); @@ -808,9 +828,18 @@ function wireFileWatcherEvents(context: ServiceContext): void { const detail = typeof row.detail === 'string' ? row.detail : ''; memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent); - if (row.type === 'config' && detail === 'config.json') { - TeamConfigReader.invalidateTeam(teamName); - getTeamDataWorkerClient().invalidateTeamConfig(teamName); + if (row.type === 'config') { + if (detail === 'config.json') { + TeamConfigReader.invalidateTeam(teamName); + getTeamDataWorkerClient().invalidateTeamConfig(teamName); + } else if (detail === 'team.meta.json' || detail === 'members.meta.json') { + TeamConfigReader.invalidateListTeamsCache(); + getTeamDataWorkerClient().invalidateTeamConfig(teamName); + } + } + + if (row.type === 'task') { + TeamTaskReader.invalidateAllTasksCache(); } if ( @@ -818,6 +847,9 @@ function wireFileWatcherEvents(context: ServiceContext): void { (row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config') ) { teamDataService.invalidateMessageFeed(teamName); + if (row.type === 'inbox' || row.type === 'lead-message') { + getTeamDataWorkerClient().invalidateTeamMessageFeed(teamName); + } } // --- Inbox change events: relay to lead + native OS notifications --- @@ -1060,7 +1092,12 @@ async function initializeServices(): Promise { ptyTerminalService = new PtyTerminalService(); const teamMemberLogsFinder = new TeamMemberLogsFinder(); const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder); - const teamTranscriptSourceLocator = new TeamTranscriptSourceLocator(); + const taskLogConfigReader = new TeamConfigReader(); + const teamTranscriptSourceLocator = new TeamTranscriptSourceLocator( + new TeamTranscriptProjectResolver({ + getConfig: (teamName) => taskLogConfigReader.getConfigSnapshot(teamName), + }) + ); teamLogSourceTracker.onLogSourceChange((teamName) => { teamTranscriptSourceLocator.invalidateTeam(teamName); }); @@ -1203,15 +1240,26 @@ async function initializeServices(): Promise { }); const forwardTeamChange = (event: TeamChangeEvent): void => { - if (event.type === 'config' && event.detail === 'config.json') { - TeamConfigReader.invalidateTeam(event.teamName); - getTeamDataWorkerClient().invalidateTeamConfig(event.teamName); + if (event.type === 'config') { + if (event.detail === 'config.json') { + TeamConfigReader.invalidateTeam(event.teamName); + getTeamDataWorkerClient().invalidateTeamConfig(event.teamName); + } else if (event.detail === 'team.meta.json' || event.detail === 'members.meta.json') { + TeamConfigReader.invalidateListTeamsCache(); + getTeamDataWorkerClient().invalidateTeamConfig(event.teamName); + } + } + if (event.type === 'task') { + TeamTaskReader.invalidateAllTasksCache(); } if ( teamDataService && (event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config') ) { teamDataService.invalidateMessageFeed(event.teamName); + if (event.type === 'inbox' || event.type === 'lead-message') { + getTeamDataWorkerClient().invalidateTeamMessageFeed(event.teamName); + } } safeSendToRenderer(mainWindow, TEAM_CHANGE, event); httpServer?.broadcast('team-change', event); @@ -1235,18 +1283,25 @@ async function initializeServices(): Promise { teamLogSourceTracker.onLogSourceChange((teamName) => { teammateToolTracker?.handleLogSourceChange(teamName); }); - void teamDataService - .listTeams() - .then(async (teams) => { - await Promise.all( - teams.map((team) => - teamProvisioningService.scanOpenCodePromptDeliveryWatchdog(team.teamName) - ) + scheduleStartupTask(() => { + void teamDataService + .listTeams() + .then(async (teams) => { + const activeTeamNames = teams + .filter((team) => !team.deletedAt) + .map((team) => team.teamName); + await runStartupJobsBounded( + activeTeamNames, + STARTUP_RECOVERY_CONCURRENCY, + async (teamName) => { + await teamProvisioningService.scanOpenCodePromptDeliveryWatchdog(teamName); + } + ); + }) + .catch((error: unknown) => + logger.warn(`[Init] OpenCode prompt delivery watchdog recovery failed: ${String(error)}`) ); - }) - .catch((error: unknown) => - logger.warn(`[Init] OpenCode prompt delivery watchdog recovery failed: ${String(error)}`) - ); + }, STARTUP_RECOVERY_DELAY_MS); teamTaskStallMonitor.start(); // Allow SchedulerService to push schedule events to renderer @@ -1301,16 +1356,25 @@ async function initializeServices(): Promise { ? memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment(input) : Promise.resolve(null) ); - void teamDataService - .listTeams() - .then(async (teams) => { - const activeTeamNames = teams.filter((team) => !team.deletedAt).map((team) => team.teamName); - await memberWorkSyncFeature?.replayPendingReports(activeTeamNames); - await memberWorkSyncFeature?.enqueueStartupScan(activeTeamNames); - }) - .catch((error: unknown) => - logger.warn(`[Init] Member work sync startup scan failed: ${String(error)}`) - ); + scheduleStartupTask(() => { + void teamDataService + .listTeams() + .then(async (teams) => { + const lifecycleActiveTeamNames = teams + .filter( + (team) => + !team.deletedAt && + (teamProvisioningService.isTeamAlive(team.teamName) || + teamProvisioningService.hasProvisioningRun(team.teamName)) + ) + .map((team) => team.teamName); + await memberWorkSyncFeature?.replayPendingReports(lifecycleActiveTeamNames); + await memberWorkSyncFeature?.enqueueStartupScan(lifecycleActiveTeamNames); + }) + .catch((error: unknown) => + logger.warn(`[Init] Member work sync startup scan failed: ${String(error)}`) + ); + }, STARTUP_RECOVERY_DELAY_MS + 2_000); codexAccountFeature = createCodexAccountFeature({ logger: createLogger('Feature:CodexAccount'), configManager, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index cd160691..80bf626b 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -220,6 +220,7 @@ import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser'; const logger = createLogger('IPC:teams'); const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000; +const TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS = 250; /** * In-memory set of rate-limit message keys already processed. @@ -916,35 +917,53 @@ async function handleGetData( const tn = validated.value!; const startedAt = Date.now(); let data: TeamViewSnapshot; + let dataSource: 'worker' | 'main-fallback' | 'main-unavailable' = 'main-unavailable'; + let workerAvailable = false; setCurrentMainOp('team:getData'); try { // Prefer worker thread to keep main event loop responsive const worker = getTeamDataWorkerClient(); - if (worker.isAvailable()) { + workerAvailable = worker.isAvailable(); + const missingState = await classifyMissingTeamData(tn); + if (missingState === 'provisioning') { + return { success: false, error: 'TEAM_PROVISIONING' }; + } + if (missingState === 'draft') { + return { success: false, error: 'TEAM_DRAFT' }; + } + + if (workerAvailable) { try { data = await worker.getTeamData(tn); + dataSource = 'worker'; } catch (workerErr) { logger.warn( `[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}` ); noteHeavyTeamDataWorkerFallback('teams:getData'); data = await getTeamDataService().getTeamData(tn); + dataSource = 'main-fallback'; } } else { noteHeavyTeamDataWorkerFallback('teams:getData'); data = await getTeamDataService().getTeamData(tn); + dataSource = 'main-unavailable'; } } catch (error) { const message = error instanceof Error ? error.message : String(error); if ( message === `Team not found: ${tn}` && - getTeamProvisioningService().hasProvisioningRun(tn) + getTeamProvisioningService().hasProvisioningRun?.(tn) === true ) { return { success: false, error: 'TEAM_PROVISIONING' }; } // Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate) if (message === `Team not found: ${tn}`) { - const meta = await teamMetaStore.getMeta(tn); + const meta = await withTimeoutValue( + teamMetaStore.getMeta(tn).catch(() => null), + TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS, + null + ); if (meta) { return { success: false, error: 'TEAM_DRAFT' }; } @@ -957,7 +976,9 @@ async function handleGetData( const getDataMs = Date.now() - startedAt; if (getDataMs >= 1500) { - logger.warn(`[teams:getData] slow team=${tn} ms=${getDataMs}`); + logger.warn( + `[teams:getData] slow team=${tn} ms=${getDataMs} source=${dataSource} workerAvailable=${workerAvailable}` + ); } const teamDataService = getTeamDataService(); if (data.processes.some((process) => !process.stoppedAt)) { @@ -1015,6 +1036,33 @@ async function handleGetData( return { success: true, data: { ...data, isAlive } }; } +async function classifyMissingTeamData(teamName: string): Promise<'provisioning' | 'draft' | null> { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + const configExists = await withTimeoutValue( + fs.promises + .access(configPath, fs.constants.F_OK) + .then(() => true) + .catch((error: unknown) => { + const code = typeof error === 'object' && error ? (error as { code?: unknown }).code : null; + return code === 'ENOENT' ? false : null; + }), + TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS, + null + ); + if (configExists !== false) { + return null; + } + if (getTeamProvisioningService().hasProvisioningRun?.(teamName) === true) { + return 'provisioning'; + } + const meta = await withTimeoutValue( + teamMetaStore.getMeta(teamName).catch(() => null), + TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS, + null + ); + return meta ? 'draft' : null; +} + async function handleGetTaskChangePresence( _event: IpcMainInvokeEvent, teamName: unknown @@ -1116,6 +1164,7 @@ async function handleDeleteTeam( getAutoResumeService().cancelPendingAutoResume(validated.value!); await getTeamProvisioningService().stopTeam(validated.value!); await getTeamDataService().deleteTeam(validated.value!); + getTeamDataWorkerClient().invalidateTeamConfig(validated.value!); }); } @@ -1127,7 +1176,10 @@ async function handleRestoreTeam( if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } - return wrapTeamHandler('restoreTeam', () => getTeamDataService().restoreTeam(validated.value!)); + return wrapTeamHandler('restoreTeam', async () => { + await getTeamDataService().restoreTeam(validated.value!); + getTeamDataWorkerClient().invalidateTeamConfig(validated.value!); + }); } async function handlePermanentlyDeleteTeam( @@ -1141,6 +1193,7 @@ async function handlePermanentlyDeleteTeam( return wrapTeamHandler('permanentlyDeleteTeam', async () => { getAutoResumeService().cancelPendingAutoResume(validated.value!); await getTeamDataService().permanentlyDeleteTeam(validated.value!); + getTeamDataWorkerClient().invalidateTeamConfig(validated.value!); // Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/ const appData = getAppDataPath(); await fs.promises @@ -1208,6 +1261,7 @@ async function handleUpdateConfig( } } + getTeamDataWorkerClient().invalidateTeamConfig(tn); return result; }); } @@ -2352,35 +2406,47 @@ async function handleGetMessagesPage( return wrapTeamHandler('getMessagesPage', async () => { let page: MessagesPage; - const notificationContext = await getTeamDataService().getTeamNotificationContext(vTeam.value!); + const teamName = vTeam.value!; + const scanNotifications = (messagesPage: MessagesPage): void => { + const notificationContextPromise: Promise<{ displayName: string; projectPath?: string }> = + getTeamDataService() + .getTeamNotificationContext(teamName) + .catch(() => ({ displayName: teamName })); + void notificationContextPromise + .then((notificationContext) => { + scanTeamMessageNotifications( + messagesPage.messages, + teamName, + notificationContext.displayName, + notificationContext.projectPath + ); + }) + .catch((error: unknown) => { + logger.debug( + `[teams:getMessagesPage] notification scan skipped team=${teamName}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + }); + }; const liveMessages = - cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!) : []; + cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(teamName) : []; if (liveMessages.length > 0) { - page = await getTeamDataService().getMessagesPage(vTeam.value!, { + page = await getTeamDataService().getMessagesPage(teamName, { cursor, limit, liveMessages, }); - scanTeamMessageNotifications( - page.messages, - vTeam.value!, - notificationContext.displayName, - notificationContext.projectPath - ); + scanNotifications(page); return page; } const worker = getTeamDataWorkerClient(); if (worker.isAvailable()) { try { - page = await worker.getMessagesPage(vTeam.value!, { cursor, limit }); - scanTeamMessageNotifications( - page.messages, - vTeam.value!, - notificationContext.displayName, - notificationContext.projectPath - ); + page = await worker.getMessagesPage(teamName, { cursor, limit }); + scanNotifications(page); return page; } catch (workerErr) { logger.warn( @@ -2391,13 +2457,8 @@ async function handleGetMessagesPage( } } noteHeavyTeamDataWorkerFallback('teams:getMessagesPage'); - page = await getTeamDataService().getMessagesPage(vTeam.value!, { cursor, limit }); - scanTeamMessageNotifications( - page.messages, - vTeam.value!, - notificationContext.displayName, - notificationContext.projectPath - ); + page = await getTeamDataService().getMessagesPage(teamName, { cursor, limit }); + scanNotifications(page); return page; }); } @@ -3307,8 +3368,8 @@ async function handleCreateConfig( }); } - return wrapTeamHandler('createConfig', () => - getTeamDataService().createTeamConfig({ + return wrapTeamHandler('createConfig', async () => { + await getTeamDataService().createTeamConfig({ teamName, displayName: payload.displayName?.trim() || undefined, description: payload.description?.trim() || undefined, @@ -3332,8 +3393,9 @@ async function handleCreateConfig( typeof payload.extraCliArgs === 'string' && payload.extraCliArgs.trim() ? payload.extraCliArgs.trim() : undefined, - }) - ); + }); + getTeamDataWorkerClient().invalidateTeamConfig(teamName); + }); } function getTeamMemberLogsFinder(): TeamMemberLogsFinder { diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 1146cf47..e3664fac 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -1008,7 +1008,8 @@ export class FileWatcher extends EventEmitter { if ( relative === 'config.json' || relative === 'kanban-state.json' || - relative === 'team.meta.json' + relative === 'team.meta.json' || + relative === 'members.meta.json' ) { const event: TeamChangeEvent = { type: 'config', diff --git a/src/main/services/team/CrossTeamService.ts b/src/main/services/team/CrossTeamService.ts index f41c4c15..6377deb4 100644 --- a/src/main/services/team/CrossTeamService.ts +++ b/src/main/services/team/CrossTeamService.ts @@ -1,10 +1,9 @@ -import { getClaudeBasePath, getTeamsBasePath } from '@main/utils/pathDecoder'; +import { getClaudeBasePath } from '@main/utils/pathDecoder'; import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, formatCrossTeamText } from '@shared/constants'; import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import * as agentTeamsControllerModule from 'agent-teams-controller'; import { randomUUID } from 'crypto'; -import * as fs from 'fs'; import { buildActionModeAgentBlock } from './actionModeInstructions'; import { CascadeGuard } from './CascadeGuard'; @@ -117,8 +116,11 @@ export class CrossTeamService { throw new Error(`Target team not found: ${toTeam}`); } - // 2. Resolve lead - const leadName = (await this.dataService.getLeadMemberName(toTeam)) ?? 'team-lead'; + // 2. Resolve lead. Reuse the verified target config before falling back to meta storage. + const leadName = + targetConfig.members?.find((m) => isLeadMember(m))?.name?.trim() || + (await this.dataService.getLeadMemberName(toTeam)) || + 'team-lead'; // 3. Format const from = `${fromTeam}.${fromMember}`; @@ -203,39 +205,34 @@ export class CrossTeamService { } async listAvailableTargets(excludeTeam?: string): Promise { - const teamsDir = getTeamsBasePath(); - let entries: string[]; + let teams: Awaited>; try { - entries = await fs.promises.readdir(teamsDir); + teams = await this.dataService.listTeams(); } catch { return []; } - const targets: CrossTeamTarget[] = []; - for (const entry of entries) { - if (excludeTeam && entry === excludeTeam) continue; - if (!TEAM_NAME_PATTERN.test(entry)) continue; - - let config: TeamConfig | null; - try { - config = await this.configReader.getConfig(entry); - } catch { - continue; - } - if (!config || config.deletedAt) continue; - - const lead = config.members?.find((m) => isLeadMember(m)); - - targets.push({ - teamName: entry, - displayName: config.name || entry, - description: config.description, - color: config.color, - leadName: lead?.name, - leadColor: lead?.color, - isOnline: this.provisioning?.isTeamAlive(entry) ?? false, + const targets: CrossTeamTarget[] = teams + .filter((team) => { + if (excludeTeam && team.teamName === excludeTeam) return false; + if (!TEAM_NAME_PATTERN.test(team.teamName)) return false; + return !team.deletedAt && !team.pendingCreate; + }) + .map((team) => { + const summaryLead = + team.leadName || team.leadColor + ? { name: team.leadName, color: team.leadColor } + : team.members?.find((member) => isLeadMember(member)); + return { + teamName: team.teamName, + displayName: team.displayName || team.teamName, + description: team.description, + color: team.color, + ...(summaryLead?.name ? { leadName: summaryLead.name } : {}), + ...(summaryLead?.color ? { leadColor: summaryLead.color } : {}), + isOnline: this.provisioning?.isTeamAlive(team.teamName) ?? false, + }; }); - } return targets.sort((a, b) => { if (a.isOnline && !b.isOnline) return -1; diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index b3060e73..aaf73ca3 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -40,6 +40,7 @@ const PER_TEAM_READ_TIMEOUT_MS = 5_000; const GET_CONFIG_SLOW_READ_WARN_MS = 500; const CONFIG_SNAPSHOT_RECENT_STAT_FAILURE_FALLBACK_MS = 5_000; const COARSE_FS_FULL_VERIFY_MS = 1_500; +const LIST_TEAMS_CACHE_TTL_MS = 5_000; const MAX_SESSION_HISTORY_IN_SUMMARY = 2000; const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200; const MAX_LAUNCH_STATE_BYTES = 32 * 1024; @@ -71,13 +72,32 @@ interface CachedTeamConfig { fullVerifiedAt: number; } +type TeamConfigReadMode = 'verified' | 'snapshot'; + interface ConfigReadTiming { teamName: string; + mode: TeamConfigReadMode; + configPath: string; size: number | null; statMs: number | null; readMs: number | null; parseMs: number | null; totalMs: number; + likelyCause: string; + fingerprintHighResolution: boolean | null; + cacheGeneration: number | null; + currentGeneration: number; + caller: string | null; +} + +interface CachedTeamList { + value: TeamSummary[]; + expiresAt: number; +} + +interface InFlightTeamList { + promise: Promise; + generationAtStart: number; } function normalizeProjectPathCandidate(value: unknown): string | undefined { @@ -197,6 +217,48 @@ function cloneConfig(config: TeamConfig): TeamConfig { return structuredClone(config); } +function cloneTeamSummaries(teams: readonly TeamSummary[]): TeamSummary[] { + return structuredClone([...teams]); +} + +function classifyConfigReadTiming(timing: { + statMs: number | null; + readMs: number | null; + parseMs: number | null; +}): string { + const statMs = timing.statMs ?? 0; + const readMs = timing.readMs ?? 0; + const parseMs = timing.parseMs ?? 0; + if (readMs >= 1_000 && readMs >= statMs * 2 && readMs >= parseMs * 2) { + return 'io_read_slow'; + } + if (statMs >= 1_000 && statMs >= readMs * 2 && statMs >= parseMs * 2) { + return 'io_stat_slow'; + } + if (parseMs >= 500 && parseMs >= readMs && parseMs >= statMs) { + return 'json_parse_slow'; + } + if (statMs + readMs >= 1_000) { + return 'filesystem_pressure'; + } + return 'mixed_or_unknown'; +} + +function captureConfigReadCaller(): string | null { + const stack = new Error().stack?.split('\n').slice(2) ?? []; + const frame = stack.find((line) => { + const normalized = line.trim(); + return ( + normalized.length > 0 && + !normalized.includes('TeamConfigReader.') && + !normalized.includes('TeamConfigReader.ts') && + !normalized.includes('captureConfigReadCaller') && + !normalized.includes('node:internal') + ); + }); + return frame?.trim().slice(0, 240) ?? null; +} + export class TeamConfigReader { private static readonly configCacheByPath = new Map(); private static readonly configReadInFlightByPath = new Map>(); @@ -205,12 +267,18 @@ export class TeamConfigReader { Promise >(); private static readonly configGenerationByPath = new Map(); + private static readonly listTeamsCacheByBasePath = new Map(); + private static readonly listTeamsInFlightByBasePath = new Map(); + private static listTeamsGeneration = 0; static clearCacheForTests(): void { TeamConfigReader.configCacheByPath.clear(); TeamConfigReader.configReadInFlightByPath.clear(); TeamConfigReader.configStatInFlightByPath.clear(); TeamConfigReader.configGenerationByPath.clear(); + TeamConfigReader.listTeamsCacheByBasePath.clear(); + TeamConfigReader.listTeamsInFlightByBasePath.clear(); + TeamConfigReader.listTeamsGeneration = 0; } static invalidateTeam(teamName: string): void { @@ -223,6 +291,17 @@ export class TeamConfigReader { TeamConfigReader.configReadInFlightByPath.delete(configPath); TeamConfigReader.configStatInFlightByPath.delete(configPath); TeamConfigReader.bumpConfigGeneration(configPath); + TeamConfigReader.invalidateListTeamsCache(); + } + + static invalidateListTeamsCache(): void { + TeamConfigReader.listTeamsCacheByBasePath.clear(); + // Do not clear in-flight scans here. Config writes can arrive while a global + // team scan is already running; dropping the in-flight entry starts a second + // full scan over all teams and amplifies launch-time filesystem pressure. + // The generation check below prevents the stale in-flight result from being + // cached after invalidation. + TeamConfigReader.listTeamsGeneration += 1; } private static invalidatePathForGeneration( @@ -245,6 +324,8 @@ export class TeamConfigReader { ): Promise { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); const generation = TeamConfigReader.bumpConfigGeneration(configPath); + TeamConfigReader.configReadInFlightByPath.delete(configPath); + TeamConfigReader.configStatInFlightByPath.delete(configPath); let internalFingerprint: InternalTeamConfigFingerprint | null = null; if (fingerprint) { internalFingerprint = { @@ -259,6 +340,7 @@ export class TeamConfigReader { ); } TeamConfigReader.storeConfigCache(configPath, config, internalFingerprint, true, generation); + TeamConfigReader.invalidateListTeamsCache(); } constructor( @@ -267,6 +349,44 @@ export class TeamConfigReader { ) {} async listTeams(): Promise { + const teamsBasePath = getTeamsBasePath(); + const cached = TeamConfigReader.listTeamsCacheByBasePath.get(teamsBasePath); + if (cached && cached.expiresAt > Date.now()) { + return cloneTeamSummaries(cached.value); + } + + const existingRequest = TeamConfigReader.listTeamsInFlightByBasePath.get(teamsBasePath); + if ( + existingRequest && + existingRequest.generationAtStart === TeamConfigReader.listTeamsGeneration + ) { + return cloneTeamSummaries(await existingRequest.promise); + } + + const request = this.listTeamsUncached(teamsBasePath); + const generationAtStart = TeamConfigReader.listTeamsGeneration; + TeamConfigReader.listTeamsInFlightByBasePath.set(teamsBasePath, { + promise: request, + generationAtStart, + }); + + try { + const teams = await request; + if (TeamConfigReader.listTeamsGeneration === generationAtStart) { + TeamConfigReader.listTeamsCacheByBasePath.set(teamsBasePath, { + value: cloneTeamSummaries(teams), + expiresAt: Date.now() + LIST_TEAMS_CACHE_TTL_MS, + }); + } + return cloneTeamSummaries(teams); + } finally { + if (TeamConfigReader.listTeamsInFlightByBasePath.get(teamsBasePath)?.promise === request) { + TeamConfigReader.listTeamsInFlightByBasePath.delete(teamsBasePath); + } + } + } + + private async listTeamsUncached(teamsBasePath: string): Promise { const worker = getTeamFsWorkerClient(); if (worker.isAvailable()) { const startedAt = Date.now(); @@ -304,7 +424,7 @@ export class TeamConfigReader { } } - const teamsDir = getTeamsBasePath(); + const teamsDir = teamsBasePath; let entries: fs.Dirent[]; try { @@ -413,6 +533,21 @@ export class TeamConfigReader { const expectedTeammateNames = new Set(); const confirmedArtifactNames = new Set(); let metaMembers: TeamMember[] = []; + let leadName: string | undefined; + let leadColor: string | undefined; + + const captureLeadMember = (m: TeamMember, overwrite = false): void => { + if (m.removedAt) return; + if (!isLeadMember(m)) return; + const name = m.name?.trim(); + if (name && (overwrite || !leadName)) { + leadName = name; + } + const colorValue = m.color?.trim(); + if (colorValue && (overwrite || !leadColor)) { + leadColor = colorValue; + } + }; const mergeMember = (m: TeamMember): void => { const name = m.name?.trim(); @@ -437,6 +572,7 @@ export class TeamConfigReader { for (const member of metaMembers) { const name = member.name?.trim(); if (!name) continue; + captureLeadMember(member); // Summary/memberCount should represent teammates (exclude the lead process). if (name === 'user' || isLeadMember(member)) continue; const key = name.toLowerCase(); @@ -462,6 +598,7 @@ export class TeamConfigReader { for (const member of config.members) { if (member && typeof member.name === 'string') { const name = member.name.trim(); + captureLeadMember(member, true); if (name && name !== 'user' && !isLeadMember(member)) { confirmedArtifactNames.add(name); } @@ -537,6 +674,8 @@ export class TeamConfigReader { taskCount: 0, lastActivity: null, ...(members.length > 0 ? { members } : {}), + ...(leadName ? { leadName } : {}), + ...(leadColor ? { leadColor } : {}), ...(color ? { color } : {}), ...(projectPath ? { projectPath } : {}), ...(leadSessionId ? { leadSessionId } : {}), @@ -578,11 +717,21 @@ export class TeamConfigReader { : teamName; let memberCount = 0; + let leadName: string | undefined; + let leadColor: string | undefined; try { - const metaStore = new TeamMembersMetaStore(); - const members = await metaStore.getMembers(teamName); + const members = await this.membersMetaStore.getMembers(teamName); memberCount = members.filter((member) => { const name = member.name?.trim() ?? ''; + if (!member.removedAt && isLeadMember(member)) { + if (name) { + leadName = name; + } + const color = member.color?.trim(); + if (color) { + leadColor = color; + } + } if (!name || name === 'user' || isLeadMember(member)) { return false; } @@ -601,6 +750,8 @@ export class TeamConfigReader { lastActivity: typeof meta.createdAt === 'number' ? new Date(meta.createdAt).toISOString() : null, color: typeof meta.color === 'string' ? meta.color : undefined, + ...(leadName ? { leadName } : {}), + ...(leadColor ? { leadColor } : {}), projectPath: typeof meta.cwd === 'string' ? meta.cwd : undefined, pendingCreate: true, }; @@ -621,7 +772,14 @@ export class TeamConfigReader { } const generation = TeamConfigReader.getConfigGeneration(configPath); - const readPromise = this.readConfigFromDisk(teamName, configPath, null, true, generation); + const readPromise = this.readConfigFromDisk( + teamName, + configPath, + null, + true, + generation, + 'verified' + ); TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise); try { @@ -700,7 +858,8 @@ export class TeamConfigReader { configPath, fingerprint, true, - generation + generation, + 'snapshot' ); TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise); try { @@ -842,21 +1001,31 @@ export class TeamConfigReader { configPath: string, knownFingerprint: InternalTeamConfigFingerprint | null = null, updateCache = false, - cacheGeneration?: number + cacheGeneration?: number, + mode: TeamConfigReadMode = 'verified' ): Promise { const startedAt = performance.now(); + const caller = captureConfigReadCaller(); let size: number | null = null; let statMs: number | null = null; let readMs: number | null = null; let parseMs: number | null = null; + let fingerprintHighResolution: boolean | null = knownFingerprint?.highResolution ?? null; const buildTiming = (): ConfigReadTiming => ({ teamName, + mode, + configPath, size, statMs, readMs, parseMs, totalMs: Math.round(performance.now() - startedAt), + likelyCause: classifyConfigReadTiming({ statMs, readMs, parseMs }), + fingerprintHighResolution, + cacheGeneration: cacheGeneration ?? null, + currentGeneration: TeamConfigReader.getConfigGeneration(configPath), + caller, }); try { @@ -865,6 +1034,7 @@ export class TeamConfigReader { knownFingerprint ?? (await TeamConfigReader.getConfigFingerprint(configPath)); statMs = Math.round(performance.now() - statStartedAt); size = fingerprint?.numericSize ?? null; + fingerprintHighResolution = fingerprint?.highResolution ?? null; // Safety: refuse special files and huge/binary configs if (!fingerprint?.isFile) { diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index b4b09fa5..3a366b93 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -112,7 +112,7 @@ const PROCESS_HEALTH_INTERVAL_MS = 2_000; const TASK_MAP_YIELD_EVERY = 250; const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification'; const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000; -const MEMBER_RUNTIME_ADVISORY_SNAPSHOT_BUDGET_MS = 750; +const MEMBER_RUNTIME_ADVISORY_SNAPSHOT_BUDGET_MS = 250; const MIXED_TEAM_LIVE_MUTATION_BLOCK_MESSAGE = 'Live roster mutation on a running mixed team is not supported in V1. Stop the team, edit the roster, then relaunch.'; @@ -422,6 +422,10 @@ export class TeamDataService { return readConfigForUiSnapshot(this.configReader, teamName); } + private invalidateGlobalTaskProjectionCache(): void { + TeamTaskReader.invalidateAllTasksCache(); + } + private getController(teamName: string): AgentTeamsController { return this.controllerFactory(teamName); } @@ -514,7 +518,7 @@ export class TeamDataService { request.catch(() => { /* background advisory refresh is best-effort */ }); - logger.warn( + logger.debug( `getTeamData team=${teamName} member runtime advisories exceeded ${MEMBER_RUNTIME_ADVISORY_SNAPSHOT_BUDGET_MS}ms budget; continuing without advisories for this snapshot` ); return new Map(); @@ -865,7 +869,7 @@ export class TeamDataService { } async getTaskChangePresence(teamName: string): Promise> { - const config = await this.configReader.getConfig(teamName); + const config = await this.readSnapshotConfig(teamName); if (!config) { throw new Error(`Team not found: ${teamName}`); } @@ -1121,6 +1125,7 @@ export class TeamDataService { const tasksDir = path.join(getTasksBasePath(), teamName); await fs.promises.rm(tasksDir, { recursive: true, force: true }); + TeamTaskReader.invalidateAllTasksCache(); } async getTeamData(teamName: string): Promise { @@ -1915,6 +1920,7 @@ export class TeamDataService { ...(request.promptTaskRefs?.length ? { promptTaskRefs: request.promptTaskRefs } : {}), ...(shouldStart ? { startImmediately: true } : {}), }) as TeamTask; + this.invalidateGlobalTaskProjectionCache(); // Controller's maybeNotifyAssignedOwner skips the lead (owner === lead). // For user-created tasks with startImmediately, ensure the lead also gets notified. @@ -1943,6 +1949,7 @@ export class TeamDataService { } this.getController(teamName).tasks.startTask(taskId, 'user'); + this.invalidateGlobalTaskProjectionCache(); if (task.owner) { try { @@ -1995,6 +2002,7 @@ export class TeamDataService { } this.getController(teamName).tasks.startTask(taskId, 'user'); + this.invalidateGlobalTaskProjectionCache(); if (task.owner) { await this.sendUserTaskStartNotification(teamName, task); @@ -2050,6 +2058,7 @@ export class TeamDataService { actor?: string ): Promise { this.getController(teamName).tasks.setTaskStatus(taskId, status, actor); + this.invalidateGlobalTaskProjectionCache(); } /** @@ -2109,10 +2118,12 @@ export class TeamDataService { async softDeleteTask(teamName: string, taskId: string): Promise { this.getController(teamName).tasks.softDeleteTask(taskId, 'user'); + this.invalidateGlobalTaskProjectionCache(); } async restoreTask(teamName: string, taskId: string): Promise { this.getController(teamName).tasks.restoreTask(taskId, 'user'); + this.invalidateGlobalTaskProjectionCache(); } async getDeletedTasks(teamName: string): Promise { @@ -2121,6 +2132,7 @@ export class TeamDataService { async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise { this.getController(teamName).tasks.setTaskOwner(taskId, owner); + this.invalidateGlobalTaskProjectionCache(); } async updateTaskFields( @@ -2129,6 +2141,7 @@ export class TeamDataService { fields: { subject?: string; description?: string } ): Promise { this.getController(teamName).tasks.updateTaskFields(taskId, fields); + this.invalidateGlobalTaskProjectionCache(); } async addTaskAttachment( @@ -2140,6 +2153,7 @@ export class TeamDataService { taskId, meta as unknown as Record ); + this.invalidateGlobalTaskProjectionCache(); } async removeTaskAttachment( @@ -2148,6 +2162,7 @@ export class TeamDataService { attachmentId: string ): Promise { this.getController(teamName).tasks.removeTaskAttachment(taskId, attachmentId); + this.invalidateGlobalTaskProjectionCache(); } async setTaskNeedsClarification( @@ -2156,6 +2171,7 @@ export class TeamDataService { value: 'lead' | 'user' | null ): Promise { this.getController(teamName).tasks.setNeedsClarification(taskId, value); + this.invalidateGlobalTaskProjectionCache(); } async addTaskRelationship( @@ -2169,6 +2185,7 @@ export class TeamDataService { targetId, type === 'blockedBy' ? 'blocked-by' : type ); + this.invalidateGlobalTaskProjectionCache(); } async removeTaskRelationship( @@ -2182,6 +2199,7 @@ export class TeamDataService { targetId, type === 'blockedBy' ? 'blocked-by' : type ); + this.invalidateGlobalTaskProjectionCache(); } async addTaskComment( @@ -2198,6 +2216,7 @@ export class TeamDataService { attachments, taskRefs, }) as { task?: TeamTask; comment?: TaskComment }; + this.invalidateGlobalTaskProjectionCache(); const comment = addResult.comment ?? ({ @@ -2832,7 +2851,7 @@ export class TeamDataService { async getTeamDisplayName(teamName: string): Promise { try { - const config = await this.configReader.getConfig(teamName); + const config = await this.readSnapshotConfig(teamName); const displayName = config?.name?.trim(); return displayName || teamName; } catch { @@ -2845,7 +2864,7 @@ export class TeamDataService { projectPath?: string; }> { try { - const config = await this.configReader.getConfig(teamName); + const config = await this.readSnapshotConfig(teamName); const displayName = config?.name?.trim() || teamName; const projectPath = typeof config?.projectPath === 'string' && config.projectPath.trim().length > 0 @@ -2943,6 +2962,7 @@ export class TeamDataService { await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, { providerBackendId: request.providerBackendId, }); + TeamConfigReader.invalidateListTeamsCache(); } async reconcileTeamArtifacts( diff --git a/src/main/services/team/TeamDataWorkerClient.ts b/src/main/services/team/TeamDataWorkerClient.ts index b628bd6c..f53259a5 100644 --- a/src/main/services/team/TeamDataWorkerClient.ts +++ b/src/main/services/team/TeamDataWorkerClient.ts @@ -59,16 +59,50 @@ function resolveWorkerPath(): string | null { } interface PendingEntry { - resolve: (v: unknown) => void; + resolve: (v: unknown, diag?: Extract['diag']) => void; reject: (e: Error) => void; } +function summarizeWorkerPayload( + payload: TeamDataWorkerRequest['payload'] +): Record { + if ('taskId' in payload) { + return { + teamName: payload.teamName, + taskId: payload.taskId, + owner: payload.options?.owner, + status: payload.options?.status, + intervals: Array.isArray(payload.options?.intervals) + ? payload.options.intervals.length + : undefined, + since: payload.options?.since, + }; + } + if ('options' in payload) { + return { + teamName: payload.teamName, + cursor: + typeof payload.options.cursor === 'string' + ? payload.options.cursor.slice(0, 24) + : payload.options.cursor, + limit: payload.options.limit, + }; + } + if ('teamName' in payload) { + return { + teamName: payload.teamName, + }; + } + return {}; +} + export class TeamDataWorkerClient { private worker: Worker | null = null; private readonly workerPath: string | null = resolveWorkerPath(); private warnedUnavailable = false; private pending = new Map(); private getTeamDataInFlight = new Map>(); + private getMessagesPageInFlight = new Map>(); private failWorker(worker: Worker, error: Error): void { if (this.worker !== worker) return; @@ -104,7 +138,7 @@ export class TeamDataWorkerClient { if (!entry) return; this.pending.delete(msg.id); if (msg.ok) { - entry.resolve(msg.result); + entry.resolve(msg.result, msg.diag); } else { entry.reject(new Error(msg.error)); } @@ -132,22 +166,45 @@ export class TeamDataWorkerClient { ): Promise { const worker = this.ensureWorker(); const id = makeId(); + const startedAt = Date.now(); + const pendingAtStart = this.pending.size; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { const timeoutError = new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms`); + logger.warn( + `worker call timeout op=${op} ms=${Date.now() - startedAt} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify( + summarizeWorkerPayload(payload) + )}` + ); this.failWorker(worker, timeoutError); worker.terminate().catch(() => undefined); reject(timeoutError); }, WORKER_CALL_TIMEOUT_MS); this.pending.set(id, { - resolve: (value) => { + resolve: (value, diag) => { clearTimeout(timeout); + const ms = Date.now() - startedAt; + if (ms >= 1500) { + logger.warn( + `worker call slow op=${op} ms=${ms} workerTotalMs=${String(diag?.totalMs ?? 'unknown')} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify( + summarizeWorkerPayload(payload) + )}` + ); + } resolve(value); }, reject: (error) => { clearTimeout(timeout); + const ms = Date.now() - startedAt; + if (ms >= 1500) { + logger.warn( + `worker call failed slow op=${op} ms=${ms} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify( + summarizeWorkerPayload(payload) + )} error=${error.message}` + ); + } reject(error); }, }); @@ -156,6 +213,23 @@ export class TeamDataWorkerClient { }); } + private postBestEffort( + op: TeamDataWorkerRequest['op'], + payload: TeamDataWorkerRequest['payload'] + ): void { + const worker = this.worker; + if (!worker) return; + try { + worker.postMessage({ id: makeId(), op, payload } as TeamDataWorkerRequest); + } catch (error) { + logger.debug( + `worker best-effort post failed op=${op} payload=${JSON.stringify( + summarizeWorkerPayload(payload) + )} error=${error instanceof Error ? error.message : String(error)}` + ); + } + } + async getTeamData(teamName: string): Promise { if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName'); const existing = this.getTeamDataInFlight.get(teamName); @@ -175,8 +249,23 @@ export class TeamDataWorkerClient { invalidateTeamConfig(teamName: string): void { if (!SAFE_NAME_RE.test(teamName)) return; this.getTeamDataInFlight.delete(teamName); - if (!this.worker) return; - void this.call('invalidateTeamConfig', { teamName }).catch(() => undefined); + this.clearMessagesPageInFlightForTeam(teamName); + this.postBestEffort('invalidateTeamConfig', { teamName }); + } + + invalidateTeamMessageFeed(teamName: string): void { + if (!SAFE_NAME_RE.test(teamName)) return; + this.clearMessagesPageInFlightForTeam(teamName); + this.postBestEffort('invalidateTeamMessageFeed', { teamName }); + } + + private clearMessagesPageInFlightForTeam(teamName: string): void { + const prefix = `{"teamName":"${teamName}",`; + for (const key of this.getMessagesPageInFlight.keys()) { + if (key.startsWith(prefix)) { + this.getMessagesPageInFlight.delete(key); + } + } } async getMessagesPage( @@ -184,7 +273,26 @@ export class TeamDataWorkerClient { options: { cursor?: string | null; limit: number } ): Promise { if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName'); - return this.call('getMessagesPage', { teamName, options }) as Promise; + const key = JSON.stringify({ + teamName, + cursor: options.cursor ?? null, + limit: options.limit, + }); + const existing = this.getMessagesPageInFlight.get(key); + if (existing) return existing; + + const promise = ( + this.call('getMessagesPage', { + teamName, + options, + }) as Promise + ).finally(() => { + if (this.getMessagesPageInFlight.get(key) === promise) { + this.getMessagesPageInFlight.delete(key); + } + }); + this.getMessagesPageInFlight.set(key, promise); + return promise; } async getMemberActivityMeta(teamName: string): Promise { @@ -213,6 +321,7 @@ export class TeamDataWorkerClient { this.worker?.terminate().catch(() => undefined); this.worker = null; this.getTeamDataInFlight.clear(); + this.getMessagesPageInFlight.clear(); for (const [, entry] of this.pending) { entry.reject(new Error('Client disposed')); } diff --git a/src/main/services/team/TeamFsWorkerClient.ts b/src/main/services/team/TeamFsWorkerClient.ts index 4a2fa090..f2089afd 100644 --- a/src/main/services/team/TeamFsWorkerClient.ts +++ b/src/main/services/team/TeamFsWorkerClient.ts @@ -43,6 +43,27 @@ type WorkerResponse = | { id: string; ok: true; result: unknown; diag?: WorkerDiag } | { id: string; ok: false; error: string }; +function summarizeWorkerPayload(payload: WorkerRequest['payload']): Record { + if ('teamsDir' in payload) { + return { + teamsDir: payload.teamsDir, + concurrency: payload.concurrency, + maxConfigReadMs: payload.maxConfigReadMs, + maxConfigBytes: payload.maxConfigBytes, + }; + } + return { + tasksBase: payload.tasksBase, + concurrency: payload.concurrency, + maxTaskReadMs: payload.maxTaskReadMs, + maxTaskBytes: payload.maxTaskBytes, + }; +} + +function getDiagTotalMs(diag: WorkerDiag | undefined): unknown { + return diag && typeof diag === 'object' ? diag.totalMs : undefined; +} + function makeId(): string { return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`; } @@ -152,6 +173,8 @@ export class TeamFsWorkerClient { ): Promise<{ result: unknown; diag?: WorkerDiag }> { const worker = this.ensureWorker(); const id = makeId(); + const startedAt = Date.now(); + const pendingAtStart = this.pending.size; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pending.delete(id); @@ -163,16 +186,37 @@ export class TeamFsWorkerClient { } finally { this.worker = null; } + logger.warn( + `worker call timeout op=${op} ms=${Date.now() - startedAt} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify( + summarizeWorkerPayload(payload) + )}` + ); reject(new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms (${op})`)); }, WORKER_CALL_TIMEOUT_MS); this.pending.set(id, { resolve: (value) => { clearTimeout(timeout); + const ms = Date.now() - startedAt; + if (ms >= 1500) { + logger.warn( + `worker call slow op=${op} ms=${ms} workerTotalMs=${String(getDiagTotalMs(value.diag))} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify( + summarizeWorkerPayload(payload) + )}` + ); + } resolve(value); }, reject: (error) => { clearTimeout(timeout); + const ms = Date.now() - startedAt; + if (ms >= 1500) { + logger.warn( + `worker call failed slow op=${op} ms=${ms} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify( + summarizeWorkerPayload(payload) + )} error=${error.message}` + ); + } reject(error); }, }); diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index f5ed4005..35adcebe 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -148,7 +148,9 @@ export class TeamMemberLogsFinder { private readonly inboxReader: TeamInboxReader = new TeamInboxReader(), private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver( - configReader + { + getConfig: (teamName) => configReader.getConfigSnapshot(teamName), + } ) ) {} diff --git a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts index 6e843e5c..8eddcde1 100644 --- a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts +++ b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts @@ -27,7 +27,7 @@ interface RuntimeAdvisoryLogsFinder { const LOOKBACK_MS = 10 * 60 * 1000; const CACHE_TTL_MS = 30_000; const TAIL_BYTES = 64 * 1024; -const BATCH_WARN_MS = 200; +const BATCH_WARN_MS = 1_000; const ADVISORY_FETCH_CONCURRENCY = 2; const QUOTA_EXHAUSTED_TOKENS = [ 'exhausted your capacity', diff --git a/src/main/services/team/TeamMessageFeedService.ts b/src/main/services/team/TeamMessageFeedService.ts index eb34ba7a..3865ef3d 100644 --- a/src/main/services/team/TeamMessageFeedService.ts +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -26,6 +26,11 @@ interface TeamMessageFeedCacheEntry { cachedAt: number; } +interface InFlightTeamMessageFeed { + promise: Promise; + generationAtStart: number; +} + export interface TeamNormalizedMessageFeed { teamName: string; feedRevision: string; @@ -411,11 +416,14 @@ function toFeedRevision(messages: readonly InboxMessage[]): string { export class TeamMessageFeedService { private readonly cacheByTeam = new Map(); private readonly dirtyTeams = new Set(); + private readonly inFlightByTeam = new Map(); + private readonly generationByTeam = new Map(); constructor(private readonly deps: TeamMessageFeedDeps) {} invalidate(teamName: string): void { this.dirtyTeams.add(teamName); + this.generationByTeam.set(teamName, this.getGeneration(teamName) + 1); } async getFeed(teamName: string): Promise { @@ -431,20 +439,65 @@ export class TeamMessageFeedService { }; } + const existingRequest = this.inFlightByTeam.get(teamName); + const generationAtStart = this.getGeneration(teamName); + if (existingRequest && existingRequest.generationAtStart === generationAtStart) { + return existingRequest.promise; + } + + const request = this.buildFeed( + teamName, + cached, + now, + cacheDirty, + cacheExpired, + generationAtStart + ).finally(() => { + if (this.inFlightByTeam.get(teamName)?.promise === request) { + this.inFlightByTeam.delete(teamName); + } + }); + this.inFlightByTeam.set(teamName, { + promise: request, + generationAtStart, + }); + return request; + } + + private getGeneration(teamName: string): number { + return this.generationByTeam.get(teamName) ?? 0; + } + + private async buildFeed( + teamName: string, + cached: TeamMessageFeedCacheEntry | undefined, + now: number, + cacheDirty: boolean, + cacheExpired: boolean, + generationAtStart: number + ): Promise { + const startedAt = Date.now(); + const configStartedAt = Date.now(); const config = await this.deps.getConfig(teamName); + const configMs = Date.now() - configStartedAt; if (!config) { const emptyEntry = { feedRevision: toFeedRevision([]), messages: [], cachedAt: now }; - this.cacheByTeam.set(teamName, emptyEntry); - this.dirtyTeams.delete(teamName); + if (this.getGeneration(teamName) === generationAtStart) { + this.cacheByTeam.set(teamName, emptyEntry); + this.dirtyTeams.delete(teamName); + } return { teamName, ...emptyEntry }; } + const sourceStartedAt = Date.now(); const [inboxMessages, leadTexts, sentMessages] = await Promise.all([ this.deps.getInboxMessages(teamName).catch(() => [] as InboxMessage[]), this.deps.getLeadSessionMessages(teamName, config).catch(() => [] as InboxMessage[]), this.deps.getSentMessages(teamName).catch(() => [] as InboxMessage[]), ]); + const sourceMs = Date.now() - sourceStartedAt; + const normalizeStartedAt = Date.now(); const syntheticMessages = buildSyntheticOpenCodeBootstrapMessages(config); let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages]; messages = dedupeLeadProcessCopies(messages, leadTexts); @@ -461,6 +514,13 @@ export class TeamMessageFeedService { }); const feedRevision = toFeedRevision(messages); + const normalizeMs = Date.now() - normalizeStartedAt; + const totalMs = Date.now() - startedAt; + if (totalMs >= 750) { + logger.warn( + `[${teamName}] message feed build slow totalMs=${totalMs} configMs=${configMs} sourceMs=${sourceMs} normalizeMs=${normalizeMs} inbox=${inboxMessages.length} lead=${leadTexts.length} sent=${sentMessages.length} synthetic=${syntheticMessages.length} cacheDirty=${cacheDirty} cacheExpired=${cacheExpired}` + ); + } if (cached && !cacheDirty && cacheExpired && cached.feedRevision !== feedRevision) { logger.warn( `[${teamName}] Message feed cache expired without dirty invalidation and recovered newer durable messages` @@ -478,8 +538,10 @@ export class TeamMessageFeedService { cachedAt: now, }; - this.cacheByTeam.set(teamName, nextEntry); - this.dirtyTeams.delete(teamName); + if (this.getGeneration(teamName) === generationAtStart) { + this.cacheByTeam.set(teamName, nextEntry); + this.dirtyTeams.delete(teamName); + } return { teamName, feedRevision: nextEntry.feedRevision, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 1188fa09..05336ee6 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4599,7 +4599,18 @@ export class TeamProvisioningService { this.inboxReader, this.membersMetaStore ); - this.transcriptProjectResolver = new TeamTranscriptProjectResolver(this.configReader); + this.transcriptProjectResolver = new TeamTranscriptProjectResolver({ + getConfig: (teamName) => this.configReader.getConfigSnapshot(teamName), + }); + } + + private async readConfigSnapshot(teamName: string): Promise { + const configReader = this.configReader as TeamConfigReader & { + getConfigSnapshot?: (name: string) => Promise; + }; + return typeof configReader.getConfigSnapshot === 'function' + ? configReader.getConfigSnapshot(teamName) + : configReader.getConfig(teamName); } setRuntimeAdapterRegistry(registry: TeamRuntimeAdapterRegistry | null): void { @@ -5473,16 +5484,15 @@ export class TeamProvisioningService { }; } - async isOpenCodeRuntimeRecipient(teamName: string, memberName: string): Promise { + private resolveRuntimeRecipientProviderIdFromSources( + memberName: string, + config: TeamConfig | null | undefined, + metaMembers: readonly TeamMember[] + ): TeamProviderId | undefined { const normalizedMemberName = memberName.trim().toLowerCase(); if (!normalizedMemberName) { - return false; + return undefined; } - - const [config, metaMembers] = await Promise.all([ - this.configReader.getConfig(teamName).catch(() => null), - this.membersMetaStore.getMembers(teamName).catch(() => []), - ]); const configMember = config?.members?.find( (member) => member.name?.trim().toLowerCase() === normalizedMemberName ); @@ -5491,13 +5501,37 @@ export class TeamProvisioningService { ); const configProvider = (configMember as { provider?: unknown } | undefined)?.provider; const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider; - const providerId = + return ( normalizeTeamProviderLike(metaMember?.providerId) ?? normalizeTeamProviderLike(metaProvider) ?? normalizeTeamProviderLike(configMember?.providerId) ?? normalizeTeamProviderLike(configProvider) ?? - inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model); - return providerId === 'opencode'; + inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model) + ); + } + + private isOpenCodeRuntimeRecipientFromSources( + memberName: string, + config: TeamConfig | null | undefined, + metaMembers: readonly TeamMember[] + ): boolean { + return ( + this.resolveRuntimeRecipientProviderIdFromSources(memberName, config, metaMembers) === + 'opencode' + ); + } + + async isOpenCodeRuntimeRecipient(teamName: string, memberName: string): Promise { + const normalizedMemberName = memberName.trim().toLowerCase(); + if (!normalizedMemberName) { + return false; + } + + const [config, metaMembers] = await Promise.all([ + this.readConfigSnapshot(teamName).catch(() => null), + this.membersMetaStore.getMembers(teamName).catch(() => []), + ]); + return this.isOpenCodeRuntimeRecipientFromSources(normalizedMemberName, config, metaMembers); } private isOpenCodeDeliveryResponseReadCommitAllowed(input: { @@ -10334,7 +10368,8 @@ export class TeamProvisioningService { let configuredMembers: TeamConfig['members'] = []; try { - configuredMembers = (await this.configReader.getConfig(teamName))?.members ?? []; + const config = await this.readConfigSnapshot(teamName); + configuredMembers = config?.members ?? []; } catch { configuredMembers = []; } @@ -10671,6 +10706,7 @@ export class TeamProvisioningService { } parsed.members = members; await atomicWriteAsync(configPath, `${JSON.stringify(parsed, null, 2)}\n`); + TeamConfigReader.invalidateTeam(input.teamName); } private enqueueDirectRestartPrompt(input: { @@ -14434,6 +14470,7 @@ export class TeamProvisioningService { ], }; await atomicWriteAsync(configPath, `${JSON.stringify(config, null, 2)}\n`); + TeamConfigReader.invalidateTeam(request.teamName); } private async persistOpenCodeRuntimeAdapterLaunchResult( @@ -15760,14 +15797,18 @@ export class TeamProvisioningService { return { kind: 'ignored', relayed: 0 }; } - const leadName = await this.configReader - .getConfig(teamName) - .then( - (config) => config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null - ) - .catch(() => null); + const [config, metaMembers] = await Promise.all([ + this.readConfigSnapshot(teamName).catch(() => null), + this.membersMetaStore.getMembers(teamName).catch(() => []), + ]); + const leadName = config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null; + const isOpenCodeRecipient = this.isOpenCodeRuntimeRecipientFromSources( + inboxName, + config, + metaMembers + ); if (inboxName.trim().toLowerCase() === leadName?.toLowerCase()) { - if (await this.isOpenCodeRuntimeRecipient(teamName, inboxName)) { + if (isOpenCodeRecipient) { const diagnostic = 'opencode_lead_runtime_session_missing: OpenCode lead inbox relay is unsupported in v1; leaving inbox unread for durable retry/diagnostics.'; logger.warn(`[${teamName}] ${diagnostic} inbox=${inboxName}`); @@ -15783,7 +15824,7 @@ export class TeamProvisioningService { }; } - if (await this.isOpenCodeRuntimeRecipient(teamName, inboxName)) { + if (isOpenCodeRecipient) { const relayOptions: OpenCodeMemberInboxRelayOptions = { source: options.source ?? 'watcher', ...(options.onlyMessageId ? { onlyMessageId: options.onlyMessageId } : {}), @@ -17444,7 +17485,7 @@ export class TeamProvisioningService { let configuredMembers: TeamConfig['members'] = []; try { - configuredMembers = (await this.configReader.getConfig(teamName))?.members ?? []; + configuredMembers = (await this.readConfigSnapshot(teamName))?.members ?? []; } catch { configuredMembers = []; } @@ -20169,9 +20210,9 @@ export class TeamProvisioningService { memberName: string, sinceMs: number | null ): Promise { - let config: Awaited>; + let config: TeamConfig | null; try { - config = await this.configReader.getConfig(teamName); + config = await this.readConfigSnapshot(teamName); } catch { return []; } @@ -20221,7 +20262,7 @@ export class TeamProvisioningService { private async collectBootstrapTranscriptProjectDirs( teamName: string, memberName: string, - config: Awaited> + config: TeamConfig | null ): Promise { const pathCandidates: string[] = []; const pathSeen = new Set(); @@ -24435,6 +24476,7 @@ export class TeamProvisioningService { config.projectPathHistory = pathHistory.slice(-500); await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); + TeamConfigReader.invalidateTeam(teamName); logger.info(`[${teamName}] Updated config.projectPath immediately: ${cwd}`); } catch (error) { // Non-fatal: updateConfigPostLaunch will update it later if provisioning succeeds. @@ -24671,6 +24713,7 @@ export class TeamProvisioningService { this.applyEffectiveLaunchStateToConfig(teamName, config, launchState); await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); + TeamConfigReader.invalidateTeam(teamName); } catch (error) { logger.warn( `[${teamName}] Failed to update config post-launch: ${ @@ -24721,6 +24764,7 @@ export class TeamProvisioningService { if (removedFromConfig.length > 0) { parsed.members = nextMembers; await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2)); + TeamConfigReader.invalidateTeam(teamName); logger.warn( `[${teamName}] Removed CLI auto-suffixed members from config.json: ${removedFromConfig.join(', ')}` ); @@ -24977,6 +25021,7 @@ export class TeamProvisioningService { config.members = leadMembers; try { await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); + TeamConfigReader.invalidateTeam(teamName); logger.info( `[${teamName}] Normalized config.json for launch: kept ${leadMembers.length} lead member(s)` ); @@ -25008,6 +25053,7 @@ export class TeamProvisioningService { return; } await atomicWriteAsync(configPath, backupRaw); + TeamConfigReader.invalidateTeam(teamName); logger.info(`[${teamName}] Restored config.json from prelaunch backup after launch failure`); } catch { logger.debug(`[${teamName}] No prelaunch backup to restore (or read failed)`); diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 81f81394..a808ea48 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -21,13 +21,18 @@ import type { const logger = createLogger('Service:TeamTaskReader'); const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024; -const ALL_TASKS_CACHE_TTL_MS = 500; +const ALL_TASKS_CACHE_TTL_MS = 5_000; interface CachedAllTasks { value: (TeamTask & { teamName: string })[]; expiresAt: number; } +interface InFlightAllTasks { + promise: Promise<(TeamTask & { teamName: string })[]>; + generationAtStart: number; +} + function cloneTasks(tasks: T[]): T[] { return structuredClone(tasks); } @@ -74,7 +79,13 @@ function normalizeTaskRefs(value: unknown): TaskRef[] | undefined { export class TeamTaskReader { private static allTasksCache: CachedAllTasks | null = null; - private static allTasksInFlight: Promise<(TeamTask & { teamName: string })[]> | null = null; + private static allTasksInFlight: InFlightAllTasks | null = null; + private static allTasksGeneration = 0; + + static invalidateAllTasksCache(): void { + TeamTaskReader.allTasksCache = null; + TeamTaskReader.allTasksGeneration += 1; + } /** * Returns the next available numeric task ID by scanning ALL task files @@ -446,26 +457,55 @@ export class TeamTaskReader { } async getAllTasks(): Promise<(TeamTask & { teamName: string })[]> { + const startedAt = Date.now(); const cached = TeamTaskReader.allTasksCache; if (cached && cached.expiresAt > Date.now()) { - return cloneTasks(cached.value); + const cloned = cloneTasks(cached.value); + const ms = Date.now() - startedAt; + if (ms >= 1500) { + logger.warn(`[getAllTasks] cache clone slow ms=${ms} tasks=${cloned.length}`); + } + return cloned; } - if (TeamTaskReader.allTasksInFlight) { - return cloneTasks(await TeamTaskReader.allTasksInFlight); + if ( + TeamTaskReader.allTasksInFlight && + TeamTaskReader.allTasksInFlight.generationAtStart === TeamTaskReader.allTasksGeneration + ) { + const waitedAt = Date.now(); + const tasks = await TeamTaskReader.allTasksInFlight.promise; + const cloned = cloneTasks(tasks); + const ms = Date.now() - startedAt; + if (ms >= 1500) { + logger.warn( + `[getAllTasks] in-flight wait slow ms=${ms} waitMs=${Date.now() - waitedAt} tasks=${cloned.length}` + ); + } + return cloned; } const request = this.readAllTasksUncached(); - TeamTaskReader.allTasksInFlight = request; + const generationAtStart = TeamTaskReader.allTasksGeneration; + TeamTaskReader.allTasksInFlight = { + promise: request, + generationAtStart, + }; try { const tasks = await request; - TeamTaskReader.allTasksCache = { - value: cloneTasks(tasks), - expiresAt: Date.now() + ALL_TASKS_CACHE_TTL_MS, - }; - return cloneTasks(tasks); + if (TeamTaskReader.allTasksGeneration === generationAtStart) { + TeamTaskReader.allTasksCache = { + value: cloneTasks(tasks), + expiresAt: Date.now() + ALL_TASKS_CACHE_TTL_MS, + }; + } + const cloned = cloneTasks(tasks); + const ms = Date.now() - startedAt; + if (ms >= 1500) { + logger.warn(`[getAllTasks] total slow ms=${ms} tasks=${cloned.length}`); + } + return cloned; } finally { - if (TeamTaskReader.allTasksInFlight === request) { + if (TeamTaskReader.allTasksInFlight?.promise === request) { TeamTaskReader.allTasksInFlight = null; } } diff --git a/src/main/services/team/teamDataWorkerTypes.ts b/src/main/services/team/teamDataWorkerTypes.ts index c130c0b8..6b14860f 100644 --- a/src/main/services/team/teamDataWorkerTypes.ts +++ b/src/main/services/team/teamDataWorkerTypes.ts @@ -42,6 +42,17 @@ export interface InvalidateTeamConfigPayload { teamName: string; } +export interface InvalidateTeamMessageFeedPayload { + teamName: string; +} + +export interface TeamDataWorkerDiag { + op: TeamDataWorkerRequest['op']; + teamName?: string; + taskId?: string; + totalMs: number; +} + // ── Request / Response ── export type TeamDataWorkerRequest = @@ -49,12 +60,14 @@ export type TeamDataWorkerRequest = | { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload } | { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload } | { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload } - | { id: string; op: 'invalidateTeamConfig'; payload: InvalidateTeamConfigPayload }; + | { id: string; op: 'invalidateTeamConfig'; payload: InvalidateTeamConfigPayload } + | { id: string; op: 'invalidateTeamMessageFeed'; payload: InvalidateTeamMessageFeedPayload }; export type TeamDataWorkerResponse = | { id: string; ok: true; result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[] | null; + diag?: TeamDataWorkerDiag; } | { id: string; ok: false; error: string }; diff --git a/src/main/workers/team-data-worker.ts b/src/main/workers/team-data-worker.ts index 55db4473..361685c4 100644 --- a/src/main/workers/team-data-worker.ts +++ b/src/main/workers/team-data-worker.ts @@ -36,11 +36,18 @@ function respond(msg: TeamDataWorkerResponse): void { } parentPort?.on('message', async (msg: TeamDataWorkerRequest) => { + const startedAt = Date.now(); + const buildDiag = (): NonNullable['diag']> => ({ + op: msg.op, + ...('teamName' in msg.payload ? { teamName: msg.payload.teamName } : {}), + ...('taskId' in msg.payload ? { taskId: msg.payload.taskId } : {}), + totalMs: Date.now() - startedAt, + }); try { switch (msg.op) { case 'getTeamData': { const result = await teamDataService.getTeamData(msg.payload.teamName); - respond({ id: msg.id, ok: true, result }); + respond({ id: msg.id, ok: true, result, diag: buildDiag() }); break; } case 'getMessagesPage': { @@ -48,17 +55,23 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => { msg.payload.teamName, msg.payload.options ); - respond({ id: msg.id, ok: true, result }); + respond({ id: msg.id, ok: true, result, diag: buildDiag() }); break; } case 'getMemberActivityMeta': { const result = await teamDataService.getMemberActivityMeta(msg.payload.teamName); - respond({ id: msg.id, ok: true, result }); + respond({ id: msg.id, ok: true, result, diag: buildDiag() }); break; } case 'invalidateTeamConfig': { TeamConfigReader.invalidateTeam(msg.payload.teamName); - respond({ id: msg.id, ok: true, result: null }); + teamDataService.invalidateMessageFeed(msg.payload.teamName); + respond({ id: msg.id, ok: true, result: null, diag: buildDiag() }); + break; + } + case 'invalidateTeamMessageFeed': { + teamDataService.invalidateMessageFeed(msg.payload.teamName); + respond({ id: msg.id, ok: true, result: null, diag: buildDiag() }); break; } case 'findLogsForTask': { @@ -95,7 +108,7 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => { logsInFlight.set(cacheKey, promise); } const result = await promise; - respond({ id: msg.id, ok: true, result }); + respond({ id: msg.id, ok: true, result, diag: buildDiag() }); break; } default: { diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index 96f41db0..aa7f3bf6 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -384,13 +384,14 @@ async function readLaunchState( */ async function readDraftTeamMeta( teamsDir: string, - teamName: string + teamName: string, + options: { maxConfigReadMs: number; maxMembersMetaBytes: number } ): Promise | null> { const metaPath = path.join(teamsDir, teamName, 'team.meta.json'); try { const stat = await fs.promises.stat(metaPath); if (!stat.isFile() || stat.size > 256 * 1024) return null; - const raw = await fs.promises.readFile(metaPath, 'utf8'); + const raw = await readFileUtf8WithTimeout(metaPath, options.maxConfigReadMs); const meta = JSON.parse(raw) as Record; if (meta?.version !== 1 || typeof meta?.cwd !== 'string') return null; @@ -401,14 +402,29 @@ async function readDraftTeamMeta( // Read members.meta.json for member count let memberCount = 0; + let leadName: string | undefined; + let leadColor: string | undefined; try { const membersPath = path.join(teamsDir, teamName, 'members.meta.json'); - const membersRaw = await fs.promises.readFile(membersPath, 'utf8'); + const membersStat = await fs.promises.stat(membersPath); + if (!membersStat.isFile() || membersStat.size > options.maxMembersMetaBytes) { + throw new Error('members_meta_too_large'); + } + const membersRaw = await readFileUtf8WithTimeout(membersPath, options.maxConfigReadMs); const membersData = JSON.parse(membersRaw) as { members?: unknown[] }; if (Array.isArray(membersData?.members)) { memberCount = membersData.members.filter((member) => { if (!isRawMember(member)) return false; const name = typeof member.name === 'string' ? member.name.trim() : ''; + if (!member.removedAt && isLeadMember(member)) { + if (name) { + leadName = name; + } + const color = typeof member.color === 'string' ? member.color.trim() : ''; + if (color) { + leadColor = color; + } + } if (!name || name === 'user' || isLeadMember(member)) return false; return !member.removedAt; }).length; @@ -426,6 +442,8 @@ async function readDraftTeamMeta( lastActivity: typeof meta.createdAt === 'number' ? new Date(meta.createdAt).toISOString() : null, color: typeof meta.color === 'string' ? meta.color : undefined, + ...(leadName ? { leadName } : {}), + ...(leadColor ? { leadColor } : {}), projectPath: typeof meta.cwd === 'string' ? meta.cwd : undefined, pendingCreate: true, }; @@ -477,12 +495,12 @@ async function listTeams( stat = await fs.promises.stat(configPath); } catch { // Fallback: check for draft team (team.meta.json without config.json) - const draft = await readDraftTeamMeta(payload.teamsDir, teamName); + const draft = await readDraftTeamMeta(payload.teamsDir, teamName, payload); if (draft) return draft; return skip('config_stat_failed'); } if (!stat.isFile()) { - const draft = await readDraftTeamMeta(payload.teamsDir, teamName); + const draft = await readDraftTeamMeta(payload.teamsDir, teamName, payload); if (draft) return draft; return skip('config_not_file'); } @@ -557,6 +575,21 @@ async function listTeams( removedAt?: unknown; }[] = []; let leadProviderId: 'anthropic' | 'codex' | 'gemini' | 'opencode' | undefined; + let leadName: string | undefined; + let leadColor: string | undefined; + + const captureLeadMember = (member: RawMember, overwrite = false): void => { + if (member.removedAt) return; + if (!isLeadMember(member)) return; + const name = typeof member.name === 'string' ? member.name.trim() : ''; + if (name && (overwrite || !leadName)) { + leadName = name; + } + const colorValue = typeof member.color === 'string' ? member.color.trim() : ''; + if (colorValue && (overwrite || !leadColor)) { + leadColor = colorValue; + } + }; try { const teamMetaPath = path.join(payload.teamsDir, teamName, 'team.meta.json'); @@ -595,6 +628,7 @@ async function listTeams( : undefined; const name = typeof member.name === 'string' ? member.name.trim() : ''; if (!name) continue; + captureLeadMember(member); if (isLeadMember(member)) continue; const key = name.toLowerCase(); if (member.removedAt) { @@ -623,6 +657,7 @@ async function listTeams( for (const member of config.members as unknown[]) { if (isRawMember(member)) { const name = typeof member.name === 'string' ? member.name.trim() : ''; + captureLeadMember(member, true); if (name && name !== 'user' && !isLeadMember(member)) { confirmedArtifactNames.add(name); } @@ -691,6 +726,8 @@ async function listTeams( taskCount: 0, lastActivity: null, ...(coloredMembers.length > 0 ? { members: coloredMembers } : {}), + ...(leadName ? { leadName } : {}), + ...(leadColor ? { leadColor } : {}), ...(color ? { color } : {}), ...(projectPath ? { projectPath } : {}), ...(leadSessionId ? { leadSessionId } : {}), diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 9f96f742..d1371c26 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -55,6 +55,8 @@ export interface TeamSummary { color?: string; memberCount: number; members?: TeamSummaryMember[]; + leadName?: string; + leadColor?: string; taskCount: number; lastActivity: string | null; projectPath?: string; diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index 9ba09292..c6801f22 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV, @@ -80,6 +80,34 @@ describe('createMemberWorkSyncFeature composition', () => { } }); + it('uses snapshot config reads for startup roster materialization', async () => { + const getConfig = vi.fn(async () => ({ members: [] })); + const getConfigSnapshot = vi.fn(async () => ({ + members: [{ name: 'alice' }], + })); + const feature = createMemberWorkSyncFeature({ + teamsBasePath: makeTempRoot(), + configReader: { + getConfig, + getConfigSnapshot, + } as never, + taskReader: {} as never, + kanbanManager: {} as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + nudgeSideEffectsEnabled: false, + }); + + try { + await feature.enqueueStartupScan(['my-team']); + expect(getConfigSnapshot).toHaveBeenCalledWith('my-team'); + expect(getConfig).not.toHaveBeenCalled(); + } finally { + await feature.dispose(); + } + }); + it('builds Claude Stop hook settings without requiring nudge side effects', async () => { const root = makeTempRoot(); const feature = createMemberWorkSyncFeature({ diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 565e1175..acbbf9ff 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -50,8 +50,29 @@ const { mockTeamDataWorkerClient } = vi.hoisted(() => ({ getMemberActivityMeta: vi.fn(), findLogsForTask: vi.fn(), invalidateTeamConfig: vi.fn(), + invalidateTeamMessageFeed: vi.fn(), }, })); + +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(); +} + vi.mock('@main/services/infrastructure/NotificationManager', () => ({ NotificationManager: { getInstance: vi.fn().mockReturnValue({ @@ -188,6 +209,8 @@ describe('ipc teams handlers', () => { projectPath: '/tmp/project', })), deleteTeam: vi.fn(async () => undefined), + restoreTeam: vi.fn(async () => undefined), + permanentlyDeleteTeam: vi.fn(async () => undefined), getLeadMemberName: vi.fn(async () => 'team-lead'), getTeamDisplayName: vi.fn(async () => 'My Team'), updateConfig: vi.fn(async () => ({ name: 'My Team' })), @@ -315,6 +338,7 @@ describe('ipc teams handlers', () => { mockTeamDataWorkerClient.getMemberActivityMeta.mockReset(); mockTeamDataWorkerClient.findLogsForTask.mockReset(); mockTeamDataWorkerClient.invalidateTeamConfig.mockReset(); + mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset(); initializeTeamHandlers( service as never, provisioningService as never, @@ -1089,6 +1113,137 @@ describe('ipc teams handlers', () => { (electron.app as { isPackaged: boolean }).isPackaged = false; }); + it('classifies draft teams before asking the team-data worker for a full snapshot', async () => { + const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-get-data-')); + setClaudeBasePathOverride(claudeRoot); + const teamDir = path.join(claudeRoot, 'teams', 'draft-team'); + await fs.promises.mkdir(teamDir, { recursive: true }); + await fs.promises.writeFile( + path.join(teamDir, 'team.meta.json'), + JSON.stringify({ + version: 1, + cwd: '/tmp/draft-team', + createdAt: Date.now(), + }) + ); + mockTeamDataWorkerClient.isAvailable.mockReturnValue(true); + + try { + const handler = handlers.get(TEAM_GET_DATA)!; + const result = (await handler({} as never, 'draft-team')) as { + success: boolean; + error?: string; + }; + + expect(result).toEqual({ success: false, error: 'TEAM_DRAFT' }); + expect(mockTeamDataWorkerClient.getTeamData).not.toHaveBeenCalled(); + expect(service.getTeamData).not.toHaveBeenCalledWith('draft-team'); + } finally { + await fs.promises.rm(claudeRoot, { recursive: true, force: true }); + setClaudeBasePathOverride(null); + } + }); + + it('classifies draft teams before falling back to main-thread getTeamData', async () => { + const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-main-get-data-')); + setClaudeBasePathOverride(claudeRoot); + const teamDir = path.join(claudeRoot, 'teams', 'draft-team'); + await fs.promises.mkdir(teamDir, { recursive: true }); + await fs.promises.writeFile( + path.join(teamDir, 'team.meta.json'), + JSON.stringify({ + version: 1, + cwd: '/tmp/draft-team', + createdAt: Date.now(), + }) + ); + mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); + + try { + const handler = handlers.get(TEAM_GET_DATA)!; + const result = (await handler({} as never, 'draft-team')) as { + success: boolean; + error?: string; + }; + + expect(result).toEqual({ success: false, error: 'TEAM_DRAFT' }); + expect(mockTeamDataWorkerClient.getTeamData).not.toHaveBeenCalled(); + expect(service.getTeamData).not.toHaveBeenCalledWith('draft-team'); + } finally { + await fs.promises.rm(claudeRoot, { recursive: true, force: true }); + setClaudeBasePathOverride(null); + } + }); + + it('does not let slow draft metadata classification block normal getData fallback', async () => { + const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-slow-meta-')); + setClaudeBasePathOverride(claudeRoot); + const teamDir = path.join(claudeRoot, 'teams', 'slow-meta-team'); + await fs.promises.mkdir(teamDir, { recursive: true }); + const { TeamMetaStore } = await import('../../../src/main/services/team/TeamMetaStore'); + const metaSpy = vi + .spyOn(TeamMetaStore.prototype, 'getMeta') + .mockImplementation(async () => new Promise(() => undefined)); + mockTeamDataWorkerClient.isAvailable.mockReturnValue(true); + mockTeamDataWorkerClient.getTeamData.mockResolvedValueOnce({ + teamName: 'slow-meta-team', + config: { name: 'Slow Meta Team' }, + tasks: [], + members: [], + kanbanState: { teamName: 'slow-meta-team', reviewers: [], tasks: {} }, + processes: [], + }); + + try { + const startedAt = Date.now(); + const handler = handlers.get(TEAM_GET_DATA)!; + const result = (await handler({} as never, 'slow-meta-team')) as { + success: boolean; + data?: { teamName: string }; + }; + + expect(Date.now() - startedAt).toBeLessThan(1500); + expect(result.success).toBe(true); + expect(result.data?.teamName).toBe('slow-meta-team'); + expect(mockTeamDataWorkerClient.getTeamData).toHaveBeenCalledWith('slow-meta-team'); + } finally { + metaSpy.mockRestore(); + await fs.promises.rm(claudeRoot, { recursive: true, force: true }); + setClaudeBasePathOverride(null); + } + }); + + it('does not let slow draft metadata classification block Team not found fallback', async () => { + const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-slow-missing-meta-')); + setClaudeBasePathOverride(claudeRoot); + const teamDir = path.join(claudeRoot, 'teams', 'slow-missing-team'); + await fs.promises.mkdir(teamDir, { recursive: true }); + const { TeamMetaStore } = await import('../../../src/main/services/team/TeamMetaStore'); + const metaSpy = vi + .spyOn(TeamMetaStore.prototype, 'getMeta') + .mockImplementation(async () => new Promise(() => undefined)); + mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); + service.getTeamData.mockRejectedValueOnce(new Error('Team not found: slow-missing-team')); + + try { + const startedAt = Date.now(); + const handler = handlers.get(TEAM_GET_DATA)!; + const result = (await handler({} as never, 'slow-missing-team')) as { + success: boolean; + error?: string; + }; + + expect(Date.now() - startedAt).toBeLessThan(1500); + expect(result).toEqual({ success: false, error: 'Team not found: slow-missing-team' }); + expect(service.getTeamData).toHaveBeenCalledWith('slow-missing-team'); + vi.mocked(console.error).mockClear(); + } finally { + metaSpy.mockRestore(); + await fs.promises.rm(claudeRoot, { recursive: true, force: true }); + setClaudeBasePathOverride(null); + } + }); + it('does not let a live duplicate of the same session rate-limit reply delay auto-resume', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-04-17T12:00:30.000Z')); @@ -1221,6 +1376,7 @@ describe('ipc teams handlers', () => { expect(result.success).toBe(true); expect(result.data.feedRevision).toBe('rev-worker'); + await flushMicrotasks(); expect(mockAddTeamNotification).toHaveBeenCalledWith( expect.objectContaining({ teamEventType: 'rate_limit', @@ -1233,6 +1389,47 @@ describe('ipc teams handlers', () => { expect(service.getMessageFeed).not.toHaveBeenCalled(); }); + it('does not block TEAM_GET_MESSAGES_PAGE on notification context reads', async () => { + mockTeamDataWorkerClient.isAvailable.mockReturnValue(true); + mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({ + messages: [ + { + from: 'team-lead', + text: "You've hit your limit. Please wait a bit before retrying.", + timestamp: '2026-02-23T10:00:01.000Z', + read: true, + source: 'lead_session' as const, + messageId: 'msg-rate-limit-nonblocking', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-worker', + }); + const context = createDeferred<{ displayName: string; projectPath: string }>(); + service.getTeamNotificationContext.mockReturnValueOnce(context.promise); + + const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!; + const result = (await handler({} as never, 'my-team', { + limit: 50, + })) as { success: boolean; data: { feedRevision: string } }; + + expect(result.success).toBe(true); + expect(result.data.feedRevision).toBe('rev-worker'); + expect(mockAddTeamNotification).not.toHaveBeenCalled(); + + context.resolve({ displayName: 'My Team', projectPath: '/tmp/project' }); + await flushMicrotasks(); + expect(mockAddTeamNotification).toHaveBeenCalledWith( + expect.objectContaining({ + teamEventType: 'rate_limit', + teamName: 'my-team', + teamDisplayName: 'My Team', + dedupeKey: 'rate-limit:my-team:msg-rate-limit-nonblocking', + }) + ); + }); + it('falls back TEAM_GET_MESSAGES_PAGE to the main thread in packaged runtime when worker is unavailable', async () => { const electron = await import('electron'); mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); @@ -2130,6 +2327,7 @@ describe('ipc teams handlers', () => { description: undefined, color: undefined, }); + expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team'); expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith( 'my-team', 'The team has been renamed to "Renamed Team". Please use this name when referring to the team going forward.' @@ -2155,6 +2353,33 @@ describe('ipc teams handlers', () => { }); }); + describe('team mutation cache invalidation', () => { + it('invalidates worker config cache after delete, restore, and permanent delete', async () => { + const deleteHandler = handlers.get(TEAM_DELETE_TEAM)!; + const restoreHandler = handlers.get(TEAM_RESTORE)!; + const permanentlyDeleteHandler = handlers.get(TEAM_PERMANENTLY_DELETE)!; + + let result = (await deleteHandler({} as never, 'my-team')) as { success: boolean }; + expect(result.success).toBe(true); + expect(service.deleteTeam).toHaveBeenCalledWith('my-team'); + expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team'); + + mockTeamDataWorkerClient.invalidateTeamConfig.mockClear(); + + result = (await restoreHandler({} as never, 'my-team')) as { success: boolean }; + expect(result.success).toBe(true); + expect(service.restoreTeam).toHaveBeenCalledWith('my-team'); + expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team'); + + mockTeamDataWorkerClient.invalidateTeamConfig.mockClear(); + + result = (await permanentlyDeleteHandler({} as never, 'my-team')) as { success: boolean }; + expect(result.success).toBe(true); + expect(service.permanentlyDeleteTeam).toHaveBeenCalledWith('my-team'); + expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team'); + }); + }); + describe('removeMember', () => { it('calls service on valid input', async () => { const handler = handlers.get(TEAM_REMOVE_MEMBER)!; @@ -2908,6 +3133,7 @@ describe('ipc teams handlers', () => { cwd: os.tmpdir(), })) as { success: boolean }; expect(result.success).toBe(true); + expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('solo-team'); }); it('handleCreateConfig preserves draft launch metadata', async () => { diff --git a/test/main/services/infrastructure/FileWatcher.test.ts b/test/main/services/infrastructure/FileWatcher.test.ts index 08cbbc9f..db3027f3 100644 --- a/test/main/services/infrastructure/FileWatcher.test.ts +++ b/test/main/services/infrastructure/FileWatcher.test.ts @@ -191,6 +191,24 @@ describe('FileWatcher', () => { ]); }); + it('emits config team-change events for team and members metadata changes', () => { + const dataCache = new DataCache(50, 10, false); + const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos'); + const events: unknown[] = []; + watcher.on('team-change', (event) => events.push(event)); + + const testWatcher = watcher as unknown as { + processTeamsChange: (eventType: string, filename: string) => void; + }; + testWatcher.processTeamsChange('change', 'team-a/team.meta.json'); + testWatcher.processTeamsChange('change', 'team-a/members.meta.json'); + + expect(events).toEqual([ + { type: 'config', teamName: 'team-a', detail: 'team.meta.json' }, + { type: 'config', teamName: 'team-a', detail: 'members.meta.json' }, + ]); + }); + it('keeps append offset pinned for partial trailing lines until completed', async () => { vi.useRealTimers(); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-')); diff --git a/test/main/services/team/CrossTeamService.test.ts b/test/main/services/team/CrossTeamService.test.ts index 4dd371e5..7ae53902 100644 --- a/test/main/services/team/CrossTeamService.test.ts +++ b/test/main/services/team/CrossTeamService.test.ts @@ -51,7 +51,10 @@ function makeConfig(overrides: Partial = {}): TeamConfig { describe('CrossTeamService', () => { let service: CrossTeamService; let configReader: { getConfig: ReturnType }; - let dataService: { getLeadMemberName: ReturnType }; + let dataService: { + getLeadMemberName: ReturnType; + listTeams: ReturnType; + }; let inboxWriter: { sendMessage: ReturnType }; let provisioning: { isTeamAlive: ReturnType; @@ -68,6 +71,7 @@ describe('CrossTeamService', () => { }; dataService = { getLeadMemberName: vi.fn().mockResolvedValue('team-lead'), + listTeams: vi.fn().mockResolvedValue([]), }; inboxWriter = { sendMessage: vi.fn().mockResolvedValue({ deliveredToInbox: true, messageId: 'mock-id' }), @@ -353,11 +357,65 @@ describe('CrossTeamService', () => { }); describe('listAvailableTargets', () => { - it('returns empty when teams dir read fails', async () => { - configReader.getConfig.mockRejectedValue(new Error('ENOENT')); + it('returns empty when team summary listing fails', async () => { + dataService.listTeams.mockRejectedValue(new Error('ENOENT')); const result = await service.listAvailableTargets(); expect(result).toEqual([]); }); + + it('uses team summaries instead of verified config reads for target discovery', async () => { + dataService.listTeams.mockResolvedValue([ + { + teamName: 'team-a', + displayName: 'Team A', + description: '', + memberCount: 1, + members: [], + }, + { + teamName: 'team-b', + displayName: 'Team B', + description: 'Target team', + color: 'blue', + memberCount: 1, + members: [{ name: 'alice', color: '#abcdef' }], + leadName: 'captain', + leadColor: '#123456', + }, + { + teamName: 'deleted-team', + displayName: 'Deleted', + description: '', + memberCount: 0, + members: [], + deletedAt: '2026-05-01T00:00:00.000Z', + }, + { + teamName: 'draft-team', + displayName: 'Draft', + description: '', + memberCount: 0, + members: [], + pendingCreate: true, + }, + ]); + provisioning.isTeamAlive.mockImplementation((teamName: string) => teamName === 'team-b'); + + const result = await service.listAvailableTargets('team-a'); + + expect(configReader.getConfig).not.toHaveBeenCalled(); + expect(result).toEqual([ + { + teamName: 'team-b', + displayName: 'Team B', + description: 'Target team', + color: 'blue', + leadName: 'captain', + leadColor: '#123456', + isOnline: true, + }, + ]); + }); }); describe('getOutbox', () => { diff --git a/test/main/services/team/TeamConfigReader.test.ts b/test/main/services/team/TeamConfigReader.test.ts index 6171fc23..31fb3fca 100644 --- a/test/main/services/team/TeamConfigReader.test.ts +++ b/test/main/services/team/TeamConfigReader.test.ts @@ -200,6 +200,118 @@ describe('TeamConfigReader', () => { expect(teams[0]?.missingMembers).toBeUndefined(); }); + it('exposes lead summary fields without adding lead to teammate member chips', async () => { + const teamName = 'lead-summary-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Lead Summary Team', + members: [ + { name: 'captain', agentType: 'team-lead', color: '#123456' }, + { name: 'alice', role: 'reviewer', color: '#abcdef' }, + ], + }), + 'utf8' + ); + + const reader = new TeamConfigReader(); + const teams = await reader.listTeams(); + + expect(teams).toHaveLength(1); + expect(teams[0]).toMatchObject({ + teamName, + displayName: 'Lead Summary Team', + memberCount: 1, + members: [{ name: 'alice', role: 'reviewer', color: '#abcdef' }], + leadName: 'captain', + leadColor: '#123456', + }); + }); + + it('dedupes and briefly caches listTeams scans until invalidated', async () => { + const teamName = 'cached-list-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Cached List Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + const readdirSpy = vi.spyOn(nodeFs.promises, 'readdir'); + + const reader = new TeamConfigReader(); + const [first, second] = await Promise.all([reader.listTeams(), reader.listTeams()]); + const readdirAfterFirstBatch = readdirSpy.mock.calls.length; + + expect(first).toHaveLength(1); + expect(second).toHaveLength(1); + + await reader.listTeams(); + expect(readdirSpy).toHaveBeenCalledTimes(readdirAfterFirstBatch); + + TeamConfigReader.invalidateTeam(teamName); + await reader.listTeams(); + expect(readdirSpy.mock.calls.length).toBeGreaterThan(readdirAfterFirstBatch); + }); + + it('does not reuse a stale in-flight listTeams scan after invalidation', async () => { + const teamName = 'inflight-invalidated-list-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Before Invalidation', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + const firstReadStarted = createDeferred(); + const releaseFirstRead = createDeferred(); + const originalReaddir = nodeFs.promises.readdir.bind(nodeFs.promises); + let blockedFirstTeamScan = false; + const readdirSpy = vi + .spyOn(nodeFs.promises, 'readdir') + .mockImplementation(async (...args: unknown[]) => { + if (!blockedFirstTeamScan && args[0] === tempDir) { + blockedFirstTeamScan = true; + firstReadStarted.resolve(); + await releaseFirstRead.promise; + } + return originalReaddir(...(args as Parameters)); + }); + + const reader = new TeamConfigReader(); + const first = reader.listTeams(); + await firstReadStarted.promise; + + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'After Invalidation', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + TeamConfigReader.invalidateTeam(teamName); + + const second = reader.listTeams(); + await Promise.resolve(); + + const teamDirReads = readdirSpy.mock.calls.filter((call) => call[0] === tempDir); + expect(teamDirReads.length).toBeGreaterThanOrEqual(2); + + releaseFirstRead.resolve(); + const [, secondTeams] = await Promise.all([first, second]); + expect(secondTeams[0]?.displayName).toBe('After Invalidation'); + }); + it('does not let a removed base member hide an active auto-suffixed teammate in team summaries', async () => { const teamName = 'suffix-team'; const teamDir = path.join(tempDir, teamName); @@ -254,7 +366,7 @@ describe('TeamConfigReader', () => { JSON.stringify({ version: 1, members: [ - { name: 'team-lead', agentType: 'team-lead' }, + { name: 'team-lead', agentType: 'team-lead', color: '#123456' }, { name: 'alice', removedAt: Date.now() - 60_000 }, { name: 'bob', role: 'developer' }, ], @@ -269,6 +381,46 @@ describe('TeamConfigReader', () => { teamName, displayName: 'Draft Summary Team', memberCount: 1, + leadName: 'team-lead', + leadColor: '#123456', + pendingCreate: true, + }); + }); + + it('uses injected members meta store for draft team summaries', async () => { + const teamName = 'draft-summary-injected-store-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'team.meta.json'), + JSON.stringify({ + version: 1, + cwd: tempDir, + displayName: 'Injected Draft Team', + createdAt: Date.parse('2026-04-22T12:00:00.000Z'), + }), + 'utf8' + ); + await fs.writeFile( + path.join(teamDir, 'members.meta.json'), + JSON.stringify({ version: 1, members: [] }), + 'utf8' + ); + const getMembers = vi.fn(async () => [ + { name: 'captain', agentType: 'team-lead', color: '#123456' }, + { name: 'alice', role: 'developer' }, + ]); + + const reader = new TeamConfigReader({ getMembers } as never); + const teams = await reader.listTeams(); + + expect(getMembers).toHaveBeenCalledWith(teamName); + expect(teams[0]).toMatchObject({ + teamName, + displayName: 'Injected Draft Team', + memberCount: 1, + leadName: 'captain', + leadColor: '#123456', pendingCreate: true, }); }); @@ -332,6 +484,59 @@ describe('TeamConfigReader', () => { expect(readFileSpy).toHaveBeenCalledTimes(1); }); + it('logs slow config reads with mode, likely cause, generation, and caller diagnostics', async () => { + const teamName = 'slow-read-diagnostics-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Slow Diagnostics Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(performance, 'now') + .mockReturnValueOnce(0) + .mockReturnValueOnce(0) + .mockReturnValueOnce(1) + .mockReturnValueOnce(1) + .mockReturnValueOnce(2_001) + .mockReturnValueOnce(2_001) + .mockReturnValueOnce(2_001) + .mockReturnValueOnce(2_001) + .mockReturnValueOnce(2_001); + + const reader = new TeamConfigReader(); + expect((await reader.getConfigVerified(teamName))?.name).toBe('Slow Diagnostics Team'); + + const slowLog = warnSpy.mock.calls.find((call) => + String(call[1] ?? '').includes('[getConfig] slow read diag=') + ); + expect(slowLog).toBeTruthy(); + const rawMessage = String(slowLog?.[1] ?? ''); + const diag = JSON.parse(rawMessage.slice(rawMessage.indexOf('diag=') + 'diag='.length)) as { + mode: string; + configPath: string; + likelyCause: string; + readMs: number; + cacheGeneration: number; + currentGeneration: number; + caller: string | null; + }; + expect(diag).toMatchObject({ + mode: 'verified', + configPath, + likelyCause: 'io_read_slow', + readMs: 2000, + cacheGeneration: 0, + currentGeneration: 0, + }); + expect(diag.caller).toBeTruthy(); + }); + it('shares in-flight snapshot stat and read work for concurrent calls', async () => { const teamName = 'snapshot-inflight-team'; const teamDir = path.join(tempDir, teamName); @@ -511,6 +716,54 @@ describe('TeamConfigReader', () => { expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Fresh Prime'); }); + it('does not reuse stale in-flight verified reads after app-owned primeConfig', async () => { + const teamName = 'verified-stale-read-prime-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + const staleRaw = JSON.stringify({ + name: 'Stale Verified Read', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }); + await fs.writeFile(configPath, staleRaw, 'utf8'); + + const readDeferred = createDeferred(); + const realReadFile = nodeFs.promises.readFile.bind(nodeFs.promises); + let intercepted = false; + vi.spyOn(nodeFs.promises, 'readFile').mockImplementation( + ((file: unknown, ...args: unknown[]) => { + if (!intercepted && String(file) === configPath) { + intercepted = true; + return readDeferred.promise as never; + } + return realReadFile(file as never, ...(args as never[])) as never; + }) as never + ); + + const reader = new TeamConfigReader(); + const staleVerified = reader.getConfig(teamName); + await vi.waitFor(() => expect(intercepted).toBe(true)); + + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Fresh Verified Prime', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + await TeamConfigReader.primeConfig(teamName, { + name: 'Fresh Verified Prime', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + } as never); + + expect((await reader.getConfig(teamName))?.name).toBe('Fresh Verified Prime'); + + readDeferred.resolve(staleRaw); + expect((await staleVerified)?.name).toBe('Stale Verified Read'); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Fresh Verified Prime'); + }); + it('does not let stale in-flight snapshot read failures invalidate a primed config cache', async () => { const teamName = 'stale-read-failure-prime-team'; const teamDir = path.join(tempDir, teamName); diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index e828792d..424ffc9e 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -9,6 +9,7 @@ import { encodePath, setClaudeBasePathOverride } from '../../../../src/main/util import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils'; import { TeamDataService } from '../../../../src/main/services/team/TeamDataService'; +import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader'; import type { TeamMetaFile } from '../../../../src/main/services/team/TeamMetaStore'; import type { @@ -237,6 +238,109 @@ afterEach(async () => { ); }); +describe('TeamDataService task projection cache invalidation', () => { + it('invalidates global task projection cache after direct task mutations', async () => { + const task: TeamTask = { + id: 'task-1', + subject: 'Task 1', + status: 'pending', + createdAt: '2026-05-02T12:00:00.000Z', + updatedAt: '2026-05-02T12:00:00.000Z', + }; + const taskController = { + createTask: vi.fn(() => task), + startTask: vi.fn(), + setTaskStatus: vi.fn(), + softDeleteTask: vi.fn(), + restoreTask: vi.fn(), + setTaskOwner: vi.fn(), + updateTaskFields: vi.fn(), + addTaskAttachmentMeta: vi.fn(), + removeTaskAttachment: vi.fn(), + setNeedsClarification: vi.fn(), + linkTask: vi.fn(), + unlinkTask: vi.fn(), + addTaskComment: vi.fn(() => ({ + comment: { + id: 'comment-1', + author: 'user', + text: 'Comment', + createdAt: '2026-05-02T12:01:00.000Z', + type: 'regular', + }, + })), + }; + const service = new TeamDataService( + { + getConfig: vi.fn(async () => ({ + name: 'my-team', + projectPath: '/repo', + members: [{ name: 'team-lead', role: 'Lead' }], + })), + } as never, + { + getTasks: vi.fn(async () => [task]), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => []), + } as never, + {} as never, + {} as never, + { resolveMembers: vi.fn(() => []) } as never, + { getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })) } as never, + {} as never, + { getMembers: vi.fn(async () => []) } as never, + { readMessages: vi.fn(async () => []) } as never, + (() => ({ tasks: taskController })) as never + ); + const invalidateSpy = vi.spyOn(TeamTaskReader, 'invalidateAllTasksCache'); + + await service.createTask('my-team', { subject: 'Task 1' }); + await service.startTask('my-team', 'task-1'); + await service.startTaskByUser('my-team', 'task-1'); + await service.updateTaskStatus('my-team', 'task-1', 'completed'); + await service.softDeleteTask('my-team', 'task-1'); + await service.restoreTask('my-team', 'task-1'); + await service.updateTaskOwner('my-team', 'task-1', 'alice'); + await service.updateTaskFields('my-team', 'task-1', { subject: 'Task 1 updated' }); + await service.addTaskAttachment('my-team', 'task-1', { + id: 'att-1', + filename: 'note.txt', + mimeType: 'text/plain', + size: 1, + createdAt: '2026-05-02T12:02:00.000Z', + } as never); + await service.removeTaskAttachment('my-team', 'task-1', 'att-1'); + await service.setTaskNeedsClarification('my-team', 'task-1', 'lead'); + await service.addTaskRelationship('my-team', 'task-1', 'task-2', 'related'); + await service.removeTaskRelationship('my-team', 'task-1', 'task-2', 'related'); + await service.addTaskComment('my-team', 'task-1', 'Comment'); + + expect(invalidateSpy).toHaveBeenCalledTimes(14); + }); + + it('invalidates config and global task caches after permanent team deletion', async () => { + const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-data-delete-cache-')); + tempPaths.push(claudeRoot); + setClaudeBasePathOverride(claudeRoot); + + await fs.mkdir(path.join(claudeRoot, 'teams', 'gone-team'), { recursive: true }); + await fs.mkdir(path.join(claudeRoot, 'tasks', 'gone-team'), { recursive: true }); + + const configInvalidateSpy = vi.spyOn(TeamConfigReader, 'invalidateTeam'); + const taskInvalidateSpy = vi.spyOn(TeamTaskReader, 'invalidateAllTasksCache'); + + const service = new TeamDataService(); + await service.permanentlyDeleteTeam('gone-team'); + + await expect(fs.access(path.join(claudeRoot, 'teams', 'gone-team'))).rejects.toThrow(); + await expect(fs.access(path.join(claudeRoot, 'tasks', 'gone-team'))).rejects.toThrow(); + expect(configInvalidateSpy).toHaveBeenCalledWith('gone-team'); + expect(taskInvalidateSpy).toHaveBeenCalledTimes(1); + }); +}); + describe('TeamDataService draft metadata', () => { it('round-trips create config metadata through getSavedRequest', async () => { const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-data-saved-request-')); @@ -244,6 +348,7 @@ describe('TeamDataService draft metadata', () => { setClaudeBasePathOverride(claudeRoot); const service = new TeamDataService(); + const listCacheInvalidateSpy = vi.spyOn(TeamConfigReader, 'invalidateListTeamsCache'); await service.createTeamConfig({ teamName: 'draft-team', displayName: 'Draft Team', @@ -271,6 +376,7 @@ describe('TeamDataService draft metadata', () => { }, ], }); + expect(listCacheInvalidateSpy).toHaveBeenCalled(); await expect(service.getSavedRequest('missing-team')).resolves.toBeNull(); await expect(service.getSavedRequest('draft-team')).resolves.toMatchObject({ @@ -1319,11 +1425,17 @@ describe('TeamDataService', () => { projectPath: '/Users/dev/my-project', members: [], })); + const getConfigSnapshot = vi.fn(async () => ({ + name: 'My Team', + projectPath: '/Users/dev/my-project', + members: [], + })); const service = new TeamDataService( { listTeams: vi.fn(), getConfig, + getConfigSnapshot, } as never, {} as never, {} as never, @@ -1343,7 +1455,8 @@ describe('TeamDataService', () => { displayName: 'My Team', projectPath: '/Users/dev/my-project', }); - expect(getConfig).toHaveBeenCalledWith('my-team'); + expect(getConfigSnapshot).toHaveBeenCalledWith('my-team'); + expect(getConfig).not.toHaveBeenCalled(); }); it('creates task with status pending when startImmediately is false', async () => { diff --git a/test/main/services/team/TeamDataWorkerClient.test.ts b/test/main/services/team/TeamDataWorkerClient.test.ts index 8628ec61..fac20503 100644 --- a/test/main/services/team/TeamDataWorkerClient.test.ts +++ b/test/main/services/team/TeamDataWorkerClient.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const hoisted = vi.hoisted(() => { + const skipResponsesForOps = new Set(); const workers: Array<{ messages: unknown[]; handlers: Map void>; @@ -15,6 +16,7 @@ const hoisted = vi.hoisted(() => { postMessage(message: unknown) { worker.messages.push(message); const request = message as { id: string; op: string; payload?: { teamName?: string } }; + if (skipResponsesForOps.has(request.op)) return; queueMicrotask(() => { const handler = worker.handlers.get('message'); if (!handler) return; @@ -24,6 +26,8 @@ const hoisted = vi.hoisted(() => { result: request.op === 'getTeamData' ? { teamName: request.payload?.teamName, config: { name: 'Team' } } + : request.op === 'getMessagesPage' + ? { messages: [], nextCursor: null, hasMore: false, feedRevision: 'rev-1' } : null, }); }); @@ -39,6 +43,7 @@ const hoisted = vi.hoisted(() => { return { workers, createMockWorker, + skipResponsesForOps, }; }); @@ -61,7 +66,9 @@ describe('TeamDataWorkerClient', () => { afterEach(() => { vi.resetModules(); vi.clearAllMocks(); + vi.useRealTimers(); hoisted.workers.length = 0; + hoisted.skipResponsesForOps.clear(); }); it('deduplicates concurrent getTeamData calls for the same team', async () => { @@ -107,6 +114,71 @@ describe('TeamDataWorkerClient', () => { client.dispose(); }); + it('deduplicates concurrent getMessagesPage calls with the same page key', async () => { + const { TeamDataWorkerClient } = await import( + '../../../../src/main/services/team/TeamDataWorkerClient' + ); + const client = new TeamDataWorkerClient(); + + const [first, second] = await Promise.all([ + client.getMessagesPage('my-team', { cursor: null, limit: 50 }), + client.getMessagesPage('my-team', { cursor: null, limit: 50 }), + ]); + + expect(first).toEqual(second); + expect(hoisted.workers).toHaveLength(1); + expect(hoisted.workers[0].messages).toHaveLength(1); + expect(hoisted.workers[0].messages[0]).toMatchObject({ + op: 'getMessagesPage', + payload: { teamName: 'my-team', options: { cursor: null, limit: 50 } }, + }); + + client.dispose(); + }); + + it('sends best-effort message feed invalidation to the worker', async () => { + const { TeamDataWorkerClient } = await import( + '../../../../src/main/services/team/TeamDataWorkerClient' + ); + const client = new TeamDataWorkerClient(); + await client.getTeamData('my-team'); + hoisted.workers[0].messages.length = 0; + + client.invalidateTeamMessageFeed('my-team'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(hoisted.workers).toHaveLength(1); + expect(hoisted.workers[0].messages).toHaveLength(1); + expect(hoisted.workers[0].messages[0]).toMatchObject({ + op: 'invalidateTeamMessageFeed', + payload: { teamName: 'my-team' }, + }); + + client.dispose(); + }); + + it('clears in-flight getMessagesPage dedupe when invalidating message feed', async () => { + const { TeamDataWorkerClient } = await import( + '../../../../src/main/services/team/TeamDataWorkerClient' + ); + const client = new TeamDataWorkerClient(); + + const first = client.getMessagesPage('my-team', { cursor: null, limit: 50 }); + client.invalidateTeamMessageFeed('my-team'); + const second = client.getMessagesPage('my-team', { cursor: null, limit: 50 }); + + await Promise.all([first, second]); + + expect(hoisted.workers).toHaveLength(1); + expect(hoisted.workers[0].messages.map((message) => (message as { op: string }).op)).toEqual([ + 'getMessagesPage', + 'invalidateTeamMessageFeed', + 'getMessagesPage', + ]); + + client.dispose(); + }); + it('clears in-flight getTeamData dedupe when invalidating team config', async () => { const { TeamDataWorkerClient } = await import( '../../../../src/main/services/team/TeamDataWorkerClient' @@ -140,4 +212,23 @@ describe('TeamDataWorkerClient', () => { expect(hoisted.workers).toHaveLength(0); }); + + it('does not attach a timeout that can kill the worker for best-effort invalidation', async () => { + vi.useFakeTimers(); + const { TeamDataWorkerClient } = await import( + '../../../../src/main/services/team/TeamDataWorkerClient' + ); + const client = new TeamDataWorkerClient(); + await client.getTeamData('my-team'); + hoisted.workers[0].messages.length = 0; + hoisted.skipResponsesForOps.add('invalidateTeamMessageFeed'); + + client.invalidateTeamMessageFeed('my-team'); + await vi.advanceTimersByTimeAsync(31_000); + + expect(hoisted.workers[0].messages).toHaveLength(1); + expect(hoisted.workers[0].terminate).not.toHaveBeenCalled(); + + client.dispose(); + }); }); diff --git a/test/main/services/team/TeamFsWorker.integration.test.ts b/test/main/services/team/TeamFsWorker.integration.test.ts index 647394fd..5cacefb2 100644 --- a/test/main/services/team/TeamFsWorker.integration.test.ts +++ b/test/main/services/team/TeamFsWorker.integration.test.ts @@ -1,4 +1,3 @@ -import { existsSync } from 'fs'; import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; @@ -18,11 +17,6 @@ interface WorkerResponse { let bundledWorkerPathPromise: Promise | null = null; async function getWorkerPath(): Promise { - const builtWorkerPath = path.join(process.cwd(), 'dist-electron', 'main', 'team-fs-worker.cjs'); - if (existsSync(builtWorkerPath)) { - return builtWorkerPath; - } - bundledWorkerPathPromise ??= bundleWorkerForTests(); return bundledWorkerPathPromise; } @@ -230,7 +224,7 @@ describe('team-fs-worker integration', () => { JSON.stringify({ version: 1, members: [ - { name: 'team-lead', agentType: 'team-lead' }, + { name: 'team-lead', agentType: 'team-lead', color: '#123456' }, { name: 'alice', removedAt: Date.parse('2026-04-22T12:01:00.000Z') }, { name: 'bob', role: 'developer' }, ], @@ -246,6 +240,8 @@ describe('team-fs-worker integration', () => { teamName, displayName: 'Draft Worker Team', memberCount: 1, + leadName: 'team-lead', + leadColor: '#123456', }); } finally { await worker.terminate(); diff --git a/test/main/services/team/TeamMessageFeedService.test.ts b/test/main/services/team/TeamMessageFeedService.test.ts index 34149bca..b35ddae4 100644 --- a/test/main/services/team/TeamMessageFeedService.test.ts +++ b/test/main/services/team/TeamMessageFeedService.test.ts @@ -17,6 +17,16 @@ function makeMessage(overrides: Partial = {}): InboxMessage { }; } +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + describe('TeamMessageFeedService', () => { const config: TeamConfig = { name: 'Signal Ops 4', @@ -101,6 +111,75 @@ describe('TeamMessageFeedService', () => { ).toBe(true); }); + it('deduplicates concurrent feed rebuilds for the same team', async () => { + const inboxRequest = createDeferred(); + const getInboxMessages = vi.fn(() => inboxRequest.promise); + const service = new TeamMessageFeedService({ + getConfig: vi.fn(async () => config), + getInboxMessages, + getLeadSessionMessages: vi.fn(async () => []), + getSentMessages: vi.fn(async () => []), + }); + + const first = service.getFeed('signal-ops-4'); + const second = service.getFeed('signal-ops-4'); + await Promise.resolve(); + + expect(getInboxMessages).toHaveBeenCalledTimes(1); + inboxRequest.resolve([makeMessage()]); + + const [firstFeed, secondFeed] = await Promise.all([first, second]); + expect(firstFeed).toEqual(secondFeed); + expect(firstFeed.messages).toHaveLength(1); + }); + + it('does not reuse or cache a stale in-flight rebuild after invalidation', async () => { + const firstInboxRequest = createDeferred(); + const secondInboxRequest = createDeferred(); + const getInboxMessages = vi + .fn() + .mockImplementationOnce(() => firstInboxRequest.promise) + .mockImplementationOnce(() => secondInboxRequest.promise); + const service = new TeamMessageFeedService({ + getConfig: vi.fn(async () => config), + getInboxMessages, + getLeadSessionMessages: vi.fn(async () => []), + getSentMessages: vi.fn(async () => []), + }); + + const staleRequest = service.getFeed('signal-ops-4'); + await Promise.resolve(); + expect(getInboxMessages).toHaveBeenCalledTimes(1); + + service.invalidate('signal-ops-4'); + const freshRequest = service.getFeed('signal-ops-4'); + await Promise.resolve(); + expect(getInboxMessages).toHaveBeenCalledTimes(2); + + secondInboxRequest.resolve([ + makeMessage({ + messageId: 'fresh-message', + text: 'fresh', + timestamp: '2026-04-19T18:46:45.000Z', + }), + ]); + const freshFeed = await freshRequest; + expect(freshFeed.messages[0]?.messageId).toBe('fresh-message'); + + firstInboxRequest.resolve([ + makeMessage({ + messageId: 'stale-message', + text: 'stale', + timestamp: '2026-04-19T18:46:44.000Z', + }), + ]); + await staleRequest; + + const cachedFeed = await service.getFeed('signal-ops-4'); + expect(cachedFeed.messages[0]?.messageId).toBe('fresh-message'); + expect(getInboxMessages).toHaveBeenCalledTimes(2); + }); + it('adds UI-only OpenCode bootstrap start rows for side-lane teammates', async () => { const opencodeConfig: TeamConfig = { name: 'relay-works-14', diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 0cb1eddb..2e79fad5 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -131,6 +131,7 @@ import { import { getTeamBootstrapStatePath } from '@main/services/team/TeamBootstrapStateReader'; import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator'; import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore'; +import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import { getOpenCodeLaneScopedRuntimeFilePath, getOpenCodeRuntimeManifestPath, @@ -8709,6 +8710,38 @@ describe('TeamProvisioningService', () => { }; } + it('invalidates config cache after writing OpenCode team config', async () => { + const teamName = 'opencode-config-cache-prime'; + fs.mkdirSync(path.join(tempTeamsBase, teamName), { recursive: true }); + const invalidateSpy = vi.spyOn(TeamConfigReader, 'invalidateTeam'); + const { svc } = createSafeLaunchService(); + + await (svc as any).writeOpenCodeTeamConfig( + { + teamName, + displayName: 'OpenCode Config Cache Prime', + cwd: tempClaudeRoot, + providerId: 'opencode', + model: 'openrouter/test/model', + effort: 'medium', + }, + [ + { + name: 'bob', + role: 'Developer', + providerId: 'opencode', + model: 'openrouter/test/model', + }, + ] + ); + + expect(invalidateSpy).toHaveBeenCalledWith(teamName); + expect((await new TeamConfigReader().getConfigSnapshot(teamName))?.name).toBe( + 'OpenCode Config Cache Prime' + ); + invalidateSpy.mockRestore(); + }); + it('starts a pure Codex team through the app createTeam path without a real CLI process', async () => { allowConsoleLogs(); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); @@ -9158,6 +9191,7 @@ describe('TeamProvisioningService', () => { const teamName = 'mixed-opencode-post-launch-config'; const teamDir = path.join(tempTeamsBase, teamName); const jackWorktree = path.join(tempClaudeRoot, 'worktrees', 'jack'); + const invalidateSpy = vi.spyOn(TeamConfigReader, 'invalidateTeam'); fs.mkdirSync(teamDir, { recursive: true }); fs.writeFileSync( path.join(teamDir, 'config.json'), @@ -9228,6 +9262,7 @@ describe('TeamProvisioningService', () => { expect(config.leadSessionId).toBe('new-lead-session'); expect(config.projectPath).toBe(tempClaudeRoot); + expect(invalidateSpy).toHaveBeenCalledWith(teamName); expect(config.members).toEqual([ expect.objectContaining({ name: 'team-lead', @@ -9255,6 +9290,7 @@ describe('TeamProvisioningService', () => { }), ]); expect(config.members.some((member) => member.name === 'alice')).toBe(false); + invalidateSpy.mockRestore(); }); it('launches isolated OpenCode side lanes from the resolved member worktree cwd', async () => { diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 1b5e8b7e..af7aab5a 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -19,6 +19,15 @@ const hoisted = vi.hoisted(() => { return { isFile: () => true, size: Buffer.byteLength(data, 'utf8'), + mode: 0o100644, + dev: 1, + ino: 1, + mtimeMs: 1, + ctimeMs: 1, + birthtimeMs: 1, + mtimeNs: 1n, + ctimeNs: 1n, + birthtimeNs: 1n, }; }); @@ -2207,10 +2216,12 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { delivered: true, diagnostics: [], }); + const recipientSpy = vi.spyOn(service, 'isOpenCodeRuntimeRecipient'); const relay = await service.relayInboxFileToLiveRecipient(teamName, 'jack'); expect(relay).toMatchObject({ kind: 'opencode_member', relayed: 1 }); + expect(recipientSpy).toHaveBeenCalledTimes(1); const rows = JSON.parse( hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' ); diff --git a/test/main/services/team/TeamTaskReader.test.ts b/test/main/services/team/TeamTaskReader.test.ts new file mode 100644 index 00000000..40426231 --- /dev/null +++ b/test/main/services/team/TeamTaskReader.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader'; + +import type { TeamTask } from '../../../../src/shared/types/team'; + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function makeTask(id: string): TeamTask & { teamName: string } { + return { + id, + subject: id, + owner: 'alice', + status: 'pending', + createdAt: '2026-05-02T12:00:00.000Z', + updatedAt: '2026-05-02T12:00:00.000Z', + teamName: 'atlas-hq', + }; +} + +describe('TeamTaskReader', () => { + afterEach(() => { + vi.restoreAllMocks(); + TeamTaskReader.invalidateAllTasksCache(); + }); + + it('does not reuse or cache a stale in-flight getAllTasks scan after invalidation', async () => { + const firstRead = createDeferred<(TeamTask & { teamName: string })[]>(); + const secondRead = createDeferred<(TeamTask & { teamName: string })[]>(); + const readAllTasksUncached = vi + .spyOn(TeamTaskReader.prototype as unknown as { readAllTasksUncached: () => Promise<(TeamTask & { teamName: string })[]> }, 'readAllTasksUncached') + .mockImplementationOnce(() => firstRead.promise) + .mockImplementationOnce(() => secondRead.promise); + + const reader = new TeamTaskReader(); + const staleRequest = reader.getAllTasks(); + await Promise.resolve(); + expect(readAllTasksUncached).toHaveBeenCalledTimes(1); + + TeamTaskReader.invalidateAllTasksCache(); + const freshRequest = reader.getAllTasks(); + await Promise.resolve(); + expect(readAllTasksUncached).toHaveBeenCalledTimes(2); + + secondRead.resolve([makeTask('fresh-task')]); + await expect(freshRequest).resolves.toEqual([makeTask('fresh-task')]); + + firstRead.resolve([makeTask('stale-task')]); + await staleRequest; + + await expect(reader.getAllTasks()).resolves.toEqual([makeTask('fresh-task')]); + expect(readAllTasksUncached).toHaveBeenCalledTimes(2); + }); +}); From a652c44794aa86cbda06918b1dca5c6e881b1b33 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 2 May 2026 21:29:22 +0300 Subject: [PATCH 2/5] perf(team): reduce verified config reads --- .../services/team/ChangeExtractorService.ts | 10 +- src/main/services/team/TeamDataService.ts | 14 +- .../services/team/TeamProvisioningService.ts | 284 ++++++++++-------- .../team/TeamTranscriptProjectResolver.ts | 15 +- .../stream/BoardTaskLogStreamService.ts | 8 +- .../stream/CodexNativeTaskLogStreamSource.ts | 8 +- .../services/team/TeamDataService.test.ts | 46 ++- .../team/TeamProvisioningService.test.ts | 147 ++++++++- .../team/TeamProvisioningServiceRelay.test.ts | 39 +++ .../TeamTranscriptProjectResolver.test.ts | 24 ++ 10 files changed, 453 insertions(+), 142 deletions(-) diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 6b84d7c6..143c76b7 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -123,6 +123,12 @@ export class ChangeExtractorService { this.taskChangeComputer = new TaskChangeComputer(logsFinder, boundaryParser); } + private readConfigForObservation(teamName: string) { + return typeof this.configReader.getConfigSnapshot === 'function' + ? this.configReader.getConfigSnapshot(teamName) + : this.configReader.getConfig(teamName); + } + setTaskChangePresenceServices( repository: TaskChangePresenceRepository, tracker: TeamLogSourceTracker @@ -671,7 +677,7 @@ export class ChangeExtractorService { try { const [meta, config] = await Promise.all([ this.teamMetaStore.getMeta(teamName).catch(() => null), - this.configReader.getConfig(teamName).catch(() => null), + this.readConfigForObservation(teamName).catch(() => null), ]); const hasOpenCodeMember = (config?.members ?? []).some( (member) => member.providerId === 'opencode' @@ -996,7 +1002,7 @@ export class ChangeExtractorService { /** Получить projectPath из конфига команды */ private async resolveProjectPath(teamName: string): Promise { try { - const config = await this.configReader.getConfig(teamName); + const config = await this.readConfigForObservation(teamName); return config?.projectPath?.trim() || undefined; } catch { return undefined; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 3a366b93..ca226987 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -1898,7 +1898,7 @@ export class TeamDataService { let projectPath: string | undefined; try { - const config = await this.configReader.getConfig(teamName); + const config = await readConfigForUiSnapshot(this.configReader, teamName); projectPath = config?.projectPath; } catch { /* best-effort */ @@ -2237,7 +2237,7 @@ export class TeamDataService { let enrichedRequest = request; if (!enrichedRequest.leadSessionId) { try { - const config = await this.configReader.getConfig(teamName); + const config = await readConfigForUiSnapshot(this.configReader, teamName); if (config?.leadSessionId) { enrichedRequest = { ...enrichedRequest, leadSessionId: config.leadSessionId }; } @@ -2310,7 +2310,7 @@ export class TeamDataService { private async resolveLeadName(teamName: string): Promise { try { - const config = await this.configReader.getConfig(teamName); + const config = await readConfigForUiSnapshot(this.configReader, teamName); return this.resolveLeadNameFromConfig(config); } catch { return 'team-lead'; @@ -2321,7 +2321,7 @@ export class TeamDataService { teamName: string ): Promise<{ leadName: string; leadSessionId?: string }> { try { - const config = await this.configReader.getConfig(teamName); + const config = await readConfigForUiSnapshot(this.configReader, teamName); return { leadName: this.resolveLeadNameFromConfig(config), leadSessionId: config?.leadSessionId, @@ -2638,7 +2638,7 @@ export class TeamDataService { const recoverPending = options?.recoverPending === true; let config: TeamConfig | null = null; try { - config = await this.configReader.getConfig(teamName); + config = await readConfigForUiSnapshot(this.configReader, teamName); } catch { return; } @@ -2793,7 +2793,7 @@ export class TeamDataService { ): Promise { let leadSessionId: string | undefined; try { - const config = await this.configReader.getConfig(teamName); + const config = await readConfigForUiSnapshot(this.configReader, teamName); leadSessionId = config?.leadSessionId; } catch { // non-critical — proceed without sessionId @@ -2826,7 +2826,7 @@ export class TeamDataService { async getLeadMemberName(teamName: string): Promise { try { - const config = await this.configReader.getConfig(teamName); + const config = await readConfigForUiSnapshot(this.configReader, teamName); // Check config.json members first (Claude Code-created teams) if (config?.members?.length) { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 05336ee6..0aae9695 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4435,6 +4435,27 @@ interface OpenCodeMemberInboxDelivery { diagnostics?: string[]; } +interface OpenCodeMemberDirectory { + config: TeamConfig | null; + teamMeta: Awaited> | null; + metaMembers: Awaited>; +} + +type OpenCodeMemberIdentityResolution = + | { + ok: true; + canonicalMemberName: string; + laneId: string; + laneIdentity: ReturnType; + configMember?: TeamMember; + metaMember?: TeamMember; + memberRuntimeCwd?: string; + } + | { + ok: false; + reason: 'recipient_is_not_opencode' | 'recipient_removed' | 'opencode_recipient_unavailable'; + }; + interface OpenCodeMemberInboxRelayResult { relayed: number; attempted: number; @@ -4613,6 +4634,106 @@ export class TeamProvisioningService { : configReader.getConfig(teamName); } + private readConfigForObservation(teamName: string): Promise { + return this.readConfigSnapshot(teamName); + } + + private readConfigForStrictDecision(teamName: string): Promise { + return this.configReader.getConfig(teamName); + } + + private async readOpenCodeMemberDirectory(teamName: string): Promise { + const [config, teamMeta, metaMembers] = await Promise.all([ + this.readConfigForObservation(teamName).catch(() => null), + this.teamMetaStore.getMeta(teamName).catch(() => null), + this.membersMetaStore.getMembers(teamName).catch(() => []), + ]); + return { config, teamMeta, metaMembers }; + } + + private resolveOpenCodeMemberIdentityFromDirectory( + teamName: string, + memberName: string, + directory: OpenCodeMemberDirectory + ): OpenCodeMemberIdentityResolution { + const normalizedMemberName = memberName.trim(); + const configMember = directory.config?.members?.find( + (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() + ); + const metaMember = directory.metaMembers.find( + (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() + ); + if (!configMember && !metaMember) { + return { ok: false, reason: 'opencode_recipient_unavailable' }; + } + + const configProvider = (configMember as { provider?: unknown } | undefined)?.provider; + const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider; + const providerId = + normalizeTeamProviderLike(metaMember?.providerId) ?? + normalizeTeamProviderLike(metaProvider) ?? + normalizeTeamProviderLike(configMember?.providerId) ?? + normalizeTeamProviderLike(configProvider) ?? + inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model); + if (providerId !== 'opencode') { + return { ok: false, reason: 'recipient_is_not_opencode' }; + } + + const removedAt = + metaMember != null + ? metaMember.removedAt + : (configMember as { removedAt?: unknown } | undefined)?.removedAt; + if (removedAt != null) { + return { ok: false, reason: 'recipient_removed' }; + } + + const canonicalMemberName = + metaMember?.name?.trim() || configMember?.name?.trim() || normalizedMemberName; + const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); + if (runtimeRun?.providerId === 'opencode') { + const laneIdentity = buildPlannedMemberLaneIdentity({ + leadProviderId: 'opencode', + member: { + name: canonicalMemberName, + providerId: 'opencode', + }, + }); + const memberRuntimeCwd = metaMember?.cwd?.trim() || configMember?.cwd?.trim(); + return { + ok: true, + canonicalMemberName, + laneId: laneIdentity.laneId, + laneIdentity, + ...(configMember ? { configMember } : {}), + ...(metaMember ? { metaMember } : {}), + ...(memberRuntimeCwd ? { memberRuntimeCwd } : {}), + }; + } + + const leadMember = directory.config?.members?.find((member) => isLeadMember(member)); + const leadProviderId = + normalizeOptionalTeamProviderId(directory.teamMeta?.launchIdentity?.providerId) ?? + normalizeOptionalTeamProviderId(directory.teamMeta?.providerId) ?? + normalizeOptionalTeamProviderId(leadMember?.providerId); + const laneIdentity = buildPlannedMemberLaneIdentity({ + leadProviderId, + member: { + name: canonicalMemberName, + providerId, + }, + }); + const memberRuntimeCwd = metaMember?.cwd?.trim() || configMember?.cwd?.trim(); + return { + ok: true, + canonicalMemberName, + laneId: laneIdentity.laneId, + laneIdentity, + ...(configMember ? { configMember } : {}), + ...(metaMember ? { metaMember } : {}), + ...(memberRuntimeCwd ? { memberRuntimeCwd } : {}), + }; + } + setRuntimeAdapterRegistry(registry: TeamRuntimeAdapterRegistry | null): void { this.runtimeAdapterRegistry = registry; } @@ -5283,7 +5404,7 @@ export class TeamProvisioningService { const adapter = this.getOpenCodeRuntimeAdapter(); const previousLaunchState = await this.launchStateStore.read(teamName).catch(() => null); const [config, metaMembers] = await Promise.all([ - this.configReader.getConfig(teamName).catch(() => null), + this.readConfigForObservation(teamName).catch(() => null), this.membersMetaStore.getMembers(teamName).catch(() => []), ]); const evidenceReader = new OpenCodeRuntimeManifestEvidenceReader({ @@ -5813,8 +5934,7 @@ export class TeamProvisioningService { }): Promise { const explicitRecipient = input.replyRecipient?.trim() || 'user'; const candidates = [explicitRecipient]; - const configuredLeadName = await this.configReader - .getConfig(input.teamName) + const configuredLeadName = await this.readConfigForObservation(input.teamName) .then( (config) => config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null ) @@ -6236,51 +6356,25 @@ export class TeamProvisioningService { if (!adapter) { return { delivered: false, reason: 'opencode_runtime_message_bridge_unavailable' }; } - const [config, teamMeta, metaMembers] = await Promise.all([ - this.configReader.getConfig(teamName).catch(() => null), - this.teamMetaStore.getMeta(teamName).catch(() => null), - this.membersMetaStore.getMembers(teamName).catch(() => []), - ]); + const directory = await this.readOpenCodeMemberDirectory(teamName); + const identity = this.resolveOpenCodeMemberIdentityFromDirectory( + teamName, + input.memberName, + directory + ); + if (!identity.ok) { + return { + delivered: false, + reason: + identity.reason === 'opencode_recipient_unavailable' + ? 'recipient_is_not_opencode' + : identity.reason, + }; + } + const { config } = directory; + const { canonicalMemberName, laneIdentity, configMember, metaMember, memberRuntimeCwd } = + identity; const normalizedMemberName = input.memberName.trim(); - const configMember = config?.members?.find( - (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() - ); - const metaMember = metaMembers.find( - (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() - ); - const configProvider = (configMember as { provider?: unknown } | undefined)?.provider; - const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider; - const providerId = - normalizeTeamProviderLike(metaMember?.providerId) ?? - normalizeTeamProviderLike(metaProvider) ?? - normalizeTeamProviderLike(configMember?.providerId) ?? - normalizeTeamProviderLike(configProvider) ?? - inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model); - if (providerId !== 'opencode') { - return { delivered: false, reason: 'recipient_is_not_opencode' }; - } - const removedAt = - metaMember != null - ? metaMember.removedAt - : (configMember as { removedAt?: unknown } | undefined)?.removedAt; - if (removedAt != null) { - return { delivered: false, reason: 'recipient_removed' }; - } - const canonicalMemberName = - metaMember?.name?.trim() || configMember?.name?.trim() || normalizedMemberName; - - const leadMember = config?.members?.find((member) => isLeadMember(member)); - const leadProviderId = - normalizeOptionalTeamProviderId(teamMeta?.launchIdentity?.providerId) ?? - normalizeOptionalTeamProviderId(teamMeta?.providerId) ?? - normalizeOptionalTeamProviderId(leadMember?.providerId); - const laneIdentity = buildPlannedMemberLaneIdentity({ - leadProviderId, - member: { - name: canonicalMemberName, - providerId, - }, - }); if ( laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode' && @@ -6288,7 +6382,6 @@ export class TeamProvisioningService { ) { return { delivered: false, reason: 'opencode_runtime_not_active' }; } - const memberRuntimeCwd = metaMember?.cwd?.trim() || configMember?.cwd?.trim(); const cwd = laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode' ? memberRuntimeCwd || @@ -7150,64 +7243,18 @@ export class TeamProvisioningService { | 'opencode_recipient_unavailable'; } > { - const [config, teamMeta, metaMembers] = await Promise.all([ - this.configReader.getConfig(teamName).catch(() => null), - this.teamMetaStore.getMeta(teamName).catch(() => null), - this.membersMetaStore.getMembers(teamName).catch(() => []), - ]); - const normalizedMemberName = memberName.trim(); - const configMember = config?.members?.find( - (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() + const directory = await this.readOpenCodeMemberDirectory(teamName); + const laneIdentity = this.resolveOpenCodeMemberIdentityFromDirectory( + teamName, + memberName, + directory ); - const metaMember = metaMembers.find( - (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() - ); - if (!configMember && !metaMember) { - return { ok: false, reason: 'opencode_recipient_unavailable' }; + if (!laneIdentity.ok) { + return laneIdentity; } - const configProvider = (configMember as { provider?: unknown } | undefined)?.provider; - const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider; - const providerId = - normalizeTeamProviderLike(metaMember?.providerId) ?? - normalizeTeamProviderLike(metaProvider) ?? - normalizeTeamProviderLike(configMember?.providerId) ?? - normalizeTeamProviderLike(configProvider) ?? - inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model); - if (providerId !== 'opencode') { - return { ok: false, reason: 'recipient_is_not_opencode' }; - } - const removedAt = - metaMember != null - ? metaMember.removedAt - : (configMember as { removedAt?: unknown } | undefined)?.removedAt; - if (removedAt != null) { - return { ok: false, reason: 'recipient_removed' }; - } - const canonicalMemberName = - metaMember?.name?.trim() || configMember?.name?.trim() || normalizedMemberName; - const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); - if (runtimeRun?.providerId === 'opencode') { - return { - ok: true, - canonicalMemberName, - laneId: 'primary', - }; - } - const leadMember = config?.members?.find((member) => isLeadMember(member)); - const leadProviderId = - normalizeOptionalTeamProviderId(teamMeta?.launchIdentity?.providerId) ?? - normalizeOptionalTeamProviderId(teamMeta?.providerId) ?? - normalizeOptionalTeamProviderId(leadMember?.providerId); - const laneIdentity = buildPlannedMemberLaneIdentity({ - leadProviderId, - member: { - name: canonicalMemberName, - providerId, - }, - }); return { ok: true, - canonicalMemberName, + canonicalMemberName: laneIdentity.canonicalMemberName, laneId: laneIdentity.laneId, }; } @@ -7216,24 +7263,21 @@ export class TeamProvisioningService { teamName: string, laneId: string ): Promise { - const [config, metaMembers] = await Promise.all([ - this.configReader.getConfig(teamName).catch(() => null), - this.membersMetaStore.getMembers(teamName).catch(() => []), - ]); + const directory = await this.readOpenCodeMemberDirectory(teamName); const names = new Set(); - for (const member of config?.members ?? []) { + for (const member of directory.config?.members ?? []) { if (member.name?.trim()) { names.add(member.name.trim()); } } - for (const member of metaMembers) { + for (const member of directory.metaMembers) { if (member.name?.trim()) { names.add(member.name.trim()); } } const resolved: string[] = []; for (const name of names) { - const identity = await this.resolveOpenCodeMemberDeliveryIdentity(teamName, name); + const identity = this.resolveOpenCodeMemberIdentityFromDirectory(teamName, name, directory); if (identity.ok && identity.laneId === laneId) { resolved.push(identity.canonicalMemberName); } @@ -7306,7 +7350,7 @@ export class TeamProvisioningService { } const [config, teamMeta, metaMembers, currentLaneIndex] = await Promise.all([ - this.configReader.getConfig(teamName).catch(() => null), + this.readConfigForObservation(teamName).catch(() => null), this.teamMetaStore.getMeta(teamName).catch(() => null), this.membersMetaStore.getMembers(teamName).catch(() => []), readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch(() => null), @@ -8831,7 +8875,7 @@ export class TeamProvisioningService { memberName: string; previousMember?: PersistedTeamLaunchMemberState; }): Promise { - const config = await this.configReader.getConfig(input.teamName).catch(() => null); + const config = await this.readConfigForStrictDecision(input.teamName).catch(() => null); const metaMembers = await this.membersMetaStore.getMembers(input.teamName).catch(() => []); const configuredMember = this.resolveEffectiveConfiguredMember( config?.members ?? [], @@ -10886,7 +10930,7 @@ export class TeamProvisioningService { metaMembers: Awaited>; configuredMember: ReturnType; }> => { - const config = await this.configReader.getConfig(teamName); + const config = await this.readConfigForStrictDecision(teamName); const configuredMembers = config?.members ?? []; let metaMembers: Awaited> = []; try { @@ -11202,7 +11246,7 @@ export class TeamProvisioningService { throw new Error('Member name is required'); } - const config = await this.configReader.getConfig(teamName); + const config = await this.readConfigForStrictDecision(teamName); if (!config) { throw new Error(`Team "${teamName}" configuration is no longer available`); } @@ -11362,7 +11406,7 @@ export class TeamProvisioningService { throw new Error('OpenCode runtime adapter is not available for controlled lane reattach.'); } - const config = await this.configReader.getConfig(teamName); + const config = await this.readConfigForStrictDecision(teamName); if (!config) { throw new Error(`Team "${teamName}" configuration is no longer available`); } @@ -16255,7 +16299,7 @@ export class TeamProvisioningService { // as read after native delivery, so we must scan ALL messages (including read). let config: Awaited> | null = null; try { - config = await this.configReader.getConfig(teamName); + config = await this.readConfigForObservation(teamName); } catch { // config not ready yet during early provisioning — skip scan } @@ -16304,7 +16348,7 @@ export class TeamProvisioningService { // Re-read config if needed (already fetched above but guard provisioningComplete path) if (!config) { try { - config = await this.configReader.getConfig(teamName); + config = await this.readConfigForObservation(teamName); } catch { return 0; } @@ -16782,7 +16826,7 @@ export class TeamProvisioningService { for (const teamName of aliveTeams) { try { - const config = await this.configReader.getConfig(teamName); + const config = await this.readConfigForStrictDecision(teamName); if (!config) continue; const oldCode = config.language || 'system'; @@ -21962,7 +22006,7 @@ export class TeamProvisioningService { let currentMembers: TeamCreateRequest['members'] = run.request.members; let leadName = 'team-lead'; try { - const config = await this.configReader.getConfig(run.teamName); + const config = await this.readConfigForObservation(run.teamName); if (config?.members) { const configLead = config.members.find((m) => isLeadMember(m)); leadName = configLead?.name?.trim() || 'team-lead'; @@ -22122,7 +22166,7 @@ export class TeamProvisioningService { let leadName = run.effectiveMembers.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; try { - const config = await this.configReader.getConfig(run.teamName); + const config = await this.readConfigForObservation(run.teamName); if (config?.members) { const configLead = config.members.find((m) => isLeadMember(m)); leadName = configLead?.name?.trim() || leadName; @@ -22833,7 +22877,7 @@ export class TeamProvisioningService { // Resolve project cwd from team config let projectCwd: string | undefined; try { - const config = await this.configReader.getConfig(run.teamName); + const config = await this.readConfigForStrictDecision(run.teamName); projectCwd = config?.projectPath ?? config?.members?.[0]?.cwd; } catch { // best-effort diff --git a/src/main/services/team/TeamTranscriptProjectResolver.ts b/src/main/services/team/TeamTranscriptProjectResolver.ts index cee44f65..dc4c0cfe 100644 --- a/src/main/services/team/TeamTranscriptProjectResolver.ts +++ b/src/main/services/team/TeamTranscriptProjectResolver.ts @@ -46,6 +46,11 @@ interface SessionProjectMatch extends ProjectDirCandidate { matchedSessionId: string; } +interface TeamTranscriptProjectConfigReader { + getConfig(teamName: string): Promise; + getConfigSnapshot?: (teamName: string) => Promise; +} + type ScannedSessionProjectMatch = Omit & { projectPath?: string; }; @@ -187,9 +192,15 @@ export class TeamTranscriptProjectResolver { >(); constructor( - private readonly configReader: Pick = new TeamConfigReader() + private readonly configReader: TeamTranscriptProjectConfigReader = new TeamConfigReader() ) {} + private readConfigForObservation(teamName: string): Promise { + return typeof this.configReader.getConfigSnapshot === 'function' + ? this.configReader.getConfigSnapshot(teamName) + : this.configReader.getConfig(teamName); + } + async getContext( teamName: string, options?: { forceRefresh?: boolean } @@ -203,7 +214,7 @@ export class TeamTranscriptProjectResolver { return cached.value; } - const config = await this.configReader.getConfig(teamName); + const config = await this.readConfigForObservation(teamName); if (!config) { return null; } diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 2789c37f..b7f973dc 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -1669,6 +1669,12 @@ export class BoardTaskLogStreamService { private readonly historicalBoardMcpRawProbe: HistoricalBoardMcpRawProbe = new HistoricalBoardMcpRawProbe() ) {} + private readConfigForObservation(teamName: string) { + return typeof this.configReader.getConfigSnapshot === 'function' + ? this.configReader.getConfigSnapshot(teamName) + : this.configReader.getConfig(teamName); + } + private buildLayoutCacheKey(teamName: string, taskId: string): string { return `${teamName}::${taskId}`; } @@ -2199,7 +2205,7 @@ export class BoardTaskLogStreamService { this.taskReader.getTasks(teamName).catch(() => []), this.taskReader.getDeletedTasks(teamName).catch(() => []), this.membersMetaStore.getMembers(teamName).catch(() => []), - this.configReader.getConfig(teamName).catch(() => null), + this.readConfigForObservation(teamName).catch(() => null), ]); const task = [...activeTasks, ...deletedTasks].find((candidate) => candidate.id === taskId); const ownerName = task?.owner?.trim(); diff --git a/src/main/services/team/taskLogs/stream/CodexNativeTaskLogStreamSource.ts b/src/main/services/team/taskLogs/stream/CodexNativeTaskLogStreamSource.ts index 10148c5e..74a051df 100644 --- a/src/main/services/team/taskLogs/stream/CodexNativeTaskLogStreamSource.ts +++ b/src/main/services/team/taskLogs/stream/CodexNativeTaskLogStreamSource.ts @@ -54,6 +54,12 @@ export class CodexNativeTaskLogStreamSource { private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder() ) {} + private readConfigForObservation(teamName: string) { + return typeof this.configReader.getConfigSnapshot === 'function' + ? this.configReader.getConfigSnapshot(teamName) + : this.configReader.getConfig(teamName); + } + async getTaskLogStream( teamName: string, taskId: string, @@ -163,7 +169,7 @@ export class CodexNativeTaskLogStreamSource { const normalizedOwner = normalizeMemberName(ownerName); const [metaMembers, config] = await Promise.all([ this.membersMetaStore.getMembers(teamName).catch(() => []), - this.configReader.getConfig(teamName).catch(() => null), + this.readConfigForObservation(teamName).catch(() => null), ]); const member = [...metaMembers, ...(config?.members ?? [])].find( (candidate) => normalizeMemberName(candidate.name) === normalizedOwner diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 424ffc9e..441f7d2e 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -339,6 +339,35 @@ describe('TeamDataService task projection cache invalidation', () => { expect(configInvalidateSpy).toHaveBeenCalledWith('gone-team'); expect(taskInvalidateSpy).toHaveBeenCalledTimes(1); }); + + it('keeps team deletion mutations on verified config reads', async () => { + const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-data-delete-verified-')); + tempPaths.push(claudeRoot); + setClaudeBasePathOverride(claudeRoot); + await fs.mkdir(path.join(claudeRoot, 'teams', 'my-team'), { recursive: true }); + + const getConfig = vi.fn(async () => ({ + name: 'My team', + members: [], + })); + const getConfigSnapshot = vi.fn(async () => { + throw new Error('snapshot config read should not be used for team deletion'); + }); + const service = new TeamDataService({ + listTeams: vi.fn(), + getConfig, + getConfigSnapshot, + } as never); + + await service.deleteTeam('my-team'); + + const written = JSON.parse( + await fs.readFile(path.join(claudeRoot, 'teams', 'my-team', 'config.json'), 'utf8') + ) as TeamConfig; + expect(written.deletedAt).toBeTruthy(); + expect(getConfig).toHaveBeenCalledWith('my-team'); + expect(getConfigSnapshot).not.toHaveBeenCalled(); + }); }); describe('TeamDataService draft metadata', () => { @@ -1370,15 +1399,20 @@ describe('TeamDataService', () => { it('includes projectPath from config when creating a task', async () => { const createTaskMock = vi.fn((task) => task); + const getConfig = vi.fn(async () => { + throw new Error('verified config read should not be used for task enrichment'); + }); + const getConfigSnapshot = vi.fn(async () => ({ + name: 'My team', + members: [], + projectPath: '/Users/dev/my-project', + })); const service = new TeamDataService( { listTeams: vi.fn(), - getConfig: vi.fn(async () => ({ - name: 'My team', - members: [], - projectPath: '/Users/dev/my-project', - })), + getConfig, + getConfigSnapshot, } as never, { getNextTaskId: vi.fn(async () => '1'), @@ -1417,6 +1451,8 @@ describe('TeamDataService', () => { expect(createTaskMock).toHaveBeenCalledWith( expect.objectContaining({ projectPath: '/Users/dev/my-project' }) ); + expect(getConfigSnapshot).toHaveBeenCalledWith('my-team'); + expect(getConfig).not.toHaveBeenCalled(); }); it('returns lightweight notification context from config without hydrating team data', async () => { diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 2e79fad5..e0f21a28 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -3439,6 +3439,111 @@ describe('TeamProvisioningService', () => { }); }); + it('uses snapshot config reads for OpenCode member delivery routing', async () => { + const getConfig = vi.fn(async () => { + throw new Error('verified config read should not be used for delivery routing'); + }); + const getConfigSnapshot = vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })); + const svc = new TeamProvisioningService({ + getConfig, + getConfigSnapshot, + } as any); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + diagnostics: [], + })); + svc.setRuntimeAdapterRegistry( + new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]) + ); + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { name: 'bob', providerId: 'opencode', model: 'opencode/minimax-m2.5-free' }, + ]), + }; + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'hello bob', + messageId: 'msg-1', + }) + ).resolves.toMatchObject({ delivered: true }); + + expect(getConfigSnapshot).toHaveBeenCalledWith('team-a'); + expect(getConfig).not.toHaveBeenCalled(); + }); + + it('resolves OpenCode runtime lane members from one snapshot directory read', async () => { + const getConfig = vi.fn(async () => { + throw new Error('verified config read should not be used for lane member resolution'); + }); + const getConfigSnapshot = vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + { name: 'alice', providerId: 'codex', model: 'gpt-5.4' }, + ], + })); + const svc = new TeamProvisioningService({ + getConfig, + getConfigSnapshot, + } as any); + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { name: 'bob', providerId: 'opencode', model: 'opencode/minimax-m2.5-free' }, + ]), + }; + + await expect( + (svc as any).resolveOpenCodeMembersForRuntimeLane( + 'team-a', + 'secondary:opencode:bob' + ) + ).resolves.toEqual(['bob']); + + expect(getConfigSnapshot).toHaveBeenCalledTimes(1); + expect(getConfig).not.toHaveBeenCalled(); + }); + it('delivers OpenCode secondary-lane messages to the member worktree cwd after restart', async () => { const svc = new TeamProvisioningService(); const sendMessageToMember = vi.fn(async (input: Record) => ({ @@ -6531,6 +6636,33 @@ describe('TeamProvisioningService', () => { ); }); + it('keeps OpenCode bootstrap check-in allowlist on verified config reads', async () => { + const getConfig = vi.fn(async () => ({ + teamName: 'mixed-team', + members: [{ name: 'bob', providerId: 'opencode' }], + })); + const getConfigSnapshot = vi.fn(async () => { + throw new Error('snapshot config read should not be used for bootstrap check-in guards'); + }); + const svc = new TeamProvisioningService({ + getConfig, + getConfigSnapshot, + } as any); + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => []), + }; + + await expect( + (svc as any).assertOpenCodeRuntimeMemberCheckinAllowed({ + teamName: 'mixed-team', + memberName: 'bob', + }) + ).resolves.toBeUndefined(); + + expect(getConfig).toHaveBeenCalledWith('mixed-team'); + expect(getConfigSnapshot).not.toHaveBeenCalled(); + }); + it('rejects duplicate OpenCode bootstrap check-ins for members removed after the first check-in', async () => { const svc = new TeamProvisioningService(); const previousSnapshot = { @@ -9664,11 +9796,16 @@ describe('TeamProvisioningService', () => { it('expands teammate permission suggestions to the operational tool set only', async () => { allowConsoleLogs(); + const getConfig = vi.fn(async () => ({ + projectPath: tempClaudeRoot, + members: [{ cwd: tempClaudeRoot }], + })); + const getConfigSnapshot = vi.fn(async () => { + throw new Error('snapshot config read should not be used for permission writes'); + }); const svc = new TeamProvisioningService({ - getConfig: vi.fn(async () => ({ - projectPath: tempClaudeRoot, - members: [{ cwd: tempClaudeRoot }], - })), + getConfig, + getConfigSnapshot, } as any); await (svc as any).respondToTeammatePermission( @@ -9696,6 +9833,8 @@ describe('TeamProvisioningService', () => { ); expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__team_stop'); expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__kanban_clear'); + expect(getConfig).toHaveBeenCalledWith('ops-team'); + expect(getConfigSnapshot).not.toHaveBeenCalled(); }); it('does not broaden admin/runtime teammate permission suggestions', async () => { diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index af7aab5a..4773a70e 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -307,6 +307,45 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1); }); + it('uses snapshot config reads for lead inbox relay routing', async () => { + const getConfig = vi.fn(async () => { + throw new Error('verified config read should not be used for inbox relay routing'); + }); + const getConfigSnapshot = vi.fn(async () => ({ + name: 'My Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + })); + const service = new TeamProvisioningService({ + getConfig, + getConfigSnapshot, + } as any); + const teamName = 'my-team'; + seedLeadInbox(teamName, [ + { + from: 'bob', + text: 'Please assign this to Alice.', + timestamp: '2026-02-23T10:00:00.000Z', + read: false, + summary: 'Need delegation', + messageId: 'm-1', + }, + ]); + + const { writeSpy } = attachAliveRun(service, teamName); + const relayPromise = service.relayLeadInboxMessages(teamName); + const run = await waitForCapture(service); + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [{ type: 'text', text: 'OK, will do.' }], + }); + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); + + await expect(relayPromise).resolves.toBe(1); + expect(writeSpy).toHaveBeenCalledTimes(1); + expect(getConfigSnapshot).toHaveBeenCalledWith(teamName); + expect(getConfig).not.toHaveBeenCalled(); + }); + it('shows assistant text after relay capture has already settled', () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; diff --git a/test/main/services/team/TeamTranscriptProjectResolver.test.ts b/test/main/services/team/TeamTranscriptProjectResolver.test.ts index e914942e..bcf81a67 100644 --- a/test/main/services/team/TeamTranscriptProjectResolver.test.ts +++ b/test/main/services/team/TeamTranscriptProjectResolver.test.ts @@ -140,6 +140,30 @@ describe('TeamTranscriptProjectResolver', () => { return { projectDir, jsonlPath }; } + it('uses snapshot-capable config readers for resolver observations', async () => { + await setupClaudeRoot(); + const { projectDir } = await createSessionFile('/repo/current', 'lead-session-1'); + const getConfig = vi.fn(async () => { + throw new Error('verified config read should not be used for transcript observations'); + }); + const getConfigSnapshot = vi.fn(async () => ({ + name: 'My Team', + projectPath: '/repo/current', + leadSessionId: 'lead-session-1', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + })); + const resolver = new TeamTranscriptProjectResolver({ + getConfig, + getConfigSnapshot, + }); + + const context = await resolver.getContext('my-team'); + + expect(context?.projectDir).toBe(projectDir); + expect(getConfigSnapshot).toHaveBeenCalledWith('my-team'); + expect(getConfig).not.toHaveBeenCalled(); + }); + it('repairs stale projectPath when exact leadSessionId exists only in the renamed project', async () => { await setupClaudeRoot(); From b187bbcdd04b7414333369beba40a8a9ae529925 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 2 May 2026 22:14:08 +0300 Subject: [PATCH 3/5] perf: add launch IO governor for team summaries --- src/main/index.ts | 11 +- src/main/ipc/handlers.ts | 7 +- src/main/ipc/teams.ts | 134 +++++-- src/main/services/team/LaunchIoGovernor.ts | 366 ++++++++++++++++++ test/main/ipc/teams.test.ts | 163 +++++++- .../services/team/LaunchIoGovernor.test.ts | 338 ++++++++++++++++ 6 files changed, 977 insertions(+), 42 deletions(-) create mode 100644 src/main/services/team/LaunchIoGovernor.ts create mode 100644 test/main/services/team/LaunchIoGovernor.test.ts 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')]); + }); +}); From fcb91799902bd9b742467f8395fa9889c574c55f Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 2 May 2026 23:15:34 +0300 Subject: [PATCH 4/5] perf(team): reduce launch IO pressure --- src/main/index.ts | 25 + .../services/team/TeamDataWorkerClient.ts | 18 + src/main/services/team/TeamFsWorkerClient.ts | 78 ++- .../services/team/TeamProvisioningService.ts | 201 ++++-- src/main/services/team/teamDataWorkerTypes.ts | 1 + src/main/workers/team-data-worker.ts | 8 +- src/main/workers/team-fs-worker.ts | 603 ++++++++++++++++-- .../team/TeamDataWorkerClient.test.ts | 19 + .../team/TeamFsWorker.integration.test.ts | 216 ++++++- .../services/team/TeamFsWorkerClient.test.ts | 152 +++++ .../team/TeamProvisioningService.test.ts | 186 ++++++ 11 files changed, 1380 insertions(+), 127 deletions(-) create mode 100644 test/main/services/team/TeamFsWorkerClient.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index e7aae28b..1bf686f0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -138,6 +138,7 @@ import { } from './services/team/TeamControlApiState'; import { TeamInboxReader } from './services/team/TeamInboxReader'; import { getTeamDataWorkerClient } from './services/team/TeamDataWorkerClient'; +import { getTeamFsWorkerClient } from './services/team/TeamFsWorkerClient'; import { TeamMemberRuntimeAdvisoryService } from './services/team/TeamMemberRuntimeAdvisoryService'; import { createTeamReconcileDrainScheduler, @@ -1841,6 +1842,30 @@ function createWindow(): void { updaterService.startPeriodicCheck(60 * 60 * 1000); } + scheduleStartupTask( + () => { + void getTeamFsWorkerClient() + .prewarm() + .catch((error: unknown) => + logger.debug( + `[startup] team-fs-worker prewarm skipped: ${ + error instanceof Error ? error.message : String(error) + }` + ) + ); + void getTeamDataWorkerClient() + .prewarm() + .catch((error: unknown) => + logger.debug( + `[startup] team-data-worker prewarm skipped: ${ + error instanceof Error ? error.message : String(error) + }` + ) + ); + }, + process.platform === 'win32' ? 2500 : 1000 + ); + // Defer non-critical startup work to avoid thread pool contention. // The window is now visible and responsive; these run in the background. scheduleStartupTask(() => { diff --git a/src/main/services/team/TeamDataWorkerClient.ts b/src/main/services/team/TeamDataWorkerClient.ts index f53259a5..ed7398a7 100644 --- a/src/main/services/team/TeamDataWorkerClient.ts +++ b/src/main/services/team/TeamDataWorkerClient.ts @@ -66,6 +66,9 @@ interface PendingEntry { function summarizeWorkerPayload( payload: TeamDataWorkerRequest['payload'] ): Record { + if (!payload) { + return {}; + } if ('taskId' in payload) { return { teamName: payload.teamName, @@ -213,6 +216,21 @@ export class TeamDataWorkerClient { }); } + async prewarm(): Promise { + if (this.worker) { + return; + } + if (!this.isAvailable()) { + return; + } + const startedAt = Date.now(); + await this.call('warmup', {}); + const ms = Date.now() - startedAt; + if (ms >= 1500) { + logger.warn(`worker prewarm slow ms=${ms}`); + } + } + private postBestEffort( op: TeamDataWorkerRequest['op'], payload: TeamDataWorkerRequest['payload'] diff --git a/src/main/services/team/TeamFsWorkerClient.ts b/src/main/services/team/TeamFsWorkerClient.ts index f2089afd..f9991efb 100644 --- a/src/main/services/team/TeamFsWorkerClient.ts +++ b/src/main/services/team/TeamFsWorkerClient.ts @@ -36,6 +36,7 @@ interface GetAllTasksPayload { } type WorkerRequest = + | { id: string; op: 'warmup'; payload?: Record } | { id: string; op: 'listTeams'; payload: ListTeamsPayload } | { id: string; op: 'getAllTasks'; payload: GetAllTasksPayload }; @@ -44,6 +45,9 @@ type WorkerResponse = | { id: string; ok: false; error: string }; function summarizeWorkerPayload(payload: WorkerRequest['payload']): Record { + if (!payload) { + return {}; + } if ('teamsDir' in payload) { return { teamsDir: payload.teamsDir, @@ -52,6 +56,9 @@ function summarizeWorkerPayload(payload: WorkerRequest['payload']): Record void; reject: (e: Error) => void } >(); + private failWorker(worker: Worker, error: Error): void { + if (this.worker !== worker) return; + + this.worker = null; + const pendingEntries = Array.from(this.pending.values()); + this.pending.clear(); + + for (const entry of pendingEntries) { + entry.reject(error); + } + } + isAvailable(): boolean { if (!this.workerPath && !this.warnedUnavailable && shouldWarnUnavailableWorker()) { this.warnedUnavailable = true; @@ -134,8 +153,9 @@ export class TeamFsWorkerClient { return this.worker; } - this.worker = new Worker(this.workerPath); - this.worker.on('message', (msg: WorkerResponse) => { + const worker = new Worker(this.workerPath); + this.worker = worker; + worker.on('message', (msg: WorkerResponse) => { const entry = this.pending.get(msg.id); if (!entry) return; this.pending.delete(msg.id); @@ -145,26 +165,18 @@ export class TeamFsWorkerClient { entry.reject(new Error(msg.error)); } }); - this.worker.on('error', (err) => { + worker.on('error', (err) => { logger.error('Worker error', err); - for (const [, entry] of this.pending) { - entry.reject(err instanceof Error ? err : new Error(String(err))); - } - this.pending.clear(); - this.worker = null; + this.failWorker(worker, err instanceof Error ? err : new Error(String(err))); }); - this.worker.on('exit', (code) => { + worker.on('exit', (code) => { if (code !== 0) { logger.warn(`Worker exited with code ${code}`); } - for (const [, entry] of this.pending) { - entry.reject(new Error(`Worker exited with code ${code}`)); - } - this.pending.clear(); - this.worker = null; + this.failWorker(worker, new Error(`Worker exited with code ${code}`)); }); - return this.worker; + return worker; } private call( @@ -177,21 +189,22 @@ export class TeamFsWorkerClient { const pendingAtStart = this.pending.size; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { - this.pending.delete(id); - try { - // Terminate and recreate on next call — worker may be stuck in native IO. - this.worker?.terminate().catch(() => undefined); - } catch { - // ignore - } finally { - this.worker = null; - } + const timeoutError = new Error( + `Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms (${op})` + ); logger.warn( `worker call timeout op=${op} ms=${Date.now() - startedAt} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify( summarizeWorkerPayload(payload) )}` ); - reject(new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms (${op})`)); + this.failWorker(worker, timeoutError); + try { + // Terminate and recreate on next call - worker may be stuck in native IO. + worker.terminate().catch(() => undefined); + } catch { + // ignore + } + reject(timeoutError); }, WORKER_CALL_TIMEOUT_MS); this.pending.set(id, { @@ -224,6 +237,21 @@ export class TeamFsWorkerClient { }); } + async prewarm(): Promise { + if (this.worker) { + return; + } + if (!this.isAvailable()) { + return; + } + const startedAt = Date.now(); + await this.call('warmup', {}); + const ms = Date.now() - startedAt; + if (ms >= 1500) { + logger.warn(`worker prewarm slow ms=${ms}`); + } + } + async listTeams(options: { largeConfigBytes: number; configHeadBytes: number; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 0aae9695..6374cb2e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4565,10 +4565,31 @@ export class TeamProvisioningService { string, { expiresAtMs: number; snapshot: TeamAgentRuntimeSnapshot } >(); + private readonly agentRuntimeSnapshotInFlightByTeam = new Map< + string, + { + generationAtStart: number; + runIdAtStart: string | null; + promise: Promise; + } + >(); private readonly liveTeamAgentRuntimeMetadataCache = new Map< string, - { expiresAtMs: number; metadata: Map } + { + expiresAtMs: number; + metadata: Map; + runId: string | null; + } >(); + private readonly liveTeamAgentRuntimeMetadataInFlightByTeam = new Map< + string, + { + generationAtStart: number; + runIdAtStart: string | null; + promise: Promise>; + } + >(); + private readonly runtimeSnapshotCacheGenerationByTeam = new Map(); private readonly launchStateStore = new TeamLaunchStateStore(); private readonly launchStateStoreQueue = new Map>(); private readonly memberLogsFinder: TeamMemberLogsFinder; @@ -4651,6 +4672,35 @@ export class TeamProvisioningService { return { config, teamMeta, metaMembers }; } + private getRuntimeSnapshotCacheGeneration(teamName: string): number { + return this.runtimeSnapshotCacheGenerationByTeam.get(teamName) ?? 0; + } + + private invalidateRuntimeSnapshotCaches(teamName: string): void { + this.runtimeSnapshotCacheGenerationByTeam.set( + teamName, + this.getRuntimeSnapshotCacheGeneration(teamName) + 1 + ); + this.agentRuntimeSnapshotCache.delete(teamName); + this.agentRuntimeSnapshotInFlightByTeam.delete(teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.liveTeamAgentRuntimeMetadataInFlightByTeam.delete(teamName); + } + + private cloneLiveTeamAgentRuntimeMetadata( + metadata: ReadonlyMap + ): Map { + return new Map( + [...metadata.entries()].map(([memberName, entry]) => [ + memberName, + { + ...entry, + ...(entry.diagnostics ? { diagnostics: [...entry.diagnostics] } : {}), + }, + ]) + ); + } + private resolveOpenCodeMemberIdentityFromDirectory( teamName: string, memberName: string, @@ -5463,6 +5513,7 @@ export class TeamProvisioningService { this.runtimeAdapterRunByTeam.delete(teamName); this.aliveRunByTeam.delete(teamName); this.provisioningRunByTeam.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); } } if (cleaned > 0) { @@ -7625,6 +7676,7 @@ export class TeamProvisioningService { private resetTeamScopedTransientStateForNewRun(teamName: string): void { peekAutoResumeService()?.cancelPendingAutoResume(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); this.retainedClaudeLogsByTeam.delete(teamName); this.persistedTranscriptClaudeLogsCache.delete(teamName); this.leadInboxRelayInFlight.delete(teamName); @@ -9070,8 +9122,7 @@ export class TeamProvisioningService { trackedUpdate.run, this.getMixedSecondaryLaunchPhase(trackedUpdate.run) ); - this.agentRuntimeSnapshotCache.delete(input.teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(input.teamName); + this.invalidateRuntimeSnapshotCaches(input.teamName); if (trackedUpdate.changed) { this.teamChangeEmitter?.({ type: 'member-spawn', @@ -9158,8 +9209,7 @@ export class TeamProvisioningService { updatedAt: input.observedAt, }); await this.writeLaunchStateSnapshot(input.teamName, snapshot); - this.agentRuntimeSnapshotCache.delete(input.teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(input.teamName); + this.invalidateRuntimeSnapshotCaches(input.teamName); if (shouldEmitMemberSpawnChange) { this.teamChangeEmitter?.({ type: 'member-spawn', @@ -9936,8 +9986,7 @@ export class TeamProvisioningService { ); return; } - this.agentRuntimeSnapshotCache.delete(run.teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); + this.invalidateRuntimeSnapshotCaches(run.teamName); this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting'); this.appendMemberBootstrapDiagnostic( run, @@ -10405,6 +10454,36 @@ export class TeamProvisioningService { return cached.snapshot; } + const generationAtStart = this.getRuntimeSnapshotCacheGeneration(teamName); + const existingRequest = this.agentRuntimeSnapshotInFlightByTeam.get(teamName); + if ( + existingRequest && + existingRequest.generationAtStart === generationAtStart && + existingRequest.runIdAtStart === runId + ) { + return existingRequest.promise; + } + + const request = this.buildTeamAgentRuntimeSnapshot(teamName, runId, generationAtStart).finally( + () => { + if (this.agentRuntimeSnapshotInFlightByTeam.get(teamName)?.promise === request) { + this.agentRuntimeSnapshotInFlightByTeam.delete(teamName); + } + } + ); + this.agentRuntimeSnapshotInFlightByTeam.set(teamName, { + generationAtStart, + runIdAtStart: runId, + promise: request, + }); + return request; + } + + private async buildTeamAgentRuntimeSnapshot( + teamName: string, + runId: string | null, + generationAtStart: number + ): Promise { const updatedAt = nowIso(); const run = runId ? (this.runs.get(runId) ?? null) : null; const currentRuntimeAdapterRun = this.runtimeAdapterRunByTeam.get(teamName); @@ -10627,10 +10706,15 @@ export class TeamProvisioningService { members: snapshotMembers, }; - this.agentRuntimeSnapshotCache.set(teamName, { - expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS, - snapshot, - }); + if ( + this.getRuntimeSnapshotCacheGeneration(teamName) === generationAtStart && + this.getTrackedRunId(teamName) === runId + ) { + this.agentRuntimeSnapshotCache.set(teamName, { + expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS, + snapshot, + }); + } return snapshot; } @@ -11004,8 +11088,7 @@ export class TeamProvisioningService { ); } - this.agentRuntimeSnapshotCache.delete(teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName); const livePids = new Set(); let hasAliveRuntimeWithoutPid = false; @@ -11150,8 +11233,7 @@ export class TeamProvisioningService { throw new Error('Lead restart is not supported from member controls'); } - this.agentRuntimeSnapshotCache.delete(teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); this.resetRuntimeToolActivity(run, memberName); this.clearMemberSpawnToolTracking(run, memberName); this.setMemberSpawnStatus(run, memberName, 'spawning'); @@ -11311,8 +11393,7 @@ export class TeamProvisioningService { : 'Skipped by user for this launch'; if (run && !run.processKilled && !run.cancelRequested) { - this.agentRuntimeSnapshotCache.delete(teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); this.resetRuntimeToolActivity(run, normalizedMemberName); this.clearMemberSpawnToolTracking(run, normalizedMemberName); this.setMemberSpawnStatus(run, normalizedMemberName, 'skipped', reason); @@ -11374,8 +11455,7 @@ export class TeamProvisioningService { updatedAt, }); await this.writeLaunchStateSnapshot(teamName, nextSnapshot); - this.agentRuntimeSnapshotCache.delete(teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); } private getMutableAliveRunOrThrow(teamName: string): ProvisioningRun { @@ -11489,8 +11569,7 @@ export class TeamProvisioningService { } this.upsertRunAllEffectiveMember(run, memberSpec); - this.agentRuntimeSnapshotCache.delete(teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); this.resetRuntimeToolActivity(run, memberName); this.clearMemberSpawnToolTracking(run, memberName); run.pendingMemberRestarts.delete(memberName); @@ -11544,8 +11623,7 @@ export class TeamProvisioningService { ); if (laneIndex < 0) { this.removeRunAllEffectiveMember(run, memberName); - this.agentRuntimeSnapshotCache.delete(teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); await this.persistLaunchStateSnapshot(run, this.getMixedSecondaryLaunchPhase(run)); return; } @@ -11553,8 +11631,7 @@ export class TeamProvisioningService { const [lane] = run.mixedSecondaryLanes.splice(laneIndex, 1); await this.stopSingleMixedSecondaryRuntimeLane(run, lane, 'cleanup'); this.removeRunAllEffectiveMember(run, memberName); - this.agentRuntimeSnapshotCache.delete(teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); this.resetRuntimeToolActivity(run, memberName); this.clearMemberSpawnToolTracking(run, memberName); run.pendingMemberRestarts.delete(memberName); @@ -14429,6 +14506,7 @@ export class TeamProvisioningService { }).catch(() => undefined); this.runtimeAdapterRunByTeam.delete(input.request.teamName); this.aliveRunByTeam.delete(input.request.teamName); + this.invalidateRuntimeSnapshotCaches(input.request.teamName); } else { this.runtimeAdapterRunByTeam.set(input.request.teamName, { runId, @@ -14437,6 +14515,7 @@ export class TeamProvisioningService { members: result.members, }); this.aliveRunByTeam.set(input.request.teamName, runId); + this.invalidateRuntimeSnapshotCaches(input.request.teamName); } if (this.provisioningRunByTeam.get(input.request.teamName) === runId) { this.provisioningRunByTeam.delete(input.request.teamName); @@ -15399,6 +15478,7 @@ export class TeamProvisioningService { if (this.provisioningRunByTeam.get(teamName) === runId) { this.provisioningRunByTeam.delete(teamName); } + this.invalidateRuntimeSnapshotCaches(teamName); this.setRuntimeAdapterProgress({ ...runtimeProgress, state: 'cancelled', @@ -15478,6 +15558,7 @@ export class TeamProvisioningService { if (this.provisioningRunByTeam.get(teamName) === runId) { this.provisioningRunByTeam.delete(teamName); } + this.invalidateRuntimeSnapshotCaches(teamName); } private recordCancelledOpenCodeRuntimeAdapterLaunch( @@ -15490,6 +15571,7 @@ export class TeamProvisioningService { this.provisioningRunByTeam.delete(teamName); this.runtimeAdapterRunByTeam.delete(teamName); this.aliveRunByTeam.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); const progress: TeamProvisioningProgress = { runId, teamName, @@ -17519,12 +17601,44 @@ export class TeamProvisioningService { private async getLiveTeamAgentRuntimeMetadata( teamName: string ): Promise> { + const runId = this.getTrackedRunId(teamName); const cached = this.liveTeamAgentRuntimeMetadataCache.get(teamName); - if (cached && cached.expiresAtMs > Date.now()) { - return cached.metadata; + if (cached && cached.expiresAtMs > Date.now() && cached.runId === runId) { + return this.cloneLiveTeamAgentRuntimeMetadata(cached.metadata); } - const runId = this.getTrackedRunId(teamName); + const generationAtStart = this.getRuntimeSnapshotCacheGeneration(teamName); + const existingRequest = this.liveTeamAgentRuntimeMetadataInFlightByTeam.get(teamName); + if ( + existingRequest && + existingRequest.generationAtStart === generationAtStart && + existingRequest.runIdAtStart === runId + ) { + return this.cloneLiveTeamAgentRuntimeMetadata(await existingRequest.promise); + } + + const request = this.buildLiveTeamAgentRuntimeMetadata( + teamName, + runId, + generationAtStart + ).finally(() => { + if (this.liveTeamAgentRuntimeMetadataInFlightByTeam.get(teamName)?.promise === request) { + this.liveTeamAgentRuntimeMetadataInFlightByTeam.delete(teamName); + } + }); + this.liveTeamAgentRuntimeMetadataInFlightByTeam.set(teamName, { + generationAtStart, + runIdAtStart: runId, + promise: request, + }); + return this.cloneLiveTeamAgentRuntimeMetadata(await request); + } + + private async buildLiveTeamAgentRuntimeMetadata( + teamName: string, + runId: string | null, + generationAtStart: number + ): Promise> { const run = runId ? (this.runs.get(runId) ?? null) : null; let configuredMembers: TeamConfig['members'] = []; @@ -17865,10 +17979,16 @@ export class TeamProvisioningService { }); } - this.liveTeamAgentRuntimeMetadataCache.set(teamName, { - expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS, - metadata: metadataByMember, - }); + if ( + this.getRuntimeSnapshotCacheGeneration(teamName) === generationAtStart && + this.getTrackedRunId(teamName) === runId + ) { + this.liveTeamAgentRuntimeMetadataCache.set(teamName, { + expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS, + metadata: this.cloneLiveTeamAgentRuntimeMetadata(metadataByMember), + runId, + }); + } return metadataByMember; } @@ -18884,14 +19004,12 @@ export class TeamProvisioningService { if (filteredSnapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') { await this.clearPersistedLaunchStateNow(run.teamName); - this.agentRuntimeSnapshotCache.delete(run.teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); + this.invalidateRuntimeSnapshotCaches(run.teamName); return null; } const writtenSnapshot = await this.writeLaunchStateSnapshotNow(run.teamName, filteredSnapshot); - this.agentRuntimeSnapshotCache.delete(run.teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); + this.invalidateRuntimeSnapshotCaches(run.teamName); return writtenSnapshot; } @@ -20772,8 +20890,7 @@ export class TeamProvisioningService { * Always uses SIGKILL via killTeamProcess() to prevent CLI cleanup. */ async stopTeam(teamName: string): Promise { - this.agentRuntimeSnapshotCache.delete(teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); this.stopPersistentTeamMembers(teamName); const runId = this.getTrackedRunId(teamName); @@ -21001,6 +21118,7 @@ export class TeamProvisioningService { this.runtimeAdapterRunByTeam.delete(teamName); this.aliveRunByTeam.delete(teamName); this.provisioningRunByTeam.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); return; } const startedAt = nowIso(); @@ -21019,6 +21137,7 @@ export class TeamProvisioningService { if (this.provisioningRunByTeam.get(teamName) === runId) { this.provisioningRunByTeam.delete(teamName); } + this.invalidateRuntimeSnapshotCaches(teamName); try { await clearOpenCodeRuntimeLaneStorage({ teamsBasePath: getTeamsBasePath(), @@ -21364,8 +21483,7 @@ export class TeamProvisioningService { ); return true; } - this.agentRuntimeSnapshotCache.delete(run.teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); + this.invalidateRuntimeSnapshotCaches(run.teamName); this.setMemberSpawnStatus(run, memberName, 'waiting'); this.appendMemberBootstrapDiagnostic( run, @@ -23760,8 +23878,7 @@ export class TeamProvisioningService { this.clearSecondaryRuntimeRuns(run.teamName); } if (!hasNewerTrackedRun) { - this.agentRuntimeSnapshotCache.delete(run.teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); + this.invalidateRuntimeSnapshotCaches(run.teamName); this.leadInboxRelayInFlight.delete(run.teamName); this.relayedLeadInboxMessageIds.delete(run.teamName); this.pendingCrossTeamFirstReplies.delete(run.teamName); diff --git a/src/main/services/team/teamDataWorkerTypes.ts b/src/main/services/team/teamDataWorkerTypes.ts index 6b14860f..11a75219 100644 --- a/src/main/services/team/teamDataWorkerTypes.ts +++ b/src/main/services/team/teamDataWorkerTypes.ts @@ -56,6 +56,7 @@ export interface TeamDataWorkerDiag { // ── Request / Response ── export type TeamDataWorkerRequest = + | { id: string; op: 'warmup'; payload?: Record } | { id: string; op: 'getTeamData'; payload: GetTeamDataPayload } | { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload } | { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload } diff --git a/src/main/workers/team-data-worker.ts b/src/main/workers/team-data-worker.ts index 361685c4..931f26ec 100644 --- a/src/main/workers/team-data-worker.ts +++ b/src/main/workers/team-data-worker.ts @@ -39,12 +39,16 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => { const startedAt = Date.now(); const buildDiag = (): NonNullable['diag']> => ({ op: msg.op, - ...('teamName' in msg.payload ? { teamName: msg.payload.teamName } : {}), - ...('taskId' in msg.payload ? { taskId: msg.payload.taskId } : {}), + ...(msg.payload && 'teamName' in msg.payload ? { teamName: msg.payload.teamName } : {}), + ...(msg.payload && 'taskId' in msg.payload ? { taskId: msg.payload.taskId } : {}), totalMs: Date.now() - startedAt, }); try { switch (msg.op) { + case 'warmup': { + respond({ id: msg.id, ok: true, result: null, diag: buildDiag() }); + break; + } case 'getTeamData': { const result = await teamDataService.getTeamData(msg.payload.teamName); respond({ id: msg.id, ok: true, result, diag: buildDiag() }); diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index aa7f3bf6..b4eb215f 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -33,6 +33,7 @@ interface GetAllTasksPayload { } type WorkerRequest = + | { id: string; op: 'warmup'; payload?: Record } | { id: string; op: 'listTeams'; payload: ListTeamsPayload } | { id: string; op: 'getAllTasks'; payload: GetAllTasksPayload }; @@ -75,6 +76,10 @@ interface ListTeamsDiag { skipped: number; skipReasons: Record; slowest: SlowEntry[]; + cacheHits: number; + cacheMisses: number; + cacheWriteSkips: number; + cacheEvictions: number; totalMs: number; } @@ -87,12 +92,19 @@ interface GetAllTasksDiag { skipped: number; skipReasons: Record; slowestTeams: SlowEntry[]; + cacheHits: number; + cacheMisses: number; + cacheWriteSkips: number; + cacheEvictions: number; totalMs: number; } interface TaskReadDiag { skipped: number; skipReasons: Record; + cacheHits: number; + cacheMisses: number; + cacheWriteSkips: number; } const MAX_LAUNCH_STATE_BYTES = 32 * 1024; @@ -104,6 +116,60 @@ const REVIEW_LIFECYCLE_EVENTS = new Set([ 'review_started', ]); const REVIEW_RESET_STATUSES = new Set(['in_progress', 'deleted']); +const TEAM_SUMMARY_CACHE_MAX_ENTRIES = 1000; +const TASK_FILE_CACHE_MAX_ENTRIES = 10000; +const BOOTSTRAP_STATE_FILE = 'bootstrap-state.json'; +const BOOTSTRAP_JOURNAL_FILE = 'bootstrap-journal.jsonl'; + +interface PathFingerprint { + exists: boolean; + isFile?: boolean; + isDirectory?: boolean; + highResolution?: boolean; + size?: string; + mode?: string; + dev?: string; + ino?: string; + mtimeNs?: string; + ctimeNs?: string; + birthtimeNs?: string; + mtimeMs?: number; + ctimeMs?: number; + birthtimeMs?: number; + errorCode?: string; +} + +interface TeamSummaryCacheEntry { + fingerprint: string; + summary: Record; + teamsDir: string; + optionKey: string; + lastUsedAt: number; +} + +type CachedTaskReadResult = + | { task: Record; skipReason?: undefined } + | { task?: undefined; skipReason: string }; + +interface TaskFileCacheEntry { + fingerprint: string; + result: CachedTaskReadResult; + tasksBase: string; + lastUsedAt: number; +} + +const teamSummaryCache = new Map(); +const taskFileCache = new Map(); + +interface TeamSummaryDependencyFingerprint { + value: string; + cacheSafe: boolean; +} + +interface LaunchStateSummaryRead { + summary: ReturnType | null; + cacheable: boolean; +} // --------------------------------------------------------------------------- // Parsed JSON types (loose shapes from disk) @@ -272,6 +338,319 @@ function pushSlowest(list: SlowEntry[], entry: SlowEntry, maxLen: number): void if (list.length > maxLen) list.length = maxLen; } +function cloneCached(value: T): T { + return typeof structuredClone === 'function' + ? structuredClone(value) + : (JSON.parse(JSON.stringify(value)) as T); +} + +async function statPathFingerprint(filePath: string): Promise { + try { + const stat = await fs.promises.stat(filePath, { bigint: true }); + const mtimeNs = + typeof (stat as fs.BigIntStats & { mtimeNs?: bigint }).mtimeNs === 'bigint' + ? (stat as fs.BigIntStats & { mtimeNs: bigint }).mtimeNs + : undefined; + const ctimeNs = + typeof (stat as fs.BigIntStats & { ctimeNs?: bigint }).ctimeNs === 'bigint' + ? (stat as fs.BigIntStats & { ctimeNs: bigint }).ctimeNs + : undefined; + const birthtimeNs = + typeof (stat as fs.BigIntStats & { birthtimeNs?: bigint }).birthtimeNs === 'bigint' + ? (stat as fs.BigIntStats & { birthtimeNs: bigint }).birthtimeNs + : undefined; + return { + exists: true, + isFile: stat.isFile(), + isDirectory: stat.isDirectory(), + highResolution: typeof mtimeNs === 'bigint' && typeof ctimeNs === 'bigint', + size: stat.size.toString(), + mode: stat.mode.toString(), + dev: stat.dev.toString(), + ino: stat.ino.toString(), + mtimeNs: mtimeNs?.toString(), + ctimeNs: ctimeNs?.toString(), + birthtimeNs: birthtimeNs?.toString(), + mtimeMs: Number(stat.mtimeMs), + ctimeMs: Number(stat.ctimeMs), + birthtimeMs: Number(stat.birthtimeMs), + }; + } catch (error) { + return { + exists: false, + errorCode: + typeof (error as NodeJS.ErrnoException | undefined)?.code === 'string' + ? (error as NodeJS.ErrnoException).code + : undefined, + }; + } +} + +function fingerprintToString(value: unknown): string { + return JSON.stringify(value); +} + +function isCacheSafeFingerprint(fingerprint: PathFingerprint): boolean { + if (fingerprint.exists) { + return fingerprint.highResolution === true; + } + return fingerprint.errorCode === 'ENOENT' || fingerprint.errorCode === 'ENOTDIR'; +} + +function makeTeamSummaryOptionKey(payload: ListTeamsPayload): string { + return fingerprintToString({ + largeConfigBytes: payload.largeConfigBytes, + configHeadBytes: payload.configHeadBytes, + maxConfigBytes: payload.maxConfigBytes, + maxConfigReadMs: payload.maxConfigReadMs, + maxMembersMetaBytes: payload.maxMembersMetaBytes, + maxSessionHistoryInSummary: payload.maxSessionHistoryInSummary, + maxProjectPathHistoryInSummary: payload.maxProjectPathHistoryInSummary, + }); +} + +function makeTeamSummaryCacheKey(teamsDir: string, teamName: string, optionKey: string): string { + return `${teamsDir}\0${teamName}\0${optionKey}`; +} + +function canCacheTeamSummary(summary: Record): boolean { + if (summary.teamLaunchState === 'partial_pending') { + return false; + } + const pendingKeys = [ + 'pendingCount', + 'runtimeAlivePendingCount', + 'shellOnlyPendingCount', + 'runtimeProcessPendingCount', + 'runtimeCandidatePendingCount', + 'noRuntimePendingCount', + 'permissionPendingCount', + ]; + return pendingKeys.every((key) => { + const value = summary[key]; + return typeof value !== 'number' || value <= 0; + }); +} + +async function readInboxNamesFingerprint(inboxDir: string): Promise<{ + dir: PathFingerprint; + names: string[]; + cacheSafe: boolean; +}> { + const dir = await statPathFingerprint(inboxDir); + if (!dir.exists || !dir.isDirectory) { + return { dir, names: [], cacheSafe: isCacheSafeFingerprint(dir) }; + } + try { + const entries = await fs.promises.readdir(inboxDir, { withFileTypes: true }); + const names = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => entry.name) + .sort(); + return { dir, names, cacheSafe: isCacheSafeFingerprint(dir) }; + } catch (error) { + return { + dir: { + ...dir, + errorCode: + typeof (error as NodeJS.ErrnoException | undefined)?.code === 'string' + ? (error as NodeJS.ErrnoException).code + : 'READDIR_FAILED', + }, + names: [], + cacheSafe: false, + }; + } +} + +async function buildTeamSummaryFingerprint( + teamsDir: string, + teamName: string, + optionKey: string +): Promise { + const teamDir = path.join(teamsDir, teamName); + const [ + config, + teamMeta, + membersMeta, + launchState, + launchSummary, + bootstrapState, + bootstrapJournal, + ] = await Promise.all([ + statPathFingerprint(path.join(teamDir, 'config.json')), + statPathFingerprint(path.join(teamDir, 'team.meta.json')), + statPathFingerprint(path.join(teamDir, 'members.meta.json')), + statPathFingerprint(path.join(teamDir, TEAM_LAUNCH_STATE_FILE)), + statPathFingerprint(path.join(teamDir, TEAM_LAUNCH_SUMMARY_FILE)), + statPathFingerprint(path.join(teamDir, BOOTSTRAP_STATE_FILE)), + statPathFingerprint(path.join(teamDir, BOOTSTRAP_JOURNAL_FILE)), + ]); + const inbox = await readInboxNamesFingerprint(path.join(teamDir, 'inboxes')); + + const dependencyFingerprint = { + version: 1, + optionKey, + config, + teamMeta, + membersMeta, + launchState, + launchSummary, + bootstrapState, + bootstrapJournal, + inbox, + }; + + return { + value: fingerprintToString(dependencyFingerprint), + cacheSafe: + [ + config, + teamMeta, + membersMeta, + launchState, + launchSummary, + bootstrapState, + bootstrapJournal, + ].every(isCacheSafeFingerprint) && inbox.cacheSafe, + }; +} + +async function cacheTeamSummaryIfStable( + cacheKey: string, + teamsDir: string, + teamName: string, + optionKey: string, + fingerprintBefore: TeamSummaryDependencyFingerprint, + summary: Record, + cacheAllowed: boolean, + diag: ListTeamsDiag +): Promise { + if (!cacheAllowed) { + teamSummaryCache.delete(cacheKey); + diag.cacheWriteSkips++; + return; + } + if (!canCacheTeamSummary(summary)) { + teamSummaryCache.delete(cacheKey); + diag.cacheWriteSkips++; + return; + } + if (!fingerprintBefore.cacheSafe) { + diag.cacheWriteSkips++; + return; + } + const fingerprintAfter = await buildTeamSummaryFingerprint(teamsDir, teamName, optionKey); + if (!fingerprintAfter.cacheSafe || fingerprintAfter.value !== fingerprintBefore.value) { + diag.cacheWriteSkips++; + return; + } + teamSummaryCache.set(cacheKey, { + fingerprint: fingerprintAfter.value, + summary: cloneCached(summary), + teamsDir, + optionKey, + lastUsedAt: nowMs(), + }); +} + +function pruneTeamSummaryCache( + teamsDir: string, + optionKey: string, + liveTeamNames: ReadonlySet, + diag: ListTeamsDiag +): void { + for (const [key, entry] of teamSummaryCache) { + if (entry.teamsDir === teamsDir && entry.optionKey === optionKey) { + const teamName = key.split('\0')[1] ?? ''; + if (!liveTeamNames.has(teamName)) { + teamSummaryCache.delete(key); + diag.cacheEvictions++; + } + } + } + while (teamSummaryCache.size > TEAM_SUMMARY_CACHE_MAX_ENTRIES) { + const oldest = teamSummaryCache.keys().next().value; + if (typeof oldest !== 'string') break; + teamSummaryCache.delete(oldest); + diag.cacheEvictions++; + } +} + +function makeTaskOptionKey(payload: GetAllTasksPayload): string { + return fingerprintToString({ + maxTaskBytes: payload.maxTaskBytes, + maxTaskReadMs: payload.maxTaskReadMs, + }); +} + +function makeTaskCacheKey( + tasksBase: string, + teamName: string, + fileName: string, + optionKey: string +): string { + return `${tasksBase}\0${teamName}\0${fileName}\0${optionKey}`; +} + +async function cacheTaskReadResultIfStable( + cacheKey: string, + taskPath: string, + tasksBase: string, + fingerprintBefore: string, + fingerprintBeforeCacheSafe: boolean, + result: CachedTaskReadResult, + taskDiag: TaskReadDiag +): Promise { + if (!fingerprintBeforeCacheSafe) { + taskDiag.cacheWriteSkips++; + return; + } + const after = await statPathFingerprint(taskPath); + if (!isCacheSafeFingerprint(after) || fingerprintToString(after) !== fingerprintBefore) { + taskDiag.cacheWriteSkips++; + return; + } + taskFileCache.set(cacheKey, { + fingerprint: fingerprintBefore, + result: cloneCached(result), + tasksBase, + lastUsedAt: nowMs(), + }); +} + +function applyCachedTaskReadResult( + cached: CachedTaskReadResult, + tasks: unknown[], + taskDiag: TaskReadDiag +): void { + if (cached.skipReason) { + taskDiag.skipped++; + bumpSkipReason(taskDiag.skipReasons, cached.skipReason); + return; + } + tasks.push(cloneCached(cached.task)); +} + +function pruneTaskFileCache( + tasksBase: string, + liveCacheKeys: ReadonlySet, + diag: GetAllTasksDiag +): void { + for (const [key, entry] of taskFileCache) { + if (entry.tasksBase === tasksBase && !liveCacheKeys.has(key)) { + taskFileCache.delete(key); + diag.cacheEvictions++; + } + } + while (taskFileCache.size > TASK_FILE_CACHE_MAX_ENTRIES) { + const oldest = taskFileCache.keys().next().value; + if (typeof oldest !== 'string') break; + taskFileCache.delete(oldest); + diag.cacheEvictions++; + } +} + // --------------------------------------------------------------------------- // listTeams // --------------------------------------------------------------------------- @@ -340,7 +719,7 @@ function dropCliProvisionerMembers( async function readLaunchState( teamsDir: string, teamName: string -): Promise> { +): Promise { const bootstrapSnapshot = await readBootstrapLaunchSnapshot(teamName); const launchStatePath = path.join(teamsDir, teamName, TEAM_LAUNCH_STATE_FILE); const launchSummaryPath = path.join(teamsDir, teamName, TEAM_LAUNCH_SUMMARY_FILE); @@ -371,11 +750,24 @@ async function readLaunchState( })(), ]); - return choosePreferredLaunchStateSummary({ + const summary = choosePreferredLaunchStateSummary({ bootstrapSnapshot, launchSnapshot, launchSummaryProjection, }); + if (launchSnapshot) { + return { summary, cacheable: true }; + } + if (!bootstrapSnapshot) { + return { summary, cacheable: true }; + } + if ( + bootstrapSnapshot.launchPhase === 'finished' && + bootstrapSnapshot.teamLaunchState !== 'partial_pending' + ) { + return { summary, cacheable: true }; + } + return { summary, cacheable: false }; } /** @@ -465,6 +857,10 @@ async function listTeams( skipped: 0, skipReasons: {}, slowest: [], + cacheHits: 0, + cacheMisses: 0, + cacheWriteSkips: 0, + cacheEvictions: 0, totalMs: 0, }; @@ -478,11 +874,26 @@ async function listTeams( const teamDirs = entries.filter((e) => e.isDirectory()); diag.totalDirs = teamDirs.length; + const optionKey = makeTeamSummaryOptionKey(payload); + const liveTeamNames = new Set(teamDirs.map((entry) => entry.name)); const perTeam = await mapLimit(teamDirs, payload.concurrency, async (entry) => { const teamName = entry.name; const t0 = nowMs(); const configPath = path.join(payload.teamsDir, teamName, 'config.json'); + const cacheKey = makeTeamSummaryCacheKey(payload.teamsDir, teamName, optionKey); + const dependencyFingerprint = await buildTeamSummaryFingerprint( + payload.teamsDir, + teamName, + optionKey + ); + const cached = teamSummaryCache.get(cacheKey); + if (dependencyFingerprint.cacheSafe && cached?.fingerprint === dependencyFingerprint.value) { + cached.lastUsedAt = nowMs(); + diag.cacheHits++; + return cloneCached(cached.summary); + } + diag.cacheMisses++; const skip = (reason: string): null => { diag.skipped++; @@ -496,12 +907,36 @@ async function listTeams( } catch { // Fallback: check for draft team (team.meta.json without config.json) const draft = await readDraftTeamMeta(payload.teamsDir, teamName, payload); - if (draft) return draft; + if (draft) { + await cacheTeamSummaryIfStable( + cacheKey, + payload.teamsDir, + teamName, + optionKey, + dependencyFingerprint, + draft, + true, + diag + ); + return draft; + } return skip('config_stat_failed'); } if (!stat.isFile()) { const draft = await readDraftTeamMeta(payload.teamsDir, teamName, payload); - if (draft) return draft; + if (draft) { + await cacheTeamSummaryIfStable( + cacheKey, + payload.teamsDir, + teamName, + optionKey, + dependencyFingerprint, + draft, + true, + diag + ); + return draft; + } return skip('config_not_file'); } if (stat.size > payload.maxConfigBytes) return skip('config_too_large'); @@ -692,32 +1127,28 @@ async function listTeams( leadProviderId, members: metaRuntimeMembers, }); - const launchStateSummary = - (await readLaunchState(payload.teamsDir, teamName)) ?? - (() => { - if (suppressLegacyLaunchArtifactHeuristic) { - return null; - } - if ( - !leadSessionId || - expectedTeammateNames.size === 0 || - confirmedArtifactNames.size === 0 - ) { - return null; - } - const missingMembers = Array.from(expectedTeammateNames).filter( - (name) => !confirmedArtifactNames.has(name) - ); - if (missingMembers.length === 0) { - return null; - } - return { - partialLaunchFailure: true as const, - expectedMemberCount: expectedTeammateNames.size, - confirmedMemberCount: confirmedArtifactNames.size, - missingMembers, - }; - })(); + const launchStateRead = await readLaunchState(payload.teamsDir, teamName); + const fallbackLaunchStateSummary = (): ReturnType => { + if (suppressLegacyLaunchArtifactHeuristic) { + return null; + } + if (!leadSessionId || expectedTeammateNames.size === 0 || confirmedArtifactNames.size === 0) { + return null; + } + const missingMembers = Array.from(expectedTeammateNames).filter( + (name) => !confirmedArtifactNames.has(name) + ); + if (missingMembers.length === 0) { + return null; + } + return { + partialLaunchFailure: true as const, + expectedMemberCount: expectedTeammateNames.size, + confirmedMemberCount: confirmedArtifactNames.size, + missingMembers, + }; + }; + const launchStateSummary = launchStateRead.summary ?? fallbackLaunchStateSummary(); const summary = { teamName, displayName, @@ -741,10 +1172,21 @@ async function listTeams( if (ms >= 250) { pushSlowest(diag.slowest, { teamName, ms }, 10); } + await cacheTeamSummaryIfStable( + cacheKey, + payload.teamsDir, + teamName, + optionKey, + dependencyFingerprint, + summary, + launchStateRead.cacheable, + diag + ); return summary; }); const teams = perTeam.filter((t): t is NonNullable => t !== null); + pruneTeamSummaryCache(payload.teamsDir, optionKey, liveTeamNames, diag); diag.returned = teams.length; diag.totalMs = nowMs() - startedAt; return { teams, diag }; @@ -880,19 +1322,27 @@ async function readTasksDirForTeam( tasksDir: string, teamName: string, payload: GetAllTasksPayload -): Promise<{ tasks: unknown[]; taskDiag: TaskReadDiag }> { - const taskDiag: TaskReadDiag = { skipped: 0, skipReasons: {} }; +): Promise<{ tasks: unknown[]; taskDiag: TaskReadDiag; liveCacheKeys: Set }> { + const taskDiag: TaskReadDiag = { + skipped: 0, + skipReasons: {}, + cacheHits: 0, + cacheMisses: 0, + cacheWriteSkips: 0, + }; let entries: string[]; try { entries = await fs.promises.readdir(tasksDir); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return { tasks: [], taskDiag }; + return { tasks: [], taskDiag, liveCacheKeys: new Set() }; } throw error; } const tasks: unknown[] = []; + const liveCacheKeys = new Set(); + const optionKey = makeTaskOptionKey(payload); for (const file of entries) { if ( !file.endsWith('.json') || @@ -904,25 +1354,61 @@ async function readTasksDirForTeam( } const taskPath = path.join(tasksDir, file); + const cacheKey = makeTaskCacheKey(payload.tasksBase, teamName, file, optionKey); + liveCacheKeys.add(cacheKey); try { - const stat = await fs.promises.stat(taskPath); - if (!stat.isFile() || stat.size > payload.maxTaskBytes) { + const pathFingerprint = await statPathFingerprint(taskPath); + const taskSize = Number(pathFingerprint.size ?? Number.NaN); + if ( + !pathFingerprint.isFile || + !Number.isFinite(taskSize) || + taskSize > payload.maxTaskBytes + ) { taskDiag.skipped++; bumpSkipReason(taskDiag.skipReasons, 'task_not_file_or_large'); continue; } + const fingerprint = fingerprintToString(pathFingerprint); + const fingerprintCacheSafe = isCacheSafeFingerprint(pathFingerprint); + const cached = taskFileCache.get(cacheKey); + if (fingerprintCacheSafe && cached?.fingerprint === fingerprint) { + cached.lastUsedAt = nowMs(); + taskDiag.cacheHits++; + applyCachedTaskReadResult(cached.result, tasks, taskDiag); + continue; + } + taskDiag.cacheMisses++; + const stat = await fs.promises.stat(taskPath); const raw = await readFileUtf8WithTimeout(taskPath, payload.maxTaskReadMs); const parsed = JSON.parse(raw) as ParsedTask; const metadata = parsed.metadata; if (metadata?._internal === true) { taskDiag.skipped++; bumpSkipReason(taskDiag.skipReasons, 'task_internal'); + await cacheTaskReadResultIfStable( + cacheKey, + taskPath, + payload.tasksBase, + fingerprint, + fingerprintCacheSafe, + { skipReason: 'task_internal' }, + taskDiag + ); continue; } if (parsed.status === 'deleted') { taskDiag.skipped++; bumpSkipReason(taskDiag.skipReasons, 'task_deleted'); + await cacheTaskReadResultIfStable( + cacheKey, + taskPath, + payload.tasksBase, + fingerprint, + fingerprintCacheSafe, + { skipReason: 'task_deleted' }, + taskDiag + ); continue; } @@ -962,7 +1448,7 @@ async function readTasksDirForTeam( deriveReviewStateFromEvents(historyEvents) ?? normalizeFallbackReviewState(parsed.reviewState, status); - tasks.push({ + const task = { id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '', displayId: typeof parsed.displayId === 'string' && parsed.displayId.trim().length > 0 @@ -1018,7 +1504,17 @@ async function readTasksDirForTeam( ? (parsed.sourceMessage as Record) : undefined, teamName, - }); + }; + tasks.push(task); + await cacheTaskReadResultIfStable( + cacheKey, + taskPath, + payload.tasksBase, + fingerprint, + fingerprintCacheSafe, + { task }, + taskDiag + ); } catch (error) { taskDiag.skipped++; const code = (error as NodeJS.ErrnoException).code; @@ -1029,11 +1525,14 @@ async function readTasksDirForTeam( } } } - return { tasks, taskDiag }; + return { tasks, taskDiag, liveCacheKeys }; } function mergeTaskDiag(target: GetAllTasksDiag, source: TaskReadDiag): void { target.skipped += source.skipped; + target.cacheHits += source.cacheHits; + target.cacheMisses += source.cacheMisses; + target.cacheWriteSkips += source.cacheWriteSkips; for (const [reason, count] of Object.entries(source.skipReasons)) { target.skipReasons[reason] = (target.skipReasons[reason] || 0) + count; } @@ -1052,6 +1551,10 @@ async function getAllTasks( skipped: 0, skipReasons: {}, slowestTeams: [], + cacheHits: 0, + cacheMisses: 0, + cacheWriteSkips: 0, + cacheEvictions: 0, totalMs: 0, }; @@ -1068,13 +1571,21 @@ async function getAllTasks( const dirs = entries.filter((e) => e.isDirectory()); diag.teamDirs = dirs.length; + const liveCacheKeys = new Set(); const chunks = await mapLimit(dirs, payload.concurrency, async (entry) => { const teamName = entry.name; const t0 = nowMs(); try { const tasksDir = path.join(payload.tasksBase, teamName); - const { tasks, taskDiag } = await readTasksDirForTeam(tasksDir, teamName, payload); + const { + tasks, + taskDiag, + liveCacheKeys: teamLiveCacheKeys, + } = await readTasksDirForTeam(tasksDir, teamName, payload); + for (const key of teamLiveCacheKeys) { + liveCacheKeys.add(key); + } mergeTaskDiag(diag, taskDiag); const ms = nowMs() - t0; if (ms >= 250) { @@ -1089,6 +1600,7 @@ async function getAllTasks( }); const tasks = chunks.flat(); + pruneTaskFileCache(payload.tasksBase, liveCacheKeys, diag); diag.returned = tasks.length; diag.totalMs = nowMs() - startedAt; return { tasks, diag }; @@ -1105,6 +1617,19 @@ function post(msg: WorkerResponse): void { parentPort?.on('message', async (msg: WorkerRequest) => { const { id, op } = msg; try { + if (op === 'warmup') { + post({ + id, + ok: true, + result: { + ready: true, + teamSummaryCacheEntries: teamSummaryCache.size, + taskFileCacheEntries: taskFileCache.size, + }, + diag: { op, totalMs: 0 }, + }); + return; + } if (op === 'listTeams') { const { teams, diag } = await listTeams(msg.payload); post({ id, ok: true, result: teams, diag }); diff --git a/test/main/services/team/TeamDataWorkerClient.test.ts b/test/main/services/team/TeamDataWorkerClient.test.ts index fac20503..a3cd518a 100644 --- a/test/main/services/team/TeamDataWorkerClient.test.ts +++ b/test/main/services/team/TeamDataWorkerClient.test.ts @@ -93,6 +93,25 @@ describe('TeamDataWorkerClient', () => { client.dispose(); }); + it('does not queue warmup behind an already running worker', async () => { + const { TeamDataWorkerClient } = await import( + '../../../../src/main/services/team/TeamDataWorkerClient' + ); + const client = new TeamDataWorkerClient(); + + await client.getTeamData('my-team'); + await client.prewarm(); + + expect(hoisted.workers).toHaveLength(1); + expect(hoisted.workers[0].messages).toHaveLength(1); + expect(hoisted.workers[0].messages[0]).toMatchObject({ + op: 'getTeamData', + payload: { teamName: 'my-team' }, + }); + + client.dispose(); + }); + it('sends best-effort team config invalidation to the worker', async () => { const { TeamDataWorkerClient } = await import( '../../../../src/main/services/team/TeamDataWorkerClient' diff --git a/test/main/services/team/TeamFsWorker.integration.test.ts b/test/main/services/team/TeamFsWorker.integration.test.ts index 5cacefb2..e2085b93 100644 --- a/test/main/services/team/TeamFsWorker.integration.test.ts +++ b/test/main/services/team/TeamFsWorker.integration.test.ts @@ -11,6 +11,7 @@ interface WorkerResponse { id: string; ok: boolean; result?: unknown; + diag?: unknown; error?: string; } @@ -44,7 +45,11 @@ function createWorker(workerPath: string): Worker { return new Worker(workerPath); } -function callListTeams(worker: Worker, teamsDir: string): Promise { +function callWorker( + worker: Worker, + op: string, + payload: Record = {} +): Promise<{ result: unknown; diag?: unknown }> { const requestId = `req-${Date.now()}`; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { @@ -72,29 +77,56 @@ function callListTeams(worker: Worker, teamsDir: string): Promise { reject(new Error(message.error || 'team-fs-worker returned an unknown error')); return; } - resolve(Array.isArray(message.result) ? message.result : []); + resolve({ result: message.result, diag: message.diag }); }; worker.on('message', onMessage); worker.on('error', onError); - worker.postMessage({ - id: requestId, - op: 'listTeams', - payload: { - teamsDir, - largeConfigBytes: 8 * 1024, - configHeadBytes: 4 * 1024, - maxConfigBytes: 256 * 1024, - maxConfigReadMs: 5_000, - maxMembersMetaBytes: 256 * 1024, - maxSessionHistoryInSummary: 10, - maxProjectPathHistoryInSummary: 10, - concurrency: 2, - }, - }); + worker.postMessage({ id: requestId, op, payload }); }); } +async function callListTeams(worker: Worker, teamsDir: string): Promise<{ + teams: unknown[]; + diag?: Record; +}> { + const { result, diag } = await callWorker(worker, 'listTeams', { + teamsDir, + largeConfigBytes: 8 * 1024, + configHeadBytes: 4 * 1024, + maxConfigBytes: 256 * 1024, + maxConfigReadMs: 5_000, + maxMembersMetaBytes: 256 * 1024, + maxSessionHistoryInSummary: 10, + maxProjectPathHistoryInSummary: 10, + concurrency: 2, + }); + return { + teams: Array.isArray(result) ? result : [], + diag: diag && typeof diag === 'object' ? (diag as Record) : undefined, + }; +} + +async function callGetAllTasks(worker: Worker, tasksBase: string): Promise<{ + tasks: unknown[]; + diag?: Record; +}> { + const { result, diag } = await callWorker(worker, 'getAllTasks', { + tasksBase, + maxTaskBytes: 256 * 1024, + maxTaskReadMs: 5_000, + concurrency: 2, + }); + return { + tasks: Array.isArray(result) ? result : [], + diag: diag && typeof diag === 'object' ? (diag as Record) : undefined, + }; +} + +async function callWarmup(worker: Worker): Promise { + await callWorker(worker, 'warmup'); +} + describe('team-fs-worker integration', () => { let tempDir = ''; @@ -183,7 +215,7 @@ describe('team-fs-worker integration', () => { const worker = createWorker(workerPath); try { - const teams = (await callListTeams(worker, tempDir)) as Array>; + const { teams } = await callListTeams(worker, tempDir); expect(teams).toHaveLength(1); expect(teams[0]).toMatchObject({ teamName, @@ -234,7 +266,7 @@ describe('team-fs-worker integration', () => { const worker = createWorker(workerPath); try { - const teams = (await callListTeams(worker, tempDir)) as Array>; + const { teams } = await callListTeams(worker, tempDir); expect(teams).toHaveLength(1); expect(teams[0]).toMatchObject({ teamName, @@ -247,4 +279,150 @@ describe('team-fs-worker integration', () => { await worker.terminate(); } }); + + it('prewarms and reuses unchanged team summaries by fingerprint', async () => { + const workerPath = await getWorkerPath(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); + const teamName = 'cached-worker-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(path.join(teamDir, 'inboxes'), { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Cached Worker Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + await fs.writeFile( + path.join(teamDir, 'members.meta.json'), + JSON.stringify({ version: 1, members: [{ name: 'alice' }] }), + 'utf8' + ); + + const worker = createWorker(workerPath); + try { + await callWarmup(worker); + const first = await callListTeams(worker, tempDir); + expect(first.teams[0]).toMatchObject({ teamName, memberCount: 1 }); + expect(first.diag?.cacheMisses).toBe(1); + + const second = await callListTeams(worker, tempDir); + expect(second.teams[0]).toMatchObject({ teamName, memberCount: 1 }); + expect(second.diag?.cacheHits).toBe(1); + + await fs.writeFile( + path.join(teamDir, 'members.meta.json'), + JSON.stringify({ version: 1, members: [{ name: 'alice' }, { name: 'bob' }] }), + 'utf8' + ); + const changed = await callListTeams(worker, tempDir); + expect(changed.teams[0]).toMatchObject({ teamName, memberCount: 2 }); + expect(changed.diag?.cacheMisses).toBe(1); + } finally { + await worker.terminate(); + } + }); + + it('does not cache pending launch summaries because liveness can change without file writes', async () => { + const workerPath = await getWorkerPath(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); + const teamName = 'pending-launch-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Pending Launch Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + await fs.writeFile( + path.join(teamDir, 'launch-summary.json'), + JSON.stringify({ + version: 1, + teamName, + updatedAt: '2026-05-02T12:00:00.000Z', + teamLaunchState: 'partial_pending', + expectedMemberCount: 1, + pendingCount: 1, + }), + 'utf8' + ); + + const worker = createWorker(workerPath); + try { + const first = await callListTeams(worker, tempDir); + expect(first.teams[0]).toMatchObject({ + teamName, + teamLaunchState: 'partial_pending', + pendingCount: 1, + }); + expect(first.diag?.cacheMisses).toBe(1); + expect(first.diag?.cacheWriteSkips).toBe(1); + + const second = await callListTeams(worker, tempDir); + expect(second.teams[0]).toMatchObject({ + teamName, + teamLaunchState: 'partial_pending', + pendingCount: 1, + }); + expect(second.diag?.cacheHits).toBe(0); + expect(second.diag?.cacheMisses).toBe(1); + expect(second.diag?.cacheWriteSkips).toBe(1); + } finally { + await worker.terminate(); + } + }); + + it('reuses unchanged parsed tasks and rereads changed task files by fingerprint', async () => { + const workerPath = await getWorkerPath(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); + const tasksBase = path.join(tempDir, 'tasks'); + const teamName = 'task-cache-team'; + const tasksDir = path.join(tasksBase, teamName); + await fs.mkdir(tasksDir, { recursive: true }); + const taskPath = path.join(tasksDir, '1.json'); + await fs.writeFile( + taskPath, + JSON.stringify({ + id: '1', + subject: 'First subject', + status: 'pending', + createdAt: '2026-05-02T12:00:00.000Z', + }), + 'utf8' + ); + + const worker = createWorker(workerPath); + try { + const first = await callGetAllTasks(worker, tasksBase); + expect(first.tasks[0]).toMatchObject({ teamName, subject: 'First subject' }); + expect(first.diag?.cacheMisses).toBe(1); + + const second = await callGetAllTasks(worker, tasksBase); + expect(second.tasks[0]).toMatchObject({ teamName, subject: 'First subject' }); + expect(second.diag?.cacheHits).toBe(1); + + await fs.writeFile( + taskPath, + JSON.stringify({ + id: '1', + subject: 'Changed subject with a different size', + status: 'pending', + createdAt: '2026-05-02T12:00:00.000Z', + }), + 'utf8' + ); + const changed = await callGetAllTasks(worker, tasksBase); + expect(changed.tasks[0]).toMatchObject({ + teamName, + subject: 'Changed subject with a different size', + }); + expect(changed.diag?.cacheMisses).toBe(1); + } finally { + await worker.terminate(); + } + }); }); diff --git a/test/main/services/team/TeamFsWorkerClient.test.ts b/test/main/services/team/TeamFsWorkerClient.test.ts new file mode 100644 index 00000000..48a3a9f9 --- /dev/null +++ b/test/main/services/team/TeamFsWorkerClient.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => { + const skipResponsesForOps = new Set(); + const workers: Array<{ + messages: unknown[]; + handlers: Map void>; + postMessage: (message: unknown) => void; + on: (event: string, handler: (value: unknown) => void) => void; + terminate: ReturnType; + }> = []; + const createMockWorker = vi.fn().mockImplementation(() => { + const worker = { + messages: [] as unknown[], + handlers: new Map void>(), + postMessage(message: unknown) { + worker.messages.push(message); + const request = message as { id: string; op: string }; + if (skipResponsesForOps.has(request.op)) return; + queueMicrotask(() => { + const handler = worker.handlers.get('message'); + if (!handler) return; + handler({ + id: request.id, + ok: true, + result: request.op === 'listTeams' || request.op === 'getAllTasks' ? [] : null, + diag: { op: request.op, totalMs: 0 }, + }); + }); + }, + on(event: string, handler: (value: unknown) => void) { + worker.handlers.set(event, handler); + }, + terminate: vi.fn(async () => undefined), + }; + workers.push(worker); + return worker; + }); + return { + workers, + createMockWorker, + skipResponsesForOps, + }; +}); + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + existsSync: vi.fn(() => true), + }; +}); + +vi.mock('node:worker_threads', () => ({ + Worker: hoisted.createMockWorker, + default: { + Worker: hoisted.createMockWorker, + }, +})); + +describe('TeamFsWorkerClient', () => { + afterEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.useRealTimers(); + hoisted.workers.length = 0; + hoisted.skipResponsesForOps.clear(); + }); + + it('prewarms the worker without running a scan', async () => { + const { TeamFsWorkerClient } = await import( + '../../../../src/main/services/team/TeamFsWorkerClient' + ); + const client = new TeamFsWorkerClient(); + + await client.prewarm(); + + expect(hoisted.workers).toHaveLength(1); + expect(hoisted.workers[0].messages).toHaveLength(1); + expect(hoisted.workers[0].messages[0]).toMatchObject({ + op: 'warmup', + payload: {}, + }); + }); + + it('does not queue warmup behind an already running worker', async () => { + const { TeamFsWorkerClient } = await import( + '../../../../src/main/services/team/TeamFsWorkerClient' + ); + const client = new TeamFsWorkerClient(); + + await client.listTeams({ + largeConfigBytes: 8 * 1024, + configHeadBytes: 4 * 1024, + maxConfigBytes: 256 * 1024, + maxMembersMetaBytes: 256 * 1024, + maxSessionHistoryInSummary: 10, + maxProjectPathHistoryInSummary: 10, + }); + await client.prewarm(); + + expect(hoisted.workers).toHaveLength(1); + expect(hoisted.workers[0].messages).toHaveLength(1); + expect(hoisted.workers[0].messages[0]).toMatchObject({ + op: 'listTeams', + }); + }); + + it('ignores stale worker exit after timeout when a replacement worker owns pending work', async () => { + vi.useFakeTimers(); + hoisted.skipResponsesForOps.add('warmup'); + hoisted.skipResponsesForOps.add('listTeams'); + const { TeamFsWorkerClient } = await import( + '../../../../src/main/services/team/TeamFsWorkerClient' + ); + const client = new TeamFsWorkerClient(); + + const prewarmResult = client.prewarm().catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(20_001); + const prewarmError = await prewarmResult; + expect(prewarmError).toBeInstanceOf(Error); + expect((prewarmError as Error).message).toContain('Worker call timeout'); + expect(hoisted.workers).toHaveLength(1); + + const listPromise = client.listTeams({ + largeConfigBytes: 8 * 1024, + configHeadBytes: 4 * 1024, + maxConfigBytes: 256 * 1024, + maxMembersMetaBytes: 256 * 1024, + maxSessionHistoryInSummary: 10, + maxProjectPathHistoryInSummary: 10, + }); + + expect(hoisted.workers).toHaveLength(2); + const staleWorker = hoisted.workers[0]; + const replacementWorker = hoisted.workers[1]; + const listRequest = replacementWorker.messages[0] as { id: string }; + + staleWorker.handlers.get('exit')?.(1); + replacementWorker.handlers.get('message')?.({ + id: listRequest.id, + ok: true, + result: [{ teamName: 'fresh-team', displayName: 'Fresh Team' }], + diag: { op: 'listTeams', totalMs: 1 }, + }); + + await expect(listPromise).resolves.toEqual({ + teams: [{ teamName: 'fresh-team', displayName: 'Fresh Team' }], + diag: { op: 'listTeams', totalMs: 1 }, + }); + }); +}); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index e0f21a28..02a791b4 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -206,6 +206,16 @@ function createPidusageStat(pid: number, memory: number) { }; } +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + function writeLaunchConfig( teamName: string, projectPath: string, @@ -619,6 +629,182 @@ describe('TeamProvisioningService', () => { }); describe('getTeamAgentRuntimeSnapshot', () => { + it('dedupes concurrent runtime snapshot probes for the same team', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', model: 'gpt-5.4-mini' }, + ], + })), + }; + (svc as any).readPersistedRuntimeMembers = vi.fn(() => [ + { + name: 'alice', + agentId: 'alice@runtime-team', + tmuxPaneId: '%1', + backendType: 'tmux', + }, + ]); + (svc as any).aliveRunByTeam.set('runtime-team', 'run-1'); + (svc as any).runs.set('run-1', { + runId: 'run-1', + child: { pid: 111 }, + request: { model: 'gpt-5.4' }, + processKilled: false, + cancelRequested: false, + spawnContext: null, + }); + const paneInfo = createDeferred>(); + vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockReturnValueOnce( + paneInfo.promise as ReturnType + ); + vi.mocked(pidusage).mockResolvedValueOnce({ + '111': createPidusageStat(111, 123_000_000), + '222': createPidusageStat(222, 456_000_000), + } as any); + + const first = svc.getTeamAgentRuntimeSnapshot('runtime-team'); + const second = svc.getTeamAgentRuntimeSnapshot('runtime-team'); + paneInfo.resolve( + new Map([ + [ + '%1', + { + paneId: '%1', + panePid: 222, + }, + ], + ]) + ); + const [firstSnapshot, secondSnapshot] = await Promise.all([first, second]); + + expect(listTmuxPaneRuntimeInfoForCurrentPlatform).toHaveBeenCalledTimes(1); + expect(pidusage).toHaveBeenCalledTimes(1); + expect(firstSnapshot.members.alice?.pid).toBe(222); + expect(secondSnapshot.members.alice?.pid).toBe(222); + }); + + it('does not cache live runtime metadata when invalidated while the probe is in flight', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', model: 'gpt-5.4-mini' }, + ], + })), + }; + const processRows = createDeferred>>(); + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform) + .mockReturnValueOnce(processRows.promise) + .mockResolvedValueOnce([]); + + const first = (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team') as Promise< + Map + >; + (svc as any).invalidateRuntimeSnapshotCaches('runtime-team'); + processRows.resolve([]); + await first; + + await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team'); + + expect(listRuntimeProcessesForCurrentTmuxPlatform).toHaveBeenCalledTimes(2); + }); + + it('returns cloned live runtime metadata maps from cache', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', model: 'gpt-5.4-mini' }, + ], + })), + }; + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([]); + + const first = (await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team')) as Map< + string, + unknown + >; + expect(first.has('alice')).toBe(true); + first.delete('alice'); + + const second = (await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team')) as Map< + string, + unknown + >; + + expect(second.has('alice')).toBe(true); + expect(listRuntimeProcessesForCurrentTmuxPlatform).toHaveBeenCalledTimes(1); + }); + + it('clears runtime probe caches when starting a new run for the team', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', model: 'gpt-5.4-mini' }, + ], + })), + }; + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team'); + (svc as any).resetTeamScopedTransientStateForNewRun('runtime-team'); + await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team'); + + expect(listRuntimeProcessesForCurrentTmuxPlatform).toHaveBeenCalledTimes(2); + }); + + it('does not cache a probe that started before runtime adapter evidence was installed', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', providerId: 'opencode', model: 'gpt-5.4-mini' }, + ], + })), + }; + (svc as any).provisioningRunByTeam.set('runtime-team', 'run-1'); + const processRows = createDeferred>>(); + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform) + .mockReturnValueOnce(processRows.promise) + .mockResolvedValueOnce([]); + + const first = (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team') as Promise< + Map + >; + (svc as any).runtimeAdapterRunByTeam.set('runtime-team', { + runId: 'run-1', + providerId: 'opencode', + cwd: '/tmp/runtime-project', + members: { + alice: { + providerId: 'opencode', + runtimeAlive: true, + bootstrapConfirmed: false, + runtimePid: 333, + livenessKind: 'runtime_process', + pidSource: 'agent_process_table', + }, + }, + }); + (svc as any).invalidateRuntimeSnapshotCaches('runtime-team'); + processRows.resolve([]); + await first; + + await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team'); + + expect(listRuntimeProcessesForCurrentTmuxPlatform).toHaveBeenCalledTimes(2); + }); + it('uses batched pidusage rss values for lead and teammates', async () => { const svc = new TeamProvisioningService(); (svc as any).configReader = { From ad7c4e24ad832bd69d33986f55188c776a41f78f Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 2 May 2026 23:55:09 +0300 Subject: [PATCH 5/5] perf(team): reduce launch status IO churn --- .../services/team/TeamProvisioningService.ts | 241 +++++++++++- .../team/TeamProvisioningService.test.ts | 363 +++++++++++++++++- 2 files changed, 589 insertions(+), 15 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 6374cb2e..20ba67b2 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -313,6 +313,11 @@ interface OpenCodeRuntimeControlAck { observedAt: string; } +interface LaunchStateWriteResult { + snapshot: PersistedTeamLaunchSnapshot; + wrote: boolean; +} + type BootstrapTranscriptOutcome = | { kind: 'success'; @@ -336,6 +341,7 @@ import type { LeadContextUsage, MemberLaunchState, MemberSpawnLivenessSource, + MemberSpawnStatusesSnapshot, MemberSpawnStatus, MemberSpawnStatusEntry, PersistedTeamLaunchMemberState, @@ -4505,6 +4511,8 @@ export class TeamProvisioningService { private static readonly SAME_TEAM_RUN_START_SKEW_MS = 1_000; private static readonly SAME_TEAM_PERSIST_RETRY_MS = 2_000; private static readonly AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS = 2_000; + private static readonly MEMBER_SPAWN_STATUS_SNAPSHOT_CACHE_TTL_MS = 500; + private static readonly LAUNCH_STATE_NOOP_REFRESH_MS = 15_000; private readonly runs = new Map(); private readonly provisioningRunByTeam = new Map(); @@ -4590,8 +4598,27 @@ export class TeamProvisioningService { } >(); private readonly runtimeSnapshotCacheGenerationByTeam = new Map(); + private readonly memberSpawnStatusesSnapshotCache = new Map< + string, + { + expiresAtMs: number; + generation: number; + runId: string; + snapshot: MemberSpawnStatusesSnapshot; + } + >(); + private readonly memberSpawnStatusesInFlightByTeam = new Map< + string, + { + generationAtStart: number; + runIdAtStart: string; + promise: Promise; + } + >(); + private readonly memberSpawnStatusesCacheGenerationByTeam = new Map(); private readonly launchStateStore = new TeamLaunchStateStore(); private readonly launchStateStoreQueue = new Map>(); + private readonly launchStateWrittenRunIdByTeam = new Map(); private readonly memberLogsFinder: TeamMemberLogsFinder; private readonly transcriptProjectResolver: TeamTranscriptProjectResolver; private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; @@ -4676,6 +4703,19 @@ export class TeamProvisioningService { return this.runtimeSnapshotCacheGenerationByTeam.get(teamName) ?? 0; } + private getMemberSpawnStatusesCacheGeneration(teamName: string): number { + return this.memberSpawnStatusesCacheGenerationByTeam.get(teamName) ?? 0; + } + + private invalidateMemberSpawnStatusesCache(teamName: string): void { + this.memberSpawnStatusesCacheGenerationByTeam.set( + teamName, + this.getMemberSpawnStatusesCacheGeneration(teamName) + 1 + ); + this.memberSpawnStatusesSnapshotCache.delete(teamName); + this.memberSpawnStatusesInFlightByTeam.delete(teamName); + } + private invalidateRuntimeSnapshotCaches(teamName: string): void { this.runtimeSnapshotCacheGenerationByTeam.set( teamName, @@ -4687,6 +4727,27 @@ export class TeamProvisioningService { this.liveTeamAgentRuntimeMetadataInFlightByTeam.delete(teamName); } + private cloneMemberSpawnStatusesSnapshot( + snapshot: MemberSpawnStatusesSnapshot + ): MemberSpawnStatusesSnapshot { + return { + ...snapshot, + statuses: Object.fromEntries( + Object.entries(snapshot.statuses).map(([memberName, entry]) => [ + memberName, + { + ...entry, + ...(entry.pendingPermissionRequestIds + ? { pendingPermissionRequestIds: [...entry.pendingPermissionRequestIds] } + : {}), + }, + ]) + ), + ...(snapshot.expectedMembers ? { expectedMembers: [...snapshot.expectedMembers] } : {}), + ...(snapshot.summary ? { summary: { ...snapshot.summary } } : {}), + }; + } + private cloneLiveTeamAgentRuntimeMetadata( metadata: ReadonlyMap ): Map { @@ -10409,6 +10470,68 @@ export class TeamProvisioningService { return readPersistedStatuses(runId); } + if (!this.shouldCacheMemberSpawnStatusesSnapshot(run)) { + return this.buildMemberSpawnStatusesSnapshotForRun(run); + } + + const generationAtStart = this.getMemberSpawnStatusesCacheGeneration(teamName); + const cached = this.memberSpawnStatusesSnapshotCache.get(teamName); + if ( + cached && + cached.expiresAtMs > Date.now() && + cached.runId === run.runId && + cached.generation === generationAtStart + ) { + return this.cloneMemberSpawnStatusesSnapshot(cached.snapshot); + } + + const existingRequest = this.memberSpawnStatusesInFlightByTeam.get(teamName); + if ( + existingRequest && + existingRequest.generationAtStart === generationAtStart && + existingRequest.runIdAtStart === run.runId + ) { + const snapshot = await existingRequest.promise; + if ( + this.getMemberSpawnStatusesCacheGeneration(teamName) === generationAtStart && + this.getTrackedRunId(teamName) === run.runId + ) { + return this.cloneMemberSpawnStatusesSnapshot(snapshot); + } + return this.getMemberSpawnStatuses(teamName); + } + + const request = this.buildMemberSpawnStatusesSnapshotForRun(run, generationAtStart).finally( + () => { + if (this.memberSpawnStatusesInFlightByTeam.get(teamName)?.promise === request) { + this.memberSpawnStatusesInFlightByTeam.delete(teamName); + } + } + ); + this.memberSpawnStatusesInFlightByTeam.set(teamName, { + generationAtStart, + runIdAtStart: run.runId, + promise: request, + }); + const snapshot = await request; + if ( + this.getMemberSpawnStatusesCacheGeneration(teamName) === generationAtStart && + this.getTrackedRunId(teamName) === run.runId + ) { + return this.cloneMemberSpawnStatusesSnapshot(snapshot); + } + return this.getMemberSpawnStatuses(teamName); + } + + private shouldCacheMemberSpawnStatusesSnapshot(run: ProvisioningRun): boolean { + return run.isLaunch === true && run.provisioningComplete !== true; + } + + private async buildMemberSpawnStatusesSnapshotForRun( + run: ProvisioningRun, + generationAtStart?: number + ): Promise { + const teamName = run.teamName; await this.refreshMemberSpawnStatusesFromLeadInbox(run); await this.maybeAuditMemberSpawnStatuses(run); await this.persistLaunchStateSnapshot(run, run.provisioningComplete ? 'finished' : 'active'); @@ -10428,23 +10551,37 @@ export class TeamProvisioningService { }); const rawSnapshot = liveSnapshot ?? persisted; const metaMembers = await this.membersMetaStore.getMembers(teamName).catch(() => []); - const snapshot = this.filterRemovedMembersFromLaunchSnapshot(rawSnapshot, metaMembers); + const launchSnapshot = this.filterRemovedMembersFromLaunchSnapshot(rawSnapshot, metaMembers); const statuses = await this.attachLiveRuntimeMetadataToStatuses( teamName, - snapshotToMemberSpawnStatuses(snapshot) + snapshotToMemberSpawnStatuses(launchSnapshot) ); - const expectedMembers = this.getPersistedLaunchMemberNames(snapshot); + const expectedMembers = this.getPersistedLaunchMemberNames(launchSnapshot); const summary = summarizeMemberSpawnStatusRecord(expectedMembers, statuses); - return { + const spawnSnapshot: MemberSpawnStatusesSnapshot = { statuses, - runId, + runId: run.runId, teamLaunchState: deriveTeamLaunchAggregateState(summary), - launchPhase: snapshot.launchPhase, + launchPhase: launchSnapshot.launchPhase, expectedMembers, - updatedAt: snapshot.updatedAt, + updatedAt: launchSnapshot.updatedAt, summary, source: persisted ? 'merged' : 'live', }; + if ( + generationAtStart != null && + this.shouldCacheMemberSpawnStatusesSnapshot(run) && + this.getMemberSpawnStatusesCacheGeneration(teamName) === generationAtStart && + this.getTrackedRunId(teamName) === run.runId + ) { + this.memberSpawnStatusesSnapshotCache.set(teamName, { + expiresAtMs: Date.now() + TeamProvisioningService.MEMBER_SPAWN_STATUS_SNAPSHOT_CACHE_TTL_MS, + generation: generationAtStart, + runId: run.runId, + snapshot: this.cloneMemberSpawnStatusesSnapshot(spawnSnapshot), + }); + } + return spawnSnapshot; } async getTeamAgentRuntimeSnapshot(teamName: string): Promise { @@ -18041,6 +18178,7 @@ export class TeamProvisioningService { private async clearPersistedLaunchStateNow(teamName: string): Promise { await this.launchStateStore.clear(teamName); + this.launchStateWrittenRunIdByTeam.delete(teamName); await clearBootstrapState(teamName); } @@ -18302,15 +18440,17 @@ export class TeamProvisioningService { teamName: string, snapshot: PersistedTeamLaunchSnapshot ): Promise { - return this.enqueueLaunchStateStoreOperation(teamName, () => + const result = await this.enqueueLaunchStateStoreOperation(teamName, () => this.writeLaunchStateSnapshotNow(teamName, snapshot) ); + return result.snapshot; } private async writeLaunchStateSnapshotNow( teamName: string, - snapshot: PersistedTeamLaunchSnapshot - ): Promise { + snapshot: PersistedTeamLaunchSnapshot, + options?: { allowNoopSkip?: boolean; runId?: string } + ): Promise { const previousSnapshot = await this.launchStateStore.read(teamName).catch(() => null); const metaMembers = await this.membersMetaStore.getMembers(teamName).catch(() => []); const overlaidSnapshot = await this.applyOpenCodeSecondaryEvidenceOverlay({ @@ -18319,8 +18459,74 @@ export class TeamProvisioningService { previousSnapshot, metaMembers, }); + if ( + options?.allowNoopSkip === true && + typeof options.runId === 'string' && + this.launchStateWrittenRunIdByTeam.get(teamName) === options.runId && + previousSnapshot && + this.areLaunchStateSnapshotsSemanticallyEqual(previousSnapshot, overlaidSnapshot) && + !this.isLaunchStateNoopRefreshDue(previousSnapshot) + ) { + return { snapshot: previousSnapshot, wrote: false }; + } await this.launchStateStore.write(teamName, overlaidSnapshot); - return overlaidSnapshot; + if (typeof options?.runId === 'string') { + this.launchStateWrittenRunIdByTeam.set(teamName, options.runId); + } + return { snapshot: overlaidSnapshot, wrote: true }; + } + + private isLaunchStateNoopRefreshDue(snapshot: PersistedTeamLaunchSnapshot): boolean { + const updatedAtMs = Date.parse(snapshot.updatedAt); + return ( + !Number.isFinite(updatedAtMs) || + Date.now() - updatedAtMs >= TeamProvisioningService.LAUNCH_STATE_NOOP_REFRESH_MS + ); + } + + private areLaunchStateSnapshotsSemanticallyEqual( + left: PersistedTeamLaunchSnapshot, + right: PersistedTeamLaunchSnapshot + ): boolean { + return ( + JSON.stringify(this.toLaunchStateSemanticValue(left)) === + JSON.stringify(this.toLaunchStateSemanticValue(right)) + ); + } + + private toLaunchStateSemanticValue(snapshot: PersistedTeamLaunchSnapshot): unknown { + const { updatedAt: _updatedAt, members, ...rest } = snapshot; + const stableMembers = Object.fromEntries( + Object.entries(members) + .sort(([leftName], [rightName]) => leftName.localeCompare(rightName)) + .map(([memberName, member]) => { + const { + lastEvaluatedAt: _lastEvaluatedAt, + lastRuntimeAliveAt: _lastRuntimeAliveAt, + ...stableMember + } = member; + return [memberName, stableMember]; + }) + ); + return this.toStableJsonValue({ + ...rest, + members: stableMembers, + }); + } + + private toStableJsonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => this.toStableJsonValue(item)); + } + if (!value || typeof value !== 'object') { + return value; + } + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) + .map(([key, entryValue]) => [key, this.toStableJsonValue(entryValue)]) + ); } private async enqueueLaunchStateStoreOperation( @@ -18657,6 +18863,7 @@ export class TeamProvisioningService { run: Pick, memberName: string ) { + this.invalidateMemberSpawnStatusesCache(run.teamName); this.teamChangeEmitter?.({ type: 'member-spawn', teamName: run.teamName, @@ -19008,9 +19215,14 @@ export class TeamProvisioningService { return null; } - const writtenSnapshot = await this.writeLaunchStateSnapshotNow(run.teamName, filteredSnapshot); - this.invalidateRuntimeSnapshotCaches(run.teamName); - return writtenSnapshot; + const writeResult = await this.writeLaunchStateSnapshotNow(run.teamName, filteredSnapshot, { + allowNoopSkip: true, + runId: run.runId, + }); + if (writeResult.wrote) { + this.invalidateRuntimeSnapshotCaches(run.teamName); + } + return writeResult.snapshot; } private async launchSingleMixedSecondaryLane( @@ -23879,6 +24091,7 @@ export class TeamProvisioningService { } if (!hasNewerTrackedRun) { this.invalidateRuntimeSnapshotCaches(run.teamName); + this.invalidateMemberSpawnStatusesCache(run.teamName); this.leadInboxRelayInFlight.delete(run.teamName); this.relayedLeadInboxMessageIds.delete(run.teamName); this.pendingCrossTeamFirstReplies.delete(run.teamName); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 02a791b4..67a6c146 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -129,7 +129,10 @@ import { initializeAutoResumeService, } from '@main/services/team/AutoResumeService'; import { getTeamBootstrapStatePath } from '@main/services/team/TeamBootstrapStateReader'; -import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator'; +import { + createPersistedLaunchSnapshot, + snapshotFromRuntimeMemberStatuses, +} from '@main/services/team/TeamLaunchStateEvaluator'; import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore'; import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import { @@ -628,6 +631,364 @@ describe('TeamProvisioningService', () => { }); }); + describe('member spawn status launch reads', () => { + it('coalesces concurrent active launch status reads and serves a short cached follow-up', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'spawn-cache-team'; + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + }); + run.isLaunch = true; + run.progress = { + teamName, + state: 'launching', + message: 'Launching', + updatedAt: '2026-05-02T10:00:00.000Z', + }; + run.onProgress = vi.fn(); + (svc as any).aliveRunByTeam.set(teamName, run.runId); + (svc as any).runs.set(run.runId, run); + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + (svc as any).launchStateStore = { + read: vi.fn(async () => null), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); + const refreshDeferred = createDeferred(); + const refreshLeadInbox = vi + .spyOn(svc as any, 'refreshMemberSpawnStatusesFromLeadInbox') + .mockImplementation(async () => refreshDeferred.promise); + const auditStatuses = vi + .spyOn(svc as any, 'maybeAuditMemberSpawnStatuses') + .mockResolvedValue(undefined); + const persistSnapshot = vi + .spyOn(svc as any, 'persistLaunchStateSnapshot') + .mockResolvedValue(null); + + const first = svc.getMemberSpawnStatuses(teamName); + const second = svc.getMemberSpawnStatuses(teamName); + await Promise.resolve(); + + expect(refreshLeadInbox).toHaveBeenCalledTimes(1); + refreshDeferred.resolve(); + const [firstSnapshot, secondSnapshot] = await Promise.all([first, second]); + + expect(firstSnapshot).toEqual(secondSnapshot); + expect(auditStatuses).toHaveBeenCalledTimes(1); + expect(persistSnapshot).toHaveBeenCalledTimes(1); + + await svc.getMemberSpawnStatuses(teamName); + + expect(refreshLeadInbox).toHaveBeenCalledTimes(1); + expect(auditStatuses).toHaveBeenCalledTimes(1); + expect(persistSnapshot).toHaveBeenCalledTimes(1); + }); + + it('invalidates the short status cache when a real member-spawn change is emitted', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'spawn-cache-invalidated-team'; + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + }); + run.isLaunch = true; + run.progress = { + teamName, + state: 'launching', + message: 'Launching', + updatedAt: '2026-05-02T10:00:00.000Z', + }; + run.onProgress = vi.fn(); + (svc as any).aliveRunByTeam.set(teamName, run.runId); + (svc as any).runs.set(run.runId, run); + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + (svc as any).launchStateStore = { + read: vi.fn(async () => null), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); + const refreshLeadInbox = vi + .spyOn(svc as any, 'refreshMemberSpawnStatusesFromLeadInbox') + .mockResolvedValue(undefined); + vi.spyOn(svc as any, 'maybeAuditMemberSpawnStatuses').mockResolvedValue(undefined); + vi.spyOn(svc as any, 'persistLaunchStateSnapshot').mockResolvedValue(null); + + await svc.getMemberSpawnStatuses(teamName); + expect(refreshLeadInbox).toHaveBeenCalledTimes(1); + + (svc as any).setMemberSpawnStatus( + run, + 'alice', + 'online', + undefined, + 'heartbeat', + '2026-05-02T10:00:05.000Z' + ); + await svc.getMemberSpawnStatuses(teamName); + + expect(refreshLeadInbox).toHaveBeenCalledTimes(2); + }); + + it('retries the owner status request when a member-spawn change lands while it is building', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'spawn-cache-owner-retry-team'; + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + }); + run.isLaunch = true; + run.progress = { + teamName, + state: 'launching', + message: 'Launching', + updatedAt: '2026-05-02T10:00:00.000Z', + }; + run.onProgress = vi.fn(); + (svc as any).aliveRunByTeam.set(teamName, run.runId); + (svc as any).runs.set(run.runId, run); + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + (svc as any).launchStateStore = { + read: vi.fn(async () => null), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); + const firstRefresh = createDeferred(); + const refreshLeadInbox = vi + .spyOn(svc as any, 'refreshMemberSpawnStatusesFromLeadInbox') + .mockImplementationOnce(async () => firstRefresh.promise) + .mockResolvedValue(undefined); + vi.spyOn(svc as any, 'maybeAuditMemberSpawnStatuses').mockResolvedValue(undefined); + vi.spyOn(svc as any, 'persistLaunchStateSnapshot').mockResolvedValue(null); + + const pending = svc.getMemberSpawnStatuses(teamName); + await Promise.resolve(); + expect(refreshLeadInbox).toHaveBeenCalledTimes(1); + + (svc as any).setMemberSpawnStatus( + run, + 'alice', + 'online', + undefined, + 'heartbeat', + '2026-05-02T10:00:05.000Z' + ); + firstRefresh.resolve(); + const result = await pending; + + expect(refreshLeadInbox).toHaveBeenCalledTimes(2); + expect(result.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + }); + }); + }); + + describe('launch-state no-op persistence guard', () => { + it('does not rewrite launch-state or invalidate runtime cache for a recent semantic no-op', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:00:05.000Z')); + const svc = new TeamProvisioningService(); + const teamName = 'launch-state-noop-team'; + const status = createMemberSpawnStatusEntry({ + updatedAt: '2026-05-02T10:00:00.000Z', + firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z', + }); + const previousSnapshot = snapshotFromRuntimeMemberStatuses({ + teamName, + expectedMembers: ['alice'], + launchPhase: 'active', + updatedAt: '2026-05-02T10:00:02.000Z', + statuses: { alice: status as any }, + }); + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([['alice', status]]), + }); + run.isLaunch = true; + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).launchStateWrittenRunIdByTeam.set(teamName, run.runId); + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches'); + + const result = await (svc as any).persistLaunchStateSnapshotNow(run, 'active'); + + expect(result).toBe(previousSnapshot); + expect((svc as any).launchStateStore.write).not.toHaveBeenCalled(); + expect(invalidateRuntime).not.toHaveBeenCalled(); + }); + + it('keeps a bounded launch-state heartbeat for unchanged active snapshots', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:00:20.000Z')); + const svc = new TeamProvisioningService(); + const teamName = 'launch-state-heartbeat-team'; + const status = createMemberSpawnStatusEntry({ + updatedAt: '2026-05-02T10:00:00.000Z', + firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z', + }); + const previousSnapshot = snapshotFromRuntimeMemberStatuses({ + teamName, + expectedMembers: ['alice'], + launchPhase: 'active', + updatedAt: '2026-05-02T10:00:00.000Z', + statuses: { alice: status as any }, + }); + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([['alice', status]]), + }); + run.isLaunch = true; + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches'); + + const result = await (svc as any).persistLaunchStateSnapshotNow(run, 'active'); + + expect(result.updatedAt).toBe('2026-05-02T10:00:20.000Z'); + expect((svc as any).launchStateStore.write).toHaveBeenCalledTimes(1); + expect(invalidateRuntime).toHaveBeenCalledTimes(1); + }); + + it('does not skip the first service-owned launch-state write for an existing snapshot', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:00:05.000Z')); + const svc = new TeamProvisioningService(); + const teamName = 'launch-state-first-write-team'; + const status = createMemberSpawnStatusEntry({ + updatedAt: '2026-05-02T10:00:00.000Z', + firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z', + }); + const previousSnapshot = snapshotFromRuntimeMemberStatuses({ + teamName, + expectedMembers: ['alice'], + launchPhase: 'active', + updatedAt: '2026-05-02T10:00:02.000Z', + statuses: { alice: status as any }, + }); + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([['alice', status]]), + }); + run.isLaunch = true; + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches'); + + await (svc as any).persistLaunchStateSnapshotNow(run, 'active'); + + expect((svc as any).launchStateStore.write).toHaveBeenCalledTimes(1); + expect(invalidateRuntime).toHaveBeenCalledTimes(1); + }); + + it('does not skip the first write for a new run even when the previous snapshot is semantically equal', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:00:05.000Z')); + const svc = new TeamProvisioningService(); + const teamName = 'launch-state-new-run-team'; + const status = createMemberSpawnStatusEntry({ + updatedAt: '2026-05-02T10:00:00.000Z', + firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z', + }); + const previousSnapshot = snapshotFromRuntimeMemberStatuses({ + teamName, + expectedMembers: ['alice'], + launchPhase: 'active', + updatedAt: '2026-05-02T10:00:02.000Z', + statuses: { alice: status as any }, + }); + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([['alice', status]]), + }); + run.isLaunch = true; + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).launchStateWrittenRunIdByTeam.set(teamName, 'previous-run-id'); + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches'); + + const result = await (svc as any).persistLaunchStateSnapshotNow(run, 'active'); + + expect(result.updatedAt).toBe('2026-05-02T10:00:05.000Z'); + expect((svc as any).launchStateStore.write).toHaveBeenCalledTimes(1); + expect(invalidateRuntime).toHaveBeenCalledTimes(1); + }); + + it('writes and invalidates runtime cache when launch-state semantics change', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:00:05.000Z')); + const svc = new TeamProvisioningService(); + const teamName = 'launch-state-change-team'; + const previousStatus = createMemberSpawnStatusEntry({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + updatedAt: '2026-05-02T10:00:00.000Z', + firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z', + }); + const nextStatus = createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + livenessSource: 'heartbeat', + updatedAt: '2026-05-02T10:00:05.000Z', + firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z', + lastHeartbeatAt: '2026-05-02T10:00:05.000Z', + }); + const previousSnapshot = snapshotFromRuntimeMemberStatuses({ + teamName, + expectedMembers: ['alice'], + launchPhase: 'active', + updatedAt: '2026-05-02T10:00:02.000Z', + statuses: { alice: previousStatus as any }, + }); + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([['alice', nextStatus]]), + }); + run.isLaunch = true; + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches'); + + const result = await (svc as any).persistLaunchStateSnapshotNow(run, 'active'); + + expect(result.members.alice?.launchState).toBe('confirmed_alive'); + expect((svc as any).launchStateStore.write).toHaveBeenCalledTimes(1); + expect(invalidateRuntime).toHaveBeenCalledTimes(1); + }); + }); + describe('getTeamAgentRuntimeSnapshot', () => { it('dedupes concurrent runtime snapshot probes for the same team', async () => { const svc = new TeamProvisioningService();