diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index c92eb224..ae290c87 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -321,6 +321,8 @@ async function handleGetData( return { success: false, error: validated.error ?? 'Invalid teamName' }; } const tn = validated.value!; + const startedAt = Date.now(); + logger.info(`[teams:getData] start team=${tn}`); let data: TeamData; try { data = await getTeamDataService().getTeamData(tn); @@ -335,6 +337,14 @@ async function handleGetData( logger.error(`[teams:getData] ${message}`); return { success: false, error: message }; } + const getDataMs = Date.now() - startedAt; + if (getDataMs >= 1000) { + logger.warn( + `[teams:getData] slow team=${tn} ms=${getDataMs} tasks=${data.tasks.length} members=${data.members.length} messages=${data.messages.length}` + ); + } else { + logger.info(`[teams:getData] done team=${tn} ms=${getDataMs}`); + } const provisioning = getTeamProvisioningService(); const isAlive = provisioning.isTeamAlive(tn); diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 75e348c3..f001933e 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -169,10 +169,21 @@ export class TeamDataService { } async getTeamData(teamName: string): Promise { + const startedAt = Date.now(); + const marks: Record = {}; + const mark = (label: string): void => { + marks[label] = Date.now(); + }; + const msSince = (label: string): number => { + const t = marks[label]; + return typeof t === 'number' ? t - startedAt : -1; + }; + const config = await this.configReader.getConfig(teamName); if (!config) { throw new Error(`Team not found: ${teamName}`); } + mark('config'); const warnings: string[] = []; @@ -184,6 +195,7 @@ export class TeamDataService { warnings.push('Tasks failed to load'); tasksLoaded = false; } + mark('tasks'); let inboxNames: string[] = []; try { @@ -191,6 +203,7 @@ export class TeamDataService { } catch { warnings.push('Inboxes failed to load'); } + mark('inboxNames'); let messages: InboxMessage[] = []; try { @@ -198,6 +211,7 @@ export class TeamDataService { } catch { warnings.push('Messages failed to load'); } + mark('messages'); try { const leadTexts = await this.extractLeadSessionTexts(config); @@ -207,6 +221,7 @@ export class TeamDataService { } catch { warnings.push('Lead session texts failed to load'); } + mark('leadTexts'); try { const sentMessages = await this.sentMessagesStore.readMessages(teamName); @@ -216,6 +231,7 @@ export class TeamDataService { } catch { warnings.push('Sent messages failed to load'); } + mark('sentMessages'); messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); @@ -225,6 +241,7 @@ export class TeamDataService { } catch { warnings.push('Member metadata failed to load'); } + mark('metaMembers'); let kanbanState: KanbanState = { teamName, @@ -238,6 +255,7 @@ export class TeamDataService { warnings.push('Kanban state failed to load'); canRunKanbanGc = false; } + mark('kanbanState'); if (canRunKanbanGc && tasksLoaded) { try { @@ -247,6 +265,7 @@ export class TeamDataService { warnings.push('Kanban state cleanup failed'); } } + mark('kanbanGc'); const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) => { const col = kanbanState.tasks[task.id]?.column; @@ -261,9 +280,11 @@ export class TeamDataService { tasksWithKanban, messages ); + mark('resolveMembers'); // Enrich members with git branch when it differs from lead's branch await this.enrichMemberBranches(members, config); + mark('enrichBranches'); // Auto-sync: create comments from task-related inbox messages if (tasksLoaded && messages.length > 0) { @@ -277,6 +298,7 @@ export class TeamDataService { warnings.push('Comment sync from messages failed'); } } + mark('syncComments'); const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) => { const col = kanbanState.tasks[task.id]?.column; @@ -290,6 +312,22 @@ export class TeamDataService { } catch { warnings.push('Processes failed to load'); } + mark('processes'); + + const totalMs = Date.now() - startedAt; + if (totalMs >= 1500) { + logger.warn( + `[getTeamData] slow team=${teamName} total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince( + 'inboxNames' + )} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince( + 'sentMessages' + )} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince( + 'kanbanGc' + )} resolveMembers=${msSince('resolveMembers')} enrichBranches=${msSince( + 'enrichBranches' + )} syncComments=${msSince('syncComments')} processes=${msSince('processes')}` + ); + } // Auto-track teams with alive processes for periodic health checks const hasAlive = processes.some((p) => !p.stoppedAt); @@ -484,20 +522,27 @@ export class TeamDataService { return; } - await Promise.all( - members.map(async (member) => { - if (!member.cwd || member.cwd === leadCwd) return; - try { - const branch = await gitIdentityResolver.getBranch(member.cwd); - if (branch && branch !== leadBranch) { - // eslint-disable-next-line no-param-reassign -- intentional in-place enrichment - member.gitBranch = branch; + const candidates = members.filter((m) => m.cwd && m.cwd !== leadCwd); + if (candidates.length === 0) return; + + const concurrency = process.platform === 'win32' ? 4 : 8; + for (let i = 0; i < candidates.length; i += concurrency) { + const batch = candidates.slice(i, i + concurrency); + await Promise.all( + batch.map(async (member) => { + if (!member.cwd) return; + try { + const branch = await gitIdentityResolver.getBranch(member.cwd); + if (branch && branch !== leadBranch) { + // eslint-disable-next-line no-param-reassign -- intentional in-place enrichment + member.gitBranch = branch; + } + } catch { + // Member cwd may not be a git repo — skip silently } - } catch { - // Member cwd may not be a git repo — skip silently - } - }) - ); + }) + ); + } } async addMember(teamName: string, request: AddMemberRequest): Promise { @@ -909,6 +954,11 @@ export class TeamDataService { const TASK_ID_PATTERN = /#(\d+)/g; let synced = false; + const tasksById = new Map(); + for (const t of tasks) { + tasksById.set(t.id, t); + } + // Dedup broadcasts: same sender + same text → process only once const processedTexts = new Set(); @@ -927,7 +977,7 @@ export class TeamDataService { } for (const taskId of taskIds) { - const task = tasks.find((t) => t.id === taskId); + const task = tasksById.get(taskId); if (!task) continue; const commentId = `msg-${msg.messageId}`; diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index e320e6f3..f4491cdf 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -7,6 +7,20 @@ import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; const logger = createLogger('teamSlice'); +const TEAM_GET_DATA_TIMEOUT_MS = 30_000; + +function withTimeout(promise: Promise, ms: number, label: string): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new Error(`Timeout after ${ms}ms: ${label}`)); + }, ms); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer) clearTimeout(timer); + }); +} + import type { AppState } from '../types'; import type { AddMemberRequest, @@ -387,8 +401,18 @@ export const createTeamSlice: StateCreator = (set, return; } + const startedAt = Date.now(); + const traceId = `${teamName}:${startedAt}`; + logger.info( + `[selectTeam] start trace=${traceId} skipProjectAutoSelect=${opts?.skipProjectAutoSelect === true}` + ); + try { - const data = await unwrapIpc('team:getData', () => api.teams.getData(teamName)); + const data = await withTimeout( + unwrapIpc('team:getData', () => api.teams.getData(teamName)), + TEAM_GET_DATA_TIMEOUT_MS, + `team:getData(${teamName}) trace=${traceId}` + ); // Stale check: user may have switched to another team during the async call if (get().selectedTeamName !== teamName) { return; @@ -400,6 +424,10 @@ export const createTeamSlice: StateCreator = (set, selectedTeamError: null, }); + logger.info( + `[selectTeam] done trace=${traceId} ms=${Date.now() - startedAt} tasks=${data.tasks.length} members=${data.members.length} messages=${data.messages.length}` + ); + // Sync tab label with the team's display name from config const displayName = data.config.name || teamName; const allTabs = get().getAllPaneTabs(); @@ -466,7 +494,7 @@ export const createTeamSlice: StateCreator = (set, : error instanceof Error ? error.message : 'Failed to fetch team data'; - logger.error(`[team:getData] ${message}`); + logger.error(`[selectTeam] fail team=${teamName} ms=${Date.now() - startedAt} ${message}`); set({ selectedTeamLoading: false, selectedTeamData: null,