From 4385b0c6793ca0051096fc173cb18befb312f095 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 2 May 2026 20:24:46 +0300 Subject: [PATCH] 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); + }); +});